diff --git a/.github/ci/apple-checks.json b/.github/ci/apple-checks.json index 51351a973cf..262c89945c4 100644 --- a/.github/ci/apple-checks.json +++ b/.github/ci/apple-checks.json @@ -166,6 +166,8 @@ ".github/workflows/ios-packaging.yml", ".github/workflows/_build-ios-port.yml", "maven/codenameone-maven-plugin/**", + "maven/build-engine/**", + "maven/project-model/**", "vm/ByteCodeTranslator/**", "Ports/iOSPort/**", "scripts/build-ios-app.sh", @@ -183,6 +185,8 @@ ".github/workflows/ios-packaging.yml", ".github/workflows/_build-ios-port.yml", "maven/codenameone-maven-plugin/**", + "maven/build-engine/**", + "maven/project-model/**", "vm/ByteCodeTranslator/**", "Ports/iOSPort/**", "scripts/build-ios-app.sh", diff --git a/.github/workflows/_build-ios-port.yml b/.github/workflows/_build-ios-port.yml index 08d8389ed25..81c18031a81 100644 --- a/.github/workflows/_build-ios-port.yml +++ b/.github/workflows/_build-ios-port.yml @@ -70,6 +70,8 @@ jobs: # forwarding fix landed in the transcoder source). SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ maven/codenameone-maven-plugin/src/main \ + maven/build-engine/src/main \ + maven/project-model/src/main \ maven/svg-transcoder/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') diff --git a/.github/workflows/archetype-smoke.yml b/.github/workflows/archetype-smoke.yml index f215deab6dd..dfa2a2da91f 100644 --- a/.github/workflows/archetype-smoke.yml +++ b/.github/workflows/archetype-smoke.yml @@ -17,6 +17,8 @@ on: - 'CodenameOneDesigner/**' - 'CodenameOne/src/com/codename1/ui/plaf/UIManager.java' - 'maven/codenameone-maven-plugin/**' + - 'maven/build-engine/**' + - 'maven/project-model/**' - 'maven/cn1app-archetype/**' - 'maven/cn1lib-archetype/**' - 'maven/backend/**' @@ -37,6 +39,8 @@ on: - 'CodenameOneDesigner/**' - 'CodenameOne/src/com/codename1/ui/plaf/UIManager.java' - 'maven/codenameone-maven-plugin/**' + - 'maven/build-engine/**' + - 'maven/project-model/**' - 'maven/cn1app-archetype/**' - 'maven/cn1lib-archetype/**' - 'maven/backend/**' diff --git a/.github/workflows/car-android.yml b/.github/workflows/car-android.yml index 15bede3c894..72e67063fd3 100644 --- a/.github/workflows/car-android.yml +++ b/.github/workflows/car-android.yml @@ -11,8 +11,8 @@ name: Car (Android Auto) integration - '.github/workflows/car-android.yml' - 'CodenameOne/src/com/codename1/car/**' - 'Ports/Android/src/com/codename1/impl/android/AndroidCarSupport.java' - - 'maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/car/**' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' + - 'maven/build-engine/src/main/resources/com/codename1/builders/car/**' + - 'maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' - 'scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt' # The screenshot transport is now a Codename One backend application, so the # two files that are ONLY exercised here have to trigger this leg: the @@ -34,8 +34,8 @@ name: Car (Android Auto) integration - '.github/workflows/car-android.yml' - 'CodenameOne/src/com/codename1/car/**' - 'Ports/Android/src/com/codename1/impl/android/AndroidCarSupport.java' - - 'maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/car/**' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' + - 'maven/build-engine/src/main/resources/com/codename1/builders/car/**' + - 'maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' - 'scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt' # The screenshot transport is now a Codename One backend application, so the # two files that are ONLY exercised here have to trigger this leg: the diff --git a/.github/workflows/car-ios.yml b/.github/workflows/car-ios.yml index db25c42d886..e319f6a6af4 100644 --- a/.github/workflows/car-ios.yml +++ b/.github/workflows/car-ios.yml @@ -15,7 +15,7 @@ name: Car (CarPlay) integration - 'Ports/iOSPort/src/com/codename1/impl/ios/IOSCarPlayCallbacks.java' - 'Ports/iOSPort/nativeSources/CodenameOne_CarPlaySceneDelegate.h' - 'Ports/iOSPort/nativeSources/CodenameOne_CarPlaySceneDelegate.m' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java' - 'scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt' - 'scripts/lib/xcode.sh' push: @@ -28,7 +28,7 @@ name: Car (CarPlay) integration - 'Ports/iOSPort/src/com/codename1/impl/ios/IOSCarPlayCallbacks.java' - 'Ports/iOSPort/nativeSources/CodenameOne_CarPlaySceneDelegate.h' - 'Ports/iOSPort/nativeSources/CodenameOne_CarPlaySceneDelegate.m' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java' - 'scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt' - 'scripts/lib/xcode.sh' diff --git a/.github/workflows/gradle-smoke.yml b/.github/workflows/gradle-smoke.yml new file mode 100644 index 00000000000..2a5e1601b07 --- /dev/null +++ b/.github/workflows/gradle-smoke.yml @@ -0,0 +1,219 @@ +name: Gradle build smoke + +# End-to-end checks of the optional Gradle build (maven/codenameone-gradle-plugin), +# through the scripts in maven/integration-tests: +# +# gradle-app-test.sh convert, build, native interfaces, every target's +# upload jar, addBackend + /healthz +# gradle-maven-parity-test.sh one app built by Maven and by Gradle uploads the +# same entries for every target and generates the +# same Android Studio project (and, on the macOS +# job, the same Xcode project) -- the proof that +# the Gradle plugin reuses the Maven engine +# gradle-cn1lib-test.sh a Gradle-built cn1lib consumed by a Gradle app +# AND a Maven app +# gradle-migrate-test.sh Ant and Maven to Gradle; legacy .cn1lib refused +# gradle-tools-test.sh Settings opens, and renders, from cn1:settings and +# from ./gradlew settings +# gradle-initializr-test.sh the initializr's Gradle downloads build; the +# backend-only one runs, packages, and its native +# binary serves /healthz +# +# The framework builds on JDK 8 as everywhere else; Gradle runs on the matrix JDK, +# the floor Gradle projects support and the current LTS, and on both the Gradle +# the wrapper pins and the oldest one the plugin supports. +on: + pull_request: + branches: [ master ] + paths: + - 'scripts/ci/apt-get-install.sh' + - 'Ports/JavaSE/**' + - 'maven/codenameone-gradle-plugin/**' + - 'maven/codenameone-gradle-plugin-marker/**' + - 'maven/codenameone-maven-plugin/**' + - 'maven/build-engine/**' + - 'maven/project-model/**' + - 'maven/cn1app-archetype/**' + - 'maven/backend/**' + - 'maven/pom.xml' + - 'maven/javase/**' + - 'maven/integration-tests/gradle-*.sh' + - 'maven/integration-tests/inc/**' + - 'scripts/settings/**' + - 'scripts/initializr/common/src/main/java/com/codename1/initializr/model/**' + - 'scripts/tests/GenerateInitializr.java' + - 'scripts/tests/generate-initializr-fixtures.py' + - 'scripts/initializr/common/src/main/resources/**' + - '.github/workflows/gradle-smoke.yml' + push: + branches: [ master ] + paths: + - 'scripts/ci/apt-get-install.sh' + - 'Ports/JavaSE/**' + - 'maven/codenameone-gradle-plugin/**' + - 'maven/codenameone-gradle-plugin-marker/**' + - 'maven/codenameone-maven-plugin/**' + - 'maven/build-engine/**' + - 'maven/project-model/**' + - 'maven/cn1app-archetype/**' + - 'maven/backend/**' + - 'maven/pom.xml' + - 'maven/javase/**' + - 'maven/integration-tests/gradle-*.sh' + - 'maven/integration-tests/inc/**' + - 'scripts/settings/**' + - 'scripts/initializr/common/src/main/java/com/codename1/initializr/model/**' + - 'scripts/tests/GenerateInitializr.java' + - 'scripts/tests/generate-initializr-fixtures.py' + - 'scripts/initializr/common/src/main/resources/**' + - '.github/workflows/gradle-smoke.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + gradle-smoke: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + name: gradle-smoke (JDK ${{ matrix.gradle-jdk }}, Gradle ${{ matrix.gradle-version || 'wrapper' }}) + strategy: + fail-fast: false + matrix: + include: + # The Gradle the template's wrapper pins, on both supported JDKs. + - gradle-jdk: '17' + - gradle-jdk: '21' + # The oldest Gradle the plugin supports (CodenameOnePlugin.MINIMUM_GRADLE), + # and the release before it, which must be refused with the fix. + - gradle-jdk: '17' + gradle-version: '8.5' + refused-gradle-version: '8.4' + env: + CN1_GRADLE_REQUIRED: '1' + CN1_TEST_GRADLE_VERSION: ${{ matrix.gradle-version }} + CN1_TEST_REFUSED_GRADLE_VERSION: ${{ matrix.refused-gradle-version }} + steps: + - uses: actions/checkout@v6 + # Installed FIRST so the JDK 8 below is the one JAVA_HOME names. + - name: Set up JDK ${{ matrix.gradle-jdk }} for Gradle + uses: actions/setup-java@v5 + with: + java-version: ${{ matrix.gradle-jdk }} + distribution: 'temurin' + - run: echo "GRADLE_JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + java-version: '8' + distribution: 'temurin' + cache: 'maven' + # xvfb for the CSS compiler, which opens an AWT frame. clang and the + # libraries the generated backend links (see archetype-smoke.yml) for + # backendPackage, which the backend-only test otherwise skips. + - name: Install xvfb and the native backend toolchain + run: | + bash scripts/ci/apt-get-install.sh xvfb clang \ + libcurl4-openssl-dev libssl-dev libnghttp2-dev + - name: Build Codename One Maven artifacts (includes archetypes and the Gradle plugin) + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + cd maven + bash ../scripts/ci/retry.sh xvfb-run -a mvn install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true + - name: Refresh archetype catalog + run: | + cd maven + xvfb-run -a mvn archetype:update-local-catalog -Plocal-dev-javase + xvfb-run -a mvn archetype:crawl -Plocal-dev-javase + - name: Gradle application + working-directory: maven/integration-tests + run: xvfb-run -a bash gradle-app-test.sh + - name: Maven and Gradle upload parity + working-directory: maven/integration-tests + run: xvfb-run -a bash gradle-maven-parity-test.sh + - name: Gradle cn1lib, consumed by Gradle and Maven + working-directory: maven/integration-tests + run: xvfb-run -a bash gradle-cn1lib-test.sh + - name: Migration to Gradle + working-directory: maven/integration-tests + run: xvfb-run -a bash gradle-migrate-test.sh + - name: Initializr Gradle downloads (app, app + backend, backend only) + working-directory: maven/integration-tests + env: + # A skip reads like a pass; this runner has clang. + CN1_BACKEND_PACKAGE_REQUIRED: '1' + run: xvfb-run -a bash gradle-initializr-test.sh + # The desktop tools are separate Maven projects under scripts/; Settings + # stands in for all four, which start through the same launcher. + - name: Build Codename One Settings + working-directory: scripts/settings + # Its own CSS compile opens an AWT frame, so it needs a display too. + run: JAVA_HOME="$GRADLE_JAVA_HOME" xvfb-run -a mvn -B -ntp install -DskipTests -Pexecutable-jar -Dcodename1.platform=javase + - name: Desktop tools launch from Maven and Gradle + working-directory: maven/integration-tests + env: + CN1_TOOLS_REQUIRED: '1' + run: xvfb-run -a bash gradle-tools-test.sh + - name: Upload the test projects' logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: gradle-smoke-logs-jdk${{ matrix.gradle-jdk }}-gradle${{ matrix.gradle-version || 'wrapper' }} + path: maven/integration-tests/build/**/*.log + if-no-files-found: ignore + + # The Xcode project a local ios-source build generates, from Maven and from + # Gradle, compared file by file. It needs Xcode (the builder asks xcodebuild + # for its version) but never compiles anything, so it stays cheap. The same + # script's upload-jar and android-source comparisons run again here for free. + gradle-maven-parity-macos: + runs-on: macos-15 + timeout-minutes: 60 + env: + CN1_GRADLE_REQUIRED: '1' + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 17 for Gradle + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + - run: echo "GRADLE_JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV + # Temurin 8 is unavailable on macOS ARM64; Zulu supplies a native Java 8. + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + java-version: '8' + distribution: 'zulu' + cache: 'maven' + - name: Build Codename One Maven artifacts (includes archetypes and the Gradle plugin) + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + cd maven + bash ../scripts/ci/retry.sh mvn install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true + - name: Refresh archetype catalog + run: | + cd maven + mvn archetype:update-local-catalog -Plocal-dev-javase + mvn archetype:crawl -Plocal-dev-javase + - name: Maven and Gradle parity, including the generated Xcode project + working-directory: maven/integration-tests + env: + # A skip reads like a pass; this runner has Xcode. + CN1_IOS_SOURCE_REQUIRED: '1' + run: bash gradle-maven-parity-test.sh + - name: Upload the test projects' logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: gradle-parity-macos-logs + path: | + maven/integration-tests/build/**/*.log + maven/integration-tests/build/**/compare-*.txt + if-no-files-found: ignore diff --git a/.github/workflows/guibuilder.yml b/.github/workflows/guibuilder.yml index 74dfc5b4970..cf0e9aa5653 100644 --- a/.github/workflows/guibuilder.yml +++ b/.github/workflows/guibuilder.yml @@ -24,6 +24,7 @@ on: - 'Ports/JavaSE/src/**' - 'maven/css-compiler/**' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + - 'maven/build-engine/src/main/java/com/codename1/maven/**' push: branches: - master @@ -40,6 +41,7 @@ on: - 'Ports/JavaSE/src/**' - 'maven/css-compiler/**' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + - 'maven/build-engine/src/main/java/com/codename1/maven/**' concurrency: # Only the latest revision needs validation. PR numbers keep forks isolated; diff --git a/.github/workflows/health-android.yml b/.github/workflows/health-android.yml index 6429f56a980..0398e33e53d 100644 --- a/.github/workflows/health-android.yml +++ b/.github/workflows/health-android.yml @@ -21,10 +21,10 @@ name: Health (Health Connect) integration - 'Ports/Android/src/com/codename1/impl/android/AndroidHealth*.java' - 'Ports/Android/src/com/codename1/impl/android/HealthConnectDelegate.java' - 'Ports/Android/src/com/codename1/health/**' - - 'maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/health/**' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthManifestFragments.java' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' + - 'maven/build-engine/src/main/resources/com/codename1/builders/health/**' + - 'maven/build-engine/src/main/java/com/codename1/builders/HealthManifestFragments.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/HealthListenerBindings.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' - 'scripts/hellocodenameone/health-smoke/**' push: branches: @@ -36,10 +36,10 @@ name: Health (Health Connect) integration - 'Ports/Android/src/com/codename1/impl/android/AndroidHealth*.java' - 'Ports/Android/src/com/codename1/impl/android/HealthConnectDelegate.java' - 'Ports/Android/src/com/codename1/health/**' - - 'maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/health/**' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthManifestFragments.java' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' + - 'maven/build-engine/src/main/resources/com/codename1/builders/health/**' + - 'maven/build-engine/src/main/java/com/codename1/builders/HealthManifestFragments.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/HealthListenerBindings.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' - 'scripts/hellocodenameone/health-smoke/**' concurrency: diff --git a/.github/workflows/identity-stack.yml b/.github/workflows/identity-stack.yml index f11d6fb0407..b85eaa8d165 100644 --- a/.github/workflows/identity-stack.yml +++ b/.github/workflows/identity-stack.yml @@ -42,8 +42,8 @@ on: - 'Ports/Android/src/com/codename1/io/oidc/**' - 'Ports/Android/src/com/codename1/io/webauthn/**' - 'Ports/Android/src/com/codename1/social/AppleSignInNativeImpl.java' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' - 'maven/core-unittests/src/test/java/com/codename1/io/oidc/**' - 'maven/core-unittests/src/test/java/com/codename1/io/webauthn/**' - 'maven/core-unittests/src/test/java/com/codename1/io/Oauth2*' @@ -69,8 +69,8 @@ on: - 'Ports/Android/src/com/codename1/io/oidc/**' - 'Ports/Android/src/com/codename1/io/webauthn/**' - 'Ports/Android/src/com/codename1/social/AppleSignInNativeImpl.java' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' - 'maven/core-unittests/src/test/java/com/codename1/io/oidc/**' - 'maven/core-unittests/src/test/java/com/codename1/io/webauthn/**' - 'Samples/samples/UniversalSignInDemo/**' diff --git a/.github/workflows/ios-packaging.yml b/.github/workflows/ios-packaging.yml index 99508001f3c..8cf6839b651 100644 --- a/.github/workflows/ios-packaging.yml +++ b/.github/workflows/ios-packaging.yml @@ -84,6 +84,8 @@ jobs: set -euo pipefail SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ maven/codenameone-maven-plugin/src/main \ + maven/build-engine/src/main \ + maven/project-model/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/.github/workflows/ios-push-completion.yml b/.github/workflows/ios-push-completion.yml index 9f249bf5a31..1a010a5e6e0 100644 --- a/.github/workflows/ios-push-completion.yml +++ b/.github/workflows/ios-push-completion.yml @@ -7,7 +7,7 @@ on: paths: - 'Ports/iOSPort/nativeSources/IOSNative.m' - 'Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java' - 'scripts/ios/push-completion-tests/**' - '.github/workflows/ios-push-completion.yml' push: @@ -15,7 +15,7 @@ on: paths: - 'Ports/iOSPort/nativeSources/IOSNative.m' - 'Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java' + - 'maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java' - 'scripts/ios/push-completion-tests/**' - '.github/workflows/ios-push-completion.yml' diff --git a/.github/workflows/javase-cef-ffmpeg-smoke.yml b/.github/workflows/javase-cef-ffmpeg-smoke.yml index 197b2b4113b..95a9260b7e5 100644 --- a/.github/workflows/javase-cef-ffmpeg-smoke.yml +++ b/.github/workflows/javase-cef-ffmpeg-smoke.yml @@ -26,6 +26,8 @@ on: - 'maven/javase/**' - 'maven/cn1-binaries/**' - 'maven/codenameone-maven-plugin/**' + - 'maven/build-engine/**' + - 'maven/project-model/**' - 'maven/tests/javase-cef-ffmpeg-smoke/**' - 'maven/pom.xml' - 'scripts/run-javase-cef-ffmpeg-smoke.py' @@ -39,6 +41,8 @@ on: - 'maven/javase/**' - 'maven/cn1-binaries/**' - 'maven/codenameone-maven-plugin/**' + - 'maven/build-engine/**' + - 'maven/project-model/**' - 'maven/tests/javase-cef-ffmpeg-smoke/**' - 'maven/pom.xml' - 'scripts/run-javase-cef-ffmpeg-smoke.py' diff --git a/.github/workflows/parparvm-tests.yml b/.github/workflows/parparvm-tests.yml index ba9944b52ff..6a3af1f9e61 100644 --- a/.github/workflows/parparvm-tests.yml +++ b/.github/workflows/parparvm-tests.yml @@ -19,6 +19,8 @@ on: # break this matrix, but almost every PR touches core and this is a long # job. The translated tests in pr.yml catch a core break first. - 'maven/codenameone-maven-plugin/**' + - 'maven/build-engine/**' + - 'maven/project-model/**' - 'maven/backend/**' # The core classes the backend compiles as its own (the ORM and the entity # annotations, marked @SharedWithBackend and listed by @@ -55,6 +57,8 @@ on: # break this matrix, but almost every PR touches core and this is a long # job. The translated tests in pr.yml catch a core break first. - 'maven/codenameone-maven-plugin/**' + - 'maven/build-engine/**' + - 'maven/project-model/**' - 'maven/backend/**' # The core classes the backend compiles as its own (the ORM and the entity # annotations, marked @SharedWithBackend and listed by diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e44a0476f98..980910452e0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -387,7 +387,7 @@ jobs: -Dcn1.binaries="${CN1_BINARIES}" \ -Pcompile-android \ -DbackendAnalysis \ - -pl android,ios,codenameone-maven-plugin,backend,backend-parparvm -am verify + -pl android,ios,project-model,build-engine,codenameone-maven-plugin,codenameone-gradle-plugin,backend,backend-parparvm -am verify - name: Type-check the SQLCipher-backed database package if: ${{ matrix.java-version == 8 }} env: @@ -602,7 +602,7 @@ jobs: - name: Generate static analysis HTML summaries if: ${{ !cancelled() && matrix.java-version == 8 }} env: - QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-parparvm/target + QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-engine/target:maven/project-model/target:maven/codenameone-gradle-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-parparvm/target QUALITY_REPORT_SERVER_URL: ${{ github.server_url }} QUALITY_REPORT_REPOSITORY: ${{ github.repository }} QUALITY_REPORT_REF: ${{ github.event.pull_request.head.sha || github.sha }} @@ -621,6 +621,9 @@ jobs: "ios:maven/ios/target" \ "bytecode-translator:vm/ByteCodeTranslator/target" \ "maven-plugin:maven/codenameone-maven-plugin/target" \ + "build-engine:maven/build-engine/target" \ + "project-model:maven/project-model/target" \ + "gradle-plugin:maven/codenameone-gradle-plugin/target" \ "backend:maven/backend/target"; do label="${entry%%:*}" dir="${entry#*:}" @@ -741,11 +744,11 @@ jobs: - name: Generate quality report summary if: ${{ !cancelled() && matrix.java-version == 8 }} env: - QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-parparvm/target + QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-engine/target:maven/project-model/target:maven/codenameone-gradle-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-parparvm/target # Every project listed here must produce a SpotBugs report and that # report must be empty. A missing report fails the gate too, so a # SpotBugs run that silently stops running is not mistaken for clean. - QUALITY_REPORT_REQUIRED_SPOTBUGS: core-unittests:android:ios:ByteCodeTranslator:codenameone-maven-plugin:build-hint-catalog:build-hint-tools:backend + QUALITY_REPORT_REQUIRED_SPOTBUGS: core-unittests:android:ios:ByteCodeTranslator:codenameone-maven-plugin:build-engine:project-model:codenameone-gradle-plugin:build-hint-catalog:build-hint-tools:backend # PMD and Checkstyle run on the core and on the backend runtime, and each # must produce its report: without one, the forbidden-rule gate below it # has nothing to read and passes. diff --git a/.github/workflows/protocol-e2e.yml b/.github/workflows/protocol-e2e.yml index c3edc3f5a86..80433677a8e 100644 --- a/.github/workflows/protocol-e2e.yml +++ b/.github/workflows/protocol-e2e.yml @@ -16,6 +16,7 @@ on: - 'CodenameOne/src/com/codename1/io/grpc/**' - 'CodenameOne/src/com/codename1/io/rest/**' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + - 'maven/build-engine/src/main/java/com/codename1/maven/**' - '.github/workflows/protocol-e2e.yml' push: branches: [ master ] diff --git a/.github/workflows/scripts-ios-native.yml b/.github/workflows/scripts-ios-native.yml index 73f88e69c78..aa38ee6ed66 100644 --- a/.github/workflows/scripts-ios-native.yml +++ b/.github/workflows/scripts-ios-native.yml @@ -68,6 +68,8 @@ jobs: set -euo pipefail SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ maven/codenameone-maven-plugin/src/main \ + maven/build-engine/src/main \ + maven/project-model/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index 307d1a58021..c1ab94c9f5f 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -214,6 +214,8 @@ jobs: set -euo pipefail SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ maven/codenameone-maven-plugin/src/main \ + maven/build-engine/src/main \ + maven/project-model/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ @@ -609,6 +611,8 @@ jobs: set -euo pipefail SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ maven/codenameone-maven-plugin/src/main \ + maven/build-engine/src/main \ + maven/project-model/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/.github/workflows/starter-launchers.yml b/.github/workflows/starter-launchers.yml index 9e33dd989c3..11a12447012 100644 --- a/.github/workflows/starter-launchers.yml +++ b/.github/workflows/starter-launchers.yml @@ -1,7 +1,7 @@ name: Starter launchers on: pull_request: - paths: ['maven/cn1app-archetype/src/main/resources/archetype-resources/**', 'scripts/test-starter-launchers.py', 'scripts/sync-initializr-launchers.py', 'scripts/tests/**', 'scripts/initializr/common/src/main/**', '.github/workflows/starter-launchers.yml'] + paths: ['maven/cn1app-archetype/src/main/resources/archetype-resources/**', 'scripts/test-starter-launchers.py', 'scripts/sync-initializr-launchers.py', 'scripts/tests/**', 'scripts/initializr/common/src/main/**', 'maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/**', '.github/workflows/starter-launchers.yml'] workflow_dispatch: permissions: contents: read diff --git a/.gitignore b/.gitignore index 50a08a7d25c..9f336a95563 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ /Ports/JavaSE/build/ *.skin *.jar +# The Gradle project template's wrapper: gradlew cannot start without it (unlike +# mvnw, it never downloads its own), so every generated Gradle project ships it. +!maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradle/wrapper/gradle-wrapper.jar *.class *.pyc **/__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index eef2e2829d2..0cc764ed55d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,8 +109,10 @@ To use locally-built version, edit the generated `pom.xml`: ### Static Analysis Gates PR CI (`.github/workflows/pr.yml`, Java 8 leg) runs SpotBugs over -`core-unittests`, `android`, `ios`, `ByteCodeTranslator` and -`codenameone-maven-plugin`, then enforces the result in +`core-unittests`, `android`, `ios`, `ByteCodeTranslator`, +`codenameone-maven-plugin`, `build-engine` (the Maven-free build logic both +build plugins share), `project-model` (the project-layout API) and +`codenameone-gradle-plugin`, then enforces the result in `.github/scripts/generate-quality-report.py`. - **SpotBugs is a zero-findings gate.** *Any* finding of *any* pattern in *any* @@ -119,7 +121,9 @@ PR CI (`.github/workflows/pr.yml`, Java 8 leg) runs SpotBugs over no per-pattern allow-list, so a pattern nobody anticipated still fails. - **Record intentional exceptions in the project's `spotbugs-exclude.xml`** (`maven/core-unittests/`, `Ports/Android/`, `Ports/iOSPort/`, - `vm/ByteCodeTranslator/`, `maven/codenameone-maven-plugin/`), scoped to the + `vm/ByteCodeTranslator/`, `maven/codenameone-maven-plugin/`, + `maven/build-engine/`, `maven/project-model/`, + `maven/codenameone-gradle-plugin/`), scoped to the class or method it applies to and with a comment explaining why. Keep the generated report at zero rather than tolerating known noise. - PMD and Checkstyle still gate on their own lists in the same script. @@ -129,7 +133,7 @@ To reproduce the SpotBugs gate locally: ```bash source tools/env.sh # JDK 8 cd maven && mvn -B -DskipTests=true -Pcompile-android \ - -pl android,ios,codenameone-maven-plugin -am verify + -pl android,ios,project-model,build-engine,codenameone-maven-plugin,codenameone-gradle-plugin -am verify mvn -B -DunitTests -DskipTests=true -pl core-unittests verify mvn -B -DskipTests=true -f ../vm/ByteCodeTranslator/pom.xml verify ``` diff --git a/CodenameOneDesigner/src/com/codename1/designer/CSSImageImporter.java b/CodenameOneDesigner/src/com/codename1/designer/CSSImageImporter.java index c099c7868e9..aea83fd5427 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/CSSImageImporter.java +++ b/CodenameOneDesigner/src/com/codename1/designer/CSSImageImporter.java @@ -25,6 +25,8 @@ import com.codename1.designer.css.CN1CSSCLI; import com.codename1.io.Log; import com.codename1.io.Util; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -70,12 +72,24 @@ private File getResourceFile() { + /// The project the resource file belongs to: Ant, Maven or Gradle. + /// Null when the file is not inside a Codename One project. + private ProjectLayout getLayout() { + return ProjectLayouts.detect(resourceFile); + } + + /// The directory holding `codenameone_settings.properties`: the project + /// root under Ant and Gradle, `common/` under Maven. private File getProjectDir() { - return MavenHelper.getProjectDir(resourceFile); + ProjectLayout layout = getLayout(); + return layout == null ? null : layout.projectDir(); } - + + /// `css/` under Ant, `src/main/css` under Maven and Gradle. private File getCSSDir() { - return MavenHelper.getCSSDir(resourceFile); + ProjectLayout layout = getLayout(); + // Outside a project this always resolved to a relative "css". + return layout == null ? new File("css") : layout.cssDir(); } private File getCSSFile() { @@ -90,7 +104,7 @@ private File getImagesDir() { private File getCodenameOneSettingsFile() { - return new File(getProjectDir(), "codenameone_settings.properties"); + return new File(getProjectDir(), ProjectLayout.SETTINGS_FILE); } private Properties loadCodenameOneSettings() throws IOException { diff --git a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java index f62fe5e0df1..cb5d33ccca4 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java +++ b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java @@ -25,6 +25,10 @@ package com.codename1.designer; import com.codename1.ui.Display; +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; import com.codename1.impl.javase.JavaSEPortWithSVGSupport; import com.codename1.ui.Command; import com.codename1.ui.Container; @@ -743,7 +747,14 @@ private static String convertToVarName(String s) { private static void migrateGuiBuilder(File projectDir, EditableResources res, String destPackageName) throws IOException { - File propertiesFile = new File(projectDir, "codenameone_settings.properties"); + // Legacy GUI builder projects are Ant projects, where every path below + // is the one this always used (settings at the root, src/, res/guibuilder). + // A directory that is not recognizable as a project keeps that Ant shape. + ProjectLayout layout = ProjectLayouts.detect(projectDir); + if (layout == null) { + layout = ProjectLayouts.of(BuildSystem.ANT, ProjectKind.APP, projectDir, projectDir); + } + File propertiesFile = new File(layout.projectDir(), ProjectLayout.SETTINGS_FILE); Properties props = new Properties(); FileInputStream pIn = new FileInputStream(propertiesFile); props.load(pIn); @@ -759,7 +770,7 @@ private static void migrateGuiBuilder(File projectDir, EditableResources res, St String mainForm = props.getProperty("mainForm"); - File stateMachineBase = new File(projectDir, "src" + File.separatorChar + "generated" + File.separator + "StateMachineBase.java"); + File stateMachineBase = new File(layout.javaSourceDir(), "generated" + File.separator + "StateMachineBase.java"); StringBuilder stateMachineBaseSource = new StringBuilder("/**\n * This class was generated by the migration wizard, ultimately both it and the Statemachine can be removed.\n"); stateMachineBaseSource.append(" * This class is no longer updated automatically\n"); stateMachineBaseSource.append("*/\n"); @@ -950,10 +961,10 @@ private static void migrateGuiBuilder(File projectDir, EditableResources res, St // passed... } } - File guiFile = new File(projectDir, "res" + File.separatorChar + "guibuilder" + File.separatorChar + + File guiFile = new File(layout.guiBuilderDir(), destPackageName.replace('.', File.separatorChar) + File.separatorChar + fileName + ".gui"); guiFile.getParentFile().mkdirs(); - File sourcePackageDir = new File(projectDir, "src" + File.separatorChar + destPackageName.replace('.', File.separatorChar) ); + File sourcePackageDir = new File(layout.javaSourceDir(), destPackageName.replace('.', File.separatorChar) ); sourcePackageDir.mkdirs(); File sourceFile = new File(sourcePackageDir, fileName + ".java"); UIBuilderOverride u = new UIBuilderOverride(); diff --git a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java index 188bfcc28fa..954b9fc0bcf 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java +++ b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java @@ -26,6 +26,8 @@ import com.codename1.analytics.AnalyticsService; import com.codename1.impl.javase.JavaSEPortWithSVGSupport; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; import com.l2fprod.common.swing.JOutlookBar; import com.codename1.ui.resource.util.BlockingAction; import com.codename1.ui.resource.util.QuitAction; @@ -206,6 +208,32 @@ public static File getBaseDir(){ return baseResDir; } + /// The project `resFile` is the main resource file of -- a file directly in + /// the project's resources directory (`src/` under Ant, `src/main/resources` + /// under Maven and Gradle) -- or null. + private static ProjectLayout projectOfResourceFile(File resFile) { + ProjectLayout layout = ProjectLayouts.detect(resFile); + if (layout == null || resFile.getParentFile() == null) { + return null; + } + try { + if (resFile.getParentFile().getCanonicalFile().equals(layout.resourcesDir().getCanonicalFile())) { + return layout; + } + } catch (IOException ex) { + return null; + } + return null; + } + + /// The directory the project's `codenameone_settings.properties` lives in. + /// For an Ant project's `src/theme.res` that is the grandparent, which is + /// all this used to assume; any other location keeps that assumption. + private static File projectDirOfResourceFile(File resFile) { + ProjectLayout layout = projectOfResourceFile(resFile); + return layout != null ? layout.projectDir() : resFile.getParentFile().getParentFile(); + } + public static File getTemporarySaveOfCurrentFile() { try { File tmp = File.createTempFile("Temp", ".res"); @@ -4095,13 +4123,17 @@ public void exectute() { platformOverrides.setSelectedIndex(0); if(selection != null) { try { - baseResDir = new File(selection.getParentFile().getParentFile(), "src"); + // Where fonts referenced by name are looked up. Ant keeps them in + // src/ beside the .res; Maven and Gradle in src/main/resources. + ProjectLayout selectionLayout = projectOfResourceFile(selection); + baseResDir = selectionLayout != null ? selectionLayout.resourcesDir() + : new File(selection.getParentFile().getParentFile(), "src"); JavaSEPortWithSVGSupport.setBaseResourceDir(baseResDir); loadedFile = selection; loadedResources.openFileWithXMLSupport(selection); //loadedResources.openFile(new FileInputStream(selection)); - File codenameone_settings = new File(selection.getParentFile().getParentFile(), "codenameone_settings.properties"); + File codenameone_settings = new File(projectDirOfResourceFile(selection), ProjectLayout.SETTINGS_FILE); if(codenameone_settings.exists()) { projectGeneratorSettings = new Properties(); InputStream i = new FileInputStream(codenameone_settings); @@ -4171,7 +4203,7 @@ public void setNewMainForm(String mainForm) { Properties p = projectGeneratorSettings; if(p != null) { p.setProperty("mainForm", mainForm); - File codenameone_settings = new File(ResourceEditorView.getLoadedFile().getParentFile().getParentFile(), "codenameone_settings.properties"); + File codenameone_settings = new File(projectDirOfResourceFile(ResourceEditorView.getLoadedFile()), ProjectLayout.SETTINGS_FILE); if(codenameone_settings.exists()) { OutputStream o = null; try { @@ -4288,7 +4320,7 @@ public void exectute() { // generate the code for the resource editor if(projectGeneratorSettings != null) { - File f = new File(loadedFile.getParentFile().getParentFile(), projectGeneratorSettings.getProperty("baseClass")); + File f = new File(projectDirOfResourceFile(loadedFile), projectGeneratorSettings.getProperty("baseClass")); if(f.exists()) { String formName = projectGeneratorSettings.getProperty("mainForm"); if(loadedResources.getResourceObject(formName) == null) { diff --git a/Ports/JavaSE/build.xml b/Ports/JavaSE/build.xml index 6b7bd91f478..8c711bc10e6 100644 --- a/Ports/JavaSE/build.xml +++ b/Ports/JavaSE/build.xml @@ -139,6 +139,18 @@ + + + diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/CN1Bootstrap.java b/Ports/JavaSE/src/com/codename1/impl/javase/CN1Bootstrap.java index 879651baf3c..9846da596d8 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/CN1Bootstrap.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/CN1Bootstrap.java @@ -156,9 +156,7 @@ public static boolean run(String mainClass, String[] argv) throws Exception { for (int iter = 0; iter < len; iter++) { files.add(new File(t.nextToken())); } - File javase = new File("native" + File.separator + "javase"); - File libJavase = new File("lib" + File.separator + "impl" + File.separator + "native" + File.separator + "javase"); - for (File dir : new File[]{javase, libJavase}) { + for (File dir : SimulatorProject.legacyNativeJarDirs()) { if (dir.exists()) { for (File jar : dir.listFiles()) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java b/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java index 1087ec8585c..74e3bf7d652 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java @@ -22,6 +22,7 @@ */ package com.codename1.impl.javase; +import com.codename1.project.ProjectLayout; import com.codename1.ui.CN; import com.codename1.ui.Display; import com.codename1.ui.Form; @@ -121,7 +122,7 @@ public static boolean isSupported() { File cn1Home = new File(userHome, ".codenameone"); File updateStatusProps = new File(cn1Home, "UpdateStatus.properties"); - File cn1Props = new File("codenameone_settings.properties"); + File cn1Props = JavaSEPort.projectSettingsFile(); if (cn1Props.exists()) { java.util.Properties cn1Properties = new Properties(); try (InputStream input = new FileInputStream(cn1Props)) { @@ -206,6 +207,13 @@ void addLocalizationCandidates(File cssFile, List out) { out.add(new File(workingDirectory, "src/main/i18n")); out.add(new File(workingDirectory, "../common/src/main/l10n")); out.add(new File(workingDirectory, "../common/src/main/i18n")); + // Last, the project model's own answer, for a launch directory none of + // the guesses above fit (a Maven project started at its root). + ProjectLayout layout = SimulatorProject.current(); + if (layout != null) { + out.add(layout.l10nDir()); + out.add(new File(layout.l10nDir().getParentFile(), "i18n")); + } } /** @@ -273,6 +281,8 @@ public void run() { } File javaBin = new File(System.getProperty("java.home"), "bin/java"); + // Maven and Gradle launches both pass codename1.css.compiler.args.* + // (read below), so this relative css/ is only ever Ant's. final File srcFile = new File("css", themePrefix + "theme.css"); String overrideInputs = System.getProperty("codename1.css.compiler.args.input", null); if (overrideInputs != null) { @@ -527,6 +537,10 @@ public static Iterable scanForThemePrefixes() { } private static File getCSSSourceDirectory() { + ProjectLayout layout = SimulatorProject.current(); + if (layout != null && layout.cssDir().isDirectory()) { + return layout.cssDir(); + } File cssDir = new File(JavaSEPort.getCWD(), "src" + File.separator + "main" + File.separator + "css"); if (cssDir.isDirectory()) return cssDir; return new File(JavaSEPort.getCWD(), "css"); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java b/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java index 0f8f786cbbd..a411dd92c40 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java @@ -22,6 +22,8 @@ */ package com.codename1.impl.javase; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; import com.codename1.impl.javase.simulator.PropertyDetailsPanel; import com.codename1.impl.javase.simulator.SelectableAction; import com.codename1.impl.javase.util.SwingUtils; @@ -773,6 +775,16 @@ private File locateCssThemeFile(int themeIdx) { if (dot >= 0) { name = name.substring(0, dot); } + // The project model knows where each build keeps its CSS + // (css/, common/src/main/css, src/main/css); the sibling + // css/ below is the old Ant-only guess, kept as a fallback. + ProjectLayout layout = ProjectLayouts.detect(resFile); + if (layout != null) { + File modelCss = new File(layout.cssDir(), name); + if (modelCss.exists()) { + return modelCss; + } + } File cssThemeFile = new File(new File(projectDir, "css"), name); if (cssThemeFile.exists()) { return cssThemeFile; @@ -780,6 +792,10 @@ private File locateCssThemeFile(int themeIdx) { } } } + ProjectLayout current = SimulatorProject.current(); + if (current != null && current.themeCss().exists()) { + return current.themeCss(); + } File defaultCss = new File(JavaSEPort.getCWD(), "css" + File.separator + "theme.css"); if (defaultCss.exists()) { return defaultCss; @@ -840,6 +856,14 @@ private boolean openCssInIde(File cssFile, String uiid) { * or any of the IDE marker directories. Returns {@code null} when no marker is found. */ private File findProjectRoot(File start) { + // The top of the build (the Maven multi-module root, the Gradle root + // project, the Ant project) is where an IDE keeps .idea, .vscode or + // nbproject; the nearest pom.xml of a Maven file is common/'s, one + // level too deep to see them. + ProjectLayout layout = ProjectLayouts.detect(start); + if (layout != null) { + return layout.rootDir(); + } File dir = start.isDirectory() ? start : start.getParentFile(); while (dir != null) { if (new File(dir, "pom.xml").isFile() diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java b/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java index 300a224ba5b..6a7e3159b6c 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java @@ -208,8 +208,13 @@ public Object invoke(Object o, Method method, Object[] os) throws Throwable { String currentDir = System.getProperty("user.dir"); File props = new File(currentDir, "codenameone_settings.properties"); if (!props.exists()) { - if (new File(currentDir, "common" + File.separator + "codenameone_settings.properties").exists()) { - System.setProperty("user.dir", currentDir + File.separator + "common"); + // Ask the project model where the settings live: `common/` for a + // Maven application, the root for Ant and Gradle. Everything else in + // the simulator resolves paths against user.dir, so it is moved to + // the project directory, as it always was for Maven's `common/`. + File located = SimulatorProject.settingsFile(); + if (located.exists()) { + System.setProperty("user.dir", located.getParentFile().getAbsolutePath()); currentDir = System.getProperty("user.dir"); props = new File(currentDir, "codenameone_settings.properties"); } @@ -310,7 +315,11 @@ public void run() { } //if (isDebug && usingHotswapAgent) { - if (MavenUtils.isRunningInMaven() && MavenUtils.isRunningInJDK()) { + // A Gradle launch may carry none of the system + // properties isRunningInMaven() keys on; the project + // layout says it is a build-tool project all the same, + // and SourceChangeWatcher recompiles it with Gradle. + if ((MavenUtils.isRunningInMaven() || SimulatorProject.isGradle()) && MavenUtils.isRunningInJDK()) { // In debug mode, when the Hotswap Agent is present, the simulator // will monitor all the source files for changes, and automatically // trigger a re-compile - then hotswap the classes. @@ -884,22 +893,42 @@ public void run() { private static void startSourceWatcher(File codenameOneSettingsFile, Class launcherClass) throws IOException { File props = codenameOneSettingsFile; + File projectDir = props.getAbsoluteFile().getParentFile(); sourceWatcher = new SourceChangeWatcher(); - File srcMain = new File(props.getParentFile(), "src" + File.separator + "main" + File.separator + "java"); - if (srcMain.exists()) { - sourceWatcher.addWatchFolder(srcMain); - } - File srcMainKotlin = new File(props.getParentFile(), "src" + File.separator + "main" + File.separator + "kotlin"); - if (srcMainKotlin.exists()) { - sourceWatcher.addWatchFolder(srcMainKotlin); - } + // A Gradle launch names the source set's real directories; a + // sourceSets block can move them off the conventional layout. + String gradleRoots = System.getProperty(SourceChangeWatcher.SOURCE_ROOTS_PROPERTY); + if (gradleRoots != null && gradleRoots.length() > 0) { + for (String root : gradleRoots.split(java.util.regex.Pattern.quote(File.pathSeparator))) { + File dir = new File(root); + if (dir.exists()) { + sourceWatcher.addWatchFolder(dir); + } + } + } else { + // Maven and Gradle both keep sources in src/main/{java,kotlin,rad} + // of the project directory (common/ for Maven, the root for + // Gradle), which props sits in either way. + File srcMain = new File(projectDir, "src" + File.separator + "main" + File.separator + "java"); + if (srcMain.exists()) { + sourceWatcher.addWatchFolder(srcMain); + } + File srcMainKotlin = new File(projectDir, "src" + File.separator + "main" + File.separator + "kotlin"); + if (srcMainKotlin.exists()) { + sourceWatcher.addWatchFolder(srcMainKotlin); + } - File srcRad = new File(props.getParentFile(), "src" + File.separator + "main" + File.separator + "rad"); - if (srcRad.exists()) { - sourceWatcher.addWatchFolder(srcRad); + File srcRad = new File(projectDir, "src" + File.separator + "main" + File.separator + "rad"); + if (srcRad.exists()) { + sourceWatcher.addWatchFolder(srcRad); + } } - File hotswapPropsFile = new File(props.getParentFile().getParentFile(), "javase" + File.separator + "src" + File.separator + "main" + File.separator + "resources" + File.separator + "hotswap-agent.properties"); + File hotswapPropsFile = new File(projectDir.getParentFile(), "javase" + File.separator + "src" + File.separator + "main" + File.separator + "resources" + File.separator + "hotswap-agent.properties"); + File gradleHotswapProps = SimulatorProject.gradleHotswapProperties(); + if (gradleHotswapProps != null) { + hotswapPropsFile = gradleHotswapProps; + } InputStream hotswapPropsStream; if (hotswapPropsFile.exists()) { @@ -918,8 +947,8 @@ private static void startSourceWatcher(File codenameOneSettingsFile, Class launc //String extraClasspath = new File(props.getParentFile(), "target" +File.separator + "classes").getAbsolutePath();//hotswapProps.getProperty("extraClasspath"); String extraClasspath = hotswapProps.getProperty("extraClasspath"); - extraClasspath = extraClasspath == null ? new File(props.getParentFile(), "target" +File.separator + "classes").getAbsolutePath() : - new File(props.getParentFile(), "target" +File.separator + "classes").getAbsolutePath() + "; " + extraClasspath; + String ownClasses = SimulatorProject.classesDir(projectDir).getAbsolutePath(); + extraClasspath = extraClasspath == null ? ownClasses : ownClasses + "; " + extraClasspath; if (extraClasspath != null) { System.out.println("extraClasspath="+extraClasspath); String[] extraPaths = extraClasspath.split(";"); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 110aa3657d9..992b38a1431 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -3532,10 +3532,7 @@ private static String buildHint(String name) { private static long cachedCnopMtime = -1L; private static Properties loadCodenameOneSettings() { - File f = new File(getCWD(), "codenameone_settings.properties"); - if (!f.exists()) { - f = new File(getCWD(), "common" + File.separator + "codenameone_settings.properties"); - } + File f = projectSettingsFile(); if (!f.exists()) { return null; } @@ -6146,7 +6143,7 @@ public void run() { String t = props.getProperty("nativeThemeAttribute", null); if (t != null) { Properties cnop = new Properties(); - File cnopFile = new File(getCWD(), "codenameone_settings.properties"); + File cnopFile = projectSettingsFile(); if (cnopFile.exists()) { cnop.load(new FileInputStream(cnopFile)); int themeConst = Integer.parseInt(cnop.getProperty("codename1.j2me.nativeThemeConst", "3")); @@ -15534,7 +15531,7 @@ public String getProperty(String key, String defaultValue) { return "SE"; } if ("AppName".equals(key)) { - File f = new File(getCWD(),"codenameone_settings.properties"); + File f = projectSettingsFile(); if (f.exists()) { try { Properties p = new Properties(); @@ -15547,7 +15544,7 @@ public String getProperty(String key, String defaultValue) { return defaultValue; } if ("AppVersion".equals(key)) { - File f = new File(getCWD(), "codenameone_settings.properties"); + File f = projectSettingsFile(); if (f.exists()) { try { Properties p = new Properties(); @@ -20524,7 +20521,7 @@ public void writeToSocketStream(Object socket, byte[] data, int offset, int len) */ @Override public void installTar() throws IOException { - File f = new File(getCWD(),"codenameone_settings.properties"); + File f = projectSettingsFile(); if(!f.exists()) { super.installTar(); } @@ -20535,7 +20532,7 @@ public void installTar() throws IOException { */ @Override public void setBrowserPageInHierarchy(PeerComponent browserPeer, String url) throws IOException { - File f = new File(getCWD(), "codenameone_settings.properties"); + File f = projectSettingsFile(); if(!f.exists()) { super.setBrowserPageInHierarchy(browserPeer, url); return; @@ -20543,6 +20540,9 @@ public void setBrowserPageInHierarchy(PeerComponent browserPeer, String url) thr String sep = File.separator; File[] searchPaths = new File[]{ + // Processed resources: Maven's target/classes, or Gradle's + // build/resources/main, whichever the project model names. + new File(SimulatorProject.resourcesOutputDir(f.getAbsoluteFile().getParentFile()), "html"), new File(f.getParent(), "target" + sep + "classes"+ sep + "html"), new File(f.getParent(), "build" + sep + "classes"+ sep + "html"), new File(f.getParent(), "src" + sep + "main"+ sep + "resources" + sep +"html"), @@ -22478,6 +22478,15 @@ static File findLocalizationDirectory(File projectDir) { candidates.add(new File(projectDir, "src" + File.separator + "main" + File.separator + "i18n")); candidates.add(new File(projectDir, "l10n")); candidates.add(new File(projectDir, "src" + File.separator + "l10n")); + // The project model's answer for whatever build owns this directory: + // it also covers a Maven simulator started at the project root, + // whose bundles are in common/ rather than in a sibling of it. + com.codename1.project.ProjectLayout layout = SimulatorProject.locate(projectDir); + if (layout != null) { + File l10n = layout.l10nDir(); + candidates.add(l10n); + candidates.add(new File(l10n.getParentFile(), "i18n")); + } File parent = projectDir.getParentFile(); if (parent != null) { File commonModule = new File(parent, "common"); @@ -22721,7 +22730,23 @@ public static File getCWD() { return new File(System.getProperty("user.dir")); } + /// The project's `codenameone_settings.properties`: in the working + /// directory, else wherever the project model finds it (`common/` of a + /// Maven project started at its root, for one). Returned whether or not it + /// exists; callers check. + static File projectSettingsFile() { + File f = new File(getCWD(), "codenameone_settings.properties"); + if (f.exists()) { + return f; + } + return SimulatorProject.settingsFile(); + } + public static File getSourceResourcesDir() { + com.codename1.project.ProjectLayout layout = SimulatorProject.current(); + if (layout != null && layout.resourcesDir().exists()) { + return layout.resourcesDir(); + } File resDir = new File(getCWD(), "src" + File.separator + "main" + File.separator + "resources"); if (!resDir.exists()) { resDir = new File(getCWD(), "src"); @@ -22732,7 +22757,7 @@ public static File getSourceResourcesDir() { @Override public Map getProjectBuildHints() { - File cnopFile = new File(getCWD(), "codenameone_settings.properties"); + File cnopFile = projectSettingsFile(); if(cnopFile.exists()) { java.util.Properties cnop = new java.util.Properties(); try(InputStream is = new FileInputStream(cnopFile)) { @@ -22756,7 +22781,7 @@ public Map getProjectBuildHints() { @Override public void setProjectBuildHint(String key, String value) { - File cnopFile = new File(getCWD(),"codenameone_settings.properties"); + File cnopFile = projectSettingsFile(); if(cnopFile.exists()) { Properties cnop = new Properties(); try(InputStream is = new FileInputStream(cnopFile)) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/Simulator.java b/Ports/JavaSE/src/com/codename1/impl/javase/Simulator.java index 9bc405fa5a9..c7dc58e127d 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/Simulator.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/Simulator.java @@ -59,9 +59,10 @@ private static boolean hasFFmpeg() { * @param projectDir */ private static void loadSimulatorProperties(File projectDir) { - if (!MavenUtils.isRunningInMaven()) { - // simulator.properties file is only for maven. - // The PrepareSimulatorClassPathMojo writes the simulator.properties file in the target/codenameone folder. + if (!MavenUtils.isRunningInMaven() && !SimulatorProject.isGradle()) { + // simulator.properties file is only for maven and gradle. + // The PrepareSimulatorClassPathMojo writes the simulator.properties file in the target/codenameone folder; + // the Gradle plugin writes the same file to build/codenameone. return; } if (System.getProperty("cn1.simulator.properties.loaded") != null) { @@ -69,7 +70,7 @@ private static void loadSimulatorProperties(File projectDir) { return; } System.setProperty("cn1.simulator.properties.loaded", "true"); - File simulatorProperties = new File(projectDir, "target" + File.separator + "codenameone" + File.separator + "simulator.properties"); + File simulatorProperties = new File(SimulatorProject.buildDir(projectDir), "codenameone" + File.separator + "simulator.properties"); if (simulatorProperties.exists()) { Properties props = new Properties(); try (FileInputStream fis = new FileInputStream(simulatorProperties)) { @@ -145,18 +146,21 @@ public static void main(final String[] argv) throws Exception { final boolean usingHotswapAgent = inputArgs.toString().indexOf("-XX:HotswapAgent") > 0; File cn1Props = new File("codenameone_settings.properties"); if (!cn1Props.exists()) { - cn1Props = new File("common" + File.separator + "codenameone_settings.properties"); - - } - if (!cn1Props.exists()) { - cn1Props = new File(".." + File.separator + "common" + File.separator + "codenameone_settings.properties").getAbsoluteFile(); - + // `common/` and `../common` for Maven, the root for Ant and Gradle: + // the project model knows each layout, see SimulatorProject. + cn1Props = SimulatorProject.settingsFile().getAbsoluteFile(); } if (cn1Props.exists()) { - File commonClasses = new File(cn1Props.getParentFile(), "target" + File.separator + "classes"); + File projectDir = cn1Props.getAbsoluteFile().getParentFile(); + File commonClasses = SimulatorProject.classesDir(projectDir); if (commonClasses.exists()) { files.add(commonClasses.getAbsoluteFile()); } + // Gradle keeps processed resources apart from the classes. + File commonResources = SimulatorProject.resourcesOutputDir(projectDir); + if (!commonResources.equals(commonClasses) && commonResources.exists()) { + files.add(commonResources.getAbsoluteFile()); + } loadSimulatorProperties(cn1Props.getParentFile()); publishAnnotationBuildHints(cn1Props.getParentFile(), classPathStr); } @@ -168,9 +172,7 @@ public static void main(final String[] argv) throws Exception { for (int iter = 0; iter < len; iter++) { files.add(new File(t.nextToken()).getAbsoluteFile()); } - File javase = new File("native" + File.separator + "javase"); - File libJavase = new File("lib" + File.separator + "impl" + File.separator + "native" + File.separator + "javase"); - for (File dir : new File[]{javase, libJavase}) { + for (File dir : SimulatorProject.legacyNativeJarDirs()) { if (dir.exists()) { for (File jar : dir.listFiles()) { @@ -365,6 +367,10 @@ private static class HotswapProperties { private File findHotswapPropertiesFile() { try { + File gradleProps = SimulatorProject.gradleHotswapProperties(); + if (gradleProps != null) { + return gradleProps.exists() ? gradleProps : null; + } File currDir = new File(System.getProperty("user.dir")).getCanonicalFile(); while (!new File(currDir, "javase").exists()) { currDir = currDir.getParentFile(); @@ -887,8 +893,12 @@ static FoundManifest findAnnotationManifest(File projectDir, String classPathStr } // Only when the classpath carries none: a launch that did not pass the // module's output directory at all still finds a conventional build. - File conventional = new File(projectDir, "target" + File.separator + "classes" - + File.separator + resource); + File conventional = new File(SimulatorProject.classesDir(projectDir), resource); + if (!conventional.isFile()) { + // Gradle keeps processed resources out of the classes directory; + // under Ant and Maven this is the same file as above. + conventional = new File(SimulatorProject.resourcesOutputDir(projectDir), resource); + } java.util.Properties loaded = conventional.isFile() ? readProperties(conventional) : null; if (loaded != null) { FoundManifest found = diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorProject.java b/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorProject.java new file mode 100644 index 00000000000..a36b6365dad --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/SimulatorProject.java @@ -0,0 +1,252 @@ +/* + * 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.impl.javase; + +import com.codename1.project.BuildSystem; +import com.codename1.project.NativePlatform; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; +import java.io.File; +import java.io.IOException; + +/// The Codename One project the simulator is running, whatever builds it. +/// +/// The simulator, CSS watcher, hot reload and component inspector used to each +/// probe the working directory for `codenameone_settings.properties`, then for +/// `common/codenameone_settings.properties`, and to assume Maven's +/// `target/classes`. That is right for an Ant or Maven project and wrong for a +/// Gradle one, whose classes land in `build/classes/java/main`. Every such +/// lookup now goes through [ProjectLayout] instead. +/// +/// The search is deliberately no wider than the one it replaces. The old code +/// looked at the working directory, its `common/` child and `../common`, so a +/// layout is accepted only when the working directory is its project +/// directory, its root, or a direct child of its root (the `javase` module a +/// Maven launch can start in). An unbounded walk up would let a simulator +/// started somewhere unrelated (a unit test in this repository, a temp +/// directory) adopt whatever project happens to enclose it. +public final class SimulatorProject { + private static File cachedFor; + private static ProjectLayout cached; + + private SimulatorProject() { + } + + /// The working directory, as [JavaSEPort#getCWD()] reports it. + private static File cwd() { + return new File(System.getProperty("user.dir")).getAbsoluteFile(); + } + + /// The project around the working directory, or null when there is none. + /// Recomputed whenever `user.dir` changes (the Executor moves it into + /// `common/` for a Maven project). + public static synchronized ProjectLayout current() { + File cwd = cwd(); + if (!cwd.equals(cachedFor)) { + cached = locate(cwd); + cachedFor = cwd; + } + return cached; + } + + /// The layout for a simulator started in `cwd`, or null. See the class + /// comment for why the search is bounded. + static ProjectLayout locate(File cwd) { + ProjectLayout layout; + try { + // Not detectWithDescriptor: the source roots a build resolved + // include generated ones, and the hot-reload watcher must not + // watch (and recompile on) its own output. + layout = ProjectLayouts.detect(cwd); + } catch (RuntimeException ex) { + return null; + } + if (layout == null || layout.kind() == ProjectKind.BACKEND) { + return null; + } + File dir = canonical(cwd); + File root = canonical(layout.rootDir()); + File project = canonical(layout.projectDir()); + if (dir.equals(project) || dir.equals(root) || root.equals(dir.getParentFile())) { + return withLaunchBuildDir(layout); + } + return null; + } + + /// The build directory a Gradle launch names (`cn1.buildDir`), which a + /// build script can move off the conventional `build/` where detection from + /// the file system would look. simulator.properties, the classes and the + /// processed resources all live under it. + static final String BUILD_DIR_PROPERTY = "cn1.buildDir"; + + /// The Gradle project path a launch names (`cn1.gradleProjectPath`), which a + /// build can decouple from the directory the path would otherwise follow. + static final String GRADLE_PATH_PROPERTY = "cn1.gradleProjectPath"; + + private static ProjectLayout withLaunchBuildDir(ProjectLayout layout) { + if (layout == null || layout.buildSystem() != BuildSystem.GRADLE) { + return layout; + } + String dir = System.getProperty(BUILD_DIR_PROPERTY); + if (dir != null && dir.length() > 0) { + layout = layout.withBuildDir(new File(dir)); + } + String path = System.getProperty(GRADLE_PATH_PROPERTY); + if (path != null && path.length() > 0) { + layout = layout.withGradlePath(path); + } + return layout; + } + + /// The layout whose project directory is `projectDir`, or null. For code + /// that already holds the directory the settings file was found in. + public static ProjectLayout forProjectDir(File projectDir) { + if (projectDir == null) { + return null; + } + ProjectLayout current = current(); + File dir = canonical(projectDir); + if (current != null && canonical(current.projectDir()).equals(dir)) { + return current; + } + ProjectLayout layout = locate(dir); + if (layout != null && canonical(layout.projectDir()).equals(dir)) { + return layout; + } + return null; + } + + /// The project's `codenameone_settings.properties`: in the detected + /// project directory, else in the working directory as before (which + /// may not exist; callers check). + public static File settingsFile() { + return new File(projectDir(), ProjectLayout.SETTINGS_FILE); + } + + /// The directory holding the project's settings and sources, else the + /// working directory. + public static File projectDir() { + ProjectLayout layout = current(); + return layout != null ? layout.projectDir() : cwd(); + } + + /// Whether the running project is built by Gradle. + public static boolean isGradle() { + ProjectLayout layout = current(); + return layout != null && layout.buildSystem() == BuildSystem.GRADLE; + } + + /// The layout of `projectDir` when its build output is somewhere other + /// than Maven's `target/`, else null. + /// + /// Ant is left on the Maven paths on purpose. Every caller hard-coded + /// `target/classes` before, which under Ant simply never existed: the Ant + /// launcher puts `build/classes` on the classpath itself and the simulator + /// never looked for it. Answering `build/classes` now would add a second, + /// earlier copy of it to the class loader, a change in behaviour this + /// refactoring is not meant to make. + private static ProjectLayout nonMavenOutput(File projectDir) { + // A null directory is the working directory: Simulator passes the + // parent of a relative "codenameone_settings.properties", which is + // null, and the paths below then stay relative, as they always were. + ProjectLayout layout = forProjectDir(projectDir == null ? cwd() : projectDir); + return layout != null && layout.buildSystem() == BuildSystem.GRADLE ? layout : null; + } + + /// Where the project in `projectDir` compiles its classes: + /// `build/classes/java/main` under Gradle, otherwise Maven's + /// `target/classes`, which is what every caller hard-coded before. + public static File classesDir(File projectDir) { + ProjectLayout layout = nonMavenOutput(projectDir); + if (layout != null) { + return layout.classesDir(); + } + return new File(projectDir, "target" + File.separator + "classes"); + } + + /// Where the project in `projectDir` puts its processed resources. The + /// same as [classesDir(File)] except under Gradle, which keeps them in + /// `build/resources/main`. + public static File resourcesOutputDir(File projectDir) { + ProjectLayout layout = nonMavenOutput(projectDir); + if (layout != null) { + return layout.resourcesOutputDir(); + } + return classesDir(projectDir); + } + + /// The build output directory of the project in `projectDir`: `build` + /// under Gradle, otherwise `target`. + public static File buildDir(File projectDir) { + ProjectLayout layout = nonMavenOutput(projectDir); + if (layout != null) { + return layout.buildDir(); + } + return new File(projectDir, "target"); + } + + /// The `hotswap-agent.properties` of a Gradle project, kept with its + /// JavaSE native sources in `src/javase/resources`, or null when the + /// running project is not a Gradle one. A Maven project keeps it in the + /// `javase` module's `src/main/resources`, which the callers still find + /// the way they always did. + public static File gradleHotswapProperties() { + ProjectLayout layout = current(); + if (layout == null || layout.buildSystem() != BuildSystem.GRADLE) { + return null; + } + File javaseSources = layout.nativeSourceDir(NativePlatform.JAVASE); + return new File(new File(javaseSources.getParentFile(), "resources"), "hotswap-agent.properties"); + } + + /// The directories an Ant project drops prebuilt JavaSE jars into: + /// `native/javase` and the cn1lib extraction dir `lib/impl/native/javase`. + /// Resolved against the Ant project when one is found, else against the + /// working directory as before. Maven and Gradle compile native sources + /// into the classpath, so neither has such a directory; the relative + /// fallback keeps whatever an old launch relied on. + public static File[] legacyNativeJarDirs() { + ProjectLayout layout = current(); + File base = layout != null && layout.buildSystem() == BuildSystem.ANT ? layout.projectDir() : null; + File javase = base != null ? layout.nativeSourceDir(NativePlatform.JAVASE) + : new File("native" + File.separator + "javase"); + File libJavase = new File(base, "lib" + File.separator + "impl" + File.separator + "native" + + File.separator + "javase"); + return new File[]{javase, libJavase}; + } + + static File canonical(File f) { + try { + return f.getCanonicalFile(); + } catch (IOException ex) { + return f.getAbsoluteFile(); + } + } + + /// Test seam: forgets the cached layout. + static synchronized void reset() { + cachedFor = null; + cached = null; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/SourceChangeWatcher.java b/Ports/JavaSE/src/com/codename1/impl/javase/SourceChangeWatcher.java index 1ac2f05c981..6aa84c1efdb 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/SourceChangeWatcher.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/SourceChangeWatcher.java @@ -24,6 +24,10 @@ import com.codename1.impl.javase.util.MavenUtils; import com.codename1.io.Log; +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; import com.codename1.ui.*; import com.sun.nio.file.SensitivityWatchEventModifier; import org.w3c.dom.Document; @@ -83,8 +87,86 @@ private Path getPathForKey(WatchKey key) { return null; } + /// The project a watched file belongs to, or null. + /// + /// This used to be "the directory of the nearest pom.xml", which is + /// `common/` for a Maven application and does not exist for Gradle or Ant. + /// The project model finds the same `common/` for Maven and the project + /// directory for the others. A cache keeps the per-event cost to one + /// directory walk per source directory. + private final Map layoutCache = new HashMap(); + + /// Set by a Gradle launch (SimulatorSupport in the build engine names them): + /// the source set's source directories, path-separated, and where javac and + /// the Kotlin compiler write its classes. They win over the conventional + /// layout, which a sourceSets block or a relocated build directory moves. + static final String SOURCE_ROOTS_PROPERTY = "cn1.hotReload.sourceRoots"; + static final String JAVA_CLASSES_PROPERTY = "cn1.hotReload.javaClasses"; + static final String KOTLIN_CLASSES_PROPERTY = "cn1.hotReload.kotlinClasses"; + + private static File fromProperty(String name, File fallback) { + String value = System.getProperty(name); + return value == null || value.length() == 0 ? fallback : new File(value); + } + + /// The configured source root holding `file`, or `fallback`. + private static File sourceRootOf(File file, File fallback) { + String roots = System.getProperty(SOURCE_ROOTS_PROPERTY); + if (roots != null && roots.length() > 0) { + String path = file.getAbsolutePath(); + for (String root : roots.split(java.util.regex.Pattern.quote(File.pathSeparator))) { + String rootPath = new File(root).getAbsolutePath(); + if (path.equals(rootPath) || path.startsWith(rootPath + File.separator)) { + return new File(root); + } + } + } + return fallback; + } + + private ProjectLayout layoutFor(File startingPoint) { + if (startingPoint == null) { + return null; + } + // A file (or a deleted one) is identified by its directory. + File dir = startingPoint.isDirectory() ? startingPoint : startingPoint.getParentFile(); + if (dir == null) { + return null; + } + synchronized (layoutCache) { + if (layoutCache.containsKey(dir)) { + return layoutCache.get(dir); + } + // A source root the launch named belongs to the running project, even + // outside its directory (sourceSets can point anywhere), where + // detection from the file system would find nothing or another project. + if (sourceRootOf(dir, null) != null && SimulatorProject.current() != null) { + ProjectLayout running = SimulatorProject.current(); + layoutCache.put(dir, running); + return running; + } + ProjectLayout layout = ProjectLayouts.detect(dir); + if (layout != null && layout.kind() == ProjectKind.BACKEND) { + layout = null; + } + layoutCache.put(dir, layout); + return layout; + } + } + + /// Where generated sources go: Maven's `target/generated-sources/`, + /// or the same name under Gradle's `build/generated-sources`. + private File generatedSourcesDir(File startingPoint, String name) { + return new File(getCN1BuildDir(startingPoint), "generated-sources" + File.separator + name); + } + + private File getCN1BuildDir(File startingPoint) { + ProjectLayout layout = layoutFor(startingPoint); + return layout == null ? null : layout.buildDir(); + } + private File getRADViewsDirectory(File viewXMLFile) { - return new File(findPom(viewXMLFile).getParentFile(), "src" + File.separator + "main" + File.separator + "rad" + File.separator + "views"); + return layoutFor(viewXMLFile).radViewsDir(); } private String getPackageForRADView(File viewXMLFile) { @@ -108,9 +190,9 @@ private String getPackageForRADView(File viewXMLFile) { private boolean isRADView(Path path) { - File pom = findPom(path.toFile().getParentFile()); - if (!pom.exists()) return false; - File radViews = new File(pom.getParentFile(), "src" + File.separator + "main" + File.separator + "rad" + File.separator + "views"); + ProjectLayout layout = layoutFor(path.toFile().getParentFile()); + if (layout == null) return false; + File radViews = layout.radViewsDir(); return path.toFile().getName().endsWith(".xml") && path.startsWith(radViews.toPath()); } @@ -129,11 +211,12 @@ private void writeStringToFile(File file, String string, String encoding) throws } private File getCN1ProjectDir(File startingPoint) { - return findPom(startingPoint).getParentFile(); + ProjectLayout layout = layoutFor(startingPoint); + return layout == null ? null : layout.projectDir(); } private void generateSchemaFor(File xmlViewFile, String contents) throws IOException { - File generatedSources = new File(getCN1ProjectDir(xmlViewFile), "target" + File.separator + "generated-sources"); + File generatedSources = new File(getCN1BuildDir(xmlViewFile), "generated-sources"); File xmlSchemasDirectory = new File(generatedSources, "rad" + File.separator + "xmlSchemas"); String packageName = getPackageForRADView(xmlViewFile); String baseName = xmlViewFile.getName(); @@ -330,7 +413,7 @@ private File getDestClassForRADView(File viewXMLFile) { } private File getRADGeneratedSourcesDirectory(File viewXMLFile) { - return new File(getCN1ProjectDir(viewXMLFile), "target" + File.separator + "generated-sources" + File.separator + "rad-views"); + return generatedSourcesDir(viewXMLFile, "rad-views"); } @@ -359,9 +442,9 @@ private boolean recompileWithJavac(Path path) throws IOException, InterruptedExc } File f = path.toFile(); - File pom = findPom(f.getParentFile()); - if (pom == null) { - System.out.println("Skipping recompile of "+path+" because no pom.xml was found"); + ProjectLayout layout = layoutFor(f.getParentFile()); + if (layout == null) { + System.out.println("Skipping recompile of "+path+" because no Codename One project was found"); return false; } @@ -390,15 +473,33 @@ private boolean recompileWithJavac(Path path) throws IOException, InterruptedExc } StringBuilder classPath = new StringBuilder(); - File classDestination = new File(getCN1ProjectDir(path.toFile()), "target" + File.separator + "classes"); + File classDestination = fromProperty(JAVA_CLASSES_PROPERTY, layout.classesDir()); classPath.append(classDestination.getAbsolutePath()).append(File.pathSeparator); + // Gradle compiles Kotlin into a directory of its own. It goes on the + // classpath -- Java calling Kotlin, or Kotlin calling another Kotlin class, + // otherwise recompiles against nothing -- and a recompiled Kotlin class + // goes back there, not beside Java's where it would shadow a stale copy. + File kotlinClasses = layout.buildSystem() == BuildSystem.GRADLE + ? fromProperty(KOTLIN_CLASSES_PROPERTY, + new File(layout.buildDir(), "classes" + File.separator + "kotlin" + File.separator + "main")) + : null; + if (kotlinClasses != null) { + classPath.append(kotlinClasses.getAbsolutePath()).append(File.pathSeparator); + } classPath.append(System.getProperty("cn1.maven.compileClasspathElements", "")); boolean isKotlinFile = path.toFile().getName().endsWith(".kt"); - File generatedSources = new File(getCN1ProjectDir(path.toFile()), "target" + File.separator + "generated-sources" + File.separator + "annotations"); - File sourcePath = isRADView ? getRADGeneratedSourcesDirectory(path.toFile()) : new File(getCN1ProjectDir(path.toFile()), "src" + File.separator + "main" + File.separator + "java"); - File kotlinSourcePath = isRADView ? getRADGeneratedSourcesDirectory(path.toFile()) : new File(getCN1ProjectDir(path.toFile()), "src" + File.separator + "main" + File.separator + "kotlin"); + // Gradle's javac writes processor output to its own directory; Maven's + // is target/generated-sources/annotations. + File generatedSources = layout.buildSystem() == BuildSystem.GRADLE + ? new File(layout.buildDir(), "generated" + File.separator + "sources" + File.separator + + "annotationProcessor" + File.separator + "java" + File.separator + "main") + : generatedSourcesDir(path.toFile(), "annotations"); + File sourcePath = isRADView ? getRADGeneratedSourcesDirectory(path.toFile()) + : sourceRootOf(path.toFile(), layout.javaSourceDir()); + File kotlinSourcePath = isRADView ? getRADGeneratedSourcesDirectory(path.toFile()) + : sourceRootOf(path.toFile(), kotlinSourceDir(layout)); String recompilingClass = isKotlinFile ? path.toFile().getAbsolutePath().substring(kotlinSourcePath.getAbsolutePath().length()+1) : isRADView ? @@ -426,24 +527,41 @@ private boolean recompileWithJavac(Path path) throws IOException, InterruptedExc pb = new ProcessBuilder( kotlinc.getAbsolutePath(), "-classpath", classPath.toString(), - "-d", classDestination.getAbsolutePath(), - "-jvm-target", "1.8", + "-d", (kotlinClasses != null ? kotlinClasses : classDestination).getAbsolutePath(), + "-jvm-target", javaLevel(layout), recompilingClass ); pb.environment().put("JAVA_HOME", System.getProperty("java.home")); pb.directory(kotlinSourcePath); } else { - pb = new ProcessBuilder( - javac.getAbsolutePath(), - "-cp", classPath.toString(), - "-d", classDestination.getAbsolutePath(), - "-source", "1.8", - "-target", "1.8", - "-encoding", "utf-8", - "-s", generatedSources.getAbsolutePath(), - "-sourcepath", sourcePath.getAbsolutePath(), - recompilingClass - ); + List cmd = new ArrayList(); + cmd.add(javac.getAbsolutePath()); + cmd.add("-cp"); + cmd.add(classPath.toString()); + cmd.add("-d"); + cmd.add(classDestination.getAbsolutePath()); + if (layout.buildSystem() == BuildSystem.GRADLE) { + // --release, as Gradle's compile does: -source/-target alone would + // let an edit call an API the host JDK has and the target release + // lacks, which hot reload runs and the next real build rejects. + cmd.add("--release"); + cmd.add(javaLevel(layout)); + } else { + cmd.add("-source"); + cmd.add(javaLevel(layout)); + cmd.add("-target"); + cmd.add(javaLevel(layout)); + } + // The encoding the build compiles in; a Gradle launch names it. + String encoding = System.getProperty("cn1.hotReload.encoding"); + cmd.add("-encoding"); + cmd.add(encoding != null && encoding.length() > 0 ? encoding : "utf-8"); + cmd.add("-s"); + cmd.add(generatedSources.getAbsolutePath()); + cmd.add("-sourcepath"); + cmd.add(sourcePath.getAbsolutePath()); + cmd.add(recompilingClass); + pb = new ProcessBuilder(cmd); pb.environment().put("JAVA_HOME", System.getProperty("java.home")); pb.directory(sourcePath); } @@ -539,7 +657,36 @@ private void registerCurrentFormForReload() { } } - private boolean recompileWithMaven(Path path) throws IOException, InterruptedException { + /// `src/main/kotlin` beside the Java sources, whether or not it exists. + /// The language level the direct recompile uses: the project's own, so a + /// source using Java 17 syntax recompiles. Gradle projects are always 17, + /// the one bytecode level Codename One builds (Android runs nothing newer); + /// a Maven or Ant project says so in codenameone_settings.properties, and + /// 8 remains the default there. + static String javaLevel(ProjectLayout layout) { + if (layout.buildSystem() == BuildSystem.GRADLE) { + return "17"; + } + Properties settings = new Properties(); + File file = layout.settingsFile(); + if (file != null && file.isFile()) { + try (InputStream in = new FileInputStream(file)) { + settings.load(in); + } catch (IOException ex) { + return "1.8"; + } + } + return "17".equals(settings.getProperty("codename1.arg.java.version", "8").trim()) ? "17" : "1.8"; + } + + private static File kotlinSourceDir(ProjectLayout layout) { + return new File(layout.javaSourceDir().getParentFile(), "kotlin"); + } + + /// Recompiles the project with its own build tool: `mvn compile` for + /// Maven, `gradlew cn1Compile` for Gradle, `ant compile` for Ant, as + /// [ProjectLayout#compileCommand(String, boolean)] spells each. + private boolean recompileWithBuildTool(Path path) throws IOException, InterruptedException { int hotReloadSetting = Integer.parseInt(System.getProperty("hotReload", "0")); if (hotReloadSetting == 0) return false; if (!path.toFile().exists()) { @@ -559,35 +706,40 @@ private boolean recompileWithMaven(Path path) throws IOException, InterruptedExc File f = path.toFile(); - File pom = findPom(f.getParentFile()); - if (pom == null) { - System.out.println("Skipping recompile of "+path+" because no pom.xml was found"); + ProjectLayout layout = layoutFor(f); + if (layout == null) { + System.out.println("Skipping recompile of "+path+" because no Codename One project was found"); return false; } - + String mavenHome = System.getProperty("maven.home"); - if (mavenHome == null) { - Log.p("Not recompiling path "+path+" because maven.home system property was not found."); - return false; - } - - String mavenPath = mavenHome + File.separator + "bin" + File.separator + "mvn"; - if (!new File(mavenPath).exists()) { - if (new File(mavenPath+".exe").exists()) { - mavenPath += ".exe"; - } else if (new File(mavenPath+".bat").exists()) { - mavenPath += ".bat"; - } else if (new File(mavenPath+".cmd").exists()) { - mavenPath += ".cmd"; - } else { - Log.p("Not recompiling path "+path+" because " +mavenPath+" could not be found."); + List command = layout.compileCommand(mavenHome, isWindows); + File workingDir = layout.compileWorkingDir(); + if (layout.buildSystem() == BuildSystem.MAVEN) { + // Maven keeps its old contract exactly: the Maven that launched the + // simulator (maven.home) or nothing -- never a wrapper or whatever + // "mvn" is on the PATH -- run in the directory of the pom nearest + // the change, which for a watched sibling module is that module. + // The one difference is on Windows, where mvn.cmd is now preferred + // to the extensionless mvn, a shell script CreateProcess cannot run. + if (mavenHome == null) { + Log.p("Not recompiling path "+path+" because maven.home system property was not found."); + return false; + } + File mavenBin = new File(mavenHome, "bin"); + if (!new File(command.get(0)).getAbsoluteFile().getParentFile().equals(mavenBin.getAbsoluteFile())) { + Log.p("Not recompiling path "+path+" because " + new File(mavenBin, "mvn") + " could not be found."); return false; } + File pom = findPom(f.getParentFile()); + if (pom != null) { + workingDir = pom.getParentFile(); + } } - ProcessBuilder pb = new ProcessBuilder(mavenPath, "compile", "-DskipComplianceCheck", "-Dmaven.compiler.useIncrementalCompilation=false", "-e"); + ProcessBuilder pb = new ProcessBuilder(command); pb.environment().put("JAVA_HOME", System.getProperty("java.home")); - pb.directory(pom.getParentFile()); + pb.directory(workingDir); pb.inheritIO(); Process p = pb.start(); int result = p.waitFor(); @@ -707,6 +859,9 @@ public void actionPerformed(ActionEvent evt) { } private File findPom(File startingPoint) { + if (startingPoint == null) { + return null; + } File pom = new File(startingPoint, "pom.xml"); if (pom.exists()) return pom; File parent = startingPoint.getParentFile(); @@ -765,9 +920,11 @@ public void accept(WatchEvent evt) { requiresRecompile = true; } File changedFile = new File(path.toFile(), evt.context().toString()); - File cn1ProjectDir = getCN1ProjectDir(changedFile); - File commonSrcDir = cn1ProjectDir == null ? null : new File(cn1ProjectDir, "src" + File.separator + "main" + File.separator + "java"); - File kotlinSrcDir = cn1ProjectDir == null ? null : new File(cn1ProjectDir, "src" + File.separator + "main" + File.separator + "kotlin"); + ProjectLayout changedLayout = layoutFor(changedFile); + File commonSrcDir = changedLayout == null ? null + : sourceRootOf(changedFile, changedLayout.javaSourceDir()); + File kotlinSrcDir = changedLayout == null ? null + : sourceRootOf(changedFile, kotlinSourceDir(changedLayout)); boolean isEligibleKotlinFile = kotlinSrcDir != null && changedFile.exists() && changedFile.getName().endsWith(".kt") && changedFile.toPath().startsWith(kotlinSrcDir.toPath()); boolean isEligibleJavaFile = commonSrcDir != null && changedFile.exists() && changedFile.getName().endsWith(".java") && changedFile.toPath().startsWith(commonSrcDir.toPath()); if (isRADView(changedFile.toPath()) || isEligibleJavaFile || isEligibleKotlinFile) { @@ -790,7 +947,7 @@ public void accept(WatchEvent evt) { Log.e(ex); } } else { - recompileWithMaven(path); + recompileWithBuildTool(path); } } @@ -839,8 +996,17 @@ public boolean hasWatchFolder(File path) { private String findKotlinVersion() { + // Maven publishes the application's classpath as cn1.class.path; Gradle's + // run task puts it on the simulator's own classpath instead, stdlib and all. String classPath = System.getProperty("cn1.class.path", null); + if (classPath == null || findKotlinStdlib(classPath) == null) { + classPath = System.getProperty("java.class.path", null); + } if (classPath == null) return null; + return findKotlinStdlib(classPath); + } + + private static String findKotlinStdlib(String classPath) { Pattern regex = Pattern.compile("kotlin-stdlib-([\\d\\.\\-]+)\\.jar"); Matcher matcher = regex.matcher(classPath); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/TestRecorder.java b/Ports/JavaSE/src/com/codename1/impl/javase/TestRecorder.java index a2fd998e0b5..3e6c7ea02e9 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/TestRecorder.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/TestRecorder.java @@ -63,7 +63,7 @@ public class TestRecorder extends javax.swing.JFrame { /** Creates new form TestRecorder */ public TestRecorder() { initComponents(); - File f = new File("codenameone_settings.properties"); + File f = JavaSEPort.projectSettingsFile(); if (!f.exists()) { saveRecording.setEnabled(false); } @@ -663,7 +663,16 @@ else if (evt.getSource() == screenshotTest) { private void saveRecordingActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_saveRecordingActionPerformed updateTestCode(); - File test = new File("test"); + // Ant keeps tests in test/; Maven and Gradle in src/test/java of the + // project directory, where their builds compile them from. + com.codename1.project.ProjectLayout layout = SimulatorProject.current(); + File test = layout != null ? layout.testSourceDir() : new File("test"); + // A Gradle launch names the test source set's real directory, which a + // sourceSets block can move; a test saved elsewhere would never run. + String configured = System.getProperty("cn1.hotReload.testRoot"); + if (configured != null && configured.length() > 0) { + test = new File(configured); + } test.mkdirs(); File tpack = new File(test, testsPackage.getText().replace('.', File.separatorChar)); tpack.mkdirs(); diff --git a/docs/demos/gradle/gradle-project-workflow.kts b/docs/demos/gradle/gradle-project-workflow.kts new file mode 100644 index 00000000000..b80f466936e --- /dev/null +++ b/docs/demos/gradle/gradle-project-workflow.kts @@ -0,0 +1,82 @@ +/* + * 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. + */ + +// Build-script snippets for the Gradle Project Workflow chapter of the developer +// guide. Each tagged region is a separate file in a real project; they are kept +// together here so the guide has one checked source to include from. + +// tag::gradle-settings[] +pluginManagement { + repositories { + maven("https://repo.codenameone.com/maven2") + gradlePluginPortal() + } +} + +plugins { + id("com.codenameone") version "VERSION" +} + +rootProject.name = "MyApp" +// end::gradle-settings[] + +// tag::gradle-build-script[] +dependencies { + // A Codename One library published to a Maven repository: + cn1lib("com.codenameone:googlemaps-lib:1.0") + + // A plain Java library that only uses APIs Codename One supports: + implementation("org.example:library:1.0") +} +// end::gradle-build-script[] + +// tag::gradle-codenameone-block[] +codenameone { + buildHints.put("ios.newStorageLocation", "true") + buildHints.put("android.targetSDKVersion", "35") +} +// end::gradle-codenameone-block[] + +// tag::gradle-kotlin-plugin[] +plugins { + kotlin("jvm") version "2.2.10" +} + +dependencies { + // the application's own dependencies, as before +} +// end::gradle-kotlin-plugin[] + +// tag::gradle-cn1lib-build[] +group = "com.example" +version = "1.0" + +publishing { + repositories { + maven { + name = "company" + url = uri("https://maven.example.com/releases") + } + } +} +// end::gradle-cn1lib-build[] diff --git a/docs/demos/gradle/gradle-project-workflow.properties b/docs/demos/gradle/gradle-project-workflow.properties new file mode 100644 index 00000000000..74e14dafa7c --- /dev/null +++ b/docs/demos/gradle/gradle-project-workflow.properties @@ -0,0 +1,14 @@ +# Property snippets for the Gradle Project Workflow chapter of the developer guide. + +# tag::gradle-properties[] +# Gradle caches the configuration of this build, which is what makes the second +# ./gradlew run start in about a second. +org.gradle.configuration-cache=true +org.gradle.caching=true +# end::gradle-properties[] + +# tag::gradle-repository-override[] +# Resolve the plugin's framework artifacts from a mirror, or from a local +# directory holding a locally built Codename One, instead of repo.codenameone.com. +codename1.repository=/path/to/maven/repository +# end::gradle-repository-override[] diff --git a/docs/demos/gradle/gradle-project-workflow.sh b/docs/demos/gradle/gradle-project-workflow.sh new file mode 100644 index 00000000000..2b175a5a72f --- /dev/null +++ b/docs/demos/gradle/gradle-project-workflow.sh @@ -0,0 +1,86 @@ +# Command snippets for the Gradle Project Workflow chapter of the developer guide. + +# tag::gradle-convert[] +cd MyApp # the Maven or Ant project +mvn com.codenameone:codenameone-maven-plugin:VERSION:convert-to-gradle +cd ../MyApp-gradle +./gradlew run +# end::gradle-convert[] + +# tag::gradle-convert-options[] +mvn com.codenameone:codenameone-maven-plugin:VERSION:convert-to-gradle \ + -Dcn1.sourceProject=/path/to/MyApp \ + -Dcn1.outputDir=/path/to/MyApp-gradle \ + -Dcn1.includeBackend=true +# end::gradle-convert-options[] + +# tag::gradle-generate-app-project[] +mvn com.codenameone:codenameone-maven-plugin:VERSION:generate-app-project \ + -DgroupId=com.example.myapp \ + -DartifactId=myapp \ + -Dversion=1.0-SNAPSHOT \ + -DmainName=MyApp \ + -DsourceProject=/path/to/AntProject \ + -Dcn1.buildTool=gradle \ + -DinteractiveMode=false +# end::gradle-generate-app-project[] + +# tag::gradle-run[] +./gradlew run # the simulator +./gradlew debug # the simulator, waiting for a debugger on port 5005 +./gradlew cn1Test # the unit tests, in the simulator's test runner +# end::gradle-run[] + +# tag::gradle-build-targets[] +./gradlew buildAndroid # send an Android build to the build server +./gradlew buildIos # send an iOS debug build +./gradlew buildIosXcodeProject # generate an Xcode project locally +./gradlew buildJavascriptLocal # build the JavaScript port locally +# end::gradle-build-targets[] + +# tag::gradle-cn1build[] +./gradlew cn1Build -Pcodename1.platform=ios -Pcodename1.buildTarget=ios-source +./gradlew buildAndroid -Pcodename1.stageOnly=true +# end::gradle-cn1build[] + +# tag::gradle-hints-cli[] +./gradlew buildIos -Pcodename1.arg.ios.newStorageLocation=true +# end::gradle-hints-cli[] + +# tag::gradle-native-generate[] +./gradlew generateNativeInterfaces +# end::gradle-native-generate[] + +# tag::gradle-native-options[] +./gradlew generateNativeInterfaces -Pcn1.nativeInterface=com.example.myapp.MyNative \ + -Pcn1.swift=true -Pcn1.kotlin=true +# end::gradle-native-options[] + +# tag::gradle-cn1lib-publish[] +./gradlew publishToMavenLocal # install into ~/.m2 for local testing +./gradlew publish # publish to the repositories the build declares +# end::gradle-cn1lib-publish[] + +# tag::gradle-backend[] +./gradlew addBackend # create backend/ once +CN1_PROFILE=dev ./gradlew :backend:runBackend # run it on this JVM +./gradlew :backend:backendPackage # build the native binary +# end::gradle-backend[] + +# tag::gradle-backend-only[] +CN1_PROFILE=dev ./gradlew runBackend +./gradlew backendPackage +CN1_PROFILE=dev PORT=9000 ./build/MyService +# end::gradle-backend-only[] + +# tag::gradle-tools[] +./gradlew settings # Codename One Settings +./gradlew guibuilder # the GUI Builder +./gradlew gameBuilder # the Game Builder +./gradlew certificateWizard # the iOS Certificate Wizard +# end::gradle-tools[] + +# tag::gradle-update[] +./gradlew cn1Update +./gradlew cn1Update -Pcodename1.updateTo=VERSION +# end::gradle-update[] diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index 5c51b19fd6d..6b07d7f2a4d 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -409,30 +409,45 @@ The implementation of this interface is identical for Android (Java/Kotlin) & Ja For iOS native interfaces you can implement the generated `...Impl` class in Objective-C _or_ Swift. + For Android native interfaces you can implement the generated `...Impl` class in Java _or_ Kotlin. -[cols="1,3",options="header"] +[cols="1,3,3",options="header"] |=== | Platform -| Typical file locations for native interface implementations +| Maven project +| Gradle project | Android (Java/Kotlin) | `android/src/main/java/com/mycompany/myapp/MyNativeImpl.java` + `android/src/main/java/com/mycompany/myapp/MyNativeImpl.kt` +| `src/android/java/com/mycompany/myapp/MyNativeImpl.java` + +`src/android/java/com/mycompany/myapp/MyNativeImpl.kt` | iOS (Objective-C/Swift) | `ios/src/main/objectivec/com_mycompany_myapp_MyNativeImpl.h` + `ios/src/main/objectivec/com_mycompany_myapp_MyNativeImpl.m` + `ios/src/main/objectivec/com_mycompany_myapp_MyNativeImpl.swift` +| `src/ios/objectivec/com_mycompany_myapp_MyNativeImpl.h` + +`src/ios/objectivec/com_mycompany_myapp_MyNativeImpl.m` + +`src/ios/objectivec/com_mycompany_myapp_MyNativeImpl.swift` + +| Java SE (simulator and desktop) +| `javase/src/main/java/com/mycompany/myapp/MyNativeImpl.java` +| `src/javase/java/com/mycompany/myapp/MyNativeImpl.java` | Native Windows (win32, C) | `win/src/main/c/com/mycompany/myapp/MyNativeImplCodenameOne.c` +| `src/win/c/com/mycompany/myapp/MyNativeImplCodenameOne.c` | Native Linux (C) | `linux/src/main/c/com/mycompany/myapp/MyNativeImplCodenameOne.c` +| `src/linux/c/com/mycompany/myapp/MyNativeImplCodenameOne.c` | JavaScript | `javascript/src/main/javascript/com_mycompany_myapp_MyNative.js` +| `src/javascript/javascript/com_mycompany_myapp_MyNative.js` |=== +A Gradle project creates each of these directories only when `./gradlew generateNativeInterfaces` first writes a stub into it; see <>. + NOTE: The native Windows and native Linux ports compile your app to C (no JVM), so their native interface implementations are plain C, generated alongside the others by the "Generate Native Sources" tool. The native Mac target reuses the iOS diff --git a/docs/developer-guide/Backend-MCP.asciidoc b/docs/developer-guide/Backend-MCP.asciidoc index f2f2efd0b52..51c4bf4a90d 100644 --- a/docs/developer-guide/Backend-MCP.asciidoc +++ b/docs/developer-guide/Backend-MCP.asciidoc @@ -160,7 +160,8 @@ claude mcp add --transport http cn1-backend http://127.0.0.1:8080/mcp A host that only speaks stdio runs `com.codename1.backend.mcp.StdioBridge` from the backend jar with that address instead, and the bridge relays each message. None of these tools are compiled into a server built with `cn1:backend-package`, unless -`-Dcn1.backend.devTools=true` asks for them. +`-Dcn1.backend.devTools=true` asks for them. A Gradle project's `backendPackage` +task takes the same switch as `-Pcn1.backend.devTools=true`. === The loop an agent works in diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index ff28fc319f3..d9622f026e6 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -124,6 +124,17 @@ mvn -pl backend -Dcodename1.platform=backend cn1:backend # run it on t mvn -pl backend -Dcodename1.platform=backend cn1:backend-package # build the native binary ---- +In a Gradle project the backend is the `backend` subproject (or the whole project, +for a backend-only application), and the same two steps are tasks: + +---- +./gradlew :backend:runBackend # run it on this JVM +./gradlew :backend:backendPackage # build the native binary +---- + +Drop the `:backend:` prefix in a backend-only project. <> covers +adding a backend to an existing Gradle application. + The first is the development loop. Both run the same protocol code -- there is one copy of `HttpServer`, and only the layer underneath it differs -- so behaviour can't drift between the loop you develop in and the binary you ship. The local diff --git a/docs/developer-guide/Gradle-Project-Workflow.asciidoc b/docs/developer-guide/Gradle-Project-Workflow.asciidoc new file mode 100644 index 00000000000..0b7549a78b9 --- /dev/null +++ b/docs/developer-guide/Gradle-Project-Workflow.asciidoc @@ -0,0 +1,387 @@ +[[gradle-project-workflow]] +== Gradle project workflow + +Maven is the standard way to build a Codename One application, and everything in the <> chapter remains the reference. Gradle is an optional alternative for applications that want a smaller project: one directory instead of a root `pom.xml` and eight modules, and no native or backend directories until the application has native code or a server. + +The Gradle build isn't a second implementation. The `com.codenameone` Gradle plugin calls the same build engine the Maven plugin uses, so a Gradle project stages the same upload for the build server, runs the same local builders, compiles CSS with the same compiler and checks the same bytecode rules. An application built both ways uploads identical content for every target. + +=== Choosing between Maven and Gradle + +Choose Gradle when: + +* You want the smallest project that works: a settings file, your sources and nothing else. +* Your team already uses Gradle, or your IDE and CI work better with it. +* The application doesn't need any of the Maven-only goals listed in <>. + +Stay with Maven when: + +* The application targets Java 8. Gradle projects compile for Java 17 only, the bytecode level Codename One builds for every platform. +* It still uses legacy `.cn1lib` files rather than libraries published to a Maven repository. +* It depends on a goal that has no Gradle task yet. + +Gradle projects leave out what the Maven build carries for older projects: legacy `.cn1lib` files (consumed or produced), the legacy Designer (resource editor) task, the Java 8 target, and the Maven-only files and profiles such as `codenameone_maven.properties` and the `install-codenameone` profile. Running Gradle needs JDK 17 or newer and Gradle 8.5 or newer; every project ships a Gradle wrapper, so you never install Gradle yourself. + +=== Getting started + +==== The initializr + +In the https://www.codenameone.com/initializr[Codename One initializr], set *Build tool* to *Gradle*. A Gradle project then offers three project types: + +App:: A client application. +App + backend:: A client application with a `backend/` subproject, as described in <>. +Backend only:: A server with no client at all: no settings file, theme, simulator or native directories. + +The download opens in IntelliJ IDEA, NetBeans, Eclipse or Visual Studio Code, and builds from the command line with the `./gradlew` wrapper it contains. + +==== Converting an existing project + +The Maven plugin converts a Maven or Ant application to the Gradle layout, writing a new project beside the old one and leaving the old one untouched. In the commands in this chapter, `VERSION` stands for the Codename One release you use: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-convert,indent=0] +---- + +The conversion runs anywhere, with or without a project in the working directory. These properties control it: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-convert-options,indent=0] +---- + +`cn1.sourceProject`:: The project to convert. Defaults to the project Maven runs in, else the working directory. +`cn1.outputDir`:: Where the Gradle project goes. Defaults to `-gradle` beside the source project. It must not exist or must be empty. +`cn1.includeBackend`:: Carries over a backend module that's still the untouched archetype skeleton. Off by default, because every Maven project has that module; a backend with code of its own is always converted. +`cn1.gradleVersion`:: The plugin version the new `settings.gradle.kts` declares. Defaults to the version of the Maven plugin doing the conversion. + +The conversion moves sources, CSS, localization and resources to `src/main/...`, each platform's native implementations to `src//`, and the dependencies of `common/pom.xml` into `build.gradle.kts`. It raises `codename1.arg.java.version` to 17 and says so. Ant projects that mix Java and Kotlin in `src/` get their Kotlin sources in `src/main/kotlin` and the Kotlin Gradle plugin in `build.gradle.kts`. + +A project that uses legacy `.cn1lib` files is refused, and the message names every one of them. Depend on each library's Maven coordinates instead, or republish it as a Gradle cn1lib (see <>), then convert again. + +The `generate-app-project` goal, which migrates an Ant project or a project template, produces a Gradle project directly with `-Dcn1.buildTool=gradle`: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-generate-app-project,indent=0] +---- + +=== The project layout + +---- +MyApp/ + settings.gradle.kts + build.gradle.kts (optional: the application's own dependencies) + gradle.properties + gradlew, gradlew.bat, gradle/wrapper/ + codenameone_settings.properties + icon.png + src/main/java/ (and src/main/kotlin/ for Kotlin sources) + src/main/css/theme.css + src/main/resources/ + src/main/l10n/ + src/test/java/ + src/android/java/ created by generateNativeInterfaces, when needed + src/ios/objectivec/ + src/javase/java/ + src/javascript/javascript/ + src/win/c/ + src/linux/c/ + backend/ created by addBackend, when needed +---- + +The native and backend directories don't exist until the application needs them. The plugin declares them anyway, so creating one later needs no build-file edit. + +The settings file is the only Codename One line a project needs, with `VERSION` replaced by the release: + +[source,kotlin] +---- +include::../demos/gradle/gradle-project-workflow.kts[tag=gradle-settings,indent=0] +---- + +The plugin, applied from the settings file, adds the repository the framework is published to (and Maven Central for other libraries), applies itself to the project and to `backend/` when there is one, and builds against its own version. There's one version number to bump. + +A project's kind comes from the files the other build tools already use: `codenameone_settings.properties` makes it an application, `codenameone_library_appended.properties` a cn1lib, and `application.properties` without a settings file a backend. The `codename1.kind` Gradle property (`APP`, `LIB` or `BACKEND`) overrides the detection for the root project. Subprojects, such as an application's `backend`, are always detected from their own files, because a Gradle property set on the command line or in `gradle.properties` reaches every project of the build. + +The generated `gradle.properties` turns on the configuration cache and the build cache, which is what makes a second `./gradlew run` start in about a second: + +[source,properties] +---- +include::../demos/gradle/gradle-project-workflow.properties[tag=gradle-properties,indent=0] +---- + +To resolve the framework from a mirror, or from a directory holding a locally built Codename One, set `codename1.repository` there: + +[source,properties] +---- +include::../demos/gradle/gradle-project-workflow.properties[tag=gradle-repository-override,indent=0] +---- + +=== Running and building + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-run,indent=0] +---- + +The simulator reloads CSS as you edit it, and its hot reload recompiles through Gradle. Cloud builds and local builds are tasks too: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-build-targets,indent=0] +---- + +`cn1Build` runs any target by name, and `codename1.stageOnly` stops once the upload jar is staged and checked, which is useful for inspecting exactly what a build would send: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-cn1build,indent=0] +---- + +Gradle runs one Codename One build at a time, even when a command line names several, because each one uses the build client and the local toolchains. + +==== Task reference + +[cols="2,2,3",options="header"] +|=== +| Gradle task +| Maven goal +| What it does + +| `run` +| `cn1:run` +| Runs the application in the simulator. + +| `debug` +| `cn1:debug` +| Runs the simulator suspended, waiting for a debugger on port 5005. + +| `cn1Css` +| `cn1:css` +| Compiles `src/main/css/theme.css`, merged with the CSS of every cn1lib, into `theme.res`. + +| `transcodeSvg` +| `cn1:transcode-svg` +| Turns SVG and Lottie assets into Java sources. + +| `generateGuiSources` +| `cn1:generate-gui-sources` +| Generates sources from GUI builder XML and CodeRAD view templates. + +| `cn1Compile` +| `compile` +| Compiles the application and its simulator native code; the simulator's hot reload runs it. + +| `prepareSimulator` +| `cn1:prepare-simulator-classpath` +| Writes the files the simulator reads at startup. + +| `cn1Test` +| `cn1:test` +| Runs the unit tests in the simulator's test runner. + +| `generateNativeInterfaces` +| `cn1:generate-native-interfaces` +| Writes implementation stubs for every native interface; see <>. + +| `verifyNativeInterfaces` +| (none) +| Runs before each build task and fails when a native interface has no implementation for that platform. + +| `buildAndroid`, `buildAndroidGradleProject` +| `cn1:buildAndroid`, `cn1:buildAndroidGradleProject` +| An Android cloud build; an Android Studio project generated locally. + +| `buildIos`, `buildIosRelease`, `buildIosXcodeProject` +| `cn1:buildIos`, `cn1:buildIosRelease`, `cn1:buildIosXcodeProject` +| An iOS debug or App Store cloud build; an Xcode project generated locally. + +| `buildMacNative`, `buildMacDesktop` +| `cn1:buildMacNative`, `cn1:buildMacDesktop` +| A native macOS build; a macOS desktop (JVM) build. + +| `buildWindowsDesktop`, `buildWindowsDevice` +| `cn1:buildWindowsDesktop`, `cn1:buildWindowsDevice` +| A Windows desktop (JVM) build; a native Windows build. + +| `buildLinuxDevice` +| `cn1:buildLinuxDevice` +| A native Linux build. + +| `buildJavascript`, `buildJavascriptLocal` +| `cn1:buildJavascript` +| A JavaScript cloud build; the JavaScript port built locally. + +| `cn1Build` +| `cn1:build` +| The build given by `-Pcodename1.platform` and `-Pcodename1.buildTarget`. + +| `settings`, `guibuilder`, `gameBuilder`, `certificateWizard` +| `cn1:settings`, `cn1:guibuilder`, `cn1:gamebuilder`, `cn1:certificatewizard` +| Opens Codename One Settings, the GUI Builder, the Game Builder or the iOS Certificate Wizard for this project. + +| `addBackend` +| (none) +| Adds a `backend/` subproject to the application. + +| `runBackend` +| `cn1:backend` +| Runs a backend on this JVM. + +| `backendPackage` +| `cn1:backend-package` +| Builds a backend as a single native binary. + +| `cn1Update` +| `cn1:update` +| Updates the plugin version in `settings.gradle.kts` and the build client. +|=== + +A build task accepts `-Pautomated=true` to wait for a cloud build and download its result, and `-Popen=false` (or `-Pcodename1.open=false`) to keep a generated Xcode or Android Studio project from opening. + +==== Build hints + +Build hints come from `codenameone_settings.properties`, as in every Codename One project. The `codenameone {}` block in `build.gradle.kts` adds hints for every build of the project, without the `codename1.arg.` prefix: + +[source,kotlin] +---- +include::../demos/gradle/gradle-project-workflow.kts[tag=gradle-codenameone-block,indent=0] +---- + +A hint on the command line applies to that build only, and wins over both. `-Pcodename1.arg.` and `-Dcodename1.arg.` are equivalent: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-hints-cli,indent=0] +---- + +The same block also sets `version`, the framework version the project builds against (it defaults to the plugin's), and `mainClass`, which defaults to the package and main name in the settings file. + +[[gradle-native-interfaces]] +=== Native interfaces + +A Gradle project starts without native directories, and adding a native interface later needs no change to any build file. + +. Declare the interface in `src/main/java`, as described in <<_native_interfaces,native interfaces>>: ++ +[source,java] +---- +include::../demos/common/src/main/java/com/mycompany/myapp/MyNative.java[tag=myNativeInterface,indent=0] +---- + +. Generate the stubs: ++ +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-native-generate,indent=0] +---- ++ +The task compiles the application, finds every interface that extends `NativeInterface`, and writes a stub for each platform into `src//`, creating only the directories it writes to. An existing implementation is never overwritten unless you pass `-Pcn1.overwrite=true`. + +. Implement the stubs. The Java SE implementation in `src/javase/java` compiles against the application and joins the simulator's classpath on the next `./gradlew run`. The other platforms' sources travel in that platform's upload. + +`-Pcn1.nativeInterface` limits the task to one interface, and `-Pcn1.swift=true` and `-Pcn1.kotlin=true` add Swift and Kotlin stubs beside the Objective-C and Java ones: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-native-options,indent=0] +---- + +Before every build, the plugin checks that each native interface has an implementation for the target platform. A missing one fails the build before anything is sent, naming the file to create, rather than failing later on the build server. + +To remove a native interface, delete the interface and its implementation files; nothing else refers to them. + +[[gradle-cn1libs]] +=== Libraries + +==== Using a cn1lib + +Declare a cn1lib by its `-lib` coordinates in `build.gradle.kts`, next to any plain Java library: + +[source,kotlin] +---- +include::../demos/gradle/gradle-project-workflow.kts[tag=gradle-build-script,indent=0] +---- + +These are the coordinates a Maven project uses with `pom`. The library's classes and CSS reach every build, and its platform-specific code reaches only that platform's: Maven chooses those jars with profiles, and the plugin reads the same profiles from the library's published `pom.xml`. The library's build hints merge into the application's, as they do under Maven. + +A project that declares repositories of its own in `build.gradle.kts` keeps the Codename One repository too: the plugin adds it beside them. + +==== Building a cn1lib + +A Gradle project with a `codenameone_library_appended.properties` file (and no `codenameone_settings.properties`) is a cn1lib. It uses the same settings file, `src/main/java` for the library, `src/main/css` for its CSS and `src//` for native code, and `generateNativeInterfaces` works as it does for an application. The build script sets the coordinates and, to publish somewhere other than the local repository, a repository: + +[source,kotlin] +---- +include::../demos/gradle/gradle-project-workflow.kts[tag=gradle-cn1lib-build,indent=0] +---- + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-cn1lib-publish,indent=0] +---- + +The library is published in the same shape the Maven `cn1lib-archetype` produces, so Maven and Gradle applications both consume it. For a project named `N`: + +`N-common`:: The compiled library, with `codenameone_library_appended.properties` and `codenameone_library_required.properties` under `META-INF/codenameone/`. +`N-common`, classifier `cn1css`:: The library's CSS, as a zip. +`N-`:: One jar per platform: compiled classes for `javase`, sources for the others, which the native builders compile. +`N-lib`:: A `pom` that applications depend on, with one `codename1.platform` profile per platform. + +Gradle cn1libs don't produce `.cn1lib` files. + +=== Kotlin + +Kotlin sources go in `src/main/kotlin`, and the project applies the Kotlin Gradle plugin in `build.gradle.kts`. `plugins {}` must be the first statement of the script: + +[source,kotlin] +---- +include::../demos/gradle/gradle-project-workflow.kts[tag=gradle-kotlin-plugin,indent=0] +---- + +Java and Kotlin classes in one project can call each other. Both are checked by the same bytecode compliance rules as a Java-only project. + +[[gradle-backend]] +=== The backend + +`addBackend` adds a `backend/` subproject to an existing application. The settings plugin includes it automatically, so `settings.gradle.kts` doesn't change. Its tasks run with the project path in front: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-backend,indent=0] +---- + +A backend-only project has the same bootstrap with the server at the root: `application.properties`, `application-dev.properties` and `src/main/java`. Its tasks are `runBackend`, `backendPackage`, `cn1Update` and the standard `test`. `backendPackage` writes the binary to `build/`: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-backend-only,indent=0] +---- + +`CN1_PROFILE=dev` selects the development profile that `application-dev.properties` configures, with an in-memory SQLite database; without it the server reads the production settings and expects `DATABASE_URL`. `PORT` overrides the configured port. `runBackend` takes `-Pcn1.backend.mainClass`, `-Pcn1.backend.args` and `-Pcn1.backend.jvmArgs`. The <<_server_side_backend,server-side backend>> chapter covers the runtime itself. + +=== Tools and updates + +The desktop tools open on the Gradle project as they do on a Maven one: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-tools,indent=0] +---- + +`cn1Update` moves the project to the latest release by rewriting the plugin version in `settings.gradle.kts`, and updates the build client. `-Pcodename1.updateTo` picks a version instead: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-update,indent=0] +---- + +[[gradle-limits]] +=== Limits + +These features remain Maven goals, with no Gradle task yet: + +* The OpenAPI, gRPC and GraphQL client generators (`cn1:generate-openapi`, `cn1:generate-grpc`, `cn1:generate-graphql`). +* `cn1:create-game-scene`. +* The on-device debugging helpers for Android and iOS. + +The initializr's Tweet app template isn't available for Gradle projects yet. An application that needs one of these stays on Maven for now. diff --git a/docs/developer-guide/Maven-Getting-Started.adoc b/docs/developer-guide/Maven-Getting-Started.adoc index 63340356817..7a053759dae 100644 --- a/docs/developer-guide/Maven-Getting-Started.adoc +++ b/docs/developer-guide/Maven-Getting-Started.adoc @@ -33,6 +33,8 @@ This tool will allow you to choose from a growing selection of project templates The starter projects are based on the <>. +The initializr also offers Gradle as the build tool. A Gradle project is a single directory with a one-line settings file, and it gets native or backend directories only once it needs them. See <> for when to choose it and how it maps to the Maven workflow in this chapter. + [TIP] ==== The following tutorials provide step-by-step instructions for getting started with bare-bones app templates. Those tutorials are a better starting place for Codename One development than this manual, as they're written in tutorial form. diff --git a/docs/developer-guide/appendix_goal_generate_native_interfaces.adoc b/docs/developer-guide/appendix_goal_generate_native_interfaces.adoc index 84408f4d2b9..1e13874968d 100644 --- a/docs/developer-guide/appendix_goal_generate_native_interfaces.adoc +++ b/docs/developer-guide/appendix_goal_generate_native_interfaces.adoc @@ -27,18 +27,24 @@ include::../demos/common/src/main/snippets/developer-guide/appendix-goal-generat This will generate the following files (if they don't exist yet). Java SE:: -`Java SE/src/main/java/com/mycompany/myapp/MyNativeImpl.java` -ios:: +`javase/src/main/java/com/mycompany/myapp/MyNativeImpl.java` +iOS:: . `ios/src/main/objectivec/com_mycompany_myapp_MyNativeImpl.h` . `ios/src/main/objectivec/com_mycompany_myapp_MyNativeImpl.m` -android:: +Android:: `android/src/main/java/com/mycompany/myapp/MyNativeImpl.java` -android (optional Kotlin):: +Android (optional Kotlin):: `android/src/main/java/com/mycompany/myapp/MyNativeImpl.kt` JavaScript:: -`JavaScript/src/main/JavaScript/com_mycompany_myapp_MyNativeImpl.js` -ios (optional Swift):: +`javascript/src/main/javascript/com_mycompany_myapp_MyNative.js` +iOS (optional Swift):: `ios/src/main/objectivec/com_mycompany_myapp_MyNativeImpl.swift` +Native Windows:: +`win/src/main/c/com/mycompany/myapp/MyNativeImplCodenameOne.c` +Native Linux:: +`linux/src/main/c/com/mycompany/myapp/MyNativeImplCodenameOne.c` Open and edit these files to implement your native interface methods as desired. +A Gradle project runs the same generator as `./gradlew generateNativeInterfaces`, and writes the stubs under `src//` instead; see <>. + diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index c06c3097ef5..631206b4dd2 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -49,6 +49,8 @@ include::Introduction.asciidoc[] include::Maven-Project-Workflow.asciidoc[] +include::Gradle-Project-Workflow.asciidoc[] + = Core concepts include::The-EDT---Event-Dispatch-Thread.asciidoc[] diff --git a/maven/build-engine/pom.xml b/maven/build-engine/pom.xml new file mode 100644 index 00000000000..f7f2d647d1a --- /dev/null +++ b/maven/build-engine/pom.xml @@ -0,0 +1,363 @@ + + + 4.0.0 + + com.codenameone + codenameone + 8.0-SNAPSHOT + + codenameone-build-engine + Codename One Build Engine + The build-tool independent half of the Codename One build: the platform builders, cloud build submission, annotation processors, native-interface stubs and preflight checks. The Maven and Gradle plugins are adapters over it. + + + + + UTF-8 + 1.8 + 1.8 + + + + + + junit + junit + test + + + + org.xerial + sqlite-jdbc + 3.46.1.0 + test + + + + org.junit.vintage + junit-vintage-engine + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + ${project.groupId} + codenameone-project-model + ${project.version} + + + ${project.groupId} + codenameone-platform-feature-catalog + ${project.version} + + + ${project.groupId} + codenameone-build-hint-catalog + ${project.version} + + + ${project.groupId} + codenameone-build-hint-tools + ${project.version} + + + org.jdom + jdom2 + 2.0.6.1 + + + ${project.groupId} + codenameone-core + ${project.version} + + + ${project.groupId} + codenameone-css-cli + + + ${project.groupId} + codenameone-svg-transcoder + ${project.version} + + + ${project.groupId} + codenameone-lottie-transcoder + ${project.version} + + + org.apache.ant + ant + + + commons-io + commons-io + + + org.apache.commons + commons-text + + + com.guardsquare + proguard-base + + + com.codenameone + cn1-binaries-javase + pom + + + org.apache.commons + commons-vfs2 + + + org.apache.httpcomponents + httpclient + + + org.ow2.asm + asm + + + org.ow2.asm + asm-util + + + org.ow2.asm + asm-commons + + + javassist + javassist + + + net.lingala.zip4j + zip4j + 2.11.3 + + + + + com.codenameone + java-runtime + runtime + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-debug-proxy + ${project.version} + runtime + + + com.codenameone + cn1-builder-resources-common + runtime + + + com.codenameone + cn1-builder-resources-android + runtime + + + com.codenameone + codenameone-android + runtime + + + com.codenameone + codenameone-ios + bundle + runtime + + + com.codenameone + codenameone-parparvm + bundle + runtime + + + + com.codenameone + codenameone-windows + bundle + ${project.version} + runtime + + + com.codenameone + codenameone-mac + bundle + ${project.version} + runtime + + + com.codenameone + codenameone-linux + bundle + ${project.version} + runtime + + + + com.codenameone + cn1-hardening + ${project.version} + provided + true + + + * + * + + + + + + + com.codenameone + codenameone-backend + ${project.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + 3.2.5 + + + org.apache.maven.plugins + maven-dependency-plugin + + + + embed-hardening-engine + prepare-package + + copy + + + + + com.codenameone + cn1-hardening + ${project.version} + standalone + jar + ${project.build.outputDirectory} + cn1-hardening.jar + + + true + true + + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + add-updater-jar + generate-resources + + run + + + + + + + + + + + add-javascript-proxy-test-resources + generate-test-resources + + run + + + + + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.7.3.4 + + Max + Low + false + true + ${project.build.directory} + ${project.basedir}/spotbugs-exclude.xml + 1536 + + + + spotbugs + verify + + spotbugs + + + + + + + diff --git a/maven/build-engine/spotbugs-exclude.xml b/maven/build-engine/spotbugs-exclude.xml new file mode 100644 index 00000000000..cecbb6ccfac --- /dev/null +++ b/maven/build-engine/spotbugs-exclude.xml @@ -0,0 +1,284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/ant/AntExecutor.java b/maven/build-engine/src/main/java/com/codename1/ant/AntExecutor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/ant/AntExecutor.java rename to maven/build-engine/src/main/java/com/codename1/ant/AntExecutor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/ant/SortedProperties.java b/maven/build-engine/src/main/java/com/codename1/ant/SortedProperties.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/ant/SortedProperties.java rename to maven/build-engine/src/main/java/com/codename1/ant/SortedProperties.java diff --git a/maven/build-engine/src/main/java/com/codename1/build/AntSupport.java b/maven/build-engine/src/main/java/com/codename1/build/AntSupport.java new file mode 100644 index 00000000000..53befc3a6ef --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/build/AntSupport.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2012, 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.build; + +import org.apache.tools.ant.Project; +import org.apache.tools.ant.input.DefaultInputHandler; +import org.apache.tools.ant.input.InputHandler; +import org.apache.tools.ant.taskdefs.Java; +import org.apache.tools.ant.taskdefs.Redirector; + +import java.io.File; + +/// The Ant plumbing the engine runs its zip, unzip and forked-java tasks in. +public final class AntSupport { + /// Forked output goes to debug. + public static final int LEVEL_DEBUG = 0; + /// Forked output goes to info. + public static final int LEVEL_INFO = 1; + /// Forked output goes to warn. + public static final int LEVEL_WARN = 2; + /// Forked output goes to error. + public static final int LEVEL_ERROR = 3; + /// Forked output is dropped. + public static final int LEVEL_DISABLED = 4; + + private AntSupport() { + } + + /// A fresh, initialized Ant project rooted at `baseDir` (the working + /// directory when null). + public static Project newProject(File baseDir) { + Project p = new Project(); + p.setBaseDir(baseDir == null ? new File(".") : baseDir); + p.setDefaultInputStream(System.in); + InputHandler handler = new DefaultInputHandler(); + p.setProjectReference(handler); + p.setInputHandler(handler); + p.init(); + return p; + } + + /// A `java` task whose output is routed to `log` at `level`, and whose + /// error output always goes to error. + public static Java createJava(Project project, final Log log, final int level) { + Java java = new Java() { + { + redirector = new Redirector(this) { + @Override + protected void handleOutput(String output) { + write(log, level, output); + } + + @Override + protected void handleErrorOutput(String output) { + log.error(output); + } + }; + } + + @Override + protected void handleOutput(String output) { + write(log, level, output); + } + + @Override + protected void handleErrorOutput(String output) { + log.error(output); + } + + @Override + protected void handleFlush(String output) { + write(log, level, output); + } + + @Override + public void log(String msg) { + log.info(msg); + } + + @Override + public void log(String msg, int msgLevel) { + log.info(msg); + } + }; + java.setProject(project); + return java; + } + + private static void write(Log log, int level, String output) { + switch (level) { + case LEVEL_DEBUG: + log.debug(output); + break; + case LEVEL_DISABLED: + break; + case LEVEL_ERROR: + log.error(output); + break; + case LEVEL_WARN: + log.warn(output); + break; + default: + log.info(output); + break; + } + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/build/BuildArtifact.java b/maven/build-engine/src/main/java/com/codename1/build/BuildArtifact.java new file mode 100644 index 00000000000..27f61c9823f --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/build/BuildArtifact.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2012, 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.build; + +import java.io.File; +import java.util.Collections; +import java.util.List; + +/// One resolved dependency of the project being built, as a build tool +/// resolved it. The getters are named like Maven's `Artifact` so engine code +/// reads the same whichever tool supplied it. +public final class BuildArtifact { + private final String groupId; + private final String artifactId; + private final String version; + private final String classifier; + private final String type; + private final String scope; + private final File file; + private final List dependencyTrail; + + /// A resolved dependency. + /// + /// @param classifier null when there is none + /// @param scope `compile`, `runtime`, `provided`, ... or null when the build + /// tool has no notion of one + /// @param file the resolved file, or null when not resolved + /// @param dependencyTrail the chain of `groupId:artifactId:type:version` + /// ids that brought it in, root first, or null when unknown + public BuildArtifact(String groupId, String artifactId, String version, String classifier, + String type, String scope, File file, List dependencyTrail) { + this.groupId = groupId; + this.artifactId = artifactId; + this.version = version; + this.classifier = classifier == null || classifier.length() == 0 ? null : classifier; + this.type = type == null ? "jar" : type; + this.scope = scope; + this.file = file; + this.dependencyTrail = dependencyTrail == null ? null : Collections.unmodifiableList(dependencyTrail); + } + + /// The group id. + public String getGroupId() { + return groupId; + } + + /// The artifact id. + public String getArtifactId() { + return artifactId; + } + + /// The version. + public String getVersion() { + return version; + } + + /// The classifier, or null. + public String getClassifier() { + return classifier; + } + + /// The type, `jar` unless said otherwise. + public String getType() { + return type; + } + + /// The scope, or null. + public String getScope() { + return scope; + } + + /// The resolved file, or null. + public File getFile() { + return file; + } + + /// The dependency trail, or null. + public List getDependencyTrail() { + return dependencyTrail; + } + + /// `groupId:artifactId:type:version`, the form trail entries use. + public String getId() { + return groupId + ":" + artifactId + ":" + type + ":" + version; + } + + @Override + public String toString() { + return groupId + ":" + artifactId + (classifier == null ? "" : ":" + classifier) + ":" + version; + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/build/BuildExecutionException.java b/maven/build-engine/src/main/java/com/codename1/build/BuildExecutionException.java new file mode 100644 index 00000000000..24074d92012 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/build/BuildExecutionException.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012, 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.build; + +/// A build step could not run: the engine's equivalent of Maven's +/// `MojoExecutionException`, and what the Gradle plugin reports as a task +/// failure. A problem with the PROJECT rather than the environment is the +/// narrower [BuildFailureException]. +public class BuildExecutionException extends Exception { + private static final long serialVersionUID = 1L; + + /// A failure described by `message`. + public BuildExecutionException(String message) { + super(message); + } + + /// A failure described by `message`, caused by `cause`. + public BuildExecutionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/build/BuildFailureException.java b/maven/build-engine/src/main/java/com/codename1/build/BuildFailureException.java new file mode 100644 index 00000000000..012048deed3 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/build/BuildFailureException.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2012, 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.build; + +/// The project itself is wrong -- a hint that cannot be honoured, a stale +/// class, a signing profile that cannot sign -- rather than the build being +/// unable to run. The engine's equivalent of Maven's `MojoFailureException`. +public class BuildFailureException extends BuildExecutionException { + private static final long serialVersionUID = 1L; + + /// A failure described by `message`. + public BuildFailureException(String message) { + super(message); + } + + /// A failure described by `message`, caused by `cause`. + public BuildFailureException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/build/CodenameOneUpdater.java b/maven/build-engine/src/main/java/com/codename1/build/CodenameOneUpdater.java new file mode 100644 index 00000000000..fa877adcdeb --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/build/CodenameOneUpdater.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2012, 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.build; + +import org.apache.tools.ant.Project; +import org.apache.tools.ant.taskdefs.Java; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; + +/// Installs and runs `UpdateCodenameOne.jar`, which keeps the build client +/// (`~/.codenameone/CodeNameOneBuildClient.jar`) and its companions current. +/// +/// A cloud build cannot be sent without the build client, so both build tools +/// run this before submitting. The updater is bundled in the engine jar; only +/// when that resource is missing is it downloaded. +public final class CodenameOneUpdater { + /// Where the updater is published. + public static final String UPDATE_CODENAMEONE_JAR_URL = "https://www.codenameone.com/files/updates/UpdateCodenameOne.jar"; + /// The fallback copy in the source repository. + public static final String UPDATE_CODENAMEONE_JAR_FALLBACK_URL = "https://github.com/codenameone/CodenameOne/raw/refs/heads/master/maven/UpdateCodenameOne.jar"; + /// The bundled copy. + public static final String UPDATE_CODENAMEONE_JAR_RESOURCE = "/com/codename1/maven/UpdateCodenameOne.jar"; + + private final Log log; + private final Project antProject; + + /// An updater that logs to `log` and forks through `antProject`. + public CodenameOneUpdater(Log log, Project antProject) { + this.log = log; + this.antProject = antProject; + } + + /// `~/.codenameone`, where the build client lives. + public static File codenameOneHome() { + return new File(System.getProperty("user.home"), ".codenameone"); + } + + /// The installed updater jar. + public static File updaterJar() { + return new File(codenameOneHome(), "UpdateCodenameOne.jar"); + } + + /// The build client jar the cloud submission runs. + public static File buildClientJar() { + return new File(codenameOneHome(), "CodeNameOneBuildClient.jar"); + } + + /// Puts the updater in place if it is not there yet. + public void installUpdater() throws IOException { + File re = updaterJar(); + if (re.exists()) { + log.debug("Designer is up to date"); + return; + } + File parent = re.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs() && !parent.isDirectory()) { + throw new IOException("Could not create " + parent); + } + InputStream bundled = CodenameOneUpdater.class.getResourceAsStream(UPDATE_CODENAMEONE_JAR_RESOURCE); + if (bundled != null) { + try { + log.info("Installing Codename One Updater from bundled plugin resource"); + copyToFile(bundled, re); + return; + } finally { + bundled.close(); + } + } + IOException lastFailure = null; + for (String url : new String[] {UPDATE_CODENAMEONE_JAR_URL, UPDATE_CODENAMEONE_JAR_FALLBACK_URL}) { + log.info("Installing Codename One Updater from " + url); + try { + InputStream is = new URL(url).openStream(); + try { + copyToFile(is, re); + } finally { + is.close(); + } + return; + } catch (IOException ex) { + lastFailure = ex; + log.warn("Failed to download Codename One Updater from " + url + ": " + ex.getMessage()); + } + } + throw lastFailure != null ? lastFailure : new IOException("Failed to install Codename One updater"); + } + + /// Runs the updater. + /// + /// @param force run it even when every one of `files` already exists + /// @param workDir a scratch directory under the build output + /// @param cn1ProjectDir the directory holding `codenameone_settings.properties` + public void update(boolean force, File workDir, File cn1ProjectDir, File... files) throws BuildExecutionException { + try { + installUpdater(); + } catch (Exception ex) { + log.error("Failed to install Codename One updater"); + throw new BuildExecutionException("Failed to install codenameone updater", ex); + } + if (!force) { + boolean missing = false; + for (File f : files) { + if (!f.exists()) { + missing = true; + break; + } + } + if (!missing) { + return; + } + } + Java java = AntSupport.createJava(antProject, log, AntSupport.LEVEL_DEBUG); + java.setFork(true); + java.setJar(updaterJar()); + File dummyProject = new File(workDir, "update-dummy"); + File dummyProjectLib = new File(dummyProject, "lib"); + if (!dummyProjectLib.isDirectory() && !dummyProjectLib.mkdirs()) { + log.warn("Could not create " + dummyProjectLib + "; the update may fail"); + } + File cn1Properties = new File(cn1ProjectDir, "codenameone_settings.properties"); + if (cn1Properties.exists()) { + try { + copyFile(cn1Properties, new File(dummyProject, cn1Properties.getName())); + } catch (IOException ex) { + log.warn("Failed to copy " + cn1Properties + " into dummy project", ex); + } + } + java.createArg().setFile(dummyProject); + java.createArg().setValue("force"); + java.executeJava(); + } + + private static void copyFile(File src, File dest) throws IOException { + InputStream in = new FileInputStream(src); + try { + copyToFile(in, dest); + } finally { + in.close(); + } + } + + private static void copyToFile(InputStream is, File dest) throws IOException { + OutputStream os = new FileOutputStream(dest); + try { + byte[] buf = new byte[65536]; + int len; + while ((len = is.read(buf)) > -1) { + os.write(buf, 0, len); + } + } finally { + os.close(); + } + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/build/Log.java b/maven/build-engine/src/main/java/com/codename1/build/Log.java new file mode 100644 index 00000000000..169991412c1 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/build/Log.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2012, 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.build; + +/// The logger the build engine writes to. +/// +/// It has exactly the methods of Maven's `org.apache.maven.plugin.logging.Log`, +/// so the engine code that used to take Maven's type reads the same, and each +/// build tool adapts its own logger to it: the Maven plugin wraps +/// `getLog()`, the Gradle plugin wraps the task logger. +public interface Log { + /// Whether debug output is wanted; callers skip building expensive debug + /// messages when it is not. + boolean isDebugEnabled(); + + /// Logs a debug message. + void debug(CharSequence content); + + /// Logs a debug message with a cause. + void debug(CharSequence content, Throwable error); + + /// Logs a debug cause. + void debug(Throwable error); + + /// Whether info output is wanted. + boolean isInfoEnabled(); + + /// Logs an info message. + void info(CharSequence content); + + /// Logs an info message with a cause. + void info(CharSequence content, Throwable error); + + /// Logs an info cause. + void info(Throwable error); + + /// Whether warnings are wanted. + boolean isWarnEnabled(); + + /// Logs a warning. + void warn(CharSequence content); + + /// Logs a warning with a cause. + void warn(CharSequence content, Throwable error); + + /// Logs a warning cause. + void warn(Throwable error); + + /// Whether errors are wanted. + boolean isErrorEnabled(); + + /// Logs an error. + void error(CharSequence content); + + /// Logs an error with a cause. + void error(CharSequence content, Throwable error); + + /// Logs an error cause. + void error(Throwable error); +} diff --git a/maven/build-engine/src/main/java/com/codename1/build/ProjectHost.java b/maven/build-engine/src/main/java/com/codename1/build/ProjectHost.java new file mode 100644 index 00000000000..de577b22fb0 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/build/ProjectHost.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2012, 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.build; + +import com.codename1.project.ProjectLayout; + +import java.io.File; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Properties; +import java.util.Set; + +/// What the build engine needs to know about the project being built, answered +/// by the build tool running it. +/// +/// The Maven plugin answers from its `MavenProject` and session, the Gradle +/// plugin from its project model and resolved configurations. Everything +/// behind this interface is the build tool's business; everything the engine +/// decides from these answers is shared, so Maven and Gradle builds of the +/// same application make the same decisions. +public interface ProjectHost { + /// The logger. + Log log(); + + /// The project's layout. + ProjectLayout layout(); + + /// The directory holding `codenameone_settings.properties`. + File cn1ProjectDir(); + + /// The module or project directory the build runs in. + File baseDir(); + + /// The build output directory (`target` under Maven, `build` under Gradle). + File buildDirectory(); + + /// Where the project's compiled classes are. + File outputDirectory(); + + /// The base name outputs are written under. + String finalName(); + + /// The project's group id. A dependency that shares it is part of the + /// application rather than a library. + String groupId(); + + /// Project-level properties (Maven's `project.getProperties()`; Gradle's + /// `codename1.*` project properties). + Properties projectProperties(); + + /// The properties given on the command line (`-D` for Maven, `-P` or + /// `-D` for Gradle). `codename1.*` entries override the settings file. + Properties userProperties(); + + /// The compile classpath, the project's own output first. + List compileClasspathElements() throws BuildExecutionException; + + /// The runtime classpath, the project's own output first. + List runtimeClasspathElements() throws BuildExecutionException; + + /// The source roots the project compiles. + List compileSourceRoots(); + + /// Every resolved dependency. + Collection artifacts(); + + /// The dependency keys (`groupId:artifactId:classifier`) the application's + /// compile dependencies need once the desktop runtime aggregator is + /// removed, or null when that cannot be determined. + Set neededWithoutDesktopRuntime(); + + /// The file of a resolved dependency, resolving it if the tool must. + File getJar(BuildArtifact artifact); + + /// The jar of `groupId:artifactId[:classifier]` among the project's + /// dependencies or the build tool's own, or null. + File getJar(String groupId, String artifactId, String classifier); + + /// When the current build invocation started, in epoch milliseconds, or + /// `Long.MAX_VALUE` when unknown. + long sessionStartTime(); + + /// The newest modification time among the project's sources. + long sourcesModificationTime() throws IOException; + + /// Records a build output beside the project's primary artifact. + /// + /// @param classifier null for none + void attachArtifact(String type, String classifier, File file); + + /// The Codename One version being built against. + String codenameOneVersion(); + + /// The build plugin's own version. + String pluginVersion(); +} diff --git a/maven/build-engine/src/main/java/com/codename1/build/SystemStreamLog.java b/maven/build-engine/src/main/java/com/codename1/build/SystemStreamLog.java new file mode 100644 index 00000000000..4ceb524089c --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/build/SystemStreamLog.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2012, 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.build; + +/// A [Log] that prints to standard output and standard error, for tests and +/// command-line use. Debug output is off. +public class SystemStreamLog implements Log { + private static void print(java.io.PrintStream out, String level, CharSequence content, Throwable error) { + out.println("[" + level + "] " + (content == null ? "" : content)); + if (error != null) { + error.printStackTrace(out); + } + } + + @Override + public boolean isDebugEnabled() { + return false; + } + + @Override + public void debug(CharSequence content) { + debug(content, null); + } + + @Override + public void debug(CharSequence content, Throwable error) { + if (isDebugEnabled()) { + print(System.out, "debug", content, error); + } + } + + @Override + public void debug(Throwable error) { + debug(null, error); + } + + @Override + public boolean isInfoEnabled() { + return true; + } + + @Override + public void info(CharSequence content) { + info(content, null); + } + + @Override + public void info(CharSequence content, Throwable error) { + print(System.out, "info", content, error); + } + + @Override + public void info(Throwable error) { + info(null, error); + } + + @Override + public boolean isWarnEnabled() { + return true; + } + + @Override + public void warn(CharSequence content) { + warn(content, null); + } + + @Override + public void warn(CharSequence content, Throwable error) { + print(System.out, "warning", content, error); + } + + @Override + public void warn(Throwable error) { + warn(null, error); + } + + @Override + public boolean isErrorEnabled() { + return true; + } + + @Override + public void error(CharSequence content) { + error(content, null); + } + + @Override + public void error(CharSequence content, Throwable error) { + print(System.err, "error", content, error); + } + + @Override + public void error(Throwable error) { + error(null, error); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java rename to maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AppleSdkFloor.java b/maven/build-engine/src/main/java/com/codename1/builders/AppleSdkFloor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AppleSdkFloor.java rename to maven/build-engine/src/main/java/com/codename1/builders/AppleSdkFloor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Base64.java b/maven/build-engine/src/main/java/com/codename1/builders/Base64.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Base64.java rename to maven/build-engine/src/main/java/com/codename1/builders/Base64.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/BluetoothManifestFragments.java b/maven/build-engine/src/main/java/com/codename1/builders/BluetoothManifestFragments.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/BluetoothManifestFragments.java rename to maven/build-engine/src/main/java/com/codename1/builders/BluetoothManifestFragments.java diff --git a/maven/build-engine/src/main/java/com/codename1/builders/BuildException.java b/maven/build-engine/src/main/java/com/codename1/builders/BuildException.java new file mode 100644 index 00000000000..25c461f937c --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/builders/BuildException.java @@ -0,0 +1,33 @@ +/* + * 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; + +public class BuildException extends Exception { + public BuildException(String message) { + super(message); + } + + public BuildException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/BuildRequest.java b/maven/build-engine/src/main/java/com/codename1/builders/BuildRequest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/BuildRequest.java rename to maven/build-engine/src/main/java/com/codename1/builders/BuildRequest.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/CallManifestFragments.java b/maven/build-engine/src/main/java/com/codename1/builders/CallManifestFragments.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/CallManifestFragments.java rename to maven/build-engine/src/main/java/com/codename1/builders/CallManifestFragments.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/ContactsPermissionScan.java b/maven/build-engine/src/main/java/com/codename1/builders/ContactsPermissionScan.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/ContactsPermissionScan.java rename to maven/build-engine/src/main/java/com/codename1/builders/ContactsPermissionScan.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/build-engine/src/main/java/com/codename1/builders/Executor.java similarity index 99% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java rename to maven/build-engine/src/main/java/com/codename1/builders/Executor.java index 9c5c397dd9a..649fd453d7f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/Executor.java @@ -61,7 +61,7 @@ import java.util.zip.ZipOutputStream; import javax.imageio.ImageIO; -import org.apache.maven.plugin.logging.Log; +import com.codename1.build.Log; import org.objectweb.asm.*; import org.objectweb.asm.commons.ClassRemapper; import org.objectweb.asm.commons.SimpleRemapper; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java b/maven/build-engine/src/main/java/com/codename1/builders/HealthListenerBindings.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java rename to maven/build-engine/src/main/java/com/codename1/builders/HealthListenerBindings.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerScan.java b/maven/build-engine/src/main/java/com/codename1/builders/HealthListenerScan.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerScan.java rename to maven/build-engine/src/main/java/com/codename1/builders/HealthListenerScan.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthManifestFragments.java b/maven/build-engine/src/main/java/com/codename1/builders/HealthManifestFragments.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthManifestFragments.java rename to maven/build-engine/src/main/java/com/codename1/builders/HealthManifestFragments.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IOSDependencyManager.java b/maven/build-engine/src/main/java/com/codename1/builders/IOSDependencyManager.java similarity index 86% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IOSDependencyManager.java rename to maven/build-engine/src/main/java/com/codename1/builders/IOSDependencyManager.java index ecf625b054d..eb2662af0f2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IOSDependencyManager.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/IOSDependencyManager.java @@ -1,3 +1,25 @@ +/* + * 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 java.util.ArrayList; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java rename to maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteBuildHints.java b/maven/build-engine/src/main/java/com/codename1/builders/InviteBuildHints.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteBuildHints.java rename to maven/build-engine/src/main/java/com/codename1/builders/InviteBuildHints.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java b/maven/build-engine/src/main/java/com/codename1/builders/InviteManifestFragments.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java rename to maven/build-engine/src/main/java/com/codename1/builders/InviteManifestFragments.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuildHints.java b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuildHints.java rename to maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java rename to maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptProxyPackager.java b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptProxyPackager.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptProxyPackager.java rename to maven/build-engine/src/main/java/com/codename1/builders/JavaScriptProxyPackager.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/build-engine/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java rename to maven/build-engine/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LibraryClassPrefixScan.java b/maven/build-engine/src/main/java/com/codename1/builders/LibraryClassPrefixScan.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LibraryClassPrefixScan.java rename to maven/build-engine/src/main/java/com/codename1/builders/LibraryClassPrefixScan.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java rename to maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/MacNativeBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java rename to maven/build-engine/src/main/java/com/codename1/builders/MacNativeBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacOSBuildHints.java b/maven/build-engine/src/main/java/com/codename1/builders/MacOSBuildHints.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacOSBuildHints.java rename to maven/build-engine/src/main/java/com/codename1/builders/MacOSBuildHints.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacOSNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacOSNativeBuilder.java rename to maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacOSXcodeProject.java b/maven/build-engine/src/main/java/com/codename1/builders/MacOSXcodeProject.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacOSXcodeProject.java rename to maven/build-engine/src/main/java/com/codename1/builders/MacOSXcodeProject.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/ManifestServiceContract.java b/maven/build-engine/src/main/java/com/codename1/builders/ManifestServiceContract.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/ManifestServiceContract.java rename to maven/build-engine/src/main/java/com/codename1/builders/ManifestServiceContract.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MapsProviderInjector.java b/maven/build-engine/src/main/java/com/codename1/builders/MapsProviderInjector.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MapsProviderInjector.java rename to maven/build-engine/src/main/java/com/codename1/builders/MapsProviderInjector.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NativeVerifyOption.java b/maven/build-engine/src/main/java/com/codename1/builders/NativeVerifyOption.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NativeVerifyOption.java rename to maven/build-engine/src/main/java/com/codename1/builders/NativeVerifyOption.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java b/maven/build-engine/src/main/java/com/codename1/builders/NearbyManifestFragments.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NearbyManifestFragments.java rename to maven/build-engine/src/main/java/com/codename1/builders/NearbyManifestFragments.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/PlayBillingVersions.java b/maven/build-engine/src/main/java/com/codename1/builders/PlayBillingVersions.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/PlayBillingVersions.java rename to maven/build-engine/src/main/java/com/codename1/builders/PlayBillingVersions.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/SmartHomeInjector.java b/maven/build-engine/src/main/java/com/codename1/builders/SmartHomeInjector.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/SmartHomeInjector.java rename to maven/build-engine/src/main/java/com/codename1/builders/SmartHomeInjector.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/SmartHomeManifestFragments.java b/maven/build-engine/src/main/java/com/codename1/builders/SmartHomeManifestFragments.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/SmartHomeManifestFragments.java rename to maven/build-engine/src/main/java/com/codename1/builders/SmartHomeManifestFragments.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TranslatorHeap.java b/maven/build-engine/src/main/java/com/codename1/builders/TranslatorHeap.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TranslatorHeap.java rename to maven/build-engine/src/main/java/com/codename1/builders/TranslatorHeap.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/TvNativeBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java rename to maven/build-engine/src/main/java/com/codename1/builders/TvNativeBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/VpnManifestFragments.java b/maven/build-engine/src/main/java/com/codename1/builders/VpnManifestFragments.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/VpnManifestFragments.java rename to maven/build-engine/src/main/java/com/codename1/builders/VpnManifestFragments.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/VpnTunnelNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/VpnTunnelNativeBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/VpnTunnelNativeBuilder.java rename to maven/build-engine/src/main/java/com/codename1/builders/VpnTunnelNativeBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/WatchNativeBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java rename to maven/build-engine/src/main/java/com/codename1/builders/WatchNativeBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/WindowsNativeBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java rename to maven/build-engine/src/main/java/com/codename1/builders/WindowsNativeBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/IOProgressListener.java b/maven/build-engine/src/main/java/com/codename1/builders/util/IOProgressListener.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/IOProgressListener.java rename to maven/build-engine/src/main/java/com/codename1/builders/util/IOProgressListener.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/JSONParseCallback.java b/maven/build-engine/src/main/java/com/codename1/builders/util/JSONParseCallback.java similarity index 60% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/JSONParseCallback.java rename to maven/build-engine/src/main/java/com/codename1/builders/util/JSONParseCallback.java index 771e97a53d6..db16e54533d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/JSONParseCallback.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/util/JSONParseCallback.java @@ -1,7 +1,24 @@ /* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. + * 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.util; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/JSONParser.java b/maven/build-engine/src/main/java/com/codename1/builders/util/JSONParser.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/JSONParser.java rename to maven/build-engine/src/main/java/com/codename1/builders/util/JSONParser.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/JSONSanitizer.java b/maven/build-engine/src/main/java/com/codename1/builders/util/JSONSanitizer.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/JSONSanitizer.java rename to maven/build-engine/src/main/java/com/codename1/builders/util/JSONSanitizer.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/Util.java b/maven/build-engine/src/main/java/com/codename1/builders/util/Util.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/util/Util.java rename to maven/build-engine/src/main/java/com/codename1/builders/util/Util.java diff --git a/maven/build-engine/src/main/java/com/codename1/maven/AnnotationBuildHints.java b/maven/build-engine/src/main/java/com/codename1/maven/AnnotationBuildHints.java new file mode 100644 index 00000000000..92b7fee08c8 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/AnnotationBuildHints.java @@ -0,0 +1,758 @@ +/* + * Copyright (c) 2012, 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.maven; + +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/// Overlays the build hints declared as annotations on the main class onto the +/// settings a build is assembled from, and refuses the builds where those +/// annotations cannot have reached it. +/// +/// Moved out of the Maven plugin's `AbstractCN1Mojo` so the Gradle plugin makes +/// the same decisions. The only thing the build tool has to answer is +/// [Sources]: which source roots produced a classpath element. +public final class AnnotationBuildHints { + /// Where the classes in a classpath element were compiled from. + /// + /// In a Maven application the classes come from `common` while a platform + /// module runs the build, so the running module's own roots answer for the + /// wrong module; the build tool maps the element back to the module that + /// produced it. A single-project build (Gradle) has one answer for all. + public interface Sources { + /// Every source root the producing module compiles, or empty when unknown. + List rootsFor(File element); + + /// The source encoding the producing module compiles with, or null. + String encodingFor(File element); + } + + private final Log log; + private final Properties settings; + private final Properties userProperties; + private final Sources sources; + + /// @param settings the project's `codenameone_settings.properties`, for the + /// main class the hints must be stamped with; may be null + /// @param userProperties the command-line properties, which win over an + /// annotation; may be null + public AnnotationBuildHints(Log log, Properties settings, Properties userProperties, Sources sources) { + this.log = log; + this.settings = settings; + this.userProperties = userProperties; + this.sources = sources; + } + + /** + * Name of the resource {@code BuildHintAnnotationProcessor} emits into + * {@code target/classes} at PROCESS_CLASSES. + */ + private static final String ANNOTATION_HINTS_RESOURCE = + "META-INF/codenameone/build-hints.properties"; + + /** + * Overlays the build hints that came from annotations onto the settings the + * build request is assembled from. + * + *

Read from the compile classpath rather than from the staged fat jar. + * {@code mergeJars} builds that jar with Ant's {@code Zip} in update mode, + * which adds and overwrites entries but never removes one, and the jar is + * reused when it is not stale — so a project that had its annotations + * deleted would keep shipping yesterday's hints. The classpath directory is + * written by the annotation processor on every build and deleted by it when + * the last annotation goes away, so it always reflects the current source.

+ */ + public void merge(Properties target, List classpathElements) + throws BuildFailureException { + if (target == null || classpathElements == null) { + return; + } + String expectedMain = null; + if (settings != null) { + String main = settings.getProperty("codename1.mainName"); + String pkg = settings.getProperty("codename1.packageName"); + if (main != null && main.trim().length() > 0) { + expectedMain = (pkg == null || pkg.trim().length() == 0) + ? main.trim() : pkg.trim() + "." + main.trim(); + } + } + // The manifest's presence, not its contents, is what proves the processor + // ran. An annotation with every member left at its default -- @Ios() after + // the last attribute was deleted -- is legal Java, and the processor emits + // a manifest carrying only the main-class stamp for it. Judging by the hint + // count alone would read that as "never processed" and refuse a build that + // is in fact perfectly configured. + String stale = null; + // The processor writes the manifest into the SAME directory it scanned, + // so a manifest stamped for a main class was written beside that class. + // An element carrying the stamp without the class is therefore left over + // -- the shape is moving the application class from a platform module + // into common without cleaning -- and project output comes before + // dependencies on the classpath, so the leftover was found first, applied + // its old values and returned before the real one was ever read. + // + // Ordered rather than refused. Refusing assumes the two can never be + // packaged apart, and an element that assembles resources from another + // module would then lose its hints entirely; putting the colocated ones + // first fixes the stale-wins bug and still falls back to a manifest that + // is all there is. + for (String element : colocatedFirst(classpathElements, expectedMain)) { + Properties found = readAnnotationHints(new File(element)); + if (found == null) { + continue; + } + String stamped = found.getProperty("cn1.buildHints.mainClass"); + if (expectedMain != null && !expectedMain.equals(stamped)) { + // A cn1lib that bound the goal itself, a stale artifact, or a + // file a project keeps in src/main/resources -- anything on the + // classpath can carry this name. It has to say it was generated + // for THIS main class: an unstamped one used to be accepted, and + // then it both applied somebody else's hints and counted as + // proof that the processor ran, which is what suppresses the + // refusal below when the goal is not bound at all. + log.debug("cn1: ignoring build hints from " + element + + " -- they were generated for " + stamped); + continue; + } + if (found.getProperty(com.codename1.maven.processors.BuildHintAnnotationProcessor + .SOURCE_DIGEST_KEY) == null) { + // The processor always records one. A manifest without it was + // written by something else. + log.debug("cn1: ignoring build hints from " + element + + " -- no processor fingerprint"); + continue; + } + // The stamp says which class produced this file, not when. Nothing + // clears target/classes between builds, so a project that ran the + // processor once and then stopped -- goal unbound, skipped, or bound + // to a phase that no longer runs -- keeps a manifest naming the right + // class while the annotations beside it have changed. Comparing the + // recorded fingerprint against the compiled class is what tells those + // apart; without it the build silently ships the older values and the + // guard below never runs. + String mismatch = digestMismatch(new File(element), expectedMain, found, classpathElements); + if (mismatch != null) { + log.debug("cn1: ignoring build hints from " + element + " -- " + mismatch); + stale = mismatch; + continue; + } + int applied = 0; + for (String key : found.stringPropertyNames()) { + if (!key.startsWith("codename1.arg.")) { + continue; + } + // The processor refuses a hint declared as an annotation and as a + // properties line, but it can only refuse the builds it runs in. + // The fingerprint above covers the annotations and nothing else, + // so with processing skipped a line added to the properties file + // afterwards leaves a manifest that still matches -- and this + // overlay would quietly replace the value the developer just + // wrote, until the next clean build regenerated the manifest and + // failed. Same declaration, same answer, whether or not + // target/classes happened to be cleaned. + String conflict = conflictingPropertiesDeclaration(target, key, found); + if (conflict != null) { + throw new BuildFailureException(conflict); + } + if (overriddenOnTheCommandLine(key)) { + // -D wins, and it wins whichever SPELLING it used. The + // overlay below only replaces the same key, so + // -Dcodename1.arg.cn1.androidTheme against an annotated + // and.themeMode left both set -- and the two readers then + // disagreed with each other: JavaSEPort takes the canonical + // and falls back to the alias, so the annotation won in the + // simulator, while AndroidGradleBuilder writes both and the + // alias landed last, so -D won on the device. Same command + // line, opposite results. + log.debug("cn1: " + key + " comes from the command line, " + + "so the annotation value is not applied"); + continue; + } + target.setProperty(key, found.getProperty(key)); + applied++; + } + // The FIRST accepted manifest is the answer, whether or not it set + // anything. Continuing when it happened to apply nothing let a later + // element decide instead: `@Ios()` with no members -- legal Java, and + // what is left after the last attribute is deleted -- produces a + // current manifest with no hint keys, and an older copy of the same + // main class further down the classpath carries a manifest that + // passes its own digest check, because it is fingerprinted against + // the stale class sitting beside it. Its obsolete hints were then + // applied over the authoritative empty one. The same is true of a + // manifest whose every key is overridden on the command line. + if (applied > 0) { + log.info("cn1: applied " + applied + " build hint(s) from annotations"); + } else { + log.debug("cn1: annotations were processed and set no build hint"); + } + failOnMisplacedAnnotations(classpathElements, expectedMain); + return; + } + // No manifest at all. If the compiled classes carry build hint + // annotations anyway, the processor never ran -- a mojo's defaultPhase + // does not add an execution to a project's POM, so an app that adopts the + // annotations without binding process-annotations compiles cleanly and + // ships with every annotated hint missing. Refuse rather than build that. + String annotated = classCarryingBuildHintAnnotations(classpathElements, expectedMain); + if (annotated != null) { + throw new BuildFailureException(annotated + " carries build hint annotations, but " + + (stale == null + ? "no " + ANNOTATION_HINTS_RESOURCE + " was produced" + : "the only " + ANNOTATION_HINTS_RESOURCE + " on the classpath is left " + + "over from an earlier build (" + stale + ")") + + ", so none of them reached this " + + "build.\n\nThe cn1 process-annotations goal has to run on the module that " + + "compiles it:\n" + + " \n" + + " cn1-process-classes\n" + + " process-classes\n" + + " \n" + + " process-annotations\n" + + " \n" + + " "); + } + } + + /** + * Refuses a build where a class other than the main one carries build hint + * annotations. + * + *

Run even when a manifest was accepted, because accepting one does not + * mean the processor ran THIS build: the fingerprint covers the main class, + * so an annotation added to a live class beside it leaves the manifest + * looking entirely current. With the goal unbound or skipped, the hints from + * that class reach nothing and the build succeeds having neither applied + * them nor said the annotation is in the wrong place -- the silent failure + * this whole feature exists to remove. Had the processor run, it would have + * refused the build for the same class.

+ */ + private void failOnMisplacedAnnotations(List classpathElements, String expectedMain) + throws BuildFailureException { + if (expectedMain == null) { + return; + } + java.util.Collection descriptors = hintAnnotationDescriptors(classpathElements); + for (String element : classpathElements) { + String live = liveAnnotatedClass(new File(element), descriptors, expectedMain); + if (live != null) { + throw new BuildFailureException(live + " carries build hint annotations, but they " + + "are only read from the application's main class (" + expectedMain + + "), so none of them reached this build.\n\nMove them onto " + + expectedMain + ", or set those hints in " + + "codenameone_settings.properties."); + } + } + } + + /** + * The duplicate-declaration message for a hint set by an annotation and by a + * properties line, or null when there is no clash. + * + *

Only reached when the processor did not run this build; when it did, it + * has already failed for the same reason and with more to say -- it can point + * at the offending line. An alias counts as the same setting, matching what + * the processor checks, so declaring {@code and.captureRecord} in the file + * still collides with {@code @Android(captureRecord)}.

+ */ + private String conflictingPropertiesDeclaration(Properties settings, String key, + Properties manifest) { + String name = key.substring("codename1.arg.".length()); + java.util.Set names = new java.util.LinkedHashSet(); + names.add(name); + for (com.codename1.build.shared.BuildHints.Hint h + : com.codename1.build.shared.BuildHints.entries()) { + if (name.equals(h.aliasOf()) + || name.equals(com.codename1.build.shared.BuildHints.canonicalName(h.name()))) { + names.add(h.name()); + } + } + for (String candidate : names) { + String candidateKey = "codename1.arg." + candidate; + String fromFile = settings.getProperty(candidateKey); + if (fromFile == null) { + continue; + } + String origin = manifest.getProperty("cn1.buildHints.origin." + name); + return candidateKey + " is declared twice.\n" + + " annotation : " + (origin == null ? "on the main class" : origin) + + " = " + manifest.getProperty(key) + "\n" + + " properties : codenameone_settings.properties\n" + + " " + candidateKey + "=" + fromFile + "\n" + + " A build hint has one source of truth. Delete the properties line and " + + "keep the annotation, or delete the annotation attribute and keep the line. " + + "(-D" + candidateKey + "=... overrides either and is not a conflict.)"; + } + return null; + } + + /** + * {@code classpathElements}, with those containing {@code expectedMain}'s + * class file first and the relative order otherwise preserved. + * + * @param classpathElements the compile classpath, in Maven's order + * @param expectedMain the binary name of the app's main class, or null + * @return the elements to search, colocated ones first + */ + private List colocatedFirst(List classpathElements, String expectedMain) { + if (expectedMain == null) { + return classpathElements; + } + List beside = new ArrayList(); + List rest = new ArrayList(); + for (String element : classpathElements) { + boolean here; + try { + here = readClass(new File(element), expectedMain) != null; + } catch (IOException | com.codename1.maven.annotations.ProcessingException ex) { + // Unreadable is not evidence either way; leave it where it was. + log.debug("cn1: could not look for " + expectedMain + " in " + element, ex); + here = false; + } + (here ? beside : rest).add(element); + } + beside.addAll(rest); + return beside; + } + + /** + * Why a manifest cannot have come from the class beside it, or null when it can. + * + *

Answered only when both halves are actually available: with no + * {@code codename1.mainName}, no class file for it ANYWHERE on the classpath, + * or no recorded fingerprint, there is nothing to compare and the manifest is + * taken at face value -- the same as before this check existed. It refuses + * only on positive evidence of a mismatch.

+ */ + private String digestMismatch(File element, String expectedMain, Properties manifest, + List classpathElements) { + String recorded = manifest.getProperty( + com.codename1.maven.processors.BuildHintAnnotationProcessor.SOURCE_DIGEST_KEY); + if (expectedMain == null || recorded == null || recorded.length() == 0) { + return null; + } + // Beside the manifest first, then anywhere on the classpath. Looking only + // beside it answers "no evidence" for the layout that packages resources + // apart from classes, and a manifest with no evidence against it is taken + // at face value -- so a manifest left by a build that no longer runs the + // processor applied its obsolete hints, and counted as proof the processor + // ran, while the recompiled class sat in another element. + com.codename1.maven.annotations.AnnotatedClass cls = classOnClasspath(element, expectedMain); + if (cls == null) { + for (String other : classpathElements) { + cls = classOnClasspath(new File(other), expectedMain); + if (cls != null) { + break; + } + } + } + if (cls == null) { + return null; + } + try { + String actual = com.codename1.maven.processors.BuildHintAnnotationProcessor + .sourceDigest(cls); + if (recorded.equals(actual)) { + return null; + } + return "it was generated from a different set of annotations on " + + expectedMain + " than the one compiled onto the classpath"; + } catch (com.codename1.maven.annotations.ProcessingException ex) { + // Unreadable is not evidence of staleness. + log.debug("cn1: could not fingerprint " + expectedMain, ex); + return null; + } + } + + /** {@code expectedMain} read out of {@code element}, or null when it is not there. */ + private com.codename1.maven.annotations.AnnotatedClass classOnClasspath(File element, + String expectedMain) { + try { + return readClass(element, expectedMain); + } catch (IOException | com.codename1.maven.annotations.ProcessingException ex) { + log.debug("cn1: could not look for " + expectedMain + " in " + element, ex); + return null; + } + } + + /** Reads one compiled class out of a classpath directory or jar. */ + private com.codename1.maven.annotations.AnnotatedClass readClass(File element, String binaryName) + throws IOException, com.codename1.maven.annotations.ProcessingException { + String path = binaryName.replace('.', '/') + ".class"; + if (element.isDirectory()) { + File f = new File(element, path.replace('/', File.separatorChar)); + if (!f.isFile()) { + return null; + } + try (InputStream in = new FileInputStream(f)) { + return com.codename1.maven.annotations.ClassScanner.readClass(in, f); + } + } + if (element.isFile() && element.getName().endsWith(".jar")) { + try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(element)) { + java.util.zip.ZipEntry entry = zip.getEntry(path); + if (entry == null) { + return null; + } + try (InputStream in = zip.getInputStream(entry)) { + return com.codename1.maven.annotations.ClassScanner.readClass(in, element); + } + } + } + return null; + } + + /** + * The application class carrying a build hint annotation, or null. + * + *

Read straight out of the class file's annotation table rather than from + * source, so it sees exactly what the compiler emitted.

+ * + *

The MAIN class alone when the project names one. The processor honours + * no other class, so no other class is evidence that annotations went + * unprocessed — and scanning them all meant a stale annotated + * {@code .class}, left behind by a rename without a clean, failed every build + * with "no manifest was produced" for a class the developer had already + * deleted. The processor ignores that orphan; so does this. Only when the + * project names no main class at all does this fall back to scanning + * everything, since then there is nothing more specific to ask about.

+ */ + private String classCarryingBuildHintAnnotations(List classpathElements, + String expectedMain) { + java.util.Collection descriptors = hintAnnotationDescriptors(classpathElements); + if (expectedMain != null) { + for (String element : classpathElements) { + try { + if (mainClassCarriesAnnotation(new File(element), expectedMain, descriptors)) { + return expectedMain; + } + } catch (IOException | RuntimeException ex) { + log.debug("cn1: could not read " + expectedMain + " from " + element, ex); + } + } + // The main class carries none. A LIVE class elsewhere still counts: + // @Target(TYPE) accepts the placement, so without this the build + // succeeds having neither applied the hint nor said the annotation is + // in the wrong place -- which is the silent failure the whole feature + // removes. It is only stale output that must not count, and that is a + // question about the source, not about which class it is. + // + // Reported as a misplacement, because that is what it is: had the + // processor run it would have refused the build for this class. + for (String element : classpathElements) { + String live = liveAnnotatedClass(new File(element), descriptors); + if (live != null) { + return live; + } + } + return null; + } + // A reactor `package` build hands us the dependency module's jar rather + // than its output directory, which findAnnotatedClasses handles alongside + // a directory -- that is exactly the shape this check has to work in. + for (String element : classpathElements) { + String hit = findAnnotatedClass(new File(element), descriptors); + if (hit != null) { + return hit; + } + } + return null; + } + + /** + * The first annotated class in this element whose source still exists, or null. + * + *

Stale output is excluded the same way the processor excludes it — + * by asking whether the module's configured source roots still declare the + * class — so an orphan left by a rename cannot fail the build, while a + * class the developer actually wrote does.

+ */ + private String liveAnnotatedClass(File element, java.util.Collection descriptors) { + return liveAnnotatedClass(element, descriptors, null); + } + + /// As above, ignoring `exclude` -- the class the manifest was generated for, + /// which carrying annotations is the whole point of. + private String liveAnnotatedClass(File element, java.util.Collection descriptors, + String exclude) { + List roots; + String encoding; + try { + // The module that PRODUCED this element, not the one running: see + // Sources. The complete set of its roots, since this list is used to + // decide that a source is ABSENT, and the charset it compiles with, + // since the scan decides absence by reading the source. + roots = sources.rootsFor(element); + encoding = sources.encodingFor(element); + } catch (RuntimeException ex) { + return null; + } + if (roots == null || roots.isEmpty()) { + // Not told where the sources are, so staleness cannot be judged and + // an orphan would fail the build. Silence is the lesser harm: the + // processor still refuses this placement whenever it runs. + return null; + } + // Every candidate, not the first: rejecting one stale class must not end + // the search, or whether a live misplacement is reported depends on the + // order the directory happened to be listed in. + for (String hit : findAnnotatedClasses(element, descriptors)) { + if (exclude != null && exclude.equals(hit)) { + continue; + } + try { + com.codename1.maven.annotations.AnnotatedClass cls = readClass(element, hit); + if (cls != null && com.codename1.maven.processors.BuildHintAnnotationProcessor + .hasBackingSource(cls, roots, encoding)) { + return hit; + } + } catch (IOException | com.codename1.maven.annotations.ProcessingException ex) { + log.debug("cn1: could not read " + hit + " from " + element, ex); + } + } + return null; + } + + /** + * Whether {@code -D} already sets this hint, under any spelling. + * + *

An alias and its target are ONE effective setting -- the builder reads + * {@code android.captureRecord} and then lets {@code and.captureRecord} + * override it -- so a command line that names either of them is setting the + * hint an annotation would otherwise supply. Comparing the keys literally + * missed that, and the documented rule is that {@code -D} overrides both the + * annotation and the properties file.

+ */ + private boolean overriddenOnTheCommandLine(String key) { + return overriddenOnTheCommandLine(key, userProperties); + } + + /// Whether the command line sets `key`, under any spelling of the hint. + public static boolean overriddenOnTheCommandLine(String key, Properties user) { + if (user == null) { + return false; + } + String canonical = com.codename1.build.shared.BuildHints.canonicalName( + com.codename1.build.shared.BuildHints.strip(key)); + for (String name : user.stringPropertyNames()) { + if (!name.startsWith(com.codename1.build.shared.BuildHints.ARG_PREFIX)) { + continue; + } + String other = com.codename1.build.shared.BuildHints.canonicalName( + com.codename1.build.shared.BuildHints.strip(name)); + if (canonical.equals(other)) { + return true; + } + } + return false; + } + + /** Whether the named class, read from this classpath element, is annotated. */ + private boolean mainClassCarriesAnnotation(File element, String binaryName, + java.util.Collection descriptors) + throws IOException { + String path = binaryName.replace('.', '/') + ".class"; + if (element.isDirectory()) { + File f = new File(element, path.replace('/', File.separatorChar)); + if (!f.isFile()) { + return false; + } + try (InputStream in = new FileInputStream(f)) { + return carriesBuildHintAnnotation(in, descriptors); + } + } + if (element.isFile() && element.getName().endsWith(".jar")) { + try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(element)) { + java.util.zip.ZipEntry entry = zip.getEntry(path); + if (entry == null) { + return false; + } + try (InputStream in = zip.getInputStream(entry)) { + return carriesBuildHintAnnotation(in, descriptors); + } + } + } + return false; + } + + + private boolean carriesBuildHintAnnotation(InputStream in, + java.util.Collection descriptors) + throws IOException { + final boolean[] seen = {false}; + new org.objectweb.asm.ClassReader(in).accept( + new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.AnnotationVisitor visitAnnotation( + String desc, boolean visible) { + if (descriptors.contains(desc)) { + seen[0] = true; + } + return null; + } + }, + org.objectweb.asm.ClassReader.SKIP_CODE + | org.objectweb.asm.ClassReader.SKIP_DEBUG + | org.objectweb.asm.ClassReader.SKIP_FRAMES); + return seen[0]; + } + + private String findAnnotatedClass(File dir, java.util.Collection descriptors) { + List all = findAnnotatedClasses(dir, descriptors); + return all.isEmpty() ? null : all.get(0); + } + + /** + * Every annotated class under this element, by BINARY name. + * + *

The binary name, not the file's own: returning {@code Wrong} for + * {@code com/example/Wrong.class} made the message name a class that does not + * exist, and made re-reading it by name fail, so the guard saw nothing.

+ * + *

All of them, not the first: an incremental output directory can hold a + * stale annotated class and a live one at once, and stopping at whichever + * {@code File.listFiles} returned first made the answer depend on directory + * order.

+ */ + private List findAnnotatedClasses(File element, java.util.Collection descriptors) { + List out = new ArrayList(); + if (element.isDirectory()) { + collectAnnotatedClasses(element, element, descriptors, out); + } else if (element.isFile() && element.getName().endsWith(".jar")) { + try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(element)) { + java.util.Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + java.util.zip.ZipEntry entry = entries.nextElement(); + if (entry.isDirectory() || !entry.getName().endsWith(".class")) { + continue; + } + try (InputStream in = zip.getInputStream(entry)) { + if (carriesBuildHintAnnotation(in, descriptors)) { + out.add(entry.getName() + .substring(0, entry.getName().length() - ".class".length()) + .replace('/', '.')); + } + } + } + } catch (IOException | RuntimeException ex) { + log.debug("cn1: could not scan " + element + ": " + ex.getMessage()); + } + } + return out; + } + + private void collectAnnotatedClasses(File root, File dir, + java.util.Collection descriptors, + List out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File f : children) { + if (f.isDirectory()) { + collectAnnotatedClasses(root, f, descriptors, out); + continue; + } + if (!f.getName().endsWith(".class")) { + continue; + } + try (InputStream in = new FileInputStream(f)) { + if (carriesBuildHintAnnotation(in, descriptors)) { + String rel = f.getAbsolutePath() + .substring(root.getAbsolutePath().length()) + .replace(File.separatorChar, '/'); + while (rel.startsWith("/")) { + rel = rel.substring(1); + } + out.add(rel.substring(0, rel.length() - ".class".length()).replace('/', '.')); + } + } catch (IOException | RuntimeException ex) { + log.debug("cn1: could not scan " + f + ": " + ex.getMessage()); + } + } + } + + /// The build hint annotation types, read off the classpath they live on. + /// + /// The package is enumerated rather than listed: a generated table naming + /// each annotation was a second statement of which ones exist, and it went + /// stale the moment one was added without regenerating it. + private java.util.Collection hintAnnotationDescriptors( + List classpathElements) { + try { + return com.codename1.build.shared.BuildHintAnnotationReader + .bindingsFromClasspath(classpathElements).descriptors(); + } catch (IOException ex) { + log.debug("cn1: could not read the build hint annotations", ex); + return java.util.Collections.emptyList(); + } + } + + /** + * Reads the emitted hints out of a classpath element, which is either the + * module's output directory or a jar. + * + * @return the properties, or null when this element carries none + */ + private Properties readAnnotationHints(File element) { + if (element == null || !element.exists()) { + return null; + } + try { + if (element.isDirectory()) { + File f = new File(element, ANNOTATION_HINTS_RESOURCE); + if (!f.isFile()) { + return null; + } + try (FileInputStream in = new FileInputStream(f)) { + Properties p = new Properties(); + p.load(in); + return p; + } + } + try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(element)) { + java.util.zip.ZipEntry entry = zip.getEntry(ANNOTATION_HINTS_RESOURCE); + if (entry == null) { + return null; + } + try (InputStream in = zip.getInputStream(entry)) { + Properties p = new Properties(); + p.load(in); + return p; + } + } + } catch (IOException ex) { + log.warn("cn1: could not read build hints from " + element + ": " + + ex.getMessage()); + return null; + } + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java b/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java new file mode 100644 index 00000000000..2b538a188c5 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java @@ -0,0 +1,295 @@ +/* + * 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.maven; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationProcessor; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.ServiceLoader; +import java.util.Set; + +/// Runs the Codename One annotation processors over compiled classes: ASM-scans +/// `outputDirectory`, dispatches each annotated class to the registered +/// [AnnotationProcessor]s, and writes what they emit back into the same tree. +/// +/// Fail-fast: any processor-reported error aborts before a generated file is +/// written, so invalid input cannot leak into the build output. This is the body +/// of the Maven plugin's `process-annotations` goal, shared with the Gradle +/// plugin's `processCn1Annotations` task. +public final class AnnotationProcessing { + private final Log log; + private final File outputDirectory; + private final File stubSourceDirectory; + private final File cn1ProjectDir; + private final Properties rawSettings; + private final String mainClass; + private final List sourceRoots; + private final String sourceEncoding; + private final List compileClasspath; + + /// @param outputDirectory the compiled classes, rewritten in place + /// @param stubSourceDirectory where generated stub sources go + /// @param cn1ProjectDir the directory holding the settings file + /// @param rawSettings `codenameone_settings.properties` exactly as on disk -- + /// NOT overlaid with the command line, since a `-D` hint is an + /// override rather than a second declaration; may be null + /// @param mainClass the binary name of the main class from the EFFECTIVE + /// settings, or null + /// @param sourceRoots every source root the project compiles + /// @param sourceEncoding the encoding javac is given, or null + /// @param compileClasspath the compile classpath, where the build hint + /// annotations are read from + public AnnotationProcessing(Log log, File outputDirectory, File stubSourceDirectory, File cn1ProjectDir, + Properties rawSettings, String mainClass, List sourceRoots, + String sourceEncoding, List compileClasspath) { + this.log = log; + this.outputDirectory = outputDirectory; + this.stubSourceDirectory = stubSourceDirectory; + this.cn1ProjectDir = cn1ProjectDir; + this.rawSettings = rawSettings; + this.mainClass = mainClass; + this.sourceRoots = sourceRoots; + this.sourceEncoding = sourceEncoding; + this.compileClasspath = compileClasspath == null ? Collections.emptyList() : compileClasspath; + } + + /// Runs every processor. + public void run() throws BuildExecutionException { + List classpath = new java.util.ArrayList(); + for (String element : compileClasspath) { + classpath.add(new File(element)); + } + com.codename1.maven.annotations.JavaSourceCompiler.setProjectClasspath(classpath); + try { + runProcessors(); + } finally { + com.codename1.maven.annotations.JavaSourceCompiler.clearProjectClasspath(); + } + } + + private void runProcessors() throws BuildExecutionException { + if (!outputDirectory.isDirectory()) { + log.debug("cn1: nothing compiled at " + outputDirectory + " -- skipping annotation processing"); + return; + } + List processors = loadProcessors(); + if (processors.isEmpty()) { + log.debug("cn1: no AnnotationProcessor services registered -- nothing to do"); + return; + } + + Map index; + try { + index = ClassScanner.scan(outputDirectory); + } catch (ProcessingException e) { + throw new BuildExecutionException("Failed to scan compiled classes under " + + outputDirectory + ": " + e.getMessage(), e); + } + + ProcessorContext ctx = new ProcessorContext(outputDirectory, stubSourceDirectory, + index, log, cn1ProjectDir, rawSettings, mainClass, + // The roots the build is actually compiling, so a processor asking + // whether a class still has a source is not guessing at the layout. + sourceRoots, + // The charset javac is given, so a processor reading a source back + // decodes the text that was actually compiled rather than one of + // the single-byte encodings that all decode without error and + // disagree about every non-ASCII character. + sourceEncoding, + // Where the build hint annotations are, so the processor reads what + // a member sets from the annotation rather than from a generated + // table naming each of them. + compileClasspath); + + // start() + for (Iterator it = processors.iterator(); it.hasNext(); ) { + AnnotationProcessor p = it.next(); + try { + p.start(ctx); + } catch (ProcessingException e) { + throw new BuildFailureException( + "Annotation processor " + p.getClass().getName() + " start failed: " + + e.getMessage(), e); + } + } + + // processClass() -- dispatched only when the class carries an annotation + // the processor declares interest in, anywhere in the class. + // + // The test is against getAllAnnotationDescriptors(), not + // getClassAnnotations(): a class whose only annotation sits on a method + // has an empty class-annotation map, so gating on that map alone would + // silently skip it. That is not hypothetical -- it is exactly the shape + // of the documented static-factory @Route form and of an @AppIntent + // handler, and such a class would be dropped with no error anywhere. + for (AnnotatedClass cls : index.values()) { + Set present = cls.getAllAnnotationDescriptors(); + if (present.isEmpty()) continue; + for (Iterator it = processors.iterator(); it.hasNext(); ) { + AnnotationProcessor p = it.next(); + if (intersects(p.getAnnotationDescriptors(), present)) { + try { + p.processClass(cls, ctx); + } catch (ProcessingException e) { + throw new BuildFailureException( + "Annotation processor " + p.getClass().getName() + " failed on class " + + cls.getBinaryName() + ": " + e.getMessage(), e); + } + } + } + } + + // finish() + for (Iterator it = processors.iterator(); it.hasNext(); ) { + AnnotationProcessor p = it.next(); + try { + p.finish(ctx); + } catch (ProcessingException e) { + throw new BuildFailureException( + "Annotation processor " + p.getClass().getName() + " finish failed: " + + e.getMessage(), e); + } + } + + // Fail-fast: surface every recoverable error and abort if any. + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("Codename One annotation processing failed:\n"); + List errs = ctx.getErrors(); + for (int i = 0; i < errs.size(); i++) { + sb.append(" - ").append(errs.get(i)).append('\n'); + } + sb.append("Aborting before any generated class is written, so the build output reflects the source."); + throw new BuildFailureException(sb.toString()); + } + + // Flush emitted bytecode. + Map emitted = ctx.getEmittedClasses(); + for (Map.Entry e : emitted.entrySet()) { + File target = new File(outputDirectory, e.getKey() + ".class"); + File parent = target.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new BuildExecutionException("Could not create " + parent); + } + try { + FileOutputStream fos = new FileOutputStream(target); + try { + fos.write(e.getValue()); + } finally { + fos.close(); + } + } catch (IOException ioe) { + throw new BuildExecutionException("Could not write generated class " + target, ioe); + } + } + + for (AnnotationProcessor processor : processors) { + if (processor instanceof com.codename1.maven.processors.OrmAnnotationProcessor) { + try { ((com.codename1.maven.processors.OrmAnnotationProcessor) processor).enhance(ctx); } + catch (ProcessingException error) { throw new BuildFailureException(error.getMessage(),error); } + } + } + + if (!emitted.isEmpty()) { + log.info("cn1: emitted " + emitted.size() + " generated class(es) under " + + outputDirectory); + } + + // Flush generated resources. These ride the project jar to the native + // builders -- including a cloud build server, which receives the whole + // artifact -- so they are how build-time metadata reaches the iOS and + // Android sides. Written after the error check for the same reason the + // classes are: a failed validation must not leave a manifest behind. + Map resources = ctx.getEmittedResources(); + for (Map.Entry e : resources.entrySet()) { + File target = new File(outputDirectory, e.getKey()); + File parent = target.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new BuildExecutionException("Could not create " + parent); + } + try { + FileOutputStream fos = new FileOutputStream(target); + try { + fos.write(e.getValue()); + } finally { + fos.close(); + } + } catch (IOException ioe) { + throw new BuildExecutionException("Could not write generated resource " + target, ioe); + } + } + + if (!resources.isEmpty()) { + log.info("cn1: emitted " + resources.size() + " generated resource(s) under " + + outputDirectory); + } + + // The build hint manifest records the main class's own bytes so the + // simulator, which has no bytecode reader, can tell a current manifest + // from one an earlier build left behind. A processor may REPLACE that + // class through emitClass -- BindingAnnotationProcessor does, for a + // two-way @Bindable setter -- and those are flushed above, after every + // finish(). So the stamp is corrected here, which is the first moment + // the class on disk is final. A no-op when there is no manifest. + try { + com.codename1.maven.processors.BuildHintAnnotationProcessor + .restampClassDigest(outputDirectory); + } catch (IOException ioe) { + throw new BuildExecutionException( + "Could not stamp the build hint manifest under " + outputDirectory, ioe); + } + } + + private static boolean intersects(Set a, Set b) { + if (a == null || b == null || a.isEmpty() || b.isEmpty()) return false; + if (a.size() > b.size()) { + for (String s : b) if (a.contains(s)) return true; + } else { + for (String s : a) if (b.contains(s)) return true; + } + return false; + } + + private static List loadProcessors() { + ServiceLoader sl = ServiceLoader.load( + AnnotationProcessor.class, AnnotationProcessor.class.getClassLoader()); + List out = new ArrayList(); + for (AnnotationProcessor p : sl) out.add(p); + return Collections.unmodifiableList(out); + } + +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java b/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java new file mode 100644 index 00000000000..ba04d727014 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java @@ -0,0 +1,3190 @@ +/* + * Copyright (c) 2021, 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.maven; + +import com.codename1.ant.AntExecutor; +import com.codename1.ant.SortedProperties; +import com.codename1.builders.*; +import org.apache.commons.io.FileUtils; +import org.apache.commons.vfs2.FileObject; +import org.apache.commons.vfs2.FileSystemManager; +import org.apache.commons.vfs2.PatternFileSelector; +import org.apache.commons.vfs2.VFS; +import com.codename1.build.BuildArtifact; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.AntSupport; +import com.codename1.build.CodenameOneUpdater; +import com.codename1.build.Log; +import com.codename1.build.ProjectHost; +import org.apache.tools.ant.Project; +import org.apache.tools.ant.taskdefs.Expand; +import org.apache.tools.ant.taskdefs.Zip; +import org.apache.tools.ant.types.FileSet; +import org.apache.tools.ant.types.ZipFileSet; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + + +import static com.codename1.maven.PathUtil.path; + +/// Sends a build to the Codename One build server, or runs one of the local +/// build targets ("ios-source", "android-source", the native desktop and +/// JavaScript targets) on this machine. +/// +/// This is the build-tool independent body of what used to be all of +/// `CN1BuildMojo`: staging the jar-with-dependencies, the class closure and +/// icon checks, merging the settings a build is submitted with (annotations, +/// command-line overrides, cn1lib properties), both preflights, and handing the +/// result to the cloud or to a local builder. A build tool subclasses it and +/// answers the handful of questions only it can -- where the build directory +/// is, what the resolved dependencies are, how to attach an output -- through +/// a [ProjectHost]. The Maven plugin's `CN1BuildMojo` and the Gradle plugin's +/// build tasks both run it, so a fix here reaches both. +public class AppBuilder { + + protected static final String GROUP_ID = "com.codenameone"; + protected static final String OS = System.getProperty("os.name", "").toLowerCase(); + protected static final boolean isWindows = OS.indexOf("win") >= 0; + protected static final boolean isMac = OS.indexOf("mac") >= 0; + + /// The Ant project the zip/unzip tasks run in. + protected final Project antProject; + + /// The build tool's answers about the project. + protected final ProjectHost host; + + /// A builder for the project `host` describes. + public AppBuilder(ProjectHost host) { + this.host = host; + this.antProject = AntSupport.newProject(host.baseDir()); + } + + /// Sets the platform (`codename1.platform`: ios, android, javase, ...). + public AppBuilder platform(String platform) { + this.platform = platform; + return this; + } + + /// Sets the build target (ios-device, android-device, ios-source, ...). + public AppBuilder buildTarget(String buildTarget) { + this.buildTarget = buildTarget; + return this; + } + + /// Whether to wait for a cloud build and download its result. + public AppBuilder automated(boolean automated) { + this.automated = automated; + return this; + } + + /// Stop once the upload jar is staged and checked. + public AppBuilder stageOnly(boolean stageOnly) { + this.stageOnly = stageOnly; + return this; + } + + /// Whether to open a generated Xcode or Android Studio project. + public AppBuilder open(boolean open) { + this.open = open; + return this; + } + + // ---- Answers from the build tool. Protected so a tool can refine one. ---------- + + protected Log getLog() { + return host.log(); + } + + protected File getCN1ProjectDir() { + return host.cn1ProjectDir(); + } + + protected File baseDir() { + return host.baseDir(); + } + + protected String buildDirectory() { + return host.buildDirectory().getPath(); + } + + protected String finalName() { + return host.finalName(); + } + + /// The scratch directory this build assembles its Ant project in: + /// `/codenameone`. A Maven platform module has a build directory + /// of its own; the Gradle plugin, whose platforms share one, gives each build + /// task a directory beneath this so two builds can run at once. + protected File workDirectory() { + return new File(buildDirectory(), "codenameone"); + } + + protected String projectGroupId() { + return host.groupId(); + } + + protected Properties projectProperties() { + return host.projectProperties(); + } + + protected List compileClasspathElements() throws Exception { + return host.compileClasspathElements(); + } + + protected Collection artifacts() { + return host.artifacts(); + } + + /// The dependency keys (see [dependencyKey(String, String, String)]) the + /// application's compile dependencies need once the desktop runtime + /// aggregator is removed, or null when that cannot be determined -- in which + /// case nothing is stripped as desktop runtime. + protected Set neededWithoutDesktopRuntime() { + return host.neededWithoutDesktopRuntime(); + } + + protected File getJar(BuildArtifact artifact) { + return host.getJar(artifact); + } + + protected File getJar(String groupId, String artifactId) { + return host.getJar(groupId, artifactId, null); + } + + protected long sessionStartTime() { + return host.sessionStartTime(); + } + + protected long getSourcesModificationTime() throws IOException { + return host.sourcesModificationTime(); + } + + /// Installs or refreshes the Codename One build client in `~/.codenameone`. + /// + /// Names the build client jar, so a machine without one gets it before the + /// first cloud build: the updater only runs when a file it is given is + /// missing, and given none it installed itself and nothing else, leaving the + /// build to fail on the absent jar. (The Maven plugin overrides this; its + /// projects get the client from the install-codenameone profile.) + protected void updateCodenameOne(boolean force) throws BuildExecutionException { + new CodenameOneUpdater(getLog(), antProject).update(force, + new File(buildDirectory(), "codenameone"), getCN1ProjectDir(), CodenameOneUpdater.buildClientJar()); + } + + /// Overlays the build hints declared as annotations on the main class. + protected void mergeAnnotationBuildHints(Properties target, List classpathElements) + throws BuildFailureException { + Properties settings = new Properties(); + File settingsFile = new File(getCN1ProjectDir(), "codenameone_settings.properties"); + if (settingsFile.isFile()) { + try (FileInputStream in = new FileInputStream(settingsFile)) { + settings.load(in); + } catch (IOException ex) { + getLog().debug("Could not read " + settingsFile, ex); + } + } + overlayCommandLineBuildHints(settings); + final List roots = host.compileSourceRoots(); + final String encoding = projectProperties().getProperty("project.build.sourceEncoding"); + new AnnotationBuildHints(getLog(), settings, host.userProperties(), new AnnotationBuildHints.Sources() { + @Override + public List rootsFor(File element) { + return roots; + } + + @Override + public String encodingFor(File element) { + return encoding; + } + }).merge(target, classpathElements); + } + + /// Overlays `codename1.*` properties given on the command line. + protected void overlayCommandLineBuildHints(Properties target) { + Properties user = host.userProperties(); + if (user == null || target == null) { + return; + } + for (String key : user.stringPropertyNames()) { + if (key.startsWith("codename1.")) { + target.setProperty(key, user.getProperty(key)); + } + } + } + + /// Records a build output beside the project's primary artifact. + protected void attachArtifact(String type, String classifier, File file) { + host.attachArtifact(type, classifier, file); + } + + /// [attachArtifact(String, String, File)] with no classifier. + protected void attachArtifact(String type, File file) { + attachArtifact(type, null, file); + } + + protected String codenameOneVersion() { + return host.codenameOneVersion(); + } + + protected String pluginVersion() { + return host.pluginVersion(); + } + + protected static boolean contains(String needle, String... haystack) { + for (String s : haystack) { + if (s.equals(needle)) { + return true; + } + } + return false; + } + + protected static long lastModifiedRecursive(File file) { + long lastModified = 0L; + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + lastModified = Math.max(lastModifiedRecursive(child), lastModified); + } + } + } else { + lastModified = file.lastModified(); + } + return lastModified; + } + + + public static final String BUILD_TARGET_XCODE_PROJECT = Executor.BUILD_TARGET_XCODE_PROJECT; + public static final String BUILD_TARGET_ANDROID_PROJECT = Executor.BUILD_TARGET_ANDROID_PROJECT; + public static final String BUILD_TARGET_WINDOWS_NATIVE = Executor.BUILD_TARGET_WINDOWS_NATIVE; + public static final String BUILD_TARGET_WINDOWS_NATIVE_PROJECT = Executor.BUILD_TARGET_WINDOWS_NATIVE_PROJECT; + + private String serverMustProvideKotlinVersion; + + /** + * The target platform. E.g. javase, javascript, ios, android, win + */ + protected String platform; + + /** + * The build target, corresponding to ANT build targets in build-template.xml. E.g. javascript, + * mac-os-x-desktop, windows-desktop, ios-device, ios-device-release, android-device, war + */ + protected String buildTarget; + + /** + * Flag to indicate whether to use an automated build or not. + */ + protected boolean automated; + + /** + * Stop once the jar the build would send has been assembled and checked, + * without submitting anything or building locally. The jar is left at + * target/<finalName>-<buildTarget>-jar-with-dependencies.jar. This is + * what lets CI inspect the real upload for every target with no account + * and no network: maven/integration-tests/cn1app-staged-jar-test.sh. + */ + protected boolean stageOnly; + + + /** + * Flag of whether to open the xcode/android studio project. + */ + protected boolean open = true; + + + private String cn1MavenPluginVersion = ""; + private String cn1MavenVersion = ""; + + + /// Runs the build. + public void execute() throws BuildExecutionException { + if ("none".equalsIgnoreCase(buildTarget)) { + getLog().info("BuildTarget is None. Skipping cn1build goal"); + return; + } + cn1MavenPluginVersion = pluginVersion() == null ? "" : pluginVersion(); + cn1MavenVersion = codenameOneVersion() == null ? "" : codenameOneVersion(); + + + String projectPlatform = projectProperties().getProperty("codename1.projectPlatform"); + if (projectPlatform == null) { + getLog().debug("Skipping build because codename1.projectPlatform property is not defined"); + return; + } + if (!projectPlatform.equals(platform)) { + getLog().debug("Skipping build because codename1.projectPlatform doesn't match the given platform"); + return; + } + + // Run the hardening pre-flight BEFORE the Android up-to-date cache short-circuit below. The cache + // check keys only on source-file timestamps (getSourcesModificationTime), not on build hints, so an + // explicit hardening request -- especially one made via a -D command-line property -- would + // otherwise return a stale, potentially unhardened APK without the pre-flight ever validating (or + // refusing) it. Checking here means an invalid/unsupported hardening request fails loudly instead + // of silently succeeding with the cached artifact. + applyHardeningPreflight(); + + // Signing problems that are visible in the .mobileprovision on disk should not cost a + // cloud build slot to discover. See applyIOSProvisioningPreflight. + applyIOSProvisioningPreflight(); + + // stageOnly builds no APK, so it must neither short-circuit on a cached one (it would + // then stage nothing) nor, below, stamp that cached APK as matching this configuration. + if (platform.contains("android") && !stageOnly) { + if (!BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { + File apkFile = androidApkFile(); + try { + // Up-to-date only when the APK is newer than the sources AND was built with the SAME + // hardening outcome, recorded in the marker beside it. The source-timestamp check cannot + // see a hardening change made through a build hint, so a change in EITHER direction -- + // enabling hardening (a stale unhardened APK) or disabling it (a stale hardened APK) -- + // must invalidate the cache and rebuild, rather than publish an APK that contradicts the + // current configuration. + if (apkFile.exists() + && hardeningCacheKey.equals(readTextFileOrNull(androidHardeningCacheMarker())) + && apkFile.lastModified() >= getSourcesModificationTime()) { + getLog().info("Sources have not been modified since APK at " + apkFile + " was created. Skipping Android build"); + return; + } + } catch (IOException ex) { + throw new BuildExecutionException("Failed to check sources modification time", ex); + } + } + } + + try { + createAntProject(); + } catch (IOException ex) { + getLog().error("Failed to create and build ANT project", ex); + throw new BuildFailureException("Failed to create and build ANT project", ex); + } catch (LibraryPropertiesException ex) { + getLog().error("Failed to merge properties from library "+ex.libName+". " + ex.getMessage()); + throw new BuildExecutionException("Failed to merge properties from library "+ex.libName+". " + ex.getMessage(), ex); + } + + // Record the hardening outcome this APK was built with, so a later invocation that changes + // hardening (in either direction) invalidates the timestamp-only up-to-date cache above. + if (platform.contains("android") && !stageOnly && !BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { + File marker = androidHardeningCacheMarker(); + if (androidApkFile().exists()) { + try { + writeStringToFile(marker, hardeningCacheKey); + } catch (IOException ex) { + getLog().debug("Could not record the hardening cache key at " + marker, ex); + } + } + } + } + + /** The Android APK this build produces (also the cache key's anchor). */ + private File androidApkFile() { + String apkName = finalName() + ".apk"; + return new File(buildDirectory() + File.separator + apkName); + } + + /** The marker recording the hardening outcome the cached APK was built with, beside the APK. */ + private File androidHardeningCacheMarker() { + File apk = androidApkFile(); + return new File(apk.getParentFile(), apk.getName() + ".cn1hardenkey"); + } + + /** + * A stable fingerprint of every effective {@code codename1.arg.harden.*} setting, so a transform, + * keep-rule or seed change -- not only a level change -- invalidates the APK cache. Keys are sorted so + * the fingerprint is order-independent. + */ + private static String hardeningSettingsFingerprint(Properties settings) { + java.util.TreeMap hints = new java.util.TreeMap(); + for (String key : settings.stringPropertyNames()) { + if (key.startsWith("codename1.arg.harden.")) { + hints.put(key, settings.getProperty(key)); + } + } + StringBuilder sb = new StringBuilder(); + for (java.util.Map.Entry e : hints.entrySet()) { + sb.append(e.getKey()).append('=').append(e.getValue()).append('\n'); + } + return sha256Hex(sb.toString()); + } + + /** SHA-256 of {@code s} as lowercase hex (falls back to the string hash if SHA-256 is somehow absent). */ + private static String sha256Hex(String s) { + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] d = md.digest(s.getBytes(java.nio.charset.Charset.forName("UTF-8"))); + StringBuilder sb = new StringBuilder(d.length * 2); + for (byte b : d) { + sb.append(Character.forDigit((b >> 4) & 0xf, 16)); + sb.append(Character.forDigit(b & 0xf, 16)); + } + return sb.toString(); + } catch (java.security.NoSuchAlgorithmException ex) { + return Integer.toHexString(s.hashCode()); + } + } + + /** Reads a small text file's trimmed content, or {@code null} if it is absent or unreadable. */ + private static String readTextFileOrNull(File f) { + if (f == null || !f.isFile()) { + return null; + } + try { + return new String(java.nio.file.Files.readAllBytes(f.toPath()), + java.nio.charset.Charset.forName("UTF-8")).trim(); + } catch (IOException ex) { + return null; + } + } + + /** Writes {@code content} to {@code f} (UTF-8), creating parent directories as needed. */ + private static void writeStringToFile(File f, String content) throws IOException { + if (f.getParentFile() != null) { + f.getParentFile().mkdirs(); + } + java.nio.file.Files.write(f.toPath(), + content.getBytes(java.nio.charset.Charset.forName("UTF-8"))); + } + + /** + * App-hardening pre-flight (Check 1). Validates {@code harden.level} and refuses targets that + * cannot be hardened before a build is spent. Runs for every target: for cloud targets it fails + * fast client-side before submission; for local/source targets it stops (or, with the escape + * hatch, forces hardening off) because a locally built binary never reaches the server and its + * mapping would be orphaned from the crash-symbolication service. + */ + /** + * The hardening pre-flight decisions, carried on the Mojo INSTANCE (not JVM-global state) so a + * concurrent module build under {@code mvn -T} cannot clobber them. Each reactor module executes its + * own CN1BuildMojo instance, so instance fields are naturally per-build; a shared System property was + * racy -- another platform's build could clear {@code cn1.harden.forceOff} between this build setting + * it and the Executor reading it, running hardening locally despite the escape-hatch decision. These + * are injected into this build's BuildRequest ({@link #applyHardeningRequestArgs}) so the Executor + * reads them from the request it was handed rather than from process-wide state. + */ + private boolean hardeningForceOff; + private String hardeningLibraryJars; + /** True once the pre-flight has resolved that hardening will ACTUALLY run for this build (non-off, + * not force-off). Used to publish the library classpath the engine needs. */ + private boolean hardeningWillRun; + /** A fingerprint of the hardening OUTCOME for this build ("unhardened", or "hardened:<level>"), + * recorded next to the Android APK so the timestamp-only up-to-date cache -- which cannot see a + * hardening change made through a build hint -- is invalidated when the outcome changes in EITHER + * direction (enabling or disabling hardening), not just off->on. */ + private String hardeningCacheKey = "unhardened"; + + /** Injects the pre-flight hardening decisions into this build's request (per-build, not global). */ + private void applyHardeningRequestArgs(BuildRequest r) { + if (hardeningForceOff) { + r.putArgument("cn1.harden.forceOff", "true"); + } + if (hardeningLibraryJars != null && hardeningLibraryJars.length() > 0) { + r.putArgument("cn1.hardening.libraryJars", hardeningLibraryJars); + } + } + + private void applyHardeningPreflight() throws BuildFailureException { + Properties settings = new Properties(); + File settingsFile = new File(getCN1ProjectDir(), "codenameone_settings.properties"); + if (settingsFile.isFile()) { + try (FileInputStream fis = new FileInputStream(settingsFile)) { + settings.load(fis); + } catch (IOException ex) { + getLog().debug("Could not read codenameone_settings.properties for hardening pre-flight", ex); + } + } + // A hint set by @Hardening reaches the settings only in createAntProject, which runs after + // the Android up-to-date short-circuit below and after hardeningCacheKey is read from it. + // Without this the early pass computes "unhardened" from the file while the completed build + // records "hardened:...", the two never match, and an up-to-date APK is rebuilt on every + // invocation -- and, worse, an unsupported hardening request made through an annotation + // escapes the refusal this early pass exists to perform. + try { + mergeAnnotationBuildHints(settings, compileClasspathElements()); + } catch (Exception ex) { + getLog().debug("Could not read annotation build hints for the hardening pre-flight", ex); + } + // Overlay -D command-line hints (e.g. -Dcodename1.arg.harden.level=standard) so an explicit + // hardening request made only on the command line is seen by this early check -- and, because this + // runs before the Android up-to-date cache short-circuit, is not silently dropped when a prior APK + // is newer than the sources (getSourcesModificationTime does not account for build hints). + // After the annotations, so -D still wins over one. + overlayCommandLineBuildHints(settings); + applyHardeningPreflight(settings); + } + + /** + * Refuses an iOS device build whose provisioning profile makes signing impossible, before + * the build is packaged and sent. + * + *

These failures used to be discoverable only on a build server: an unreadable profile + * surfaced as an XML parser stack trace that never named the profile, and a profile of the + * wrong kind surfaced as an {@code exportArchive} error minutes into the build, after the + * whole app had already been compiled and archived. Both are decided by a file sitting in + * the project. + * + *

Only cloud iOS device builds are checked. A local Xcode-project generation signs + * later (or not at all), and the native-Mac targets ride the same platform with a different + * signing identity, so neither is this check's business. + */ + private void applyIOSProvisioningPreflight() throws BuildFailureException { + if (!isIOSDeviceBuild()) { + return; + } + Properties settings = new Properties(); + File settingsFile = new File(getCN1ProjectDir(), "codenameone_settings.properties"); + if (settingsFile.isFile()) { + try (FileInputStream fis = new FileInputStream(settingsFile)) { + settings.load(fis); + } catch (IOException ex) { + getLog().debug("Could not read codenameone_settings.properties for the iOS provisioning pre-flight", ex); + return; + } + } else { + return; + } + overlayCommandLineBuildHints(settings); + + // Only what the file itself decides. Whether the profile's KIND matches the export + // method cannot be judged yet: a CN1Lib can supply ios.*.distributionMethod through + // its appended/required properties, which createAntProject merges further down, so + // that comparison waits for applyIOSProvisioningPreflight(Properties) below. + report(IOSProvisioningPreflight.checkProfileFile(settings, + IOSProvisioningPreflight.isReleaseTarget(buildTarget), new Date())); + } + + private boolean isIOSDeviceBuild() { + return IOSProvisioningPreflight.appliesTo(platform, buildTarget); + } + + /** + * The full pre-flight, run against the settings the build is actually submitted with -- + * the project's own, plus the command-line overlay, plus every CN1Lib-contributed + * property. This is where a profile-kind/distribution-method mismatch is decided, since + * a library can still change the method after the early pass has run. + */ + private void applyIOSProvisioningPreflight(Properties mergedSettings) throws BuildFailureException { + if (!isIOSDeviceBuild()) { + return; + } + boolean release = IOSProvisioningPreflight.isReleaseTarget(buildTarget); + report(IOSProvisioningPreflight.check(mergedSettings, release, new Date())); + // Every embedded app extension needs a profile of its own, and the app's profile says + // whether it can stand in for one. Run after check() so an unreadable or expired + // profile is reported as itself rather than as an extension problem. + // The directory holding app_extensions, where packaging takes them from: + // the ios module under Maven, src/ios under Gradle, native/ios under Ant. + report(IOSProvisioningPreflight.checkAppExtensions(mergedSettings, release, + host.layout().iosAppExtensionsDir().getParentFile())); + report(IOSProvisioningPreflight.checkGeneratedExtensions(mergedSettings, release)); + report(IOSProvisioningPreflight.checkContinuitySync(mergedSettings, release)); + } + + private void report(List problems) throws BuildFailureException { + String fatal = null; + for (IOSProvisioningPreflight.Problem problem : problems) { + if (problem.fatal) { + getLog().error(problem.message); + if (fatal == null) { + fatal = problem.message; + } + } else { + getLog().warn(problem.message); + } + } + if (fatal != null) { + throw new BuildFailureException(fatal); + } + } + + /** + * Runs the hardening pre-flight against a given set of effective settings. Called first with the + * project's own {@code codenameone_settings.properties} (fail-fast before the merged jar is built), + * and again after {@code createAntProject} merges the CN1Lib-contributed + * {@code codenameone_library_appended/required.properties} -- a library can turn hardening on via + * {@code codename1.arg.harden.level}, which the early call cannot see, and without this second pass + * such a build would slip past the local-build refusal / force-off and produce a locally hardened + * artifact whose mapping is never uploaded. + */ + private void applyHardeningPreflight(Properties settings) throws BuildFailureException { + String level = settings.getProperty("codename1.arg.harden.level", "off"); + // A per-platform opt-out (harden..enabled=false) means hardening won't run for + // this target, so the pre-flight must not reject it -- treat the level as off. The native-Mac + // targets ride the iOS pipeline with platform=ios, so derive their opt-out key from the + // build target instead (matching IPhoneBuilder, which reports "mac" for them). + String hardenPlatform = hardenPlatformForBuildTarget(buildTarget); + if (hardenPlatform == null) { + hardenPlatform = normalizeHardenPlatform(platform); + } + // A combined Apple build (iOS app + native-Mac/watch/tvOS slice) hardens ONE shared jar, so the + // builder hardens it unless EVERY shipped slice is opted out (Executor.anySliceHardeningEnabled). + // The preflight must use that same all-slice decision: keying off only the selected slice would, + // e.g. with harden.mac.enabled=false but iOS still on, treat the level as off and skip the + // local-build/on-device-debug refusal while the engine goes on to harden the shared jar locally + // and orphan its mapping. A non-Apple target has a single slice, so its own opt-out still applies. + boolean platformOptedOut; + if (isNativeMacOsTarget(buildTarget)) { + // A native macOS target is not a slice of an iOS build. It runs its + // own builder against the macosx SDK, and that builder's hardening + // platform list is "mac" alone -- there is no shared jar and no iOS + // app. The all-slice rule below starts by reading harden.ios.enabled, + // which defaults to true and is answering about a slice this build + // does not ship, so harden.mac.enabled=false was overruled by it and + // the build was refused as a hardened local/source one that the + // builder would in fact have hardened nothing for. + // + // Mac Catalyst is deliberately NOT here: it really is an iOS build + // with a hint set, it does ship the shared jar, and the all-slice + // rule is right for it. hardenPlatformForBuildTarget draws the same + // line for the same reason. + platformOptedOut = isHardenFalse( + settings.getProperty("codename1.arg.harden.mac.enabled", "true")); + } else if (isAppleHardenPlatform(hardenPlatform)) { + platformOptedOut = allAppleHardeningSlicesOptedOut(settings); + } else { + platformOptedOut = hardenPlatform != null && isHardenFalse( + settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true")); + } + if (platformOptedOut) { + level = "off"; + } + // Even at a non-off level, a build that has overridden every individual transform off + // (e.g. harden.rename=false, harden.strings=off, harden.controlFlow=false) requests nothing: + // the engine treats that as SKIPPED_NOT_REQUESTED, which is equivalent to off. Resolve the + // overrides to the effective transform set so such a build is not rejected on a local/source + // or on-device-debug target for a "hardening" it isn't actually asking for. An unknown level + // is NOT reduced here -- it must reach the preflight so the invalid-level check rejects it. + if (hardeningReducesToOff(settings, level, hardenPlatform)) { + level = "off"; + } + boolean allowLocal = "true".equalsIgnoreCase( + settings.getProperty("codename1.arg.harden.allowUnhardenedLocalBuild", "false").trim()); + boolean onDeviceDebug = "true".equalsIgnoreCase( + settings.getProperty("codename1.arg.android.onDeviceDebug", "false").trim()) + || (buildTarget != null && buildTarget.contains("on-device-debug")); + + HardeningPreflight.Result r = HardeningPreflight.check(level, buildTarget, allowLocal, onDeviceDebug); + if (r.isFailed()) { + throw new BuildFailureException(r.getMessage()); + } + if (r.isForceOff()) { + getLog().warn(r.getMessage()); + hardeningForceOff = true; + } else { + hardeningForceOff = false; + } + hardeningWillRun = !"off".equalsIgnoreCase(level.trim()) && !r.isForceOff(); + // The APK's hardening OUTCOME. An unhardened build has one key regardless of the nominal level, so + // two off/force-off invocations still hit the cache. A hardened build fingerprints EVERY effective + // harden.* setting -- not just the level -- because harden.strings/rename/controlFlow/keep/seed all + // change the produced transforms and mapping, so two hardened:aggressive builds with different seeds + // or keep rules must still invalidate the cache. Compared against the marker recorded beside the APK. + hardeningCacheKey = hardeningWillRun + ? "hardened:" + hardeningSettingsFingerprint(settings) + : "unhardened"; + // Publish the compile classpath so the hardening engine can hand it to ProGuard as library + // jars (so an application method that overrides a framework method is not renamed apart from + // its superclass). Only needed when hardening will actually run. + if (hardeningWillRun) { + try { + List cp = compileClasspathElements(); + StringBuilder sb = new StringBuilder(); + for (String element : cp) { + File f = new File(element); + if (f.isFile() && element.endsWith(".jar")) { + if (sb.length() > 0) { + sb.append(File.pathSeparator); + } + sb.append(f.getAbsolutePath()); + } + } + hardeningLibraryJars = sb.toString(); + } catch (Exception ex) { + getLog().debug("Could not resolve compile classpath for hardening library jars", ex); + } + } else { + hardeningLibraryJars = null; + } + } + + /** + * True for the targets that run the standalone macOS builder rather than a + * slice of an iOS build. + * + *

The same three {@link #hardenPlatformForBuildTarget(String)} maps to + * "mac", kept beside it so the two cannot come to disagree about which + * targets are natively macOS.

+ */ + // Package-visible so a test can pin which targets are natively macOS; the + // hardening preflight's answer turns on it. + static boolean isNativeMacOsTarget(String buildTarget) { + return BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget) + || BUILD_TARGET_MAC_NATIVE.equals(buildTarget) + || BUILD_TARGET_MAC_NATIVE_LOCAL.equals(buildTarget); + } + + /** + * The {@code harden..enabled} opt-out key implied by the build target, for targets + * whose {@code codename1.platform} does not name their real hardening platform. The macOS + * targets run with {@code platform=ios} but harden as "mac", so their opt-out is + * {@code harden.mac.enabled}. Mac Catalyst needs no entry: it has no target of its own and + * really is an iOS build, so {@code harden.ios.enabled} is the correct key for it. + * Returns {@code null} when the target carries no such override and the platform value + * should be used. + */ + static String hardenPlatformForBuildTarget(String buildTarget) { + if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget) + || BUILD_TARGET_MAC_NATIVE.equals(buildTarget) + || BUILD_TARGET_MAC_NATIVE_LOCAL.equals(buildTarget)) { + return "mac"; + } + return null; + } + + /** + * True when a {@code harden.*} boolean setting reads as disabled, using the same tri-state rules + * as the engine's {@code HardeningConfig.boolTri}: {@code false}, {@code 0} and {@code off} all + * mean off. Recognizing only the literal {@code false} here would preflight-reject a + * local/source build that {@code harden..enabled=off} had actually turned off. + */ + private static boolean isHardenFalse(String value) { + if (value == null) { + return false; + } + String t = value.trim().toLowerCase(); + return "false".equals(t) || "0".equals(t) || "off".equals(t); + } + + /** True for the Apple hardening tags whose build ships several slices from one shared hardened jar. */ + private static boolean isAppleHardenPlatform(String hardenPlatform) { + return "ios".equals(hardenPlatform) || "mac".equals(hardenPlatform); + } + + /** + * True only when EVERY Apple slice this build ships (the iOS app plus any native-Mac/watch/tvOS + * target) has opted out via {@code harden..enabled}. Mirrors IPhoneBuilder.appleHardeningSlices + * / Executor.anySliceHardeningEnabled from the settings so the preflight's "reduced to off" decision + * matches the builder's "harden unless every slice opted out". A slice is present only when its target + * is enabled, so an unrelated tvOS opt-out never affects a plain iOS build. + */ + static boolean allAppleHardeningSlicesOptedOut(Properties settings) { + if (!isHardenFalse(settings.getProperty("codename1.arg.harden.ios.enabled", "true"))) { + return false; + } + if ("true".equals(settings.getProperty("codename1.arg.macNative.enabled", "false")) + && !isHardenFalse(settings.getProperty("codename1.arg.harden.mac.enabled", "true"))) { + return false; + } + if (appleSliceTargetEnabled(settings, "watch") + && !isHardenFalse(settings.getProperty("codename1.arg.harden.watch.enabled", "true"))) { + return false; + } + if (appleSliceTargetEnabled(settings, "tv") + && !isHardenFalse(settings.getProperty("codename1.arg.harden.tv.enabled", "true"))) { + return false; + } + return true; + } + + /** True when the watch/tv slice is shipped: its {@code Native.enabled} or a {@code Main}. */ + private static boolean appleSliceTargetEnabled(Properties settings, String slice) { + return "true".equals(settings.getProperty("codename1.arg." + slice + "Native.enabled", "false")) + || settings.getProperty("codename1.arg." + slice + "Main", + settings.getProperty("codename1.arg." + slice + "Native.mainClass", "")).trim() + .length() > 0; + } + + /** + * True when, at this level, at least one hardening transform is still requested once the + * individual {@code harden.*} overrides are applied -- mirroring the engine's + * {@code HardeningConfig}/{@code willApplyAnyTransform} "is anything requested" decision (the + * platform-safety refinement is the server's, and only ever narrows this). A level whose every + * transform is overridden off requests nothing and is equivalent to {@code off}, so the preflight + * must not reject it. + */ + /** + * True when a valid non-off level requests no transform once the {@code harden.*} + * overrides are applied, so it is equivalent to {@code off} and must not be rejected. An unknown + * or misspelled level (rank 0) returns {@code false} so it is left untouched and reaches + * {@link HardeningPreflight#check} -- which rejects it fast, client-side, rather than letting a + * cloud build be submitted for the forked engine to reject later. + */ + static boolean hardeningReducesToOff(Properties settings, String level) { + return hardeningReducesToOff(settings, level, null); + } + + /** + * As {@link #hardeningReducesToOff(Properties, String)}, but taking the resolved hardening + * {@code platform} so a transform the engine SKIPS as unsafe on that target does not keep the level + * from reducing to off. Without this, a local iOS build with only control-flow left on (which the + * engine skips on the ParparVM native ports), or a JavaScript build with only string encryption left + * on (skipped on JS), would be rejected for a hardening it would never actually apply. + */ + static boolean hardeningReducesToOff(Properties settings, String level, String platform) { + return hardenLevelRank(level) >= 1 && !hardeningRequestsAnyTransform(settings, level, platform); + } + + static boolean hardeningRequestsAnyTransform(Properties settings, String level) { + return hardeningRequestsAnyTransform(settings, level, null); + } + + static boolean hardeningRequestsAnyTransform(Properties settings, String level, String platform) { + int rank = hardenLevelRank(level); + if (rank <= 0) { + return false; + } + // rank is 1..3 here (standard/aggressive/paranoid), so the standard-level defaults -- renaming + // and constant-string encryption -- are on unless explicitly overridden off. Control-flow is a + // default only from aggressive up. + boolean atLeastAggressive = rank >= 2; + // Rename is delivered on every platform (by the engine, or by R8 on Android), so it always counts. + boolean rename = hardenBoolTri( + settings.getProperty("codename1.arg.harden.rename"), true); + // String encryption and control-flow are subject to the engine's platform-safety rules: a + // transform the engine would skip on this target must not, on its own, keep the level from + // reducing to off. When the platform is unknown the safety checks pass (conservative -- the + // build is still preflighted rather than silently allowed). + boolean stringsOn = hardenStringsRequested( + settings.getProperty("codename1.arg.harden.strings"), true) + && stringEncryptionAppliesOn(platform); + boolean controlFlow = hardenBoolTri( + settings.getProperty("codename1.arg.harden.controlFlow"), atLeastAggressive) + && controlFlowAppliesOn(platform); + return rename || stringsOn || controlFlow; + } + + /** Engine rule: string encryption is skipped only on JavaScript (it would break the JS bridge). */ + private static boolean stringEncryptionAppliesOn(String platform) { + return !"javascript".equals(platform); + } + + /** + * Engine rule: control-flow obfuscation runs only on the JVM-bytecode ports (Android, JavaSE/ + * desktop); it is skipped on the ParparVM native ports and JavaScript. An unknown platform is + * treated as applicable so an ambiguous build is preflighted rather than silently allowed. + */ + private static boolean controlFlowAppliesOn(String platform) { + if (platform == null) { + return true; + } + return "and".equals(platform) || "android".equals(platform) + || "javase".equals(platform) || "desktop".equals(platform); + } + + /** off/empty/unknown = 0, standard = 1, aggressive = 2, paranoid = 3. */ + private static int hardenLevelRank(String level) { + if (level == null) { + return 0; + } + String v = level.trim().toLowerCase(); + if ("standard".equals(v)) { + return 1; + } + if ("aggressive".equals(v)) { + return 2; + } + if ("paranoid".equals(v)) { + return 3; + } + return 0; + } + + /** Tri-state boolean matching the engine's {@code HardeningConfig.boolTri}. */ + private static boolean hardenBoolTri(String value, boolean def) { + if (value == null) { + return def; + } + String t = value.trim().toLowerCase(); + if (t.isEmpty()) { + return def; + } + if ("true".equals(t) || "1".equals(t) || "2".equals(t) || "3".equals(t) || "on".equals(t)) { + return true; + } + if ("false".equals(t) || "0".equals(t) || "off".equals(t)) { + return false; + } + return def; + } + + /** + * Whether string encryption is requested, matching {@code HardeningConfig}'s {@code harden.strings} + * parsing: {@code off} disables; {@code constants}/{@code all} enable; anything else (including an + * unset value) falls back to the level default, which the CLI validates up front. + */ + private static boolean hardenStringsRequested(String strings, boolean def) { + if (strings == null) { + return def; + } + String v = strings.trim().toLowerCase(); + if (v.isEmpty()) { + return def; + } + if ("off".equals(v)) { + return false; + } + if ("constants".equals(v) || "all".equals(v)) { + return true; + } + return def; + } + + /** Maps {@code codename1.platform} to the {@code harden..enabled} opt-out key. */ + private static String normalizeHardenPlatform(String platform) { + if (platform == null) { + return null; + } + String p = platform.trim().toLowerCase(); + if (p.startsWith("android")) { + return "and"; + } + if (p.startsWith("ios")) { + return "ios"; + } + if (p.contains("javascript")) { + return "javascript"; + } + if (p.contains("win")) { + return "win"; + } + if (p.contains("mac")) { + return "mac"; + } + if (p.contains("linux")) { + return "linux"; + } + if (p.contains("javase") || p.contains("desktop")) { + return "javase"; + } + return null; + } + + /** + * Merge a set of jars into a single jar file. + * @param dest The destination jar file. Also the first source if it already exists. + * @param src The source jar files to be merged into the destination. + */ + private void mergeJars(File dest, File... src) { + Zip task = (Zip)antProject.createTask("zip"); + task.setDestFile(dest); + task.setUpdate(true); + for (File srcFile : src) { + + if (srcFile.isDirectory()) { + FileSet fs = new FileSet(); + // Multiversioned jars trip out the ASM class parsing. + // Specifically module-info.class files + fs.setExcludes("**/META-INF/versions/**"); + fs.setProject(this.antProject); + fs.setDir(srcFile); + task.addFileset(fs); + + } else { + ZipFileSet fileset = new ZipFileSet(); + // Multiversioned jars trip out the ASM class parsing. + // Specifically module-info.class files + fileset.setExcludes("**/META-INF/versions/**"); + fileset.setProject(antProject); + fileset.setSrc(srcFile); + task.addZipfileset(fileset); + } + } + task.execute(); + } + + /** + * Local JavaScript builds run ParparVM's translator on this machine, so + * they keep the classes a server build would have re-supplied. + */ + public static boolean isLocalJavascriptBuild(String buildTarget) { + return buildTarget != null && buildTarget.contains("javascript") && isLocalBuildTarget(buildTarget); + } + + // A null build target never reaches these from the mojo -- the parameter is + // required -- but they are static and package private, so treat an absent + // target as "not local": the conservative answer, since it keeps the server + // supplied artifacts out of the staged jar rather than throwing. + + /** + * Whether the build itself re-supplies the artifact's classes after they + * are left out of the staged jar: codenameone-core and java-runtime come + * from the build server (or, for a local JavaScript build, stay in the jar + * so ParparVM's translator can see them), and kotlin-stdlib comes from the + * build server too. This is deliberately narrower than + * {@link #isStrippedFromStagedJar}: a reference into anything else that is + * left out really is a reference to a class nobody will supply. + */ + public static boolean isSuppliedByBuildServer(String groupId, String artifactId, String buildTarget) { + if (GROUP_ID.equals(groupId) && contains(artifactId, BUNDLE_ARTIFACT_ID_BLACKLIST)) { + return !isLocalJavascriptBuild(buildTarget); + } + return !isLocalBuildTarget(buildTarget) + && "org.jetbrains.kotlin".equals(groupId) + && "kotlin-stdlib".equals(artifactId); + } + + /** + * Whether the given artifact is left out of the jar-with-dependencies that + * is staged for the build, either because the build re-supplies it or + * because its scope says it is not part of the application. Shared by the + * jar assembly and the class closure check so the two can never disagree + * about what is in the jar. + */ + public static boolean isStrippedFromStagedJar(String groupId, String artifactId, String scope, String buildTarget) { + if (GROUP_ID.equals(groupId) && contains(artifactId, BUNDLE_ARTIFACT_ID_BLACKLIST)) { + // For local JavaScript builds we need codenameone-core and java-runtime classes + // in the staged jar - the build server normally re-supplies those, but ParparVM's + // ByteCodeTranslator runs locally here and resolves everything from the staged class + // directory. + return !isLocalJavascriptBuild(buildTarget); + } + if (isSuppliedByBuildServer(groupId, artifactId, buildTarget)) { + // When sending to the build server, we'll strip the kotlin-stdlib and the server will + // provide it. For local builds, it's easier to just include it. + return true; + } + // An artifact with no scope was never scoped out of the application, so + // keep its classes rather than dropping them from the jar. + return scope != null && !"compile".equals(scope); + } + + private boolean isStrippedFromStagedJar(BuildArtifact artifact) { + return isStrippedAsDesktopRuntime(artifact) + || isStrippedFromStagedJar(artifact.getGroupId(), artifact.getArtifactId(), artifact.getScope(), buildTarget); + } + + private boolean isStrippedAsDesktopRuntime(BuildArtifact artifact) { + if (!isDesktopRuntimeBinary(artifact.getGroupId(), artifact.getArtifactId(), artifact.getDependencyTrail())) { + return false; + } + if (isDesktopRuntimeAggregator(artifact.getGroupId(), artifact.getArtifactId())) { + return true; + } + return isStrippedAsDesktopRuntime(dependencyKey(artifact.getGroupId(), artifact.getArtifactId(), + artifact.getClassifier()), neededWithoutDesktopRuntime()); + } + + /** + * Whether an artifact reached through the desktop runtime aggregator is left out of the + * staged jar: only when nothing else the application ships needs it. + * + *

The dependency trail cannot answer that on its own. Maven keeps one resolved + * artifact and one winning trail per coordinate, so when an application library also + * needs an ffmpeg artifact but the aggregator's path is the nearer one, the trail names + * only the aggregator. Stripping on the trail alone would then leave the library without + * classes it needs.

+ * + * @param neededElsewhere the dependency keys the application's own compile dependencies + * need with the aggregator removed, or {@code null} when that could + * not be determined, in which case nothing is stripped + */ + public static boolean isStrippedAsDesktopRuntime(String dependencyKey, Set neededElsewhere) { + return neededElsewhere != null && !neededElsewhere.contains(dependencyKey); + } + + /** groupId:artifactId:classifier, the identity {@link #isStrippedAsDesktopRuntime} compares. */ + public static String dependencyKey(String groupId, String artifactId, String classifier) { + return groupId + ":" + artifactId + ":" + (classifier == null ? "" : classifier); + } + + public static boolean isDesktopRuntimeAggregator(String groupId, String artifactId) { + return GROUP_ID.equals(groupId) && DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID.equals(artifactId); + } + + /** + * Whether an existing staged jar may be used instead of merging a new one (before the + * timestamp check, which still applies). + * + *

With a record, only when it names exactly the current inputs. Without one, the jar + * was not written by this mojo -- or was written by a plugin older than the record, which + * is indistinguishable from a stale one. A project may deliberately produce this jar from + * its own pom to control what is uploaded, and that is still honoured: such a jar is + * written during the current Maven run, while a stale one predates it.

+ * + *

Known limit, left deliberately: a record wins over the current-run exception. A + * project that once let this mojo stage the jar, then starts producing it from its own + * pom without cleaning target, and whose dependencies changed in between, has its jar + * replaced by a merged one. That customization is undocumented and used by nothing in + * the tree or the archetypes; the fix is to clean target once.

+ * + * @param recorded the recorded inputs, trimmed, or {@code null} if there is no record + * @param current the inputs this build would merge + * @param jarModified the staged jar's modification time + * @param sessionStart when the current Maven run started + */ + static boolean mayReuseStagedJar(String recorded, String current, long jarModified, long sessionStart) { + if (recorded == null) { + return jarModified >= sessionStart; + } + return recorded.equals(current.trim()); + } + + /** + * Deletes a staged jar that must not be reused, and fails if it is still there. Ignoring + * a failed delete (a jar held open on Windows, a read-only target) would skip the merge + * and upload the very jar that was just found stale. + */ + static void discardStagedJar(File jar) throws BuildExecutionException { + if (!jar.delete() && jar.exists()) { + throw new BuildExecutionException("Could not delete the out of date staged jar " + jar + + ", so it cannot be rebuilt and would be uploaded as it is. Close anything that" + + " has it open (an IDE, an antivirus scanner) or delete it by hand, then build again."); + } + } + + /** + * The record of what a staged jar was merged from, one absolute path per line in merge + * order. Compared, not parsed: any difference means the cached jar is not this build's. + */ + static String describeStagedInputs(List jarsToMerge) { + StringBuilder sb = new StringBuilder(); + for (File f : jarsToMerge) { + sb.append(f.getAbsolutePath()).append('\n'); + } + return sb.toString(); + } + + /** + * The aggregator that puts the desktop media runtime -- org.bytedeco's ffmpeg, + * with natives for Android, iOS, Linux, macOS and Windows -- on the simulator + * and desktop run classpaths. + */ + public static final String DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID = "cn1-binaries-javase"; + + /** + * Whether the artifact is the desktop runtime aggregator or was resolved + * through it: the candidates for leaving out of the staged jar, whatever + * their scope. The aggregator itself is always left out; anything it pulled + * in is left out only when nothing else needs it + * ({@link #isStrippedAsDesktopRuntime(String, Set)}). + * + *

Projects generated from the archetype between #5380 and the fix for it + * declare the aggregator at compile scope, and no profile re-scopes it, so + * by scope alone it lands in the staged jar: about 300 MB of ffmpeg natives + * that the build client refuses to upload, failing every desktop build of + * every such project. Deciding by the dependency graph rather than the scope + * repairs those projects without asking anyone to edit a pom.

+ */ + public static boolean isDesktopRuntimeBinary(String groupId, String artifactId, List dependencyTrail) { + if (isDesktopRuntimeAggregator(groupId, artifactId)) { + return true; + } + if (dependencyTrail == null) { + return false; + } + // Trail entries are Artifact.getId(): groupId:artifactId:type:version. + String aggregator = GROUP_ID + ":" + DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID + ":"; + for (String node : dependencyTrail) { + if (node != null && node.startsWith(aggregator)) { + return true; + } + } + return false; + } + + /** + * Fails the build when the staged jar-with-dependencies contains an + * application class that references another application class which is not + * in the jar. Stale build output causes exactly this -- e.g. an IDE deletes + * the class files of removed/renamed sources while a dependent class + * compiled against them survives in target/classes, and an incremental + * Maven compile then ships it. The server-side VM translators only warn + * about the dangling reference and the build later fails with an obscure + * native compiler error (a missing generated header on iOS), so catch it + * here with an actionable message before uploading anything. + */ + private void verifyApplicationClassClosure(File jarWithDependencies, List cpElements) throws BuildFailureException { + if ("true".equals(System.getProperty("codename1.skipClassClosureCheck", "false")) + || "true".equals(projectProperties().getProperty("codename1.skipClassClosureCheck", "false"))) { + getLog().info("Skipping application class closure check because codename1.skipClassClosureCheck=true"); + return; + } + // The project package space: compiled output directories plus module + // jars owned by this build (the app's common jar reaches the platform + // modules as a jar dependency, identified by the shared groupId). + List projectClassRoots = new ArrayList(); + for (String element : cpElements) { + File dir = new File(element); + if (dir.isDirectory()) { + projectClassRoots.add(dir); + } + } + for (BuildArtifact artifact : artifacts()) { + if (projectGroupId().equals(artifact.getGroupId())) { + File jar = getJar(artifact); + if (jar != null && jar.isFile()) { + projectClassRoots.add(jar); + } + } + } + // Classes left out of the staged jar that the build re-supplies: references + // into them are not missing. Anything else that was left out -- a `provided` + // scope third party dependency, say -- stays reportable, because nothing + // puts those classes back before the translators run. + boolean localJavascriptBuild = isLocalJavascriptBuild(buildTarget); + Set providedJars = new LinkedHashSet(); + for (BuildArtifact artifact : artifacts()) { + if (isSuppliedByBuildServer(artifact.getGroupId(), artifact.getArtifactId(), buildTarget)) { + File jar = getJar(artifact); + if (jar != null && jar.isFile()) { + providedJars.add(jar); + } + } + } + if (!localJavascriptBuild) { + // codenameone-core and java-runtime are `provided` scope in the app's common + // module and `provided` is not transitive, so a platform module (ios, android, + // ...) that only depends on common does not see them among its own artifacts. + // They still have to count as provided: without them every reference into a + // package the app shares with the framework -- a patched framework class, or a + // helper deliberately placed in a com.codename1 package to reach package + // private API -- makes the whole framework package look like it is missing + // from the build. Resolve them from the plugin's own dependencies instead. + boolean coreResolved = false; + for (String bundled : BUNDLE_ARTIFACT_ID_BLACKLIST) { + File jar = getJar(GROUP_ID, bundled); + if (jar != null && jar.isFile()) { + providedJars.add(jar); + if ("codenameone-core".equals(bundled)) { + coreResolved = true; + } + } + } + if (!coreResolved) { + // Every application class references the framework, so without its class + // list the check cannot tell a stale class from a framework class and would + // report false positives. Skipping is the only safe answer, but say so + // loudly: a stale class will now reach the build unreported. + getLog().warn("Skipping the application class closure check for build target " + buildTarget + + " because " + GROUP_ID + ":codenameone-core could not be resolved." + + " Stale application classes will not be detected before the build runs."); + return; + } + } + Map> missing; + try { + missing = ClassClosureVerifier.findMissingProjectReferences(jarWithDependencies, projectClassRoots, providedJars); + } catch (IOException ex) { + getLog().warn("Could not verify the consistency of the application classes: " + ex.getMessage()); + return; + } + if (missing.isEmpty()) { + return; + } + StringBuilder sb = new StringBuilder(); + sb.append("The compiled application classes are inconsistent. The following class"); + sb.append(missing.size() == 1 ? " is" : "es are"); + sb.append(" referenced by your code but missing from the build:\n"); + for (Map.Entry> e : missing.entrySet()) { + sb.append(" - ").append(e.getKey().replace('/', '.')) + .append(" (referenced from "); + boolean first = true; + for (String referencing : e.getValue()) { + if (!first) { + sb.append(", "); + } + first = false; + sb.append(referencing.replace('/', '.')); + } + sb.append(")\n"); + } + sb.append("This usually means the build output contains stale classes from a previous build,\n"); + sb.append("e.g. after renaming, moving or deleting a class, or switching branches, without a\n"); + sb.append("clean rebuild. Run 'mvn clean' and rebuild. If a listed class was removed\n"); + sb.append("intentionally, also remove or update the code that still references it.\n"); + sb.append("To bypass this check build with -Dcodename1.skipClassClosureCheck=true"); + throw new BuildFailureException(sb.toString()); + } + + + /** + * Localized launcher icons (cn1_icon_<lang>[_<country>].png) are scaled up to the + * largest launcher density by the build server, so a low-resolution source produces a + * blurry icon. Maven copies these into the build output (target/classes) with its + * incremental resource plugin, which also leaves stale copies behind when a source icon + * is removed or replaced (only {@code mvn clean} clears them). Scan the compiled output + * directories that will be bundled and sent to the build server and warn about any + * localized icon that is too small to render sharply -- this catches both an undersized + * new icon and an outdated low-resolution one lingering in target/classes. + * + * @param classpathElements the compile classpath; directory entries are the project / + * module {@code target/classes} folders that get bundled. + * @param codenameOneSettings the project's codenameone_settings.properties, used to detect + * whether adaptive icons are enabled (which raises the target size). + */ + private void warnAboutSmallLocalizedIcons(List classpathElements, File codenameOneSettings) + throws BuildFailureException { + int largestTarget = 192; + try { + Properties settings = new Properties(); + try (FileInputStream fis = new FileInputStream(codenameOneSettings)) { + settings.load(fis); + } + if ("true".equals(settings.getProperty("codename1.arg.android.enableAdaptiveIcons", "false").trim())) { + largestTarget = 432; + } + } catch (IOException ex) { + getLog().debug("Could not read " + codenameOneSettings + " to determine adaptive icon setting", ex); + } + List undersizedIcons = new ArrayList(); + for (String element : classpathElements) { + File dir = new File(element); + if (dir.isDirectory()) { + collectSmallLocalizedIcons(dir, largestTarget, undersizedIcons); + } + } + if (!undersizedIcons.isEmpty()) { + throw new BuildFailureException("The following localized launcher icon(s) are smaller than " + + largestTarget + "x" + largestTarget + "px and would be upscaled to a blurry icon in the " + + "production build:\n " + String.join("\n ", undersizedIcons) + + "\nSupply each localized icon at no less than " + largestTarget + "x" + largestTarget + + "px (1024x1024 recommended, matching the main app icon). NOTE: if you recently replaced an icon, " + + "an offending copy may be a stale resource left in target/classes -- run 'mvn clean' to clear it."); + } + } + + private void collectSmallLocalizedIcons(File dir, int largestTarget, List undersizedIcons) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collectSmallLocalizedIcons(child, largestTarget, undersizedIcons); + continue; + } + String lower = child.getName().toLowerCase(); + if (!lower.startsWith("cn1_icon_") || !lower.endsWith(".png")) { + continue; + } + try { + BufferedImage img = ImageIO.read(child); + if (img == null) { + undersizedIcons.add(child + " (not a valid PNG image)"); + continue; + } + if (img.getWidth() < largestTarget || img.getHeight() < largestTarget) { + undersizedIcons.add(child + " (" + img.getWidth() + "x" + img.getHeight() + "px)"); + } + } catch (IOException ex) { + getLog().debug("Could not read localized icon " + child + " to check its resolution", ex); + } + } + } + + /** + * The dependency scopes to include in the jar file that is sent to the build server. + */ + private static String[] BUNDLE_ARTIFACT_SCOPES = new String[] { "compile" }; + + /** + * Artifact IDs that should not be sent to the build server. + */ + private static String[] BUNDLE_ARTIFACT_ID_BLACKLIST = new String[] {"codenameone-core", "java-runtime"}; + + + /** + * Gets the app extensions jar file that should be included in any iOS builds. + * @return The app extensions jar file if it exists. null otherwise. + * @throws IOException + */ + private File getAppExtensionsJar() throws IOException { + + if (!"ios".equalsIgnoreCase(platform)) { + // App extensions are only for iOS + return null; + } + + File appExtensionsDir = host.layout().iosAppExtensionsDir(); + + if (!appExtensionsDir.isDirectory()) return null; + + File appExtensionsJar = new File(workDirectory(), "app_extensions.jar"); + if (appExtensionsJar.exists() && appExtensionsJar.lastModified() < lastModifiedRecursive(appExtensionsDir)) { + // The app extensions jar is out of date. + appExtensionsJar.delete(); + } + + if (!appExtensionsJar.exists()) { + File tmpDir = new File(appExtensionsJar.getParentFile(), "app_extensions"); + if (tmpDir.exists()) { + FileUtils.deleteDirectory(tmpDir); + } + tmpDir.mkdirs(); + for (File appExtension : appExtensionsDir.listFiles()) { + Zip task = (Zip)antProject.createTask("zip"); + File dest = new File(tmpDir, appExtension.getName()+".ios.appext"); + task.setDestFile(dest); + task.setUpdate(false); + if (appExtension.isDirectory()) { + FileSet fs = new FileSet(); + fs.setProject(this.antProject); + fs.setDir(appExtension); + task.addFileset(fs); + task.execute(); + } else if (appExtension.getName().endsWith(".zip")) { + ZipFileSet fileset = new ZipFileSet(); + fileset.setProject(antProject); + fileset.setSrc(appExtension); + task.addZipfileset(fileset); + task.execute(); + } + + + } + Zip task = (Zip)antProject.createTask("zip"); + task.setDestFile(appExtensionsJar); + task.setUpdate(false); + FileSet fs = new FileSet(); + fs.setProject(this.antProject); + fs.setDir(tmpDir); + task.addFileset(fs); + task.execute(); + + } + + if (appExtensionsJar.exists()) { + return appExtensionsJar; + } + + return null; + } + + /** + * Gets the localizations jar file that should be included in any iOS builds. + * @return The localizations jar file if it exists. null otherwise. + * @throws IOException + */ + private File getStringsJar() throws IOException { + + if (!"ios".equalsIgnoreCase(platform)) { + // Localized strings are only for iOS + return null; + } + + File stringsDir = host.layout().iosStringsDir(); + + if (!stringsDir.isDirectory()) return null; + + File stringsJar = new File(workDirectory(), "strings.jar"); + if (stringsJar.exists() && stringsJar.lastModified() < lastModifiedRecursive(stringsDir)) { + // The app extensions jar is out of date. + stringsJar.delete(); + } + + if (!stringsJar.exists()) { + File tmpDir = new File(stringsJar.getParentFile(), "strings"); + if (tmpDir.exists()) { + FileUtils.deleteDirectory(tmpDir); + } + tmpDir.mkdirs(); + for (File lproj : stringsDir.listFiles()) { + Zip task = (Zip)antProject.createTask("zip"); + File dest = new File(tmpDir, lproj.getName()+".zip"); + task.setDestFile(dest); + task.setUpdate(false); + if (lproj.isDirectory()) { + FileSet fs = new FileSet(); + fs.setProject(this.antProject); + fs.setDir(lproj); + task.addFileset(fs); + task.execute(); + } else if (lproj.getName().endsWith(".zip")) { + ZipFileSet fileset = new ZipFileSet(); + fileset.setProject(antProject); + fileset.setSrc(lproj); + task.addZipfileset(fileset); + task.execute(); + } + + + } + Zip task = (Zip)antProject.createTask("zip"); + task.setDestFile(stringsJar); + task.setUpdate(false); + FileSet fs = new FileSet(); + fs.setProject(this.antProject); + fs.setDir(tmpDir); + task.addFileset(fs); + task.execute(); + + } + + if (stringsJar.exists()) { + return stringsJar; + } + + return null; + } + + public static final String BUILD_TARGET_MAC_NATIVE_PROJECT = Executor.BUILD_TARGET_MAC_NATIVE_PROJECT; + public static final String BUILD_TARGET_MAC_NATIVE = Executor.BUILD_TARGET_MAC_NATIVE; + public static final String BUILD_TARGET_MAC_NATIVE_LOCAL = Executor.BUILD_TARGET_MAC_NATIVE_LOCAL; + public static final String BUILD_TARGET_LINUX_NATIVE = Executor.BUILD_TARGET_LINUX_NATIVE; + + /** + * The entry points a project can declare besides {@code codename1.mainName}, + * mapped to the build argument each one becomes. A project with a + * {@code codename1.watchMain} gets an Apple Watch and a Wear OS app built + * from that root; {@code codename1.tvMain} does the same for tvOS. The + * accompanying {@code codename1.watchStandalone} says the watch app ships on + * its own rather than alongside the phone app. + * + *

These ride the extensible build-argument map rather than the + * {@link BuildRequest} wire format, so adding an entry point needs no + * protocol change. + */ + private static final Map SECONDARY_ENTRY_POINTS; + static { + Map m = new LinkedHashMap(); + m.put("codename1.watchMain", "watchMain"); + m.put("codename1.watchStandalone", "watchStandalone"); + m.put("codename1.tvMain", "tvMain"); + SECONDARY_ENTRY_POINTS = Collections.unmodifiableMap(m); + } + + /** + * Copies the secondary entry points declared in the project settings onto a + * local {@link BuildRequest}. The cloud path does the equivalent by mirroring + * them into the {@code codename1.arg.} namespace of the uploaded settings + * file, so both paths hand the builders the same arguments. + * + * @param r the request being assembled + * @param props the project's codenameone_settings.properties + */ + static void putSecondaryEntryPointArguments(BuildRequest r, Properties props) { + for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) { + // The overlaid build argument first, exactly as the cloud mirror resolves it. + // + // Reading only the unprefixed project setting made the two paths disagree about the + // same invocation: -Dcodename1.arg.watchMain=... reached the daemon, because the + // mirror leaves an existing value alone, and was ignored locally -- so the standard + // override built one product in the cloud and a different one, or none, on the + // developer's machine. An override that works in one place and silently does nothing + // in the other is worse than one that works nowhere. + String value = props.getProperty("codename1.arg." + entry.getValue()); + if (value == null || value.trim().length() == 0) { + value = props.getProperty(entry.getKey()); + } + if (value != null && value.trim().length() > 0) { + r.putArgument(entry.getValue(), value.trim()); + } + } + } + + /** + * Copies the secondary entry points into the {@code codename1.arg.} namespace + * of the settings file that is uploaded to the build server. + * + *

They are declared without that prefix because they sit next to + * {@code codename1.mainName} and that is the shape developers expect. The + * server, however, only lifts {@code codename1.arg.*} keys out of the + * uploaded file, so without this mirror a cloud build never learns that the + * project has a watch or TV app and silently produces neither. + * + * @param props the settings being prepared for upload, mutated in place + */ + static void mirrorSecondaryEntryPointsToBuildArgs(Properties props) { + for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) { + String value = props.getProperty(entry.getKey()); + if (value == null || value.trim().length() == 0) { + continue; + } + String argKey = "codename1.arg." + entry.getValue(); + // An existing value WINS. This runs after overlayCommandLineBuildHints, so a + // -Dcodename1.arg.watchMain=... passed on the command line is already sitting here -- + // and overwriting it with the project file's codename1.watchMain made the standard + // build-argument override silently do nothing, handing the cloud the wrong lifecycle or + // a companion where a standalone Wear build was asked for. + // + // The mirror exists to carry a project-file setting into the args channel the daemon + // reads, which is only needed when nothing has put it there already. + String existing = props.getProperty(argKey); + if (existing != null && existing.trim().length() > 0) { + continue; + } + props.setProperty(argKey, value.trim()); + } + } + + public static boolean isLocalBuildTarget(String buildTarget) { + if (buildTarget == null) { + return false; + } + // windows-device (BUILD_TARGET_WINDOWS_NATIVE) is a *cloud* build: it sends + // a "win32" build to the server (see the windows-device target in + // buildxml-template.xml), mirroring linux-device. Only the explicit + // local-windows-device cross-compile and the windows-source project + // generation are local. + return (buildTarget.startsWith("local-") || BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) + || BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget) + || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget) + || BUILD_TARGET_WINDOWS_NATIVE_PROJECT.equals(buildTarget)); + } + + /** + * Collapses build-type-qualified app-extension keys into the unqualified keys the rest + * of the build pipeline understands. Like the app's own signing assets + * ({@code codename1.ios.debug.provision} vs {@code codename1.ios.release.provision}), + * an extension's provisioning profile differs between development and distribution + * builds, so both plain settings ({@code codename1.ios.debug.appext..provision}) + * and build hints ({@code codename1.arg.ios.release.appext..provisioningURL}) + * accept a {@code debug}/{@code release} qualifier after the {@code ios.} segment. + * The variant matching the build target overrides the unqualified key; both variants + * are removed afterwards so the build server only ever sees the resolved value. + */ + static void resolveAppExtensionBuildTypeQualifiers(Properties props, String buildTarget) { + String matching = buildTarget != null && buildTarget.contains("release") ? "release" : "debug"; + for (String key : new ArrayList(props.stringPropertyNames())) { + for (String prefix : new String[] {"codename1.arg.ios.", "codename1.ios."}) { + String qualifier; + if (key.startsWith(prefix + "debug.appext.")) { + qualifier = "debug"; + } else if (key.startsWith(prefix + "release.appext.")) { + qualifier = "release"; + } else { + continue; + } + String value = props.getProperty(key); + props.remove(key); + if (qualifier.equals(matching) && value != null && value.trim().length() > 0) { + props.setProperty(prefix + "appext." + + key.substring((prefix + qualifier + ".appext.").length()), value); + } + break; + } + } + } + + /// One property of a settings file, or null. + private static String settingsProperty(File settings, String key) throws IOException { + java.util.Properties p = new java.util.Properties(); + if (settings.isFile()) { + try (java.io.InputStream in = new java.io.FileInputStream(settings)) { + p.load(in); + } + } + return p.getProperty(key); + } + + private void createAntProject() throws IOException, LibraryPropertiesException, BuildExecutionException, BuildFailureException { + File cn1dir = workDirectory(); + File antProject = new File(cn1dir, "antProject"); + + antProject.mkdirs(); + File codenameOneSettings = new File(getCN1ProjectDir(), "codenameone_settings.properties"); + File icon = new File(getCN1ProjectDir(), "icon.png"); + if (icon.exists()) { + FileUtils.copyFile(icon, new File(antProject, "icon.png")); + } else { + FileUtils.copyInputStreamToFile(AppBuilder.class.getResourceAsStream("codenameone-icon.png"), new File(antProject, "icon.png")); + } + + File codenameOneSettingsCopy = new File(antProject, codenameOneSettings.getName()); + FileUtils.copyFile(codenameOneSettings, codenameOneSettingsCopy); + // build.xml hands every target icon="${codename1.icon}", resolved against + // this staging project: an icon the settings name at another relative path + // (branding/app.png) has to be staged there too, or the build cannot read it. + String customIcon = settingsProperty(codenameOneSettings, "codename1.icon"); + if (customIcon != null && customIcon.trim().length() > 0 && !new File(customIcon.trim()).isAbsolute() + && !"icon.png".equals(customIcon.trim())) { + File source = new File(getCN1ProjectDir(), customIcon.trim()); + if (source.isFile()) { + FileUtils.copyFile(source, new File(antProject, customIcon.trim())); + } + } + FileUtils.copyInputStreamToFile(AppBuilder.class.getResourceAsStream("buildxml-template.xml"), new File(antProject, "build.xml")); + File distDir = new File(antProject, "dist"); + distDir.mkdirs(); + // The UpdateCodenameOne run inside the ant build writes lib/CLDC11.jar without + // creating parent dirs; make sure the dir exists (same latent hole as the + // template tmpProject -- only surfaces when the server publishes new versions). + new File(antProject, "lib").mkdirs(); + + + // Build a jar with all dependencies that we will send to the build server. + File jarWithDependencies = new File(path(buildDirectory(), finalName() + "-"+buildTarget+"-jar-with-dependencies.jar")); + List cpElements; + try { + //getLog().info("Classpath Elements: "+ compileClasspathElements()); + cpElements = compileClasspathElements(); + } catch (Exception ex) { + throw new BuildExecutionException("Failed to get classpath elements", ex); + + } + + warnAboutSmallLocalizedIcons(cpElements, codenameOneSettings); + + File appExtensionsJar = getAppExtensionsJar(); + if (appExtensionsJar != null) { + cpElements.add(appExtensionsJar.getAbsolutePath()); + } + File stringsJar = getStringsJar(); + if (stringsJar != null) { + cpElements.add(stringsJar.getAbsolutePath()); + } + getLog().debug("Classpath Elements: "+cpElements); + // Decide what goes into the staged jar BEFORE deciding whether a cached one can be + // reused. The decision has side effects the build needs either way (the kotlin-stdlib + // version the server must supply), and the cached jar is only reusable when it was + // built from exactly this set. + List blackListJars = new ArrayList(); + boolean localJsBuild = isLocalJavascriptBuild(buildTarget); + for (BuildArtifact artifact : artifacts()) { + boolean addToBlacklist = isStrippedFromStagedJar(artifact); + if (addToBlacklist && !isLocalBuildTarget(buildTarget) + && "org.jetbrains.kotlin".equals(artifact.getGroupId()) + && "kotlin-stdlib".equals(artifact.getArtifactId())) { + serverMustProvideKotlinVersion = artifact.getVersion(); + getLog().debug("Adding kotlin-stdlib to blacklist. Server will provide this:" + artifact); + } + if (addToBlacklist) { + File jar = getJar(artifact); + if (jar != null) { + blackListJars.add(jar.getAbsolutePath()); + blackListJars.add(jar.getPath()); + try { + blackListJars.add(jar.getCanonicalPath()); + getLog().debug("Added "+jar+" to blacklist"); + } catch (Exception ex){ + getLog().debug("Failed to add " + jar + " to blacklist. This is not a fatal error: " + ex); + } + } + } + } + List jarsToMerge = new ArrayList(); + for (String element : cpElements) { + + String canonicalEl = element; + try { + canonicalEl = new File(canonicalEl).getCanonicalPath(); + } catch (Exception ex){ + if (getLog().isDebugEnabled()) { + getLog().warn("Failed to resolve canonical path for " + element, ex); + } + } + + if (blackListJars.contains(element) || blackListJars.contains(canonicalEl)) { + getLog().debug("NOT adding jar "+element+" because it is on the blacklist"); + continue; + } + if (!new File(element).exists()) { + continue; + } + jarsToMerge.add(new File(element)); + } + if (localJsBuild) { + // For local JavaScript builds we need codenameone-core and java-runtime classes + // in the staged jar -- the build server normally re-supplies those, but ParparVM's + // ByteCodeTranslator runs locally here and resolves everything from the staged class + // directory. `provided`-scope deps are not transitive, so a child module that only + // depends on a `common` library never sees the project's codenameone-core / + // java-runtime jars on its compile classpath. Pull them in explicitly here. + for (String bundled : BUNDLE_ARTIFACT_ID_BLACKLIST) { + File jar = getJar("com.codenameone", bundled); + if (jar != null && jar.isFile() && !jarsToMerge.contains(jar)) { + getLog().info("Adding local-javascript dependency to jar-with-dependencies: " + jar); + jarsToMerge.add(jar); + } + } + } + + // Each staged jar this mojo writes records the inputs it was merged from. Timestamps + // alone cannot see a change in what the plugin excludes, so a jar staged before an + // exclusion was added (the ~157 MB of ffmpeg natives from #5380) would otherwise be + // reused, and uploaded, for as long as nothing on the classpath was rebuilt. + File stagedInputsFile = new File(jarWithDependencies.getPath() + ".inputs"); + String stagedInputs = describeStagedInputs(jarsToMerge); + if (jarWithDependencies.exists()) { + getLog().debug("Found jar file with dependencies at "+jarWithDependencies+". Will use that one unless it is out of date."); + long sessionStart = sessionStartTime(); + if (!mayReuseStagedJar(readTextFileOrNull(stagedInputsFile), stagedInputs, + jarWithDependencies.lastModified(), sessionStart)) { + getLog().debug("Jar file was not staged from these inputs. "+jarWithDependencies+". Deleting"); + discardStagedJar(jarWithDependencies); + } else { + for (String artifact : cpElements) { + File jar = new File(artifact); + if (jar.isDirectory()) { + if (jarWithDependencies.lastModified() < lastModifiedRecursive(jar)) { + getLog().debug("Jar file out of date. Dependencies have changed. "+jarWithDependencies+". Deleting"); + discardStagedJar(jarWithDependencies); + break; + } + } else if (jar.exists() && jar.lastModified() > jarWithDependencies.lastModified()) { + // One of the dependency jar files is newer... so we delete the dependencies jar file + // and will generate a new one. + getLog().debug("Jar file out of date. Dependencies have changed. "+jarWithDependencies+". Deleting"); + discardStagedJar(jarWithDependencies); + break; + } + } + } + } + + if (!jarWithDependencies.exists()) { + getLog().info(jarWithDependencies + " not found. Generating jar with dependencies now"); + getLog().debug("Merging into jar with dependencies: "+jarsToMerge); + stagedInputsFile.delete(); + mergeJars(jarWithDependencies, jarsToMerge.toArray(new File[jarsToMerge.size()])); + writeStringToFile(stagedInputsFile, stagedInputs); + } + + verifyApplicationClassClosure(jarWithDependencies, cpElements); + + if (stageOnly) { + getLog().info("codename1.stageOnly is set: staged " + jarWithDependencies + " (" + + jarWithDependencies.length() + " bytes) for " + buildTarget + " and stopped before building"); + return; + } + + try { + updateCodenameOne(false); + } catch (BuildExecutionException ex) { + getLog().error("Failed to update Codename One"); + throw new IOException("Failed to update Codename One", ex); + } + File antDistDir = new File(antProject, "dist"); + File antDistJar = new File(antDistDir, finalName() + "-"+buildTarget+"-jar-with-dependencies.jar"); + antDistDir.mkdirs(); + FileUtils.copyFile(jarWithDependencies, antDistJar); + Properties p = new Properties(); + p.setProperty("codenameone_settings.properties", codenameOneSettingsCopy.getAbsolutePath()); + p.setProperty("CodeNameOneBuildClient.jar", path(System.getProperty("user.home"), ".codenameone", "CodeNameOneBuildClient.jar")); + p.setProperty("dist.jar", antDistJar.getAbsolutePath()); + if (automated) { + p.setProperty("automated", "true"); + } + getLog().info("Running ANT build target " + buildTarget); + String logPasskey = UUID.randomUUID().toString(); + Properties cn1SettingsProps = new Properties(); + try (FileInputStream fis = new FileInputStream(codenameOneSettingsCopy)) { + cn1SettingsProps.load(fis); + } + // Build hints declared as annotations on the main class. Merged here, before + // everything that consumes the effective configuration: the command-line + // overlay below (so -D still wins), the CN1Lib appended/required merges (so a + // library appends onto an annotation-supplied value exactly as it would onto a + // file-supplied one), the gradle sanity check, both preflights, and the copy + // that is written back out and uploaded. + mergeAnnotationBuildHints(cn1SettingsProps, cpElements); + // The build request is assembled from this copy, not from the mojo's + // own properties, so the command-line overlay has to be applied here + // too -- otherwise a hint passed with -D is read by the mojo and still + // absent from what the builder actually sees. + overlayCommandLineBuildHints(cn1SettingsProps); + if (serverMustProvideKotlinVersion != null) { + cn1SettingsProps.setProperty("codename1.arg.requireKotlinStdlib", serverMustProvideKotlinVersion); + } + FileSystemManager fsManager = VFS.getManager(); + FileObject jarFile = fsManager.resolveFile( "jar:"+jarWithDependencies.getAbsolutePath() + "!/META-INF/codenameone" ); + if (jarFile != null) { + FileObject[] appendedPropsFiles = jarFile.findFiles(new PatternFileSelector(".*\\/codenameone_library_appended.properties")); + if (appendedPropsFiles != null) { + for (FileObject appendedPropsFile : appendedPropsFiles) { + SortedProperties appendedProps = new SortedProperties(); + try (InputStream appendedPropsIn = appendedPropsFile.getContent().getInputStream()) { + appendedProps.load(appendedPropsIn); + } + + for (String propName : appendedProps.stringPropertyNames()) { + String propVal = appendedProps.getProperty(propName); + if (!cn1SettingsProps.containsKey(propName)) { + cn1SettingsProps.put(propName, propVal); + } else { + String existing = cn1SettingsProps.getProperty(propName); + // Separator decided by the hint rather than by whatever the library + // baked into its own value -- see LibraryHintMerger for why a bare + // concatenation welds two Gradle statements into one. + if (!LibraryHintMerger.alreadyContains(propName, existing, propVal)) { + cn1SettingsProps.setProperty(propName, + LibraryHintMerger.append(propName, existing, propVal)); + } + } + } + } + } + FileObject[] requiredPropsFiles = jarFile.findFiles(new PatternFileSelector(".*\\/codenameone_library_required.properties")); + if (requiredPropsFiles != null) { + for (FileObject requiredPropsFile : requiredPropsFiles) { + SortedProperties requiredProps = new SortedProperties(); + try (InputStream appendedPropsIn = requiredPropsFile.getContent().getInputStream()) { + requiredProps.load(appendedPropsIn); + } + + String artifactId = requiredPropsFile.getParent().getName().getBaseName(); + String groupId = requiredPropsFile.getParent().getParent().getName().getBaseName(); + String libraryName = groupId + ":" + artifactId; + cn1SettingsProps = mergeRequiredProperties(libraryName, requiredProps, cn1SettingsProps); + } + } + + } + + // Fail here rather than in Gradle. A dependency hint that ran two statements together + // surfaces on the build server as a Groovy MissingMethodException against a generated + // build.gradle line, which says nothing about which hint or which library produced it -- + // and on a cloud build that answer costs a queue slot and a round trip to discover. + for (String gradleHint : new String[] {"codename1.arg.android.gradleDep", + "codename1.arg.gradleDependencies"}) { + String problem = LibraryHintMerger.findUnseparatedStatement( + gradleHint, cn1SettingsProps.getProperty(gradleHint)); + if (problem != null) { + throw new BuildExecutionException(problem); + } + } + + // Re-run the hardening pre-flight against the MERGED effective settings: a CN1Lib can supply + // codename1.arg.harden.level (or a per-platform opt-out) via the appended/required properties + // just merged above, which the early pre-flight -- run before this jar existed -- could not see. + // Without this, a library that turns hardening on would slip a local/source build past the + // local-build refusal / force-off and produce a locally hardened artifact whose mapping is never + // uploaded (so its crashes could never be retraced). + applyHardeningPreflight(cn1SettingsProps); + + // Same reason, for the same reason: a CN1Lib can supply ios.*.distributionMethod, so the + // profile's kind is compared against the export method only now that those properties + // have been merged -- an early refusal would reject a build the merge was about to fix. + applyIOSProvisioningPreflight(cn1SettingsProps); + + + cn1SettingsProps.setProperty("codename1.arg.hyp.beamId", logPasskey); + cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-core.version", cn1MavenVersion); + cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-maven-plugin", cn1MavenPluginVersion); + + mirrorSecondaryEntryPointsToBuildArgs(cn1SettingsProps); + + // App-extension provisioning profiles (e.g. the generated CN1Widgets WidgetKit + // extension) are named by the codename1.ios.appext..provision setting, which + // points at a local .mobileprovision file. Cloud builds have no folder to drop the + // file into, so base64-encode its bytes into the ios.appext..provisioningData + // build arg; the daemon decodes it back to .mobileprovision before signing. + // Done generically over , mirroring the daemon's per-extension plumbing. + // Extension profiles differ between build types just like the app's own + // (development for device/debug builds, distribution for release), so debug/release + // qualified keys are collapsed into the unqualified ones first. + resolveAppExtensionBuildTypeQualifiers(cn1SettingsProps, buildTarget); + String appExtPrefix = "codename1.ios.appext."; + String appExtSuffix = ".provision"; + for (String settingKey : new ArrayList(cn1SettingsProps.stringPropertyNames())) { + if (!settingKey.startsWith(appExtPrefix) || !settingKey.endsWith(appExtSuffix)) { + continue; + } + String extName = settingKey.substring(appExtPrefix.length(), settingKey.length() - appExtSuffix.length()); + if (extName.isEmpty()) { + continue; + } + String dataKey = "codename1.arg.ios.appext." + extName + ".provisioningData"; + if (cn1SettingsProps.containsKey(dataKey)) { + continue; + } + String profilePath = cn1SettingsProps.getProperty(settingKey); + if (profilePath == null || profilePath.trim().isEmpty()) { + continue; + } + File profileFile = new File(profilePath.trim()); + if (!profileFile.exists() || !profileFile.isFile()) { + getLog().warn("The app extension provisioning profile referenced by " + settingKey + + " was not found at " + profileFile.getAbsolutePath() + ". Skipping it; the " + + extName + " extension will not receive a provisioning profile for this build."); + continue; + } + try { + byte[] profileBytes = FileUtils.readFileToByteArray(profileFile); + cn1SettingsProps.setProperty(dataKey, java.util.Base64.getEncoder().encodeToString(profileBytes)); + } catch (IOException ex) { + getLog().warn("Failed to read the app extension provisioning profile referenced by " + settingKey + + " at " + profileFile.getAbsolutePath() + ". Skipping it: " + ex.getMessage()); + } + } + + try (FileOutputStream fos = new FileOutputStream(codenameOneSettingsCopy)) { + cn1SettingsProps.store(fos,""); + + } + final Process[] proc = new Process[1]; + final boolean[] closingHypLog = new boolean[1]; + Thread hyperBeamThread = new Thread(()->{ + + ProcessBuilder pb = new ProcessBuilder("hyp", "beam", logPasskey); + pb.redirectErrorStream(true); + try { + proc[0] = pb.start(); + + + InputStream out = proc[0].getInputStream(); + + + byte[] buffer = new byte[4000]; + while (isAlive(proc[0])) { + int no = out.available(); + if (no > 0) { + int n = out.read(buffer, 0, Math.min(no, buffer.length)); + getLog().info(new String(buffer, 0, n, StandardCharsets.UTF_8)); + } + + + try { + Thread.sleep(10); + } + catch (InterruptedException e) { + } + } + + } catch (Exception ex) { + if (!closingHypLog[0]) { + getLog().warn("Failed to start hyperlog. The build log will not stream to your console. If the build fails, you can download the error log at https://cloud.codenameone.com/secure/index.html"); + getLog().debug(ex); + } + + } + + }); + + + try { + + if (isLocalBuildTarget(buildTarget)) { + automated = false; + if (BUILD_TARGET_WINDOWS_NATIVE_PROJECT.equals(buildTarget) + || "local-windows-device".equals(buildTarget)) { + // Local native ParparVM Windows cross-compile (clang-cl) and the + // windows-source project generation. The cloud win32 build + // (windows-device) is NOT local -- it falls through to the + // server submission below. Distinct from the JVM-bundled + // "windows-desktop" (javase) target. + doWindowsNativeLocalBuild(antProject, cn1SettingsProps, antDistJar); + } else if ("local-linux-device".equals(buildTarget)) { + // Native ParparVM Linux build (GTK3/Cairo, CMake/Ninja). Distinct + // from the JVM-bundled "linux-desktop" (javase) target. + doLinuxNativeLocalBuild(antProject, cn1SettingsProps, antDistJar); + } else if (buildTarget.contains("android") || BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { + doAndroidLocalBuild(antProject, cn1SettingsProps, antDistJar); + } else if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget) + || BUILD_TARGET_MAC_NATIVE_LOCAL.equals(buildTarget)) { + // The native AppKit build. mac-source stops once the Xcode + // project exists; local-mac-device goes on to build it, and + // both run the same builder, so what a developer opens in + // Xcode is what the device target compiles. mac-os-x-native + // is the cloud target and is not handled here -- it has its + // own queue rather than riding the iOS one. + if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { + cn1SettingsProps.setProperty("codename1.arg.macos.sourceOnly", "true"); + } + doMacOSNativeLocalBuild(antProject, cn1SettingsProps, antDistJar); + } else if (buildTarget.contains("ios") || BUILD_TARGET_XCODE_PROJECT.equals(buildTarget)) { + doIOSLocalBuild(antProject, cn1SettingsProps, antDistJar); + } else if (buildTarget.contains("javascript")) { + doJavaScriptLocalBuild(antProject, cn1SettingsProps, antDistJar); + } else { + throw new BuildExecutionException("Build target not supported "+buildTarget); + } + } else { + // Cloud builds route through a remote build server. Nothing is + // injected for Mac Catalyst here: it is an iOS build that the + // user turns on with macNative.enabled, and the server-side + // IPhoneBuilder reads that hint directly. + if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { + cn1SettingsProps.setProperty("codename1.arg.macos.sourceOnly", "true"); + } + if (automated) { + getLog().debug("Attempting to start hyper beam stream the build log to the console"); + hyperBeamThread.start(); + } + AntExecutor.executeAntTask(new File(antProject, "build.xml").getAbsolutePath(), buildTarget, p); + } + } finally { + if (automated) { + try { + closingHypLog[0] = true; + proc[0].destroyForcibly(); + } catch (Exception ex) { + if (getLog().isDebugEnabled()) { + getLog().warn("Failed to shut down hyperlog process cleanly", ex); + } + } + } + } + + if (automated) { + getLog().info("Extracting server result"); + File result = new File(antDistDir, "result.zip"); + if (!result.exists()) { + throw new IOException("Failed to find result.zip after automated build"); + } + + Expand unzip = (Expand)this.antProject.createTask("unzip"); + unzip.setSrc(result); + File resultDir = new File(antDistDir, "result"); + resultDir.mkdir(); + unzip.setDest(resultDir); + unzip.execute(); + File[] resultFiles = resultDir.listFiles(); + // Every returned base, by extension, collected BEFORE anything is classified: see + // roleSuffixFor, which needs to know whether a suffixed entry names an artifact that + // is also here. + java.util.Map> basesByExtension = + new java.util.HashMap>(); + for (File child : resultFiles) { + String name = child.getName(); + int dot = name.lastIndexOf("."); + if (dot < 0) { + continue; + } + String ext = name.substring(dot); + java.util.Set bases = basesByExtension.get(ext); + if (bases == null) { + bases = new java.util.HashSet(); + basesByExtension.put(ext, bases); + } + bases.add(name.substring(0, dot)); + } + for (File child : resultFiles) { + String name = child.getName(); + int dotpos = name.lastIndexOf("."); + if (dotpos < 0) { + continue; + } + String extension = name.substring(dotpos); + String base = name.substring(0, dotpos); + // The role suffix has to survive into the copied name. Every entry used to land on + // target/, keyed on the extension alone, so a build that + // returns two artifacts of the same kind -- a phone APK and its companion Wear APK + // -- collapsed both onto one path and the last one written won. That is silent and + // it corrupts the primary artifact, not merely the secondary one. + String roleSuffix = roleSuffixFor(base, extension, basesByExtension); + File copyTo = new File(buildDirectory() + File.separator + finalName() + roleSuffix + extension); + FileUtils.copyFile(child, copyTo); + if (roleSuffix.length() > 0) { + // Attached with a classifier so the companion artifact is installed and + // deployed beside the primary one rather than being an orphan in target/. + attachArtifact(extension.substring(1), + roleSuffix.substring(1), copyTo); + } else if (".war".equals(extension)) { + attachArtifact("war", copyTo); + } else if (".zip".equals(extension) && "javascript".equals(buildTarget)) { + attachArtifact("zip", "webapp", copyTo); + } else if (".dmg".equals(extension) && "mac-os-x-desktop".equals(buildTarget)) { + attachArtifact("dmg", "mac-app", copyTo); + + } else if (".pkg".equals(extension) && "mac-os-x-desktop".equals(buildTarget)) { + attachArtifact("pkg", "mac-app-installer", copyTo); + + } + + } + FileUtils.deleteDirectory(resultDir); + result.delete(); + afterBuild(); + } + + + + + } + + private static boolean isAlive(Process proc) { + try { + proc.exitValue(); + return false; + } + catch (IllegalThreadStateException e) { + return true; + } + } + private String generateCertificate(String password, String alias, String fullName, String orgName, String company, String city, String state, String twoLetterCountryCode, boolean sha512) throws Exception { + File keyTool = new File(System.getProperty("java.home") + File.separator + "bin" + File.separator + "keytool"); + if (!keyTool.exists()) { + keyTool = new File(System.getProperty("java.home") + File.separator + "bin" + File.separator + "keytool.exe"); + } + File keyfileLocation = new File(System.getProperty("user.home") + File.separator + "Keychain.ks"); + int counter = 1; + while (keyfileLocation.exists()) { + keyfileLocation = new File(System.getProperty("user.home") + File.separator + "Keychain_" + counter + ".ks"); + counter++; + } + + ProcessBuilder pb = new ProcessBuilder(keyTool.getAbsolutePath(), + "-genkey", "-keystore", keyfileLocation.getAbsolutePath(), "-storetype", "jks", "-alias", alias, + "-keyalg", "RSA", "-keysize", "2048", "-validity", "15000", "-dname", "CN=" + fullName.replace(",", "\\,") + + ", OU=" + orgName.replace(",", "\\,") + + ", O=" + company.replace(",", "\\,") + + ", L=" + city.replace(",", "\\,") + + ", S=" + state.replace(",", "\\,") + + ", C=" + twoLetterCountryCode, "-storepass", password, "-keypass", password, "-v"); + + if(sha512) { + pb.command().add("-sigalg"); + pb.command().add("SHA512withRSA"); + } + + Process p = pb.start(); + int res = p.waitFor(); + //error occurred + if(res > 0){ + final InputStream input = p.getInputStream(); + final InputStream stream = p.getErrorStream(); + + byte[] buffer = new byte[8192]; + int i = input.read(buffer); + while (i > -1) { + String str = new String(buffer, 0, i, StandardCharsets.UTF_8); + System.out.print(str); + i = stream.read(buffer); + } + i = stream.read(buffer); + while (i > -1) { + String str = new String(buffer, 0, i, StandardCharsets.UTF_8); + System.out.print(str); + i = stream.read(buffer); + } + + return null; + } + + + + return keyfileLocation.getAbsolutePath(); + } + + + private File getGeneratedAndroidProjectSourceDirectory() { + return new File(buildDirectory(), finalName() + "-android-source"); + } + + private File getGeneratedIOSProjectSourceDirectory() { + return new File(buildDirectory(), finalName() + "-ios-source"); + } + + private File getGeneratedMacProjectSourceDirectory() { + return new File(buildDirectory(), finalName() + "-mac-source"); + } + + private boolean isMacNativeBuild(Properties props) { + return "true".equalsIgnoreCase(props.getProperty("codename1.arg.macNative.enabled", "false")); + } + + private void doAndroidLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException { + if (BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { + + File generatedProject = getGeneratedAndroidProjectSourceDirectory(); + getLog().info("Generating android gradle Project to "+generatedProject+"..."); + try { + if (generatedProject.exists()) { + getLog().info("Android gradle project already exists. Checking to see if it needs updating..."); + if (getSourcesModificationTime() <= lastModifiedRecursive(generatedProject)) { + getLog().info("Sources have not changed. Skipping android gradle project generation"); + if (open) { + openAndroidStudioProject(generatedProject); + } + return; + + } + } + + } catch (IOException ex) { + throw new BuildExecutionException("Failed to find last modification time of "+generatedProject); + } + } + File codenameOneJar = getJar("com.codenameone", "codenameone-core"); + + AndroidGradleBuilder e = new AndroidGradleBuilder(); + e.setBuildTarget(buildTarget); + e.setLogger(getLog()); + File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "android-build"); + e.setBuildDirectory(buildDirectory); + + e.setCodenameOneJar(codenameOneJar); + + BuildRequest r = new BuildRequest(); + r.setDisplayName(props.getProperty("codename1.displayName")); + r.setPackageName(props.getProperty("codename1.packageName")); + r.setMainClass(props.getProperty("codename1.mainName")); + putSecondaryEntryPointArguments(r, props); + r.setVersion(props.getProperty("codename1.version")); + String iconPath = props.getProperty("codename1.icon"); + File iconFile = new File(iconPath); + if (!iconFile.isAbsolute()) { + iconFile = new File(getCN1ProjectDir(), iconPath); + } + try { + BufferedImage bi = ImageIO.read(iconFile); + if(bi.getWidth() != 512 || bi.getHeight() != 512) { + throw new BuildExecutionException("The icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices"); + } + r.setIcon(iconFile.getAbsolutePath()); + } catch (IOException ex) { + throw new BuildExecutionException("Error reading the icon: the icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices"); + } + + r.setVendor(props.getProperty("codename1.vendor")); + r.setSubTitle(props.getProperty("codename1.secondaryTitle")); + r.setType("android"); + + r.setKeystoreAlias(props.getProperty("codename1.android.keystoreAlias")); + String keystorePath = props.getProperty("codename1.android.keystore"); + if (keystorePath != null) { + File keystoreFile = new File(keystorePath); + if (!keystoreFile.isAbsolute()) { + keystoreFile = new File(getCN1ProjectDir(), keystorePath); + } + if (keystoreFile.exists() && keystoreFile.isFile()) { + try { + r.setCertificate(keystoreFile.getAbsolutePath()); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to load keystore file. ", ex); + } + } else { + + File androidCerts = new File(getCN1ProjectDir(), "androidCerts"); + androidCerts.mkdirs(); + keystoreFile = new File(androidCerts, "KeyChain.ks"); + if (!keystoreFile.exists()) { + try { + String alias = r.getKeystoreAlias(); + if (alias == null || alias.isEmpty()) { + alias = "androidKey"; + r.setKeystoreAlias(alias); + props.setProperty("codename1.android.keystoreAlias", alias); + } + String password = props.getProperty("codename1.android.keystorePassword"); + if (password == null || password.isEmpty()) { + password = "password"; + props.setProperty("codename1.android.keystorePassword", password); + + + } + getLog().info("No Keystore found. Generating one now"); + String keyPath = generateCertificate(password, alias, r.getVendor(), "", r.getVendor(), "Vancouver", "BC", "CA", false); + FileUtils.copyFile(new File(keyPath), keystoreFile); + r.setCertificate(keystoreFile.getAbsolutePath()); + getLog().info("Generated keystore with password 'password' at "+keystoreFile+". alias=androidKey"); + new File(keyPath).delete(); + SortedProperties sp = new SortedProperties(); + try (FileInputStream fis = new FileInputStream(new File(getCN1ProjectDir(), "codenameone_settings.properties"))) { + sp.load(fis); + } + sp.setProperty("codename1.android.keystore", keystoreFile.getAbsolutePath()); + sp.setProperty("codename1.android.keystorePassword", password); + sp.setProperty("codename1.android.keystoreAlias", alias); + try (FileOutputStream fos = new FileOutputStream(new File(getCN1ProjectDir(), "codenameone_settings.properties"))) { + sp.store(fos, "Updated keystore"); + } + } catch (Exception ex) { + getLog().error("Failed to generate keystore", ex); + throw new BuildExecutionException("Failed to generate keystore", ex); + } + } + + + } + } + r.setCertificatePassword(props.getProperty("codename1.android.keystorePassword")); + + for (Object k : props.keySet()) { + String key = (String)k; + if(key.startsWith("codename1.arg.")) { + String value = props.getProperty(key); + String currentKey = key.substring(14); + if(currentKey.indexOf(' ') > -1) { + throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); + } + r.putArgument(currentKey, value); + } + } + applyHardeningRequestArgs(r); + + BuildRequest request = r; + request.setIncludeSource(true); + String testBuild = request.getArg("build.unitTest", null); + if(testBuild != null && testBuild.equals("1")) { + e.setUnitTestMode(true); + } + + try { + getLog().info("Starting android project builder..."); + boolean result = e.runBuild(distJar, request); + getLog().info("Android project builder completed with result "+result); + if (!result) { + getLog().error("Received false return value from build()"); + throw new BuildExecutionException("Android build failed. Received false return value for build"); + } + + if (BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget) && e.getGradleProjectDirectory() != null) { + File gradleProject = e.getGradleProjectDirectory(); + File output = getGeneratedAndroidProjectSourceDirectory(); + output.getParentFile().mkdirs(); + try { + getLog().info("Copying Gradle Project to "+output); + FileUtils.copyDirectory(gradleProject, output); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to copy gradle project at "+gradleProject+" to "+output, ex); + } + + } + if (open) { + openAndroidStudioProject(getGeneratedAndroidProjectSourceDirectory()); + } + + + } catch (BuildException ex) { + + getLog().error("Failed to build Android project with error: "+ex.getMessage(), ex); + getLog().error(e.getErrorMessage()); + throw new BuildExecutionException("Failed to build android app", ex); + } finally { + + e.cleanup(); + } + + } + + private void openAndroidStudioProject(File generatedProject) { + if (isMac) { + getLog().info("Trying to open project in Android studio"); + ProcessBuilder pb = new ProcessBuilder("open", "-a", "/Applications/Android Studio.app", generatedProject.getAbsolutePath()); + try { + pb.start(); + } catch (Exception ex) { + getLog().warn("Failed to open project in Android studio", ex); + getLog().warn("Please open the project in Android studio manually."); + getLog().warn("The project is located at "+generatedProject.getAbsolutePath()); + } + } else if (isWindows) { + getLog().info("Trying to open project in Android studio"); + ProcessBuilder pb = new ProcessBuilder("C:\\Program Files\\Android\\Android Studio\\bin\\studio.bat", generatedProject.getAbsolutePath()); + try { + pb.start(); + } catch (Exception ex) { + getLog().warn("Failed to open project in Android studio", ex); + getLog().warn("Please open the project in Android studio manually."); + getLog().warn("The project is located at "+generatedProject.getAbsolutePath()); + } + } else { + getLog().warn("Opening automatically in Android studio not supported on this platform."); + getLog().warn("Please open the project in Android studio manually."); + getLog().warn("The project is located at "+generatedProject.getAbsolutePath()); + } + } + + private File getWorkspace(Properties props, File xcprojectRoot) { + return new File(xcprojectRoot, props.getProperty("codename1.mainName")+".xcworkspace"); + } + + private File getXcodeProject(Properties props, File xcprojectRoot) { + return new File(xcprojectRoot, props.getProperty("codename1.mainName")+".xcodeproj"); + } + + private File getWorkspaceOrProject(Properties props, File xcprojectRoot) { + File workspace = getWorkspace(props, xcprojectRoot); + if (workspace.exists()) { + return workspace; + } + return getXcodeProject(props, xcprojectRoot); + } + + private void openWorkspace(File workspace) throws BuildExecutionException { + try { + ProcessBuilder pb = new ProcessBuilder("open", workspace.getAbsolutePath()); + Process p = pb.start(); + int result = p.waitFor(); + if (result != 0) { + throw new BuildExecutionException("Failed to open project at "+workspace+". Result code: "+result); + } + } catch (Exception ex) { + throw new BuildExecutionException("Failed to open project at "+workspace, ex); + } + } + + private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException { + + boolean macNativeBuild = isMacNativeBuild(props); + + if (BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { + + File generatedProject = macNativeBuild + ? getGeneratedMacProjectSourceDirectory() + : getGeneratedIOSProjectSourceDirectory(); + getLog().info("Generating Xcode Project to "+generatedProject+"..."); + try { + if (generatedProject.exists()) { + getLog().info("Xcode project already exists. Checking to see if it needs updating..."); + if (getSourcesModificationTime() <= lastModifiedRecursive(generatedProject)) { + getLog().info("Sources have not changed. Skipping Xcode project generation"); + if (open) { + File projectToOpen = getWorkspaceOrProject(props, generatedProject); + getLog().info("Opening Xcode project "+projectToOpen); + openWorkspace(projectToOpen); + } + return; + + } + } + + } catch (IOException ex) { + throw new BuildExecutionException("Failed to find last modification time of "+generatedProject); + } + } + + File codenameOneJar = getJar("com.codenameone", "codenameone-core"); + + IPhoneBuilder e = new IPhoneBuilder(); + e.setLogger(getLog()); + File buildDirectory = new File(tmpProjectDir, + "dist" + File.separator + (macNativeBuild ? "mac-build" : "ios-build")); + e.setBuildDirectory(buildDirectory); + + e.setCodenameOneJar(codenameOneJar); + + BuildRequest r = new BuildRequest(); + r.setAppid(props.getProperty("codename1.ios.appid")); + r.setDisplayName(props.getProperty("codename1.displayName")); + r.setPackageName(props.getProperty("codename1.packageName")); + r.setMainClass(props.getProperty("codename1.mainName")); + putSecondaryEntryPointArguments(r, props); + r.setVersion(props.getProperty("codename1.version")); + String iconPath = props.getProperty("codename1.icon"); + File iconFile = new File(iconPath); + if (!iconFile.isAbsolute()) { + iconFile = new File(getCN1ProjectDir(), iconPath); + } + try { + BufferedImage bi = ImageIO.read(iconFile); + if(bi.getWidth() != 512 || bi.getHeight() != 512) { + throw new BuildExecutionException("The icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices"); + } + r.setIcon(iconFile.getAbsolutePath()); + } catch (IOException ex) { + throw new BuildExecutionException("Error reading the icon: the icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices"); + } + + r.setVendor(props.getProperty("codename1.vendor")); + r.setSubTitle(props.getProperty("codename1.secondaryTitle")); + r.setType("ios"); + + + for (Object k : props.keySet()) { + String key = (String)k; + if(key.startsWith("codename1.arg.")) { + String value = props.getProperty(key); + String currentKey = key.substring(14); + if(currentKey.indexOf(' ') > -1) { + throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); + } + r.putArgument(currentKey, value); + } + } + applyHardeningRequestArgs(r); + + BuildRequest request = r; + String incSources = request.getArg("build.incSources", null); + request.setIncludeSource(true); + + String testBuild = request.getArg("build.unitTest", null); + if(testBuild != null && testBuild.equals("1")) { + e.setUnitTestMode(true); + } + + try { + boolean result = e.runBuild(distJar, request); + if (!result) { + String builderLog = e.getErrorMessage(); + if (builderLog != null && builderLog.trim().length() > 0) { + getLog().error("iOS builder log:\n" + builderLog); + } + throw new BuildExecutionException("iOS build failed"); + } + + if ((BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) && e.getXcodeProjectDir() != null) { + File xcodeProject = e.getXcodeProjectDir(); + File output = macNativeBuild + ? getGeneratedMacProjectSourceDirectory() + : getGeneratedIOSProjectSourceDirectory(); + output.getParentFile().mkdirs(); + try { + // This directory is a generated target. Replacing it is + // required when class scanning removes a dependency: + // copyDirectory() alone leaves stale Podfiles, workspaces, + // Pods and optional native sources from the prior build. + if (output.exists()) { + FileUtils.deleteDirectory(output); + } + getLog().info("Copying Xcode Project to "+output); + FileUtils.copyDirectory(xcodeProject, output); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to copy xcode project at "+xcodeProject+" to "+output, ex); + } + if (open) { + + File projectToOpen = getWorkspaceOrProject(props, output); + getLog().info("Opening Xcode project "+projectToOpen); + openWorkspace(projectToOpen); + + } + } + + + } catch (BuildException ex) { + String builderLog = e.getErrorMessage(); + if (builderLog != null && builderLog.trim().length() > 0) { + getLog().error("iOS builder log:\n" + builderLog); + } + throw new BuildExecutionException("Failed to build ios app", ex); + } finally { + + e.cleanup(); + } + + } + + /** + * Local native Windows build via {@link WindowsNativeBuilder}: translates the + * app with ParparVM's windows target and compiles it with clang-cl for the + * selected architecture ({@code windows.arch}). Mirrors the iOS local-build + * wiring. The native compile only succeeds on Windows with the MSVC/clang-cl + * toolchain present; elsewhere the builder fails fast with a clear message. + */ + /** + * Builds the native macOS (AppKit) application locally. + * + *

Unlike Mac Catalyst, which is the iOS build with one hint set, this + * runs its own builder against the macosx SDK. Both {@code mac-source} and + * {@code mac-os-x-native} come here; the former sets + * {@code macos.sourceOnly} so the builder stops once the Xcode project + * exists.

+ */ + private void doMacOSNativeLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException { + File codenameOneJar = getJar("com.codenameone", "codenameone-core"); + MacOSNativeBuilder e = new MacOSNativeBuilder(); + e.setLogger(getLog()); + File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "macos-build"); + e.setBuildDirectory(buildDirectory); + e.setCodenameOneJar(codenameOneJar); + + BuildRequest r = new BuildRequest(); + r.setDisplayName(props.getProperty("codename1.displayName")); + r.setPackageName(props.getProperty("codename1.packageName")); + r.setMainClass(props.getProperty("codename1.mainName")); + putSecondaryEntryPointArguments(r, props); + r.setVersion(props.getProperty("codename1.version")); + r.setVendor(props.getProperty("codename1.vendor")); + // The icon rides the request rather than the source archive, exactly as + // it does for iOS and Windows, and the builder renders the asset catalog + // from it. Omitted here, the generated AppIcon.appiconset references ten + // PNGs that are never written: a generic icon in the Dock, and an App + // Store validation failure for having none. + String iconPath = props.getProperty("codename1.icon"); + if (iconPath != null && iconPath.length() > 0) { + File iconFile = new File(iconPath); + if (!iconFile.isAbsolute()) { + iconFile = new File(getCN1ProjectDir(), iconPath); + } + try { + r.setIcon(iconFile.getAbsolutePath()); + } catch (IOException ex) { + throw new BuildExecutionException("Error reading the icon at " + + iconFile.getAbsolutePath() + + ": it must be a 512x512 pixel PNG, which is scaled to the sizes the " + + "macOS asset catalog asks for.", ex); + } + } + r.setType("macos"); + for (Object k : props.keySet()) { + String key = (String) k; + if (key.startsWith("codename1.arg.")) { + String currentKey = key.substring("codename1.arg.".length()); + if (currentKey.indexOf(' ') > -1) { + throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); + } + r.putArgument(currentKey, props.getProperty(key)); + } + } + applyHardeningRequestArgs(r); + r.setIncludeSource(true); + + try { + boolean result = e.runBuild(distJar, r); + if (!result) { + String builderLog = e.getErrorMessage(); + if (builderLog != null && builderLog.trim().length() > 0) { + getLog().error("macOS builder log:\n" + builderLog); + } + throw new BuildExecutionException("Native macOS build failed"); + } + if (e.getXcodeProjectDir() != null && BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { + // Collected under the same -mac-source name the + // Catalyst path used, so a project that switches between the + // two ports finds its Xcode project in the same place. + File output = getGeneratedMacProjectSourceDirectory(); + output.getParentFile().mkdirs(); + try { + // Replaced rather than merged: a stale source file from a + // previous build is still compiled by the regenerated + // project, and the resulting failure names a file the + // developer never wrote. + if (output.exists()) { + FileUtils.deleteDirectory(output); + } + getLog().info("Copying macOS Xcode project to " + output); + FileUtils.copyDirectory(e.getXcodeProjectDir(), output); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to collect the generated macOS Xcode project", ex); + } + // Opened, as the iOS project path beside this one does. The + // Mac Native Project IDE shortcut and a plain mac-source build + // both default to open=true, and without this they completed + // with no Xcode window and no indication that the documented + // option had been ignored. + // + // getWorkspaceOrProject rather than the .xcodeproj directly: + // it prefers a workspace when the generated project has one, + // which is the thing Xcode should be handed. + if (open) { + File projectToOpen = getWorkspaceOrProject(props, output); + getLog().info("Opening macOS Xcode project " + projectToOpen); + openWorkspace(projectToOpen); + } + } + if (e.getAppBundle() != null) { + getLog().info("Built native macOS application: " + e.getAppBundle().getAbsolutePath()); + // Every artifact, not just the first bundle: with + // macos.distribution=both there are two, each with its own + // container, and a dmg or pkg nobody is told about is a dmg + // nobody ships. + for (java.io.File artifact : e.getArtifacts()) { + if (!artifact.equals(e.getAppBundle())) { + getLog().info(" also produced: " + artifact.getAbsolutePath()); + } + } + } else if (e.getXcodeProjectDir() != null) { + getLog().info("Generated macOS Xcode project: " + e.getXcodeProjectDir().getAbsolutePath()); + } + } catch (com.codename1.builders.BuildException hardeningEx) { + throw new BuildExecutionException(hardeningEx.getMessage(), hardeningEx); + } catch (org.apache.tools.ant.BuildException ex) { + String builderLog = e.getErrorMessage(); + if (builderLog != null && builderLog.trim().length() > 0) { + getLog().error("macOS builder log:\n" + builderLog); + } + throw new BuildExecutionException("Failed to build the macOS app", ex); + } finally { + e.cleanup(); + } + } + + private void doWindowsNativeLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException { + File codenameOneJar = getJar("com.codenameone", "codenameone-core"); + WindowsNativeBuilder e = new WindowsNativeBuilder(); + e.setLogger(getLog()); + File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "windows-build"); + e.setBuildDirectory(buildDirectory); + e.setCodenameOneJar(codenameOneJar); + + BuildRequest r = new BuildRequest(); + r.setDisplayName(props.getProperty("codename1.displayName")); + r.setPackageName(props.getProperty("codename1.packageName")); + r.setMainClass(props.getProperty("codename1.mainName")); + putSecondaryEntryPointArguments(r, props); + r.setVersion(props.getProperty("codename1.version")); + r.setVendor(props.getProperty("codename1.vendor")); + r.setType("windows"); + for (Object k : props.keySet()) { + String key = (String) k; + if (key.startsWith("codename1.arg.")) { + String currentKey = key.substring("codename1.arg.".length()); + if (currentKey.indexOf(' ') > -1) { + throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); + } + r.putArgument(currentKey, props.getProperty(key)); + } + } + applyHardeningRequestArgs(r); + // Authenticode signing certificate. Configured through settings/properties + // (codename1.windows.signing.certificate = path to the .p12/.pfx, and + // codename1.windows.signing.password). This mirrors the cloud build, whose + // codeNameOne task uploads the same certificate into the request, so a + // local build and a cloud build sign from the same configuration. + String winCert = props.getProperty("codename1.windows.signing.certificate"); + if (winCert != null && !winCert.isEmpty()) { + File certFile = new File(winCert); + if (!certFile.isAbsolute()) { + certFile = new File(getCN1ProjectDir(), winCert); + } + if (certFile.isFile()) { + try { + r.setCertificate(certFile.getAbsolutePath()); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to read the Windows signing certificate: " + certFile, ex); + } + r.setCertificatePassword(props.getProperty("codename1.windows.signing.password")); + } else { + getLog().warn("codename1.windows.signing.certificate points at a missing file: " + certFile); + } + } + r.setIncludeSource(true); + + try { + boolean result = e.runBuild(distJar, r); + if (!result) { + String builderLog = e.getErrorMessage(); + if (builderLog != null && builderLog.trim().length() > 0) { + getLog().error("Windows builder log:\n" + builderLog); + } + throw new BuildExecutionException("Windows native build failed"); + } + if (e.getWindowsExecutable() != null) { + getLog().info("Built native Windows executable: " + e.getWindowsExecutable().getAbsolutePath()); + } + } catch (com.codename1.builders.BuildException hardeningEx) { + throw new BuildExecutionException(hardeningEx.getMessage(), hardeningEx); + } catch (org.apache.tools.ant.BuildException ex) { + String builderLog = e.getErrorMessage(); + if (builderLog != null && builderLog.trim().length() > 0) { + getLog().error("Windows builder log:\n" + builderLog); + } + throw new BuildExecutionException("Failed to build Windows app", ex); + } finally { + e.cleanup(); + } + } + + /** + * Local native Linux build via {@link LinuxNativeBuilder}: translates the app + * with ParparVM's linux target and compiles it with CMake/Ninja for the + * selected architecture ({@code linux.arch}). Mirrors the Windows local-build + * wiring. The native compile only succeeds on Linux with the GTK3 dev stack + + * pkg-config present (and, for musl targets, a {@code zig}/musl toolchain); + * elsewhere the builder fails fast with a clear message. + */ + private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException { + File codenameOneJar = getJar("com.codenameone", "codenameone-core"); + LinuxNativeBuilder e = new LinuxNativeBuilder(); + e.setLogger(getLog()); + File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "linux-build"); + e.setBuildDirectory(buildDirectory); + e.setCodenameOneJar(codenameOneJar); + + BuildRequest r = new BuildRequest(); + r.setDisplayName(props.getProperty("codename1.displayName")); + r.setPackageName(props.getProperty("codename1.packageName")); + r.setMainClass(props.getProperty("codename1.mainName")); + putSecondaryEntryPointArguments(r, props); + r.setVersion(props.getProperty("codename1.version")); + r.setVendor(props.getProperty("codename1.vendor")); + r.setType("linux"); + for (Object k : props.keySet()) { + String key = (String) k; + if (key.startsWith("codename1.arg.")) { + String currentKey = key.substring("codename1.arg.".length()); + if (currentKey.indexOf(' ') > -1) { + throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); + } + r.putArgument(currentKey, props.getProperty(key)); + } + } + applyHardeningRequestArgs(r); + r.setIncludeSource(true); + + try { + boolean result = e.runBuild(distJar, r); + if (!result) { + String builderLog = e.getErrorMessage(); + if (builderLog != null && builderLog.trim().length() > 0) { + getLog().error("Linux builder log:\n" + builderLog); + } + throw new BuildExecutionException("Linux native build failed"); + } + if (e.getLinuxExecutable() != null) { + getLog().info("Built native Linux executable: " + e.getLinuxExecutable().getAbsolutePath()); + } + } catch (com.codename1.builders.BuildException hardeningEx) { + throw new BuildExecutionException(hardeningEx.getMessage(), hardeningEx); + } catch (org.apache.tools.ant.BuildException ex) { + String builderLog = e.getErrorMessage(); + if (builderLog != null && builderLog.trim().length() > 0) { + getLog().error("Linux builder log:\n" + builderLog); + } + throw new BuildExecutionException("Failed to build Linux app", ex); + } finally { + e.cleanup(); + } + } + + // Local ParparVM-backed JavaScript build target. + private void doJavaScriptLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException { + File codenameOneJar = getJar("com.codenameone", "codenameone-core"); + + JavaScriptBuilder e = new JavaScriptBuilder(); + e.setLogger(getLog()); + e.setBuildTarget(buildTarget); + File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "javascript-build"); + e.setBuildDirectory(buildDirectory); + e.setCodenameOneJar(codenameOneJar); + + BuildRequest r = new BuildRequest(); + r.setDisplayName(props.getProperty("codename1.displayName")); + r.setPackageName(props.getProperty("codename1.packageName")); + r.setMainClass(props.getProperty("codename1.mainName")); + putSecondaryEntryPointArguments(r, props); + r.setVersion(props.getProperty("codename1.version")); + String iconPath = props.getProperty("codename1.icon"); + if (iconPath != null) { + File iconFile = new File(iconPath); + if (!iconFile.isAbsolute()) { + iconFile = new File(getCN1ProjectDir(), iconPath); + } + if (iconFile.isFile()) { + try { + r.setIcon(iconFile.getAbsolutePath()); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to read icon " + iconFile, ex); + } + } + } + r.setVendor(props.getProperty("codename1.vendor")); + r.setSubTitle(props.getProperty("codename1.secondaryTitle")); + r.setType("javascript"); + + for (Object k : props.keySet()) { + String key = (String) k; + if (key.startsWith("codename1.arg.")) { + String value = props.getProperty(key); + String currentKey = key.substring(14); + if (currentKey.indexOf(' ') > -1) { + throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); + } + r.putArgument(currentKey, value); + } + } + applyHardeningRequestArgs(r); + r.setIncludeSource(true); + + try { + boolean result = e.runBuild(distJar, r); + if (!result) { + String builderLog = e.getErrorMessage(); + if (builderLog != null && builderLog.trim().length() > 0) { + getLog().error("JavaScript builder log:\n" + builderLog); + } + throw new BuildExecutionException("JavaScript build failed"); + } + File outputZip = e.getJavaScriptOutputZip(); + if (outputZip != null && outputZip.isFile()) { + File copyTo = new File(buildDirectory() + File.separator + finalName() + ".zip"); + try { + FileUtils.copyFile(outputZip, copyTo); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to copy JavaScript bundle to " + copyTo, ex); + } + attachArtifact("zip", "webapp", copyTo); + getLog().info("JavaScript bundle written to " + copyTo); + } + File deployable = e.getJavaScriptDeployableArtifact(); + if (deployable != null && deployable.isFile()) { + String name = deployable.getName(); + int dot = name.lastIndexOf('.'); + String extension = dot < 0 ? "zip" : name.substring(dot + 1); + String classifier = "war".equals(extension) ? "webapp-proxy" : "proxy-" + + r.getArg("javascript.proxy.target", "jakarta-servlet"); + File copyTo = new File(buildDirectory(), name); + try { + FileUtils.copyFile(deployable, copyTo); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to copy JavaScript deployable bundle to " + copyTo, ex); + } + attachArtifact(extension, classifier, copyTo); + getLog().info("JavaScript deployable bundle written to " + copyTo); + } + // The translator writes its deployment configuration and its build report BESIDE + // the bundle rather than inside it, so unpacking the zip into a web root cannot + // publish them. The zip copied above is all that leaves the build directory, and + // the finally below deletes that directory outright -- without this the developer + // never sees either artifact at all. They are copied, not attached: host + // configuration is not something to publish to a Maven repository. + for (File artifact : e.getJavaScriptBuildArtifacts()) { + File copyTo = new File(buildDirectory(), artifact.getName()); + try { + if (artifact.isDirectory()) { + FileUtils.copyDirectory(artifact, copyTo); + } else { + FileUtils.copyFile(artifact, copyTo); + } + } catch (IOException ex) { + throw new BuildExecutionException("Failed to copy JavaScript build artifact to " + + copyTo, ex); + } + getLog().info("JavaScript build artifact written to " + copyTo); + } + } catch (BuildException ex) { + String builderLog = e.getErrorMessage(); + if (builderLog != null && builderLog.trim().length() > 0) { + getLog().error("JavaScript builder log:\n" + builderLog); + } + throw new BuildExecutionException("Failed to build JavaScript app", ex); + } finally { + e.cleanup(); + } + } + + /// Called once a cloud build's results have been collected. + protected void afterBuild() { + + } + + /** + * Role suffixes a returned artifact may carry, longest first so a future + * "-wear-debug" cannot be shadowed by "-wear". + * + *

Kept deliberately closed. Anything not on this list is the primary artifact and + * keeps the plain {@code } name it has always had, so adding a + * role here is the only way to change where a file lands.

+ */ + // Longest first: "-wear-debug" ends with neither "-wear" nor anything else here, but a + // future suffix that is a tail of another would match the shorter one if it came first. + /** + * The role a result entry plays, given what else came back. + * + *

A role suffix is a claim about a SET, not about a name, and the question it answers is + * "is there something here that this one is the companion TO". An app called + * {@code fitness-wear} returns one APK whose base ends in {@code -wear} and it is the primary + * artifact; the same app with a companion returns {@code fitness-wear} and + * {@code fitness-wear-wear}, where the second is not. Neither reading the name alone nor + * asking whether any unsuffixed entry exists separates those two cases -- in the second, no + * entry is unsuffixed at all.

+ * + *

What does separate them is the artifact the suffix points at: strip it, and a companion + * names something else in the set while a primary names nothing.

+ * + * @param base the entry's name with its extension removed + * @param extension the entry's extension, including the dot + * @param basesByExtension every returned base, keyed by extension + * @return the role suffix including its leading dash, or an empty string + */ + static String roleSuffixFor(String base, String extension, + java.util.Map> basesByExtension) { + String suffix = roleSuffixOf(base); + if (suffix.length() == 0 || basesByExtension == null) { + return ""; + } + java.util.Set siblings = basesByExtension.get(extension); + if (siblings == null) { + return ""; + } + return siblings.contains(base.substring(0, base.length() - suffix.length())) + ? suffix : ""; + } + + private static final String[] ARTIFACT_ROLE_SUFFIXES = {"-wear-debug", "-wear"}; + + /** + * The role suffix carried by a result entry's base name, or an empty string when it is + * the primary artifact. + * + *

A build may hand back more than one artifact of the same kind -- an Android + * companion build returns the phone APK and the Wear APK beside it -- and the two + * cannot share a destination path. The builder names the secondary one with a role + * suffix; this recovers it so the copy keeps it and the artifact can be attached + * under a matching classifier.

+ * + * @param base the result file's name with its extension already removed + * @return the matching role suffix including its leading dash, or an empty string + */ + static String roleSuffixOf(String base) { + if (base == null) { + return ""; + } + for (String suffix : ARTIFACT_ROLE_SUFFIXES) { + if (base.endsWith(suffix)) { + return suffix; + } + } + return ""; + } + + static class LibraryPropertiesException extends Exception { + private String libName; + LibraryPropertiesException(String libName, String message) { + super(message); + this.libName = libName; + } + } + + private static class VersionMismatchException extends LibraryPropertiesException { + VersionMismatchException(String libName, String message) { + super(libName, message); + } + } + + private static class PropertyConflictException extends LibraryPropertiesException { + PropertyConflictException(String libName, String message) { + super(libName, message); + } + } + + static SortedProperties mergeRequiredProperties(String libraryName, Properties libProps, Properties projectProps) throws LibraryPropertiesException { + + + String javaVersion = (String)projectProps.getProperty("codename1.arg.java.version", "8"); + String javaVersionLib = (String)libProps.get("codename1.arg.java.version"); + if(javaVersionLib != null){ + int v1 = JavaVersionUtil.parseJavaVersion(javaVersion, 5); + int v2 = JavaVersionUtil.parseJavaVersion(javaVersionLib, 5); + //if the lib java version is bigger, this library cannot be used + if(v1 < v2){ + throw new VersionMismatchException(libraryName, "Cannot use a cn1lib with java version " + + "greater then the project java version"); + } + } + //merge and save + SortedProperties merged = new SortedProperties(); + merged.putAll(projectProps); + Enumeration keys = libProps.propertyNames(); + while(keys.hasMoreElements()){ + String key = (String) keys.nextElement(); + if(!merged.containsKey(key)){ + merged.put(key, libProps.getProperty(key)); + }else{ + + //if this property already exists with a different value the + //install will fail + if(!merged.get(key).equals(libProps.getProperty(key))){ + if ("codename1.arg.java.version".equals(key)) { + // Preserve the project's java version when it is equal to or greater than + // the library requirement. This is validated above and allows using + // Java 8 cn1libs in Java 11/17+ projects. + continue; + } + throw new PropertyConflictException(libraryName, "Property " + key + " has a conflict"); + } + } + } + return merged; + + } + +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BackendMainClass.java b/maven/build-engine/src/main/java/com/codename1/maven/BackendMainClass.java new file mode 100644 index 00000000000..1b0c715a8aa --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendMainClass.java @@ -0,0 +1,198 @@ +/* + * 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.maven; + +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/// Finds the class a backend runs: the entry point annotation processing +/// generated for its `@RestController`s, else the one class that declares a +/// `main` method. Shared by the Maven `cn1:backend` goal and the Gradle +/// `runBackend` task. +public final class BackendMainClass { + private final Log log; + private final String mainClassOption; + + /// @param mainClassOption how the build tool lets a user name the class, for + /// the messages (`-Dcn1.backend.mainClass`, `-Pcn1.backend.mainClass`) + public BackendMainClass(Log log, String mainClassOption) { + this.log = log; + this.mainClassOption = mainClassOption; + } + + /// `explicit` when set, else the generated entry point, else the one class + /// with a main method. + public String resolve(File classesDir, String explicit) throws BuildFailureException { + if (explicit != null && explicit.length() > 0) { + return explicit; + } + String main = generatedMainClass(classesDir); + return main != null && main.length() > 0 ? main : findMainClass(classesDir); + } + + /** + * The one class in this module with a main method. + * + * Deliberately an error when there are several rather than a guess: picking + * one and running it is how a developer ends up debugging the wrong process. + */ + /** + * The entry point annotation processing generated, or null when this module + * has none -- one written by hand, with no @RestController in it, has no + * marker and falls through to the scan below. + */ + public String generatedMainClass(File classesDir) { + File marker = new File(classesDir, + com.codename1.maven.processors.RestControllerAnnotationProcessor + .MAIN_CLASS_RESOURCE.replace('/', File.separatorChar)); + if (!marker.isFile()) { + return null; + } + try { + byte[] raw = new byte[(int) marker.length()]; + InputStream in = new java.io.FileInputStream(marker); + try { + int at = 0; + while (at < raw.length) { + int n = in.read(raw, at, raw.length - at); + if (n <= 0) { + break; + } + at += n; + } + } finally { + in.close(); + } + String name = new String(raw, "UTF-8").trim(); + return name.length() == 0 ? null : name; + } catch (IOException err) { + // Unreadable is not the same as absent, and the scan below still has + // a fair chance of being right; refusing outright would be worse. + log.warn("cn1: could not read " + marker + ": " + err); + return null; + } + } + + public String findMainClass(File classesDir) throws BuildFailureException { + return findMainClass(java.util.Collections.singletonList(classesDir)); + } + + /// The one class with a main method across `classesDirs` -- Gradle compiles + /// Java and Kotlin into separate directories, and a main in each is as + /// ambiguous as two in one. + public String findMainClass(List classesDirs) throws BuildFailureException { + List found = new ArrayList(); + for (File classesDir : classesDirs) { + collectMainClasses(classesDir, classesDir, found); + } + File classesDir = classesDirs.size() == 1 ? classesDirs.get(0) : null; + if (found.size() == 1) { + return found.get(0); + } + if (found.isEmpty()) { + throw new BuildFailureException("No class with a main method under " + + (classesDir != null ? classesDir : classesDirs) + "; set " + mainClassOption); + } + throw new BuildFailureException("Several classes have a main method (" + + join(found, ", ") + "); choose one with " + mainClassOption); + } + + private void collectMainClasses(File root, File dir, List found) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collectMainClasses(root, child, found); + } else if (child.getName().endsWith(".class") && child.getName().indexOf('$') < 0) { + String name = child.getAbsolutePath() + .substring(root.getAbsolutePath().length() + 1) + .replace(File.separatorChar, '.'); + name = name.substring(0, name.length() - ".class".length()); + if (hasMainMethod(child)) { + found.add(name); + } + } + } + } + + /** + * Whether the class DECLARES `public static void main(String[])`. + * + * Read from the class file rather than by loading it: loading runs the static + * initialiser, and a backend's initialiser is as likely as not to open a + * socket or a database. The method table is read with ASM rather than by + * searching the bytes, because the constant pool of a class that merely CALLS + * main carries the same two strings. + */ + private boolean hasMainMethod(File classFile) { + final boolean[] found = new boolean[1]; + try { + InputStream in = new java.io.FileInputStream(classFile); + try { + new org.objectweb.asm.ClassReader(in).accept( + new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, + String name, String descriptor, String signature, + String[] exceptions) { + int wanted = org.objectweb.asm.Opcodes.ACC_PUBLIC + | org.objectweb.asm.Opcodes.ACC_STATIC; + if ("main".equals(name) + && "([Ljava/lang/String;)V".equals(descriptor) + && (access & wanted) == wanted) { + found[0] = true; + } + return null; + } + }, + org.objectweb.asm.ClassReader.SKIP_CODE + | org.objectweb.asm.ClassReader.SKIP_DEBUG + | org.objectweb.asm.ClassReader.SKIP_FRAMES); + } finally { + in.close(); + } + } catch (Exception err) { + return false; + } + return found[0]; + } + + private static String join(List parts, String separator) { + StringBuilder sb = new StringBuilder(); + for (String p : parts) { + if (sb.length() > 0) { + sb.append(separator); + } + sb.append(p); + } + return sb.toString(); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java b/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java new file mode 100644 index 00000000000..3216536a05f --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java @@ -0,0 +1,1354 @@ +/* + * Copyright (c) 2012, 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.maven; + + +import com.codename1.build.BuildArtifact; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; +import com.codename1.build.ProjectHost; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; +import com.codename1.maven.processors.OrmAnnotationProcessor; +import com.codename1.maven.processors.RestControllerAnnotationProcessor; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Translates a backend module to C and compiles it to a native binary: + * `mvn cn1:backend-package`. + * + * The counterpart to {@link BackendRunMojo}. That one runs the module on this JVM + * in a couple of seconds and is what a developer uses; this one produces the + * artifact that deploys -- a single executable with no runtime to install, which + * is what makes a scratch container the size of the binary and a Lambda cold start + * a process exec. + * + * What it does, in order: + * + * 1. Compiles the module's sources together with the backend runtime's SHARED and + * PARPARVM halves against the ParparVM JavaAPI as the BOOTCLASSPATH. That last + * part is the important one: the bootclasspath IS the server-safe surface, so a + * reference to something the translated runtime does not have fails here, in + * the IDE and in the build, rather than at link time or in production. + * 2. Runs the translator over the result, with the runtime's C sources already in + * the source root -- they have to be there BEFORE it runs, because a native's + * Java method is kept alive by its C symbol being present. + * 3. Compiles the generated C, either with the host compiler or, for a named + * Linux target, in a container. + * + * The compiler flags are not negotiable and are documented at the call site: + * generated C relies on wrapping arithmetic, and clang -O3 miscompiles it without + * them. + */ +public class BackendPackager { + + /// The build tool's answers about the project. + protected final ProjectHost host; + + /// A packager for the backend `host` describes. + public BackendPackager(ProjectHost host) { + this.host = host; + } + + protected Log getLog() { + return host.log(); + } + + /// The entry point, when the module names one. + protected String mainClass; + /// Where the binary goes; defaults to the build directory. + protected File output; + /// A Linux cross-compile target; not supported yet. + protected String target; + /// The JDK to compile and translate with. + protected String jdkHome; + /// The JDK 8 this used to require; still honoured. + protected String jdk8Home; + /// Extra C compiler flags. + protected String cflags; + /// Link the bundled SQLite engine. + protected boolean sqlite = true; + /// Make a failed cast throw. + protected boolean checkedCasts = true; + /// Whether the binary carries the development MCP tools; see [#devTools(boolean)]. + protected boolean devTools; + + /// Sets [mainClass]. + public BackendPackager mainClass(String v) { + this.mainClass = v; + return this; + } + + /// Sets [output]. + public BackendPackager output(File v) { + this.output = v; + return this; + } + + /// Sets [target]. + public BackendPackager target(String v) { + this.target = v; + return this; + } + + /// Sets [jdkHome] and [jdk8Home]. + public BackendPackager jdk(String jdkHome, String jdk8Home) { + this.jdkHome = jdkHome; + this.jdk8Home = jdk8Home; + return this; + } + + /// Sets [cflags]. + public BackendPackager cflags(String v) { + this.cflags = v; + return this; + } + + /// Sets [sqlite]. + public BackendPackager sqlite(boolean v) { + this.sqlite = v; + return this; + } + + /// Whether the packaged server carries the development MCP tools. Off by + /// default: they read the database and call the server on an agent's + /// behalf, and a production binary should not contain them at all -- not + /// merely have them switched off. The JVM run has them. + public BackendPackager devTools(boolean v) { + this.devTools = v; + return this; + } + + /// Sets [checkedCasts]. + public BackendPackager checkedCasts(boolean v) { + this.checkedCasts = v; + return this; + } + + /// The name the binary gets by default: the module's artifact id. + protected String binaryName() { + return host.finalName(); + } + + /// Where the build tool put the module's processed resources. + protected File processedResourcesDirectory() { + return host.outputDirectory(); + } + + /// Whether the module declares resources at all, for the warning when none + /// were processed. + protected boolean declaresResources() { + return true; + } + + /// The encoding the module's sources are written in; see the Maven + /// plugin's override, which reads the compiler plugin's configuration. + protected String sourceEncoding() { + String property = host.projectProperties() == null ? null + : host.projectProperties().getProperty("project.build.sourceEncoding"); + if (property != null && property.trim().length() > 0) { + return property.trim(); + } + return "UTF-8"; + } + + /// Resolves `groupId:artifactId:version:classifier`. + protected File resolve(String groupId, String artifactId, String version, String classifier) + throws BuildExecutionException { + File f = host.getJar(groupId, artifactId, classifier); + if (f == null) { + throw new BuildExecutionException("Could not resolve " + groupId + ":" + + artifactId + ":" + version + ":" + classifier); + } + return f; + } + + /// Builds the binary and returns where it went. + public File execute() throws BuildExecutionException { + File jdk = resolveJdk(); + File work = new File(host.buildDirectory().getPath(), "cn1-backend"); + File classes = new File(work, "classes"); + File javaApi = new File(work, "javaapi-classes"); + File runtimeSources = new File(work, "runtime-src"); + File nativeSources = new File(work, "native"); + File translated = new File(work, "translated"); + File dependencyClasses = new File(work, "dependency-classes"); + // Emptied, not just created. Every one of these is derived, and nothing here + // removes a file that stopped being produced: a renamed or deleted source + // left its old .class behind, the translator still read it, and even + // requireMainClass accepted a main class the module no longer had -- so the + // package that came out was the previous implementation. Rebuilding from + // clean costs nothing, since neither the javac nor the clang pass below was + // ever incremental. + emptyDirs(classes, javaApi, runtimeSources, nativeSources, translated, + dependencyClasses); + mkdirs(work, classes, javaApi, runtimeSources, nativeSources, translated, + dependencyClasses); + + // The version of the runtime THIS MODULE compiles against, not the + // module's own: the sources handed to the translator have to be the same + // ones behind the classes the developer just built against, or the local + // run and the deployed binary are different programs. + String runtimeVersion = backendRuntimeVersion(); + File runtimeJar = resolve("com.codenameone", "codenameone-backend", + runtimeVersion, "parparvm-sources"); + unzip(runtimeJar, runtimeSources, nativeSources); + File parparvmBundle = resolve("com.codenameone", "codenameone-parparvm", + runtimeVersion, "bundle"); + File bundleDir = new File(work, "parparvm"); + mkdirs(bundleDir); + unzip(parparvmBundle, bundleDir, null); + File compilerJar = new File(bundleDir, "parparvm-compiler.jar"); + File javaApiJar = new File(bundleDir, "parparvm-java-api.jar"); + if (!compilerJar.isFile() || !javaApiJar.isFile()) { + throw new BuildExecutionException("The ParparVM bundle is missing its " + + "compiler or JavaAPI jar: " + parparvmBundle); + } + unzip(javaApiJar, javaApi, null); + + compile(jdk, javaApi, runtimeSources, classes); + generateControllers(classes, work); + requireMainClass(classes); + translate(jdk, compilerJar, javaApi, classes, nativeSources, translated, + dependencyClasses); + File binary = output != null ? output + : new File(host.buildDirectory().getPath(), binaryName()); + link(translated, binary); + getLog().info("built " + binary); + return binary; + } + + /** + * Compiles the module's sources and the runtime's against the JavaAPI as the + * BOOTCLASSPATH. See the class comment for why that matters. + */ + /** + * Generates the routers and the bootstrap for this module's `@RestController` + * classes, into the directory this goal has just compiled into. + * + * Not left to the `process-annotations` goal, which writes into Maven's + * target/classes: that is a different build, made against a JDK rather than + * against the backend's class library, and the translator never reads it. A + * router generated there would be absent from the binary while looking present + * in the project. Generating into the tree that is about to be translated is + * what makes the wiring real. + * + * Sets mainClass to the generated bootstrap when the module did not name one. + */ + private void generateControllers(File classes, File work) throws BuildExecutionException { + Map index; + try { + index = ClassScanner.scan(classes); + } catch (ProcessingException err) { + throw new BuildExecutionException("Could not scan the compiled backend classes: " + + err.getMessage(), err); + } + RestControllerAnnotationProcessor processor = new RestControllerAnnotationProcessor(); + processor.setDevTools(devTools); + ProcessorContext ctx = new ProcessorContext(classes, new File(work, "stubs"), index, + getLog(), host.baseDir(), new Properties(), mainClass, + java.util.Collections.emptyList(), "UTF-8", + compileClasspathWithoutRuntime()); + // THE ENTITIES FIRST, into this same tree. + // + // The entry point generated below references cn1app.BackendDaoBootstrap + // whenever the module has an @Entity, because that reference is the only + // thing that keeps the generated daos in the binary -- the translator + // drops a class nothing names. This goal empties and rebuilds its own + // class tree and never copies Maven's target/classes into it, so a dao + // generated by the process-annotations goal is not here: without this + // pass the entry point names a class that does not exist and packaging + // fails at its own javac, on any project that has both an entity and a + // controller. + // + // The flavour is forced because the classpath cannot answer for it here; + // see OrmAnnotationProcessor#setBackendFlavour. + OrmAnnotationProcessor entities = new OrmAnnotationProcessor(); + entities.setBackendFlavour(true); + try { + entities.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) { + entities.processClass(cls, ctx); + } + } + entities.finish(ctx); + } catch (ProcessingException err) { + throw new BuildExecutionException("Could not process @Entity: " + + err.getMessage(), err); + } + // Re-scanned, so the controller pass sees the daos and the bootstrap the + // pass above just wrote. hasGeneratedDaos() reads this index. + try { + index = ClassScanner.scan(classes); + } catch (ProcessingException err) { + throw new BuildExecutionException("Could not re-scan the backend classes after " + + "generating the entity daos: " + err.getMessage(), err); + } + try { + processor.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) { + processor.processClass(cls, ctx); + } + } + // finish() runs the bean pass -- the wiring, the rewritten classes and + // the classes generated beside them -- into this same tree. + processor.finish(ctx); + entities.enhance(ctx); + } catch (ProcessingException err) { + throw new BuildExecutionException("Could not process @RestController: " + + err.getMessage(), err); + } + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("The backend's annotations could not be processed:"); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) { + sb.append("\n ").append(e); + } + throw new BuildExecutionException(sb.toString()); + } + byte[] generated = ctx.getEmittedResources() + .get(RestControllerAnnotationProcessor.MAIN_CLASS_RESOURCE); + if (generated == null) { + return; + } + String name; + try { + name = new String(generated, "UTF-8").trim(); + } catch (java.io.UnsupportedEncodingException err) { + throw new BuildExecutionException("UTF-8 is required of every JDK", err); + } + if (mainClass == null || mainClass.length() == 0) { + mainClass = name; + getLog().info("cn1: entry point " + name + ", generated from @RestController"); + } + } + + /** + * Fails here, with the reason, rather than inside the translator. + * + * The compile below reads .java and only .java, on purpose: recompiling against + * the JavaAPI bootclasspath is what turns "this backend uses a class the runtime + * does not have" into a compile error instead of a link failure on the device, + * and reusing the jar Maven already built would give that up. The cost is that a + * main class written in Kotlin -- which `cn1:backend` runs happily, because that + * goal is a JVM launch -- never reaches this directory, and the translator's own + * complaint about it names neither Kotlin nor the reason. So say it plainly. The + * developer guide's "Limits worth knowing" carries the same statement. + */ + /** + * The encoding this module's sources are actually written in. + * + *

These are the SAME sources the lifecycle has already compiled, so + * reading them differently here is a second, disagreeing compilation of one + * tree: a module that declares another encoding either fails packaging on + * bytes javac accepted a phase earlier, or -- worse, because nothing says so + * -- translates string literals and identifiers that are not the ones the JVM + * build produced. + * + *

Resolved the way the compiler plugin resolves it, most specific first: + * an explicit <encoding> on maven-compiler-plugin, then + * project.build.sourceEncoding, and UTF-8 only when the module says nothing. + * The platform default is deliberately not the last resort -- it makes the + * build depend on the machine that runs it, which is the reason Maven warns + * about it. + */ + private void requireMainClass(File classes) throws BuildFailureException { + if (mainClass == null || mainClass.length() == 0) { + throw new BuildFailureException("No entry point: set , or annotate " + + "a class with @RestController and let the bootstrap be generated " + + "from it"); + } + if (new File(classes, mainClass.replace('.', '/') + ".class").isFile()) { + return; + } + throw new BuildFailureException("The main class " + mainClass + " was not " + + "produced by the backend compile. This goal compiles Java sources " + + "against the backend class library, so a main class written in " + + "Kotlin or generated into the build output is not visible to it " + + "yet -- write the entry point in Java, or keep it on the JVM with " + + "cn1:backend"); + } + + private void compile(File jdk, File javaApi, File runtimeSources, File classes) + throws BuildExecutionException, BuildFailureException { + List sources = new ArrayList(); + for (Object root : host.compileSourceRoots()) { + collectJava(new File(String.valueOf(root)), sources); + } + collectJava(runtimeSources, sources); + if (sources.isEmpty()) { + throw new BuildFailureException("No Java sources to compile"); + } + + List command = new ArrayList(Arrays.asList( + new File(jdk, "bin/javac").getAbsolutePath(), + "-nowarn", "-encoding", sourceEncoding(), + "-bootclasspath", javaApi.getAbsolutePath(), + "-source", "1.8", "-target", "1.8", + "-d", classes.getAbsolutePath())); + // The module's own dependencies, MINUS the backend runtime: its compiled + // form was built against a JDK, and the sources unpacked above are the + // half that belongs on this bootclasspath. + List classpath = new ArrayList(); + for (Object element : compileClasspathWithoutRuntime()) { + classpath.add(String.valueOf(element)); + } + if (!classpath.isEmpty()) { + command.add("-classpath"); + command.add(join(classpath, File.pathSeparator)); + } + command.addAll(sources); + // -source/-target 8 is the translator's input format and is not a property + // of the compiler running here: every javac from 8 up emits the same class + // file version 52 for it. A javac that has dropped the option says so in + // its own words and names no remedy, so the remedy is added to it. + // + // THE REMEDY IS THE JDK RUNNING MAVEN, not -Dcn1.backend.jdk, even though + // that property is what selects the compiler on this line. Only the two + // FORKED steps read it -- this javac and the translator's java. + // generateControllers() compiles the router and the entry point in + // process, through ToolProvider.getSystemJavaCompiler(), which is the + // Maven JVM's compiler and cannot be pointed anywhere; it asks for + // -source 1.8 as well, so it fails next on a compiler that has dropped + // it. Naming the property here would send a developer to a setting that + // moves the failure by one step and no further. + // + // Routing that in-process compile through the selected JDK is not the + // answer either. A release that removes -source 8 takes it away from the + // Maven JVM too, and this build needs a javac that emits class file + // version 52 in many more places than this goal -- codenameone-core + // compiles at 1.5. The whole toolchain moves then, not one mojo. + try { + run(command, host.baseDir(), "compile the backend sources"); + } catch (BuildFailureException err) { + throw dropsSourceEight(err) + ? new BuildFailureException(err.getMessage() + "\n\n" + + SOURCE_EIGHT_REMOVED_HINT, err) + : err; + } + stageResources(classes); + } + + /** + * Copies the module's resources in beside the classes just compiled. + * + * This directory is emptied and then filled from .java alone, and the project's + * own output directory is excluded from the translator input on purpose -- its + * classes were built against a JDK. The consequence was that anything read from + * the classpath, a properties or configuration file, was present under + * cn1:backend and simply absent from the packaged binary. Nothing failed at + * build time; the resource was just not there at runtime. + * + * Everything EXCEPT .class is taken, which is exactly the resources and none of + * the JDK-compiled code. + * + * ONLY Maven's processed output, never the raw resource directories. Those + * directories are what a / selects FROM, so copying them + * wholesale packaged the files the build was configured to leave out -- an + * environment file or a secret excluded on purpose would have gone into the + * executable, and the later overlay could not remove it. The processed copy is + * the answer Maven already computed. + */ + private void stageResources(File classes) throws BuildExecutionException { + File processed = processedResourcesDirectory(); + if (!processed.isDirectory()) { + // Nothing has processed the resources, so there are none to stage and + // nothing to guess at. Said out loud, because a resource silently absent + // from the binary is the failure this whole step exists to prevent. + if (declaresResources()) { + getLog().warn("cn1: this module declares resources but " + + processed + " does not exist, so none are packaged. Run " + + "process-resources first, or invoke this through the " + + "lifecycle rather than as a bare goal."); + } + return; + } + try { + int staged = copyNonClasses(processed, classes); + // Staged is not the same as READABLE, and the difference is silent. + // These files reach the translator, so anything that reads them at + // BUILD time works -- but the backend translates as app type "clean", + // and only the linux and windows types embed classpath resources into + // the binary. The clean runtime's Class.getResourceAsStream returns + // null unconditionally, so getResourceAsStream finds the file under + // cn1:backend, on the JVM, and finds nothing in the packaged + // executable. Said out loud rather than left to be discovered in + // production; embedding them is a change to the translator and the + // shared runtime, not to this goal. + if (staged > 0) { + getLog().warn("cn1: staged " + staged + " resource file(s) for translation, " + + "but a packaged backend cannot READ them: getResourceAsStream " + + "answers null in the translated runtime, though it works under " + + "cn1:backend. Read configuration from a file path or the " + + "environment instead of the classpath."); + } + } catch (IOException err) { + // A resource that cannot be staged is a packaging failure, not a note: + // the executable would be reported as built while missing something + // cn1:backend has, and the difference would first appear in production. + throw new BuildExecutionException("Could not stage the processed resources " + + "from " + processed + " into " + classes, err); + } + } + + /** @return how many non-class files were copied. */ + private int copyNonClasses(File from, File to) throws IOException { + if (from == null || !from.isDirectory()) { + return 0; + } + File[] children = from.listFiles(); + if (children == null) { + return 0; + } + int copied = 0; + for (File child : children) { + File target = new File(to, child.getName()); + if (child.isDirectory()) { + target.mkdirs(); + copied += copyNonClasses(child, target); + } else if (!child.getName().endsWith(".class")) { + copyFile(child, target); + copied++; + } + } + return copied; + } + + private static void copyFile(File from, File to) throws IOException { + InputStream in = new java.io.FileInputStream(from); + try { + OutputStream out = new java.io.FileOutputStream(to); + try { + byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) > 0) { + out.write(chunk, 0, n); + } + } finally { + out.close(); + } + } finally { + in.close(); + } + } + + private List compileClasspathWithoutRuntime() throws BuildExecutionException { + List classpath = new ArrayList(); + try { + for (Object element : host.compileClasspathElements()) { + classpath.add(String.valueOf(element)); + } + } catch (Exception err) { + throw new BuildExecutionException("Could not resolve the compile classpath", err); + } + return withoutRuntime(classpath, runtimeArtifactFile(), + host.outputDirectory().getPath()); + } + + /** + * The classpath without the backend runtime and without this module's own + * output. + * + *

IDENTIFIED BY FILE, not by looking for "codenameone-backend" anywhere in + * a path. A project checked out under a directory whose name contains that -- + * /work/codenameone-backend-demo/ is the obvious one -- has EVERY reactor + * dependency below it match, so a backend depending on a sibling contract + * module lost it from both the compile classpath and the translation, and + * failed on classes it plainly depends on. The name of a directory somewhere + * above the project is not something a build should read meaning into. + * + *

The runtime is left out because its sources are compiled into `classes` + * already; the module's own output for the same reason. + * + * @param runtime the resolved runtime artifact, or null when it cannot be + * located -- then nothing is dropped for it, which is + * duplicate work rather than a missing class + */ + static List withoutRuntime(List classpath, File runtime, String ownOutput) { + List out = new ArrayList(); + File runtimeFile = runtime == null ? null : runtime.getAbsoluteFile(); + File output = ownOutput == null ? null : new File(ownOutput).getAbsoluteFile(); + for (int i = 0; i < classpath.size(); i++) { + String path = classpath.get(i); + File element = new File(path).getAbsoluteFile(); + if (runtimeFile != null && runtimeFile.equals(element)) { + continue; + } + if (output != null && output.equals(element)) { + continue; + } + out.add(path); + } + return out; + } + + /** The resolved file of com.codenameone:codenameone-backend, or null. */ + private File runtimeArtifactFile() { + java.util.Collection artifacts = host.artifacts(); + if (artifacts != null) { + for (BuildArtifact artifact : artifacts) { + if ("com.codenameone".equals(artifact.getGroupId()) + && "codenameone-backend".equals(artifact.getArtifactId())) { + return artifact.getFile(); + } + } + } + return null; + } + + /** + * The compile classpath staged as ONE directory the translator can read. + * + * ByteCodeTranslator walks its inputs with File.listFiles, which answers NULL + * for a jar -- and the walk reads null as an empty directory, so a dependency + * resolved from the repository as a jar contributed nothing at all, without a + * word. The build then failed much later, while linking, on the symbols of + * classes the translator had never been shown. A module in the same reactor + * resolves to its target/classes and worked, which is why the generated + * project's own contract module never showed this. + * + *

ONE TREE, FIRST WINS, IN CLASSPATH ORDER, and not a list of inputs. + * Parser.classIndex keeps the first definition of a class it parsed, so the + * order of the translator's inputs IS precedence -- and javac resolved the + * same classpath the same way, so anything that reorders it compiles against + * one definition and translates another. Staging settles it on disk instead: + * whatever arrives first is what is there, every later copy is skipped, and + * there is no order left to get wrong. It also means a class two dependencies + * both carry is PARSED once rather than twice, which is where duplicate + * symbols came from. + * + *

Directories are copied rather than passed through for that reason alone. + * They cost a copy of their class files per build, which is a reactor + * module's output and small beside the translation that follows. + * + *

cn1-native goes where the runtime jar's natives go, so a dependency that + * ships them is built rather than dropped just as quietly. META-INF is + * skipped out of jars by the same unpacking the runtime gets; a directory is + * copied as it stands, which is what passing it as an input already did. + * + * @param classpath compile classpath elements, in classpath order + * @param staged directory to stage into; assumed empty + * @param nativeSources where a dependency's cn1-native entries belong + */ + List stageDependencyClasses(List classpath, File staged, + File nativeSources) throws BuildExecutionException { + List out = new ArrayList(); + boolean any = false; + for (int i = 0; i < classpath.size(); i++) { + File element = new File(classpath.get(i)); + if (element.isDirectory()) { + copyDirectoryFirstWins(element, staged, nativeSources); + any = true; + } else if (element.isFile()) { + unzip(element, staged, nativeSources, true); + any = true; + } + // An entry that is neither is one javac will complain about; there is + // nothing here to stage and nothing to say that it will not say. + } + if (any) { + out.add(staged.getAbsolutePath()); + } + return out; + } + + private void translate(File jdk, File compilerJar, File javaApi, File classes, + File nativeSources, File translated, File dependencyClasses) + throws BuildExecutionException, BuildFailureException { + String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1); + String packageName = mainClass.lastIndexOf('.') < 0 ? "" + : mainClass.substring(0, mainClass.lastIndexOf('.')); + + // BEFORE the natives are copied below, because a dependency that ships + // cn1-native adds to them. + List dependencyInputs = stageDependencyClasses( + compileClasspathWithoutRuntime(), dependencyClasses, nativeSources); + + // The C has to be in the source root BEFORE the translator runs: it reads + // the directory to decide which native-only Java methods to keep, and the + // signature verifier checks every declared native against an actual + // implementation. + File sourceDir = new File(translated, "dist/" + simpleName + "-src"); + mkdirs(sourceDir); + copyDirectory(nativeSources, sourceDir); + + List command = new ArrayList(); + command.add(new File(jdk, "bin/java").getAbsolutePath()); + if (sqlite) { + command.add("-Dcn1.sqlite=true"); + } + if (checkedCasts) { + command.add("-Dcn1.checkedCasts=true"); + } + command.add("-cp"); + command.add(compilerJar.getAbsolutePath()); + command.add("com.codename1.tools.translator.ByteCodeTranslator"); + command.add("clean"); + // The module's dependencies belong on the translator's input, not only on + // javac's classpath. Without them a backend that uses a type from another + // module -- the shared contract or DTO module the generated project + // recommends -- compiles here and then fails to translate, because javac + // resolved the type from a jar whose bytecode the translator never sees. + // The runtime is excluded for the same reason it is excluded from javac's + // classpath: its sources are compiled into `classes` already. + StringBuilder translatorInput = new StringBuilder(); + translatorInput.append(javaApi.getAbsolutePath()) + .append(';').append(classes.getAbsolutePath()); + for (int i = 0; i < dependencyInputs.size(); i++) { + translatorInput.append(';').append(dependencyInputs.get(i)); + } + command.add(translatorInput.toString()); + command.add(translated.getAbsolutePath()); + command.add(simpleName); + command.add(packageName); + command.add(simpleName); + command.add("1.0"); + command.add("clean"); + command.add("none"); + run(command, host.baseDir(), "translate the backend to C"); + } + + private void link(File translated, File binary) + throws BuildExecutionException, BuildFailureException { + String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1); + File sourceDir = new File(translated, "dist/" + simpleName + "-src"); + // Kept as a loud failure rather than dropped: the parameter names a real + // capability, and silently ignoring -Dcn1.backend.target would hand back a + // host binary labelled as a cross-compiled one. The script named here lives in + // the Codename One repository, not in a generated project, which is why the + // message says where it is instead of assuming it is on hand. + if (target != null && target.length() > 0) { + throw new BuildFailureException("cn1.backend.target is not supported from " + + "this goal yet: it builds for the machine it runs on. The " + + "cross-compiled targets (musl-x86_64, musl-arm64, glibc-x86_64, " + + "glibc-arm64) are produced by package.sh in the Codename One " + + "repository, which drives one container image per target; run " + + "this goal inside a container of the target flavour to get the " + + "same artifact here"); + } + List command = new ArrayList(Arrays.asList( + "clang", "-O3", "-w", + // Mandatory for generated C: Java arithmetic wraps, and clang -O3 + // provably miscompiles the output without these. + "-fwrapv", "-fno-strict-aliasing", + "-fno-builtin-fmod", "-fno-builtin-fmodf")); + if (!sqlite) { + // Turning the engine OFF is two changes, not one. Without + // -Dcn1.sqlite=true the translator leaves cn1_sqlite3.h out, but + // cn1_backend_db.c is copied and compiled either way -- and its + // SQLite branch includes that header unconditionally, so the compile + // fails with "cn1_sqlite3.h file not found" and the option advertised + // as saving the engine could not produce a binary at all. The macro + // is what compiles that file to stubs instead, which answer "could + // not open" and become an IOException, rather than dropping the Db + // natives and taking their Java methods with them. build.sh has + // always set both; this half had only the first. + command.add("-DCN1_BACKEND_NO_SQLITE"); + } + if (cflags != null && cflags.trim().length() > 0) { + command.addAll(Arrays.asList(cflags.trim().split("\\s+"))); + } + // Before -I on the generated sources, which is where build.sh puts them. + command.addAll(hostLibraryFlags(opensslPrefixes(), nghttp2Prefixes())); + command.add("-I" + sourceDir.getAbsolutePath()); + File[] cFiles = sourceDir.listFiles(); + if (cFiles == null) { + throw new BuildExecutionException("The translator produced nothing in " + sourceDir); + } + for (File file : cFiles) { + String name = file.getName(); + // .S as well as .c, which is what vm/backend/build.sh compiles. The + // translator always emits cn1_virtual_thread_asm.S, and + // cn1_virtual_thread.c calls cn1VirtualThreadSwitch out of it, so a + // command that passed only .c reached the linker with that symbol + // undefined and this goal could not produce a binary at all. + // Generated resource assembly is in the same position. + if (name.endsWith(".c") || name.endsWith(".S") || name.endsWith(".s")) { + command.add(file.getAbsolutePath()); + } + } + command.addAll(Arrays.asList("-lm", "-lpthread", + "-lcurl", "-lssl", "-lcrypto", "-lnghttp2")); + command.add("-o"); + command.add(binary.getAbsolutePath()); + run(command, host.baseDir(), "compile the generated C"); + } + + /** + * -I and -L for the TLS and HTTP/2 libraries, where this machine keeps them. + * + * macOS ships libcrypto WITHOUT its headers, so a stock machine with the + * usual Homebrew OpenSSL could not compile the generated C at all: the goal + * the documentation tells a developer to run failed on openssl/ssl.h, and the + * only way out was to work out cn1.backend.cflags for themselves. The same + * prefixes and the same probe headers as vm/backend/build.sh, so the two ways + * of building a backend look in the same places -- including OPENSSL_PREFIX + * and NGHTTP2_PREFIX, which a developer who has already set them for build.sh + * should not have to set again under another name. + * + *

On a Linux box the distribution's -dev package puts the headers where + * clang already looks, none of these probes match, and this adds nothing. + * + * @param openssl candidate prefixes for OpenSSL, in order of preference + * @param nghttp2 candidate prefixes for nghttp2 + */ + static List hostLibraryFlags(List openssl, List nghttp2) { + List out = new ArrayList(); + addPrefix(out, openssl, "include/openssl/sha.h"); + addPrefix(out, nghttp2, "include/nghttp2/nghttp2.h"); + return out; + } + + /** The first prefix that actually carries `probe`, as -I and -L. */ + private static void addPrefix(List out, List prefixes, String probe) { + if (prefixes == null) { + return; + } + for (int i = 0; i < prefixes.size(); i++) { + String prefix = prefixes.get(i); + if (prefix == null || prefix.length() == 0) { + continue; + } + if (new File(prefix, probe).isFile()) { + out.add("-I" + new File(prefix, "include").getAbsolutePath()); + out.add("-L" + new File(prefix, "lib").getAbsolutePath()); + return; + } + } + } + + private List opensslPrefixes() { + return prefixesFrom(System.getenv("OPENSSL_PREFIX"), + "/opt/homebrew/opt/openssl@3", "/usr/local/opt/openssl@3"); + } + + private List nghttp2Prefixes() { + return prefixesFrom(System.getenv("NGHTTP2_PREFIX"), + "/opt/homebrew/opt/libnghttp2", "/opt/homebrew/opt/nghttp2", + "/usr/local/opt/libnghttp2"); + } + + private static List prefixesFrom(String fromEnvironment, String... defaults) { + List out = new ArrayList(); + if (fromEnvironment != null && fromEnvironment.length() > 0) { + out.add(fromEnvironment); + } + out.addAll(Arrays.asList(defaults)); + return out; + } + + /** + * The codenameone-backend version this module depends on. + * + * Deliberately an error rather than a default when the dependency is absent: + * guessing a version here would translate a different runtime from the one the + * module was compiled and tested against. + */ + private String backendRuntimeVersion() throws BuildFailureException { + java.util.Collection artifacts = host.artifacts(); + if (artifacts != null) { + for (BuildArtifact artifact : artifacts) { + if ("com.codenameone".equals(artifact.getGroupId()) + && "codenameone-backend".equals(artifact.getArtifactId())) { + return artifact.getVersion(); + } + } + } + throw new BuildFailureException("This module does not depend on " + + "com.codenameone:codenameone-backend, so there is no backend " + + "runtime to translate. Add it as a dependency."); + } + + /** + * The JDK that compiles the sources for translation and runs the translator. + * + * ANY JDK 8 OR NEWER, which for almost every project means the one already + * running Maven and nothing to configure. This goal used to demand a JDK 8 + * and refuse to run without one, on the premise that a newer javac emits + * class files the translator cannot read. The premise was wrong: {@link + * #compile} passes -source 1.8 -target 1.8, so the class file version is 52 + * whichever javac produces it, and the bootclasspath that confines the build + * to the server-safe surface is enforced identically -- a reference to + * java.nio.file still fails to compile on 8, 17, 21 and 25 alike. The cost of + * the premise was paid entirely by the developer, who had to install a JDK + * from 2014 to package a server. + * + * The two properties are tried before the running JDK so an explicit choice + * still wins, and cn1.backend.jdk8 is among them so the setups that were + * required to set it keep working. + * + * WHAT THEY SELECT IS THE TWO FORKED STEPS -- {@link #compile}'s javac and + * {@link #translate}'s java -- and nothing else. The router and entry point + * that {@link #generateControllers} produces are compiled in process by the + * JDK running Maven, which JSR 199 gives no way to redirect. That costs + * nothing today, because every JDK from 8 up emits the class file version 52 + * the translator reads; see {@link #compile} for why it is also not worth + * forking, and why the error message there names Maven's own JDK. + */ + File resolveJdk() throws BuildFailureException { + String[] configured = {jdkHome, jdk8Home}; + for (int i = 0; i < configured.length; i++) { + if (configured[i] != null && configured[i].length() > 0) { + File home = new File(configured[i]); + if (hasJavac(home)) { + return requireEightOrNewer(home); + } + } + } + File running = new File(System.getProperty("java.home")); + if (hasJavac(running)) { + return requireEightOrNewer(running); + } + // The Java 8 layout points java.home at the jre inside the JDK, where + // there is no compiler; it is one level up. + File parent = running.getParentFile(); + if (parent != null && hasJavac(parent)) { + return requireEightOrNewer(parent); + } + throw new BuildFailureException("Packaging a backend needs a JDK, and " + + System.getProperty("java.home") + " has no javac -- Maven is " + + "running on a JRE. Run it on a JDK, or point " + + "-Dcn1.backend.jdk at one."); + } + + /** + * Refuses a JDK older than 8, whose javac cannot emit the format the + * translator reads. + * + * Only reachable through an explicitly configured JDK: Maven itself needs 8 + * or newer, so the running one always passes. A javac that cannot be asked + * its version is ACCEPTED rather than refused -- the compile that follows + * reports what is actually wrong with it, and inventing a failure here would + * hide that. + */ + File requireEightOrNewer(File home) throws BuildFailureException { + int major = javacMajor(home); + if (major > 0 && major < 8) { + throw new BuildFailureException("A JDK 8 or newer is required to " + + "package a backend; " + home + " is a JDK " + major + "."); + } + return home; + } + + /** + * The feature version of a JDK's javac, or -1 when it cannot be determined. + * + * Read by running it, not by parsing the path: a directory name says nothing + * reliable. Java 8 prints "javac 1.8.0_402" on stderr and later releases + * print "javac 21.0.2" on stdout, so both streams are read and both spellings + * are understood. + */ + private static int javacMajor(File home) { + try { + ProcessBuilder builder = new ProcessBuilder( + new File(home, "bin/javac").isFile() + ? new File(home, "bin/javac").getAbsolutePath() + : new File(home, "bin/javac.exe").getAbsolutePath(), + "-version"); + builder.redirectErrorStream(true); + Process process = builder.start(); + StringBuilder output = new StringBuilder(); + InputStream in = process.getInputStream(); + byte[] chunk = new byte[512]; + int n; + while ((n = in.read(chunk)) > 0) { + output.append(new String(chunk, 0, n, "UTF-8")); + } + process.waitFor(); + return parseJavacVersion(output.toString()); + } catch (IOException err) { + return -1; + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return -1; + } + } + + /** Package private so the parsing is testable without a JDK to run. */ + static int parseJavacVersion(String output) { + if (output == null) { + return -1; + } + int at = output.indexOf("javac "); + if (at < 0) { + return -1; + } + String version = output.substring(at + "javac ".length()).trim(); + // "1.8.0_402" is Java 8; "21.0.2" is Java 21. The leading "1." is the old + // spelling and the number after it is the feature version. + if (version.startsWith("1.")) { + version = version.substring(2); + } + int end = 0; + while (end < version.length() && Character.isDigit(version.charAt(end))) { + end++; + } + if (end == 0) { + return -1; + } + try { + return Integer.parseInt(version.substring(0, end)); + } catch (NumberFormatException err) { + return -1; + } + } + + /** + * What to do about a javac that has dropped -source 8. + * + * Package private so a test can hold the wording to a remedy that WORKS. An + * earlier version of this sent the developer to -Dcn1.backend.jdk, which + * selects the two forked steps and not the in-process compile of the + * generated router and entry point, so following it moved the failure by one + * step and no further. See #compile. + */ + static final String SOURCE_EIGHT_REMOVED_HINT = + "This javac no longer accepts -source 8, which is the format the " + + "translator reads. Run Maven itself on a JDK that still does: " + + "-Dcn1.backend.jdk selects the compiler for this step and the " + + "translator, but the generated router and entry point are compiled " + + "in process by the JDK running Maven."; + + /** + * Whether a failed compile is javac refusing -source 8 outright, rather than + * anything about the sources. + * + * A future release will remove the option -- 21 and 25 already warn that it is + * obsolete -- and javac's own wording ("Source option 8 is no longer + * supported") names no way forward, so the caller adds one. + */ + static boolean dropsSourceEight(BuildFailureException err) { + String message = err.getMessage(); + return message != null + && message.indexOf("Source option") >= 0 + && message.indexOf("no longer supported") >= 0; + } + + /** Windows names it javac.exe, and a JRE has neither. */ + private static boolean hasJavac(File home) { + return new File(home, "bin/javac").isFile() + || new File(home, "bin/javac.exe").isFile(); + } + + /** + * Unpacks a jar. Entries under cn1-native/ go to `nativeTarget` when one is + * given, because the C belongs in the translator's source root rather than on + * the Java source path. + */ + private void unzip(File jar, File javaTarget, File nativeTarget) + throws BuildExecutionException { + unzip(jar, javaTarget, nativeTarget, false); + } + + /** + * @param firstWins leave an entry alone when something is already at its + * destination, which is how a classpath resolves a class two + * entries both carry + */ + private void unzip(File jar, File javaTarget, File nativeTarget, boolean firstWins) + throws BuildExecutionException { + try { + ZipFile zip = new ZipFile(jar); + try { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.isDirectory()) { + continue; + } + String name = entry.getName(); + File destination; + if (name.startsWith("cn1-native/")) { + if (nativeTarget == null) { + continue; + } + destination = resolveInside(nativeTarget, + name.substring("cn1-native/".length()), jar, name); + } else if (name.startsWith("META-INF/")) { + continue; + } else { + destination = resolveInside(javaTarget, name, jar, name); + } + if (firstWins && destination.isFile()) { + continue; + } + mkdirs(destination.getParentFile()); + InputStream in = zip.getInputStream(entry); + try { + copy(in, destination); + } finally { + in.close(); + } + } + } finally { + zip.close(); + } + } catch (IOException err) { + throw new BuildExecutionException("Could not unpack " + jar, err); + } + } + + /** + * The entry's destination, proven to be inside the directory it unpacks into. + * + * An archive entry name is attacker-controlled data, not a path this build + * chose: an entry called `../../../../etc/whatever` makes `new File(root, name)` + * resolve outside `root`, so unpacking writes wherever the entry says. That is + * Zip Slip, and here it would run with the developer's privileges during an + * ordinary `mvn package` against whatever jar the coordinates resolved to. + * + * Compared after canonicalisation rather than on the raw string, because `..` + * is not the only way out -- a symlinked parent resolves elsewhere too, and the + * textual check passes for both. The separator is appended to the root so a + * sibling whose name merely starts with it ("/tmp/outdir-evil" against + * "/tmp/outdir") cannot satisfy the prefix test. + */ + private static File resolveInside(File root, String relative, File jar, String entryName) + throws IOException { + File destination = new File(root, relative); + String prefix = root.getCanonicalPath() + File.separator; + String resolved = destination.getCanonicalPath(); + if (!resolved.startsWith(prefix)) { + throw new IOException("Refusing to unpack " + jar + ": entry \"" + entryName + + "\" resolves to " + resolved + ", outside " + root.getCanonicalPath()); + } + return destination; + } + + private static void copy(InputStream in, File destination) throws IOException { + OutputStream out = new FileOutputStream(destination); + try { + byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) > 0) { + out.write(chunk, 0, n); + } + } finally { + out.close(); + } + } + + /** As copyDirectory, but never replacing a file that is already there. */ + private void copyDirectoryFirstWins(File from, File to) throws BuildExecutionException { + copyDirectoryFirstWins(from, to, null); + } + + /** + * @param nativeTarget where a top-level cn1-native subtree belongs, or null + * when this is already below one + * + *

The same routing the jar branch does, and for the same reason: the + * translator has no code that looks for cn1-native inside a class input, and + * only nativeSources is copied into its C source root. Copied as it stood, + * a reactor module's C was staged among the classes where nothing reads it -- + * and a native whose C is absent is not a link error, because a Java native + * method is kept alive BY its symbol appearing in the native sources, so the + * dead-code pass drops the method and the build stays green with the feature + * inert. The same dependency packaged correctly once it was installed as a + * jar and consumed that way, which is the worst shape for this to take. + */ + private void copyDirectoryFirstWins(File from, File to, File nativeTarget) + throws BuildExecutionException { + File[] children = from.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (nativeTarget != null && child.isDirectory() + && "cn1-native".equals(child.getName())) { + mkdirs(nativeTarget); + // Without the native target below it: cn1-native is matched at the + // root only, exactly as the jar branch matches the prefix. + copyDirectoryFirstWins(child, nativeTarget); + continue; + } + File destination = new File(to, child.getName()); + if (child.isDirectory()) { + mkdirs(destination); + copyDirectoryFirstWins(child, destination); + continue; + } + if (destination.isFile()) { + continue; + } + try { + InputStream in = new java.io.FileInputStream(child); + try { + copy(in, destination); + } finally { + in.close(); + } + } catch (IOException err) { + throw new BuildExecutionException("Could not copy " + child, err); + } + } + } + + private void copyDirectory(File from, File to) throws BuildExecutionException { + File[] children = from.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + File destination = new File(to, child.getName()); + if (child.isDirectory()) { + mkdirs(destination); + copyDirectory(child, destination); + continue; + } + try { + InputStream in = new java.io.FileInputStream(child); + try { + copy(in, destination); + } finally { + in.close(); + } + } catch (IOException err) { + throw new BuildExecutionException("Could not copy " + child, err); + } + } + } + + private void collectJava(File dir, List out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collectJava(child, out); + } else if (child.getName().endsWith(".java")) { + out.add(child.getAbsolutePath()); + } + } + } + + private void run(List command, File directory, String what) + throws BuildExecutionException, BuildFailureException { + try { + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(directory); + builder.redirectErrorStream(true); + Process process = builder.start(); + StringBuilder output = new StringBuilder(); + InputStream in = process.getInputStream(); + byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) > 0) { + output.append(new String(chunk, 0, n, "UTF-8")); + } + int status = process.waitFor(); + if (status != 0) { + throw new BuildFailureException("Could not " + what + ":\n" + output); + } + if (output.length() > 0) { + getLog().debug(output.toString()); + } + } catch (IOException err) { + throw new BuildExecutionException("Could not " + what, err); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + throw new BuildExecutionException("Interrupted while trying to " + what, err); + } + } + + /** + * Removes each directory and its contents, and refuses to continue if one + * survives. + * + * The emptiness is the point, and it used to be assumed: File.delete returns + * false for a locked file on Windows or anything under a read-only directory, + * nothing looked at that, and the stale .class stayed where ClassScanner, + * requireMainClass and the translator would all find it. A controller or an + * entry point deleted from the source tree is then still packaged, so the + * build ships the previous implementation and says nothing. Checking the + * result rather than each delete catches every reason one can survive. + */ + private static void emptyDirs(File... dirs) throws BuildExecutionException { + for (File dir : dirs) { + deleteTree(dir); + if (dir == null || !dir.exists()) { + continue; + } + String[] left = dir.list(); + if (left != null && left.length > 0) { + throw new BuildExecutionException("Could not empty " + dir + + ": " + left.length + " entr" + (left.length == 1 ? "y" : "ies") + + " could not be deleted, and building over them would package " + + "classes that are no longer in the source tree."); + } + } + } + + private static void deleteTree(File file) { + if (file == null || !file.exists()) { + return; + } + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteTree(child); + } + } + // The result is checked by emptyDirs, which looks at what actually + // survived rather than at each delete: a directory that could not be + // removed but is empty is harmless, and one that still holds a class is + // not, whatever the reason. + file.delete(); + } + + private static void mkdirs(File... dirs) { + for (File dir : dirs) { + if (dir != null && !dir.isDirectory()) { + dir.mkdirs(); + } + } + } + + private static String join(List parts, String separator) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < parts.size(); iter++) { + if (iter > 0) { + out.append(separator); + } + out.append(parts.get(iter)); + } + return out.toString(); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BytecodeCompliance.java b/maven/build-engine/src/main/java/com/codename1/maven/BytecodeCompliance.java new file mode 100644 index 00000000000..1c85b905e57 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/BytecodeCompliance.java @@ -0,0 +1,1395 @@ +/* + * Copyright (c) 2012, 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.maven; + +import org.apache.commons.io.FileUtils; +import com.codename1.build.BuildArtifact; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; +import com.codename1.build.ProjectHost; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.FieldVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.util.CheckClassAdapter; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.InvokeDynamicInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.analysis.Analyzer; +import org.objectweb.asm.tree.analysis.AnalyzerException; +import org.objectweb.asm.tree.analysis.BasicInterpreter; +import org.objectweb.asm.tree.analysis.BasicValue; +import org.objectweb.asm.tree.analysis.Frame; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FilenameFilter; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static com.codename1.maven.PathUtil.path; + +/** + * Performs bytecode-level API compliance checks by scanning compiled classes. + */ +/// Checks the compiled application against the Codename One Java runtime API and +/// rewrites what it can: caps class files at Java 17, redirects the JDK calls the +/// runtime provides elsewhere, and fails the build on anything a device build +/// could not link. +/// +/// The body of the Maven plugin's `bytecode-compliance` goal, shared with the +/// Gradle plugin, which runs it after every compile. +public class BytecodeCompliance { + protected static final String GROUP_ID = "com.codenameone"; + protected static final String JAVA_RUNTIME_ARTIFACT_ID = "java-runtime"; + + /// The build tool's answers about the project. + protected final ProjectHost host; + + private List siblingClassRoots = Collections.emptyList(); + + private Set pendingProjectClasses = Collections.emptySet(); + + /// A check of the project `host` describes. + public BytecodeCompliance(ProjectHost host) { + this.host = host; + } + + /// Other directories of this project's own compiled classes, which the + /// checked classes may refer to but which are not checked here. Gradle + /// compiles Kotlin and Java into separate directories and checks each in its + /// own compile task, so a Java class calling a Kotlin one needs Kotlin's + /// directory as a sibling. Maven compiles both into one directory and needs + /// none. + public BytecodeCompliance siblingClassRoots(List roots) { + this.siblingClassRoots = roots == null ? Collections.emptyList() : new ArrayList(roots); + return this; + } + + /// Internal names (`a/b/C`) of this project's classes that are not compiled + /// yet, so a reference to one -- or to a class nested in one -- is the + /// project's own and allowed. Kotlin compiles before javac, so a Kotlin class + /// calling a Java one is checked before that class exists. The Java class + /// itself is checked by javac's own pass; what this gives up is only the + /// inherited-member walk through it, for members the Kotlin compiler has + /// already resolved against the Java source. + public BytecodeCompliance pendingProjectClasses(Set internalNames) { + this.pendingProjectClasses = internalNames == null ? Collections.emptySet() + : new HashSet(internalNames); + return this; + } + + private boolean isPendingProjectClass(String owner) { + if (pendingProjectClasses.isEmpty() || owner == null) { + return false; + } + int nested = owner.indexOf('$'); + return pendingProjectClasses.contains(nested < 0 ? owner : owner.substring(0, nested)); + } + + protected Log getLog() { + return host.log(); + } + + /// The newest modification time among the sources that decide whether the + /// last check still stands. + protected long sourcesModificationTime() throws IOException { + return host.sourcesModificationTime(); + } + + /// Runs before the output is examined; the Maven plugin copies Kotlin's + /// incremental output into place here. + protected void beforeCheck() { + } + + protected static long lastModifiedRecursive(File file, FilenameFilter filter) { + long lastModified = 0L; + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + lastModified = Math.max(lastModifiedRecursive(child, filter), lastModified); + } + } + } else if (filter.accept(file.getParentFile(), file.getName())) { + lastModified = file.lastModified(); + } + return lastModified; + } + + + private static final Map SUGGESTED_REPLACEMENTS; + private static final Set SIMD_OWNER_NAMES; + private static final Set PRIMITIVE_WRAPPER_INTERNAL_NAMES; + + static { + Map m = new HashMap(); + m.put("java/lang/System#exit(I)V", "Use com.codename1.ui.CN.exitApplication() to close apps on supported targets."); + m.put("java/lang/Thread#sleep(J)V", "Use com.codename1.ui.util.UITimer or Display.callSerially() instead of blocking sleeps."); + m.put("java/lang/Thread#sleep(JI)V", "Use com.codename1.ui.util.UITimer or Display.callSerially() instead of blocking sleeps."); + m.put("java/lang/Runtime#getRuntime()Ljava/lang/Runtime;", "Use Codename One platform services instead of raw java.lang.Runtime access."); + SUGGESTED_REPLACEMENTS = Collections.unmodifiableMap(m); + Set simdOwners = new HashSet(); + simdOwners.add("com/codename1/util/Simd"); + simdOwners.add("com/codename1/impl/ios/IOSSimd"); + simdOwners.add("com/codename1/impl/javase/JavaSESimd"); + SIMD_OWNER_NAMES = Collections.unmodifiableSet(simdOwners); + Set primitiveWrappers = new HashSet(); + primitiveWrappers.add("java/lang/Boolean"); + primitiveWrappers.add("java/lang/Byte"); + primitiveWrappers.add("java/lang/Character"); + primitiveWrappers.add("java/lang/Double"); + primitiveWrappers.add("java/lang/Float"); + primitiveWrappers.add("java/lang/Integer"); + primitiveWrappers.add("java/lang/Long"); + primitiveWrappers.add("java/lang/Short"); + PRIMITIVE_WRAPPER_INTERNAL_NAMES = Collections.unmodifiableSet(primitiveWrappers); + } + + private static final int MAX_CLASS_MAJOR_VERSION = Opcodes.V17; + private static final String JDK_API_REWRITE_HELPER_INTERNAL_NAME = "com/codename1/impl/JdkApiRewriteHelper"; + private static final String SIMD_INTERNAL_NAME = "com/codename1/util/Simd"; + private static final Map INVOCATION_REWRITE_RULES = createInvocationRewriteRules(); + + private File complianceOutputFile; + private InvocationRewriteSummary lastInvocationRewriteSummary = new InvocationRewriteSummary(); + private URLClassLoader validationClassLoader; + + private static Map createInvocationRewriteRules() { + Map rules = new LinkedHashMap(); + rules.put( + MethodRef.virtual("java/lang/String", "split", "(Ljava/lang/String;)[Ljava/lang/String;"), + MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "split", "(Ljava/lang/String;Ljava/lang/String;)[Ljava/lang/String;") + ); + rules.put( + MethodRef.virtual("java/lang/String", "split", "(Ljava/lang/String;I)[Ljava/lang/String;"), + MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "split", "(Ljava/lang/String;Ljava/lang/String;I)[Ljava/lang/String;") + ); + rules.put( + MethodRef.virtual("java/lang/String", "replaceAll", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"), + MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "replaceAll", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;") + ); + rules.put( + MethodRef.virtual("java/lang/String", "replaceFirst", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"), + MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "replaceFirst", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;") + ); + return Collections.unmodifiableMap(rules); + } + + private static boolean isSimdOwner(String owner) { + return owner != null && SIMD_OWNER_NAMES.contains(owner); + } + + private static boolean isSimdAllocaMethod(String owner, String name, String descriptor) { + if (!isSimdOwner(owner)) { + return false; + } + return name != null + && name.startsWith("alloca") + && name.length() > "alloca".length() + && Character.isUpperCase(name.charAt("alloca".length())) + && isSimdAllocaDescriptor(descriptor); + } + + private static boolean isSimdAllocaDescriptor(String descriptor) { + if (descriptor == null) { + return false; + } + Type returnType = Type.getReturnType(descriptor); + if (returnType == null || returnType.getSort() != Type.ARRAY || returnType.getDimensions() != 1) { + return false; + } + Type elementType = returnType.getElementType(); + int elementSort = elementType.getSort(); + return elementSort == Type.BYTE || elementSort == Type.INT || elementSort == Type.FLOAT; + } + + /// Runs the check. + public void execute() throws BuildExecutionException { + if (shouldSkipComplianceCheck()) { + return; + } + + complianceOutputFile = new File(path(host.buildDirectory().getPath(), "codenameone", "compliance_check.txt")); + getLog().info("Running bytecode compliance check against Codename One Java Runtime API"); + getLog().info("See https://www.codenameone.com/javadoc/ for supported Classes and Methods"); + + if (!hasChangedSinceLastCheck()) { + getLog().info("Sources haven't changed since the last compliance check. Skipping check"); + return; + } + + beforeCheck(); + + File outputDir = new File(host.outputDirectory().getPath()); + if (!outputDir.isDirectory()) { + writeComplianceSuccess("No output classes found for compliance check in " + outputDir.getAbsolutePath(), 0); + return; + } + + int rewrittenClassCount = enforceMaxClassVersion(outputDir, MAX_CLASS_MAJOR_VERSION); + InvocationRewriteSummary invocationRewriteSummary = applyInvocationRewrites(outputDir); + lastInvocationRewriteSummary = invocationRewriteSummary; + + // Both calls above rewrite class files IN PLACE -- capping a class to the + // supported version, and redirecting a call the runtime does not have. If + // the main class is one of them, its bytes no longer match the digest the + // build hint manifest recorded, and the simulator -- which has no bytecode + // reader and so compares the class file itself -- reads a manifest written + // moments ago as stale and publishes none of the annotated hints. + // + // Every pom in this repository runs process-annotations after this goal, + // where the stamp would be taken from the rewritten bytes anyway. Stamping + // here as well is what makes that ordering stop mattering: whichever of + // the two runs last leaves a manifest describing the class on disk. A + // no-op when there is no manifest, which is every project that declares + // its hints in codenameone_settings.properties. + try { + com.codename1.maven.processors.BuildHintAnnotationProcessor + .restampClassDigest(outputDir); + } catch (IOException ioe) { + throw new BuildExecutionException( + "Could not stamp the build hint manifest under " + outputDir, ioe); + } + + List dependencyJars = getDependencyJarsForScanning(); + Map allowedIndex = buildClassIndex(Arrays.asList(getJavaRuntimeJar(), getCodenameOneJar())); + Map projectAndDependencyIndex = buildClassIndexWithOutput(outputDir, dependencyJars); + projectAndDependencyIndex.putAll(buildClassIndex(siblingClassRoots)); + + List violations = scanProjectClasses(outputDir, allowedIndex, projectAndDependencyIndex); + if (!violations.isEmpty()) { + writeComplianceReport(violations, outputDir, dependencyJars, rewrittenClassCount); + logViolationSummary(violations); + throw new BuildFailureException(buildFailureSummary(violations)); + } + + writeComplianceSuccess("Completed compliance check on " + host.finalName(), rewrittenClassCount); + getLog().info("Invocation rewrite summary: classes rewritten=" + invocationRewriteSummary.rewrittenClasses + ", callsites rewritten=" + invocationRewriteSummary.rewrittenCallsites); + } + + private boolean shouldSkipComplianceCheck() { + if ("true".equals(System.getProperty("skipComplianceCheck", "false"))) { + return true; + } + if ("true".equals(host.projectProperties().getProperty("skipComplianceCheck", "false"))) { + return true; + } + if ("true".equals(System.getProperty("reloadClasses", "false"))) { + return true; + } + return "true".equals(host.projectProperties().getProperty("reloadClasses", "false")); + } + + private boolean hasChangedSinceLastCheck() { + if (!complianceOutputFile.exists()) { + return true; + } + try { + long lastCheck = complianceOutputFile.lastModified(); + if (sourcesModificationTime() > lastCheck) { + return true; + } + if (lastCheckFailed()) { + // A failure report is not a completed check. Without this a + // rerun with unchanged sources would skip straight past the + // violations that just failed the build. + return true; + } + // The invocation rewrites and the class-version cap mutate the + // compiled classes in place, so gating on source mtimes alone is + // not enough: a later compile pass can regenerate target/classes + // with unchanged sources (e.g. another `mvn package` re-running + // javac), silently shedding the rewrites. Shipping such classes + // breaks device builds -- the iOS translator emits calls to + // virtual_java_lang_String_replaceAll etc. that ParparVM's + // JavaAPI never declares. Re-run whenever any compiled class is + // newer than the last check; the marker is written after the + // rewrites, so an up-to-date output tree stays skippable. + return getCompiledClassesModificationTime() > lastCheck; + } catch (IOException ex) { + getLog().error("Failed to check sources/classes modification time for compliance check", ex); + return true; + } + } + + private boolean lastCheckFailed() throws IOException { + String content = FileUtils.readFileToString(complianceOutputFile, "UTF-8"); + return content.startsWith(FAILURE_REPORT_HEADER); + } + + private static final FilenameFilter CLASS_FILES_FILTER = (dir, name) -> name.endsWith(".class"); + + private long getCompiledClassesModificationTime() { + long mTime = lastModifiedRecursive(new File(host.outputDirectory().getPath()), CLASS_FILES_FILTER); + // With kotlin.compiler.incremental the Kotlin compiler writes to its + // own output tree which executeImpl copies into the output directory, + // so fresh classes can sit there before any copy has happened. + File kotlinIncrementalOutputDir = new File(path(host.buildDirectory().getPath(), "kotlin-ic", "compile", "classes")); + if (kotlinIncrementalOutputDir.exists()) { + mTime = Math.max(mTime, lastModifiedRecursive(kotlinIncrementalOutputDir, CLASS_FILES_FILTER)); + } + return mTime; + } + + private void writeComplianceSuccess(String message, int rewrittenClassCount) throws BuildExecutionException { + complianceOutputFile.getParentFile().mkdirs(); + try { + StringBuilder content = new StringBuilder(); + content.append(message).append("\n"); + content.append("Rewritten class files to Java 17 major version: ").append(rewrittenClassCount).append("\n"); + content.append("Rewritten JDK API callsites: ").append(lastInvocationRewriteSummary.rewrittenCallsites) + .append(" across ").append(lastInvocationRewriteSummary.rewrittenClasses).append(" class(es)").append("\n"); + FileUtils.writeStringToFile(complianceOutputFile, content.toString(), "UTF-8"); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to write compliance file", ex); + } + } + + private static final String FAILURE_REPORT_HEADER = "Codename One compliance check failed."; + + private void writeComplianceReport(List violations, File outputDir, List dependencyJars, int rewrittenClassCount) throws BuildExecutionException { + StringBuilder report = new StringBuilder(); + report.append(FAILURE_REPORT_HEADER).append("\n"); + report.append("Project: ").append(host.finalName()).append("\n"); + report.append("Output classes: ").append(outputDir.getAbsolutePath()).append("\n"); + report.append("Dependency jars scanned: ").append(dependencyJars.size()).append("\n"); + report.append("Rewritten class files to Java 17 major version: ").append(rewrittenClassCount).append("\n\n"); + report.append("Rewritten JDK API callsites: ").append(lastInvocationRewriteSummary.rewrittenCallsites) + .append(" across ").append(lastInvocationRewriteSummary.rewrittenClasses).append(" class(es)").append("\n\n"); + report.append("Violations (").append(violations.size()).append(")\n"); + report.append("========================================\n"); + int i = 1; + for (Violation violation : violations) { + report.append(i++).append(") ").append(violation.render()).append("\n\n"); + } + + complianceOutputFile.getParentFile().mkdirs(); + try { + FileUtils.writeStringToFile(complianceOutputFile, report.toString(), "UTF-8"); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to write compliance report", ex); + } + } + + private String buildFailureSummary(List violations) { + int maxInMessage = Math.min(5, violations.size()); + StringBuilder sb = new StringBuilder(); + sb.append("Compliance check failed with ").append(violations.size()).append(" forbidden API reference"); + if (violations.size() != 1) { + sb.append("s"); + } + sb.append(".\n"); + sb.append("See ").append(complianceOutputFile.getAbsolutePath()).append(" for the full report.\n"); + sb.append("First ").append(maxInMessage).append(" violation(s):"); + for (int i = 0; i < maxInMessage; i++) { + Violation v = violations.get(i); + sb.append("\n - ").append(v.renderInline()); + } + return sb.toString(); + } + + private void logViolationSummary(List violations) { + int maxToLog = Math.min(5, violations.size()); + getLog().error("Bytecode compliance check found " + violations.size() + " violation(s)."); + getLog().error("Detailed report written to " + complianceOutputFile.getAbsolutePath()); + for (int i = 0; i < maxToLog; i++) { + getLog().error("[" + (i + 1) + "] " + violations.get(i).renderInline()); + } + } + + + private int enforceMaxClassVersion(File outputDir, final int maxVersion) throws BuildExecutionException { + List classFiles = new ArrayList(); + collectClassFiles(outputDir, classFiles); + int rewritten = 0; + for (File classFile : classFiles) { + try { + byte[] originalBytes = FileUtils.readFileToByteArray(classFile); + ClassVersionInfo versionInfo = readClassVersion(originalBytes); + if (versionInfo.majorVersion > maxVersion) { + byte[] rewrittenBytes = rewriteClassVersion(originalBytes, maxVersion); + FileUtils.writeByteArrayToFile(classFile, rewrittenBytes); + rewritten++; + getLog().info("Rewrote class major version " + versionInfo.majorVersion + " -> " + maxVersion + " for " + classFile.getAbsolutePath()); + } + } catch (IOException ex) { + throw new BuildExecutionException("Failed to enforce class version for " + classFile, ex); + } + } + if (rewritten > 0) { + getLog().info("Rewrote " + rewritten + " class file(s) to Java 17 major version " + maxVersion); + } + return rewritten; + } + + private ClassVersionInfo readClassVersion(byte[] classBytes) { + final ClassVersionInfo out = new ClassVersionInfo(); + ClassReader reader = new ClassReader(classBytes); + reader.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { + out.majorVersion = version; + out.className = name; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + return out; + } + + private byte[] rewriteClassVersion(byte[] classBytes, final int maxVersion) { + ClassReader reader = new ClassReader(classBytes); + ClassWriter writer = new ClassWriter(reader, 0); + ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) { + @Override + public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { + int effectiveVersion = version > maxVersion ? maxVersion : version; + super.visit(effectiveVersion, access, name, signature, superName, interfaces); + } + }; + reader.accept(visitor, 0); + return writer.toByteArray(); + } + + private InvocationRewriteSummary applyInvocationRewrites(File outputDir) throws BuildExecutionException { + InvocationRewriteSummary summary = new InvocationRewriteSummary(); + List classFiles = new ArrayList(); + collectClassFiles(outputDir, classFiles); + try { + for (File classFile : classFiles) { + try { + byte[] originalBytes = FileUtils.readFileToByteArray(classFile); + InvocationRewriteResult rewriteResult = rewriteClassInvocations(originalBytes); + if (rewriteResult.rewrittenCallsites > 0) { + validateClass(rewriteResult.bytes, classFile, outputDir); + FileUtils.writeByteArrayToFile(classFile, rewriteResult.bytes); + summary.rewrittenClasses++; + summary.rewrittenCallsites += rewriteResult.rewrittenCallsites; + getLog().info("Applied " + rewriteResult.rewrittenCallsites + " invocation rewrite(s) in " + classFile.getAbsolutePath()); + } + } catch (IOException ex) { + throw new BuildExecutionException("Failed to rewrite invocations for " + classFile, ex); + } + } + } finally { + closeValidationClassLoader(); + } + return summary; + } + + private InvocationRewriteResult rewriteClassInvocations(byte[] classBytes) { + final InvocationRewriteResult result = new InvocationRewriteResult(); + final ClassReader reader = new ClassReader(classBytes); + final ClassWriter writer = new ClassWriter(reader, 0); + ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + MethodVisitor delegate = super.visitMethod(access, name, descriptor, signature, exceptions); + return new MethodVisitor(Opcodes.ASM9, delegate) { + @Override + public void visitMethodInsn(int opcode, String owner, String methodName, String methodDescriptor, boolean isInterface) { + MethodRef source = new MethodRef(opcode, owner, methodName, methodDescriptor); + MethodRef target = INVOCATION_REWRITE_RULES.get(source); + if (target != null) { + result.rewrittenCallsites++; + super.visitMethodInsn(target.opcode, target.owner, target.name, target.descriptor, false); + return; + } + super.visitMethodInsn(opcode, owner, methodName, methodDescriptor, isInterface); + } + }; + } + }; + reader.accept(visitor, 0); + result.bytes = result.rewrittenCallsites > 0 ? writer.toByteArray() : classBytes; + return result; + } + + private void validateClass(byte[] classBytes, File classFile, File outputDir) throws BuildExecutionException { + ClassLoader loader = getValidationClassLoader(outputDir); + try { + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + CheckClassAdapter.verify(new ClassReader(classBytes), loader, false, printWriter); + printWriter.flush(); + String validationOutput = stringWriter.toString().trim(); + if (!validationOutput.isEmpty()) { + if (isUnresolvableTypeOutput(validationOutput)) { + getLog().debug("Skipping deep verification for " + classFile.getName() + + ": referenced type(s) not on classpath. Output: " + validationOutput); + return; + } + throw new BuildExecutionException("Bytecode validation failed for " + classFile + ": " + validationOutput); + } + } catch (RuntimeException ex) { + if (isUnresolvableTypeCause(ex)) { + getLog().debug("Skipping deep verification for " + classFile.getName() + + ": referenced type(s) not on classpath (" + ex.getMessage() + ")."); + return; + } + throw new BuildExecutionException("Bytecode validation failed for " + classFile, ex); + } + } + + private ClassLoader getValidationClassLoader(File outputDir) { + if (validationClassLoader != null) { + return validationClassLoader; + } + List urls = new ArrayList(); + try { + if (outputDir != null && outputDir.isDirectory()) { + urls.add(outputDir.toURI().toURL()); + } + if (host != null) { + for (File jar : getDependencyJarsForScanning()) { + if (jar != null && jar.exists()) { + urls.add(jar.toURI().toURL()); + } + } + } + } catch (MalformedURLException ex) { + getLog().debug("Failed to assemble validation classloader URLs; falling back to plugin classloader.", ex); + return getClass().getClassLoader(); + } + validationClassLoader = new URLClassLoader(urls.toArray(new URL[0]), getClass().getClassLoader()); + return validationClassLoader; + } + + private void closeValidationClassLoader() { + if (validationClassLoader != null) { + try { + validationClassLoader.close(); + } catch (IOException ex) { + getLog().debug("Failed to close validation classloader", ex); + } + validationClassLoader = null; + } + } + + private static boolean isUnresolvableTypeCause(Throwable ex) { + Throwable t = ex; + while (t != null) { + if (t instanceof ClassNotFoundException || t instanceof TypeNotPresentException || t instanceof NoClassDefFoundError) { + return true; + } + t = t.getCause(); + } + return false; + } + + private static boolean isUnresolvableTypeOutput(String output) { + return output.contains("ClassNotFoundException") + || output.contains("TypeNotPresentException") + || output.contains("NoClassDefFoundError") + || output.contains(" not present"); + } + + private List scanProjectClasses(File outputDir, final Map allowedIndex, final Map projectAndDependencyIndex) throws BuildExecutionException { + List classFiles = new ArrayList(); + collectClassFiles(outputDir, classFiles); + List violations = new ArrayList(); + for (File classFile : classFiles) { + try { + InputStream inputStream = new BufferedInputStream(new FileInputStream(classFile)); + try { + ClassReader reader = new ClassReader(inputStream); + reader.accept(new ComplianceScanner(classFile, outputDir, allowedIndex, projectAndDependencyIndex, violations), ClassReader.SKIP_FRAMES); + addSemanticStackViolations(classFile, outputDir, reader, violations); + } finally { + inputStream.close(); + } + } catch (IOException ex) { + throw new BuildExecutionException("Failed to scan class " + classFile, ex); + } + } + return violations; + } + + private void addSemanticStackViolations(File classFile, File outputDir, ClassReader reader, List violations) throws IOException, BuildExecutionException { + ClassNode classNode = new ClassNode(); + reader.accept(classNode, ClassReader.EXPAND_FRAMES); + for (MethodNode method : classNode.methods) { + if (method.instructions == null || method.instructions.size() == 0) { + continue; + } + Frame[] frames; + try { + Analyzer analyzer = new Analyzer(new SimdAllocaInterpreter()); + frames = analyzer.analyze(classNode.name, method); + } catch (AnalyzerException ex) { + throw new BuildExecutionException("Failed to analyze bytecode semantics for " + classFile + " in " + classNode.name + "#" + method.name + method.desc, ex); + } + for (AbstractInsnNode instruction = method.instructions.getFirst(); instruction != null; instruction = instruction.getNext()) { + int index = method.instructions.indexOf(instruction); + Frame frame = frames[index]; + if (frame == null) { + continue; + } + int opcode = instruction.getOpcode(); + if (opcode == Opcodes.MONITORENTER) { + BasicValue lockValue = frame.getStack(frame.getStackSize() - 1); + if (isPrimitiveWrapperValue(lockValue)) { + addSemanticViolation(violations, classFile, outputDir, classNode.name, method, + "Synchronization on primitive wrapper " + lockValue.getType().getInternalName(), + "Use a dedicated Object lock instead of synchronizing on primitive wrapper values."); + } + continue; + } + if (opcode == Opcodes.ARETURN) { + if (isAllocaValue(frame.getStack(frame.getStackSize() - 1))) { + addSemanticViolation(violations, classFile, outputDir, classNode.name, method, + "SIMD alloca value returned from method", + "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); + } + continue; + } + if (opcode == Opcodes.PUTSTATIC) { + if (isAllocaValue(frame.getStack(frame.getStackSize() - 1))) { + addSemanticViolation(violations, classFile, outputDir, classNode.name, method, + "SIMD alloca value stored into static field", + "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); + } + continue; + } + if (opcode == Opcodes.PUTFIELD) { + if (isAllocaValue(frame.getStack(frame.getStackSize() - 1))) { + addSemanticViolation(violations, classFile, outputDir, classNode.name, method, + "SIMD alloca value stored into instance field", + "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); + } + continue; + } + if (opcode == Opcodes.AASTORE) { + if (isAllocaValue(frame.getStack(frame.getStackSize() - 1))) { + addSemanticViolation(violations, classFile, outputDir, classNode.name, method, + "SIMD alloca value stored into object array", + "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); + } + continue; + } + if (instruction instanceof MethodInsnNode) { + MethodInsnNode methodInsn = (MethodInsnNode) instruction; + int argumentCount = Type.getArgumentTypes(methodInsn.desc).length; + boolean usesAlloca = false; + for (int i = 0; i < argumentCount; i++) { + if (isAllocaValue(frame.getStack(frame.getStackSize() - 1 - i))) { + usesAlloca = true; + break; + } + } + // Non-static calls also consume the receiver object from the stack. + if (!usesAlloca && opcode != Opcodes.INVOKESTATIC + && isAllocaValue(frame.getStack(frame.getStackSize() - 1 - argumentCount))) { + usesAlloca = true; + } + if (usesAlloca && !isSimdOwner(methodInsn.owner)) { + addSemanticViolation(violations, classFile, outputDir, classNode.name, method, + "SIMD alloca value passed to non-Simd method " + methodInsn.owner + "#" + methodInsn.name + methodInsn.desc, + "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); + } + continue; + } + if (instruction instanceof InvokeDynamicInsnNode) { + Type[] args = Type.getArgumentTypes(((InvokeDynamicInsnNode) instruction).desc); + for (int i = 0; i < args.length; i++) { + if (isAllocaValue(frame.getStack(frame.getStackSize() - 1 - i))) { + addSemanticViolation(violations, classFile, outputDir, classNode.name, method, + "SIMD alloca value passed to invokedynamic", + "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); + break; + } + } + } + } + } + } + + private void addSemanticViolation(List violations, File classFile, File outputDir, String sourceClass, MethodNode method, String referencedMember, String suggestion) { + String relativePath = classFile.getAbsolutePath().replace(outputDir.getAbsolutePath(), ""); + if (relativePath.startsWith(File.separator)) { + relativePath = relativePath.substring(1); + } + violations.add(new Violation(sourceClass, method.name + method.desc, referencedMember, + suggestion, relativePath)); + } + + private static boolean isAllocaValue(BasicValue value) { + return value instanceof SimdAllocaValue && ((SimdAllocaValue) value).alloca; + } + + private static boolean isPrimitiveWrapperValue(BasicValue value) { + if (value == null || value.getType() == null || value.getType().getSort() != Type.OBJECT) { + return false; + } + return PRIMITIVE_WRAPPER_INTERNAL_NAMES.contains(value.getType().getInternalName()); + } + + private static final class SimdAllocaValue extends BasicValue { + private final boolean alloca; + + private SimdAllocaValue(Type type, boolean alloca) { + super(type); + this.alloca = alloca; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || obj.getClass() != SimdAllocaValue.class) { + return false; + } + SimdAllocaValue other = (SimdAllocaValue) obj; + Type type = getType(); + Type otherType = other.getType(); + if (type == null ? otherType != null : !type.equals(otherType)) { + return false; + } + return alloca == other.alloca; + } + + @Override + public int hashCode() { + Type type = getType(); + return 31 * (type != null ? type.hashCode() : 0) + (alloca ? 1 : 0); + } + } + + private static final class SimdAllocaInterpreter extends BasicInterpreter { + private SimdAllocaInterpreter() { + super(Opcodes.ASM9); + } + + @Override + public BasicValue newValue(Type type) { + if (type == null) { + return BasicValue.UNINITIALIZED_VALUE; + } + if (type == Type.VOID_TYPE) { + return null; + } + return new SimdAllocaValue(type, false); + } + + @Override + public BasicValue copyOperation(AbstractInsnNode insn, BasicValue value) throws AnalyzerException { + return value; + } + + @Override + public BasicValue unaryOperation(AbstractInsnNode insn, BasicValue value) throws AnalyzerException { + BasicValue base = super.unaryOperation(insn, value); + if (base == null) { + return null; + } + return new SimdAllocaValue(base.getType(), isAllocaValue(value)); + } + + @Override + public BasicValue binaryOperation(AbstractInsnNode insn, BasicValue value1, BasicValue value2) throws AnalyzerException { + BasicValue base = super.binaryOperation(insn, value1, value2); + if (base == null) { + return null; + } + return new SimdAllocaValue(base.getType(), isAllocaValue(value1) || isAllocaValue(value2)); + } + + @Override + public BasicValue ternaryOperation(AbstractInsnNode insn, BasicValue value1, BasicValue value2, BasicValue value3) throws AnalyzerException { + BasicValue base = super.ternaryOperation(insn, value1, value2, value3); + if (base == null) { + return null; + } + return new SimdAllocaValue(base.getType(), isAllocaValue(value1) || isAllocaValue(value2) || isAllocaValue(value3)); + } + + @Override + public BasicValue naryOperation(AbstractInsnNode insn, List values) throws AnalyzerException { + if (insn instanceof MethodInsnNode) { + MethodInsnNode methodInsn = (MethodInsnNode) insn; + if (isSimdAllocaMethod(methodInsn.owner, methodInsn.name, methodInsn.desc)) { + return new SimdAllocaValue(Type.getReturnType(methodInsn.desc), true); + } + } + BasicValue base = super.naryOperation(insn, values); + if (base == null) { + return null; + } + boolean alloca = false; + for (BasicValue value : values) { + if (isAllocaValue(value)) { + alloca = true; + break; + } + } + return new SimdAllocaValue(base.getType(), alloca); + } + + @Override + public BasicValue merge(BasicValue value1, BasicValue value2) { + boolean alloca = isAllocaValue(value1) || isAllocaValue(value2); + if (value1.equals(value2)) { + return new SimdAllocaValue(value1.getType(), alloca); + } + if (isReferenceValue(value1) && isReferenceValue(value2)) { + return new SimdAllocaValue(Type.getObjectType("java/lang/Object"), alloca); + } + BasicValue base = super.merge(value1, value2); + if (base == null) { + return null; + } + return new SimdAllocaValue(base.getType(), alloca); + } + } + + private static boolean isReferenceValue(BasicValue value) { + if (value == null || value.getType() == null) { + return false; + } + int sort = value.getType().getSort(); + return sort == Type.OBJECT || sort == Type.ARRAY; + } + + private void collectClassFiles(File file, List out) { + if (file == null || !file.exists()) { + return; + } + if (file.isFile() && file.getName().endsWith(".class")) { + out.add(file); + return; + } + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + collectClassFiles(child, out); + } + } + } + + private Map buildClassIndexWithOutput(File outputDir, List dependencyJars) throws BuildExecutionException { + Map index = buildClassIndex(Collections.singletonList(outputDir)); + Map dependencyIndex = buildClassIndex(dependencyJars); + index.putAll(dependencyIndex); + return index; + } + + private Map buildClassIndex(List roots) throws BuildExecutionException { + Map index = new HashMap(); + for (File root : roots) { + if (root == null || !root.exists()) { + continue; + } + if (root.isDirectory()) { + List classFiles = new ArrayList(); + collectClassFiles(root, classFiles); + for (File classFile : classFiles) { + try { + InputStream inputStream = new BufferedInputStream(new FileInputStream(classFile)); + try { + ClassMetadata metadata = readClassMetadata(inputStream, classFile.getAbsolutePath()); + if (metadata != null) { + index.put(metadata.name, metadata); + } + } finally { + inputStream.close(); + } + } catch (IOException ex) { + throw new BuildExecutionException("Failed reading class metadata from " + classFile, ex); + } + } + } else if (isClassArchive(root)) { + try { + indexArchive(root, index); + } catch (IOException ex) { + throw new BuildExecutionException("Failed reading jar metadata from " + root, ex); + } + } + } + return index; + } + + private void indexArchive(File archive, Map index) throws IOException { + InputStream fis = new BufferedInputStream(new FileInputStream(archive)); + try { + indexArchiveStream(fis, archive.getAbsolutePath(), index); + } finally { + fis.close(); + } + } + + private void indexArchiveStream(InputStream archiveStream, String sourcePrefix, Map index) throws IOException { + ZipInputStream zip = new ZipInputStream(archiveStream); + try { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + String entryName = entry.getName(); + byte[] bytes = readAllBytes(zip); + if (entryName.endsWith(".class")) { + if (shouldSkipArchiveClassEntry(entryName)) { + continue; + } + ClassMetadata metadata = readClassMetadata(new ByteArrayInputStream(bytes), sourcePrefix + "!" + entryName); + if (metadata != null) { + index.put(metadata.name, metadata); + } + } else if (isClassArchiveName(entryName)) { + indexArchiveStream(new ByteArrayInputStream(bytes), sourcePrefix + "!" + entryName, index); + } + } + } finally { + zip.close(); + } + } + + private byte[] readAllBytes(InputStream input) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int len; + while ((len = input.read(buffer)) != -1) { + out.write(buffer, 0, len); + } + return out.toByteArray(); + } + + private boolean shouldSkipArchiveClassEntry(String name) { + if (name == null || name.isEmpty()) { + return true; + } + if (!name.endsWith(".class")) { + return true; + } + if ("module-info.class".equals(name)) { + return true; + } + return name.startsWith("META-INF/versions/"); + } + + private boolean isClassArchive(File file) { + if (file == null || !file.isFile()) { + return false; + } + return isClassArchiveName(file.getName()); + } + + private boolean isClassArchiveName(String name) { + if (name == null) { + return false; + } + String lower = name.toLowerCase(); + return lower.endsWith(".jar") || lower.endsWith(".cn1lib") || lower.endsWith(".zip"); + } + + private ClassMetadata readClassMetadata(InputStream inputStream, String sourceDescription) throws IOException { + final ClassMetadata metadata = new ClassMetadata(); + try { + ClassReader reader = new ClassReader(inputStream); + reader.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { + metadata.name = name; + metadata.superName = superName; + metadata.interfaces = interfaces == null ? Collections.emptyList() : Arrays.asList(interfaces); + } + + @Override + public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { + metadata.fields.add(memberKey(name, descriptor)); + return null; + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + metadata.methods.add(memberKey(name, descriptor)); + return null; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + return metadata; + } catch (RuntimeException ex) { + getLog().warn("Skipping unreadable class metadata from " + sourceDescription + ": " + ex.getMessage()); + return null; + } + } + + private List getDependencyJarsForScanning() { + List jars = new ArrayList(); + for (BuildArtifact artifact : host.artifacts()) { + if (artifact == null || artifact.getScope() == null) { + continue; + } + if (artifact.getGroupId().equals("com.codenameone") && artifact.getArtifactId().equals("codenameone-core")) { + continue; + } + if (artifact.getGroupId().equals("com.codenameone") && artifact.getArtifactId().equals("java-runtime")) { + continue; + } + if ("compile".equals(artifact.getScope()) + || "provided".equals(artifact.getScope()) + || "system".equals(artifact.getScope()) + || "runtime".equals(artifact.getScope()) + || "test".equals(artifact.getScope())) { + File jar = host.getJar(artifact); + if (isClassArchive(jar)) { + jars.add(jar); + } + } + } + return jars; + } + + private File getJavaRuntimeJar() { + for (BuildArtifact artifact : host.artifacts()) { + if (JAVA_RUNTIME_ARTIFACT_ID.equals(artifact.getArtifactId()) && GROUP_ID.equals(artifact.getGroupId())) { + return host.getJar(artifact); + } + } + File fromTool = host.getJar(GROUP_ID, JAVA_RUNTIME_ARTIFACT_ID, null); + if (fromTool != null) { + return fromTool; + } + throw new RuntimeException(JAVA_RUNTIME_ARTIFACT_ID + " not found in dependencies"); + } + + private File getCodenameOneJar() { + String codenameOneCoreId = "codenameone-core"; + for (BuildArtifact artifact : host.artifacts()) { + if (codenameOneCoreId.equals(artifact.getArtifactId()) && GROUP_ID.equals(artifact.getGroupId())) { + return host.getJar(artifact); + } + } + File fromTool = host.getJar(GROUP_ID, codenameOneCoreId, null); + if (fromTool != null) { + return fromTool; + } + throw new RuntimeException(codenameOneCoreId + " not found in dependencies"); + } + + private static String memberKey(String name, String descriptor) { + return name + descriptor; + } + + private static final class MethodRef { + final int opcode; + final String owner; + final String name; + final String descriptor; + + private MethodRef(int opcode, String owner, String name, String descriptor) { + this.opcode = opcode; + this.owner = owner; + this.name = name; + this.descriptor = descriptor; + } + + private static MethodRef virtual(String owner, String name, String descriptor) { + return new MethodRef(Opcodes.INVOKEVIRTUAL, owner, name, descriptor); + } + + private static MethodRef staticRef(String owner, String name, String descriptor) { + return new MethodRef(Opcodes.INVOKESTATIC, owner, name, descriptor); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof MethodRef)) { + return false; + } + MethodRef other = (MethodRef) obj; + return opcode == other.opcode + && owner.equals(other.owner) + && name.equals(other.name) + && descriptor.equals(other.descriptor); + } + + @Override + public int hashCode() { + int result = opcode; + result = 31 * result + owner.hashCode(); + result = 31 * result + name.hashCode(); + result = 31 * result + descriptor.hashCode(); + return result; + } + } + + private static final class InvocationRewriteResult { + byte[] bytes; + int rewrittenCallsites; + } + + private static final class InvocationRewriteSummary { + int rewrittenClasses; + int rewrittenCallsites; + } + + private static final class ClassVersionInfo { + int majorVersion; + String className; + } + + private static final class ClassMetadata { + String name; + String superName; + List interfaces = Collections.emptyList(); + Set methods = new HashSet(); + Set fields = new HashSet(); + } + + private final class ComplianceScanner extends ClassVisitor { + private final File classFile; + private final File outputDir; + private final Map allowedIndex; + private final Map projectAndDependencyIndex; + private final List violations; + private String className; + + private ComplianceScanner(File classFile, + File outputDir, + Map allowedIndex, + Map projectAndDependencyIndex, + List violations) { + super(Opcodes.ASM9); + this.classFile = classFile; + this.outputDir = outputDir; + this.allowedIndex = allowedIndex; + this.projectAndDependencyIndex = projectAndDependencyIndex; + this.violations = violations; + } + + @Override + public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { + className = name; + } + + @Override + public MethodVisitor visitMethod(int access, final String name, final String descriptor, String signature, String[] exceptions) { + final String sourceMethod = name + descriptor; + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitMethodInsn(int opcode, String owner, String memberName, String memberDescriptor, boolean isInterface) { + checkMethodReference(className, sourceMethod, owner, memberName, memberDescriptor); + } + + @Override + public void visitFieldInsn(int opcode, String owner, String memberName, String memberDescriptor) { + checkFieldReference(className, sourceMethod, owner, memberName, memberDescriptor); + } + + @Override + public void visitTypeInsn(int opcode, String type) { + checkTypeReference(className, sourceMethod, type); + } + }; + } + + private void checkMethodReference(String sourceClass, String sourceMethod, String owner, String memberName, String memberDescriptor) { + if (shouldAllowMethod(owner, memberName, memberDescriptor)) { + return; + } + addViolation(sourceClass, sourceMethod, owner + "#" + memberName + memberDescriptor); + } + + private void checkFieldReference(String sourceClass, String sourceMethod, String owner, String memberName, String memberDescriptor) { + if (shouldAllowField(owner, memberName, memberDescriptor)) { + return; + } + addViolation(sourceClass, sourceMethod, owner + "#" + memberName + ":" + memberDescriptor); + } + + private void checkTypeReference(String sourceClass, String sourceMethod, String owner) { + if (isArrayDescriptor(owner)) { + return; + } + if (isInternalRewriteHelper(owner)) { + return; + } + if (projectAndDependencyIndex.containsKey(owner) || allowedIndex.containsKey(owner) + || isPendingProjectClass(owner)) { + return; + } + addViolation(sourceClass, sourceMethod, owner + " (type)"); + } + + private void addViolation(String sourceClass, String sourceMethod, String referencedMember) { + String relativePath = classFile.getAbsolutePath().replace(outputDir.getAbsolutePath(), ""); + if (relativePath.startsWith(File.separator)) { + relativePath = relativePath.substring(1); + } + violations.add(new Violation(sourceClass, sourceMethod, referencedMember, replacementFor(referencedMember), relativePath)); + } + + private boolean shouldAllowMethod(String owner, String name, String descriptor) { + if (isArrayDescriptor(owner)) { + return true; + } + if (isInternalRewriteHelper(owner) || isPendingProjectClass(owner)) { + return true; + } + return resolveMember(owner, memberKey(name, descriptor), true); + } + + private boolean shouldAllowField(String owner, String name, String descriptor) { + if (isArrayDescriptor(owner)) { + return true; + } + if (isInternalRewriteHelper(owner) || isPendingProjectClass(owner)) { + return true; + } + return resolveMember(owner, memberKey(name, descriptor), false); + } + + private boolean resolveMember(String owner, String member, boolean method) { + if (owner == null || owner.isEmpty()) { + return false; + } + Deque queue = new ArrayDeque(); + Set seen = new HashSet(); + queue.add(owner); + while (!queue.isEmpty()) { + String current = queue.removeFirst(); + if (!seen.add(current)) { + continue; + } + if (isPendingProjectClass(current)) { + // A Kotlin class extending a Java one not compiled yet + // inherits members the Kotlin compiler already resolved. + return true; + } + ClassMetadata metadata = projectAndDependencyIndex.get(current); + if (metadata == null) { + metadata = allowedIndex.get(current); + } + if (metadata == null) { + continue; + } + Set members = method ? metadata.methods : metadata.fields; + if (members.contains(member)) { + return true; + } + if (metadata.superName != null) { + queue.add(metadata.superName); + } + for (String iface : metadata.interfaces) { + queue.add(iface); + } + } + return false; + } + } + + private static boolean isArrayDescriptor(String type) { + return type != null && type.startsWith("["); + } + + private static boolean isInternalRewriteHelper(String owner) { + return JDK_API_REWRITE_HELPER_INTERNAL_NAME.equals(owner); + } + + private static String replacementFor(String referencedMember) { + String direct = SUGGESTED_REPLACEMENTS.get(referencedMember); + if (direct != null) { + return direct; + } + int hashPos = referencedMember.indexOf('#'); + if (hashPos > 0) { + String ownerOnly = referencedMember.substring(0, hashPos); + if (ownerOnly.startsWith("java/awt/") || ownerOnly.startsWith("javax/swing/")) { + return "Codename One does not support AWT/Swing APIs. Use com.codename1.ui components for UI logic."; + } + } + return null; + } + + private static final class Violation { + private final String sourceClass; + private final String sourceMethod; + private final String referencedMember; + private final String suggestion; + private final String sourcePath; + + private Violation(String sourceClass, String sourceMethod, String referencedMember, String suggestion, String sourcePath) { + this.sourceClass = sourceClass; + this.sourceMethod = sourceMethod; + this.referencedMember = referencedMember; + this.suggestion = suggestion; + this.sourcePath = sourcePath; + } + + private String render() { + StringBuilder sb = new StringBuilder(); + sb.append("Source class: ").append(sourceClass).append("\n"); + sb.append("Source method: ").append(sourceMethod).append("\n"); + sb.append("Source bytecode file: ").append(sourcePath).append("\n"); + sb.append("Forbidden reference: ").append(referencedMember); + if (suggestion != null && !suggestion.isEmpty()) { + sb.append("\nSuggested replacement: ").append(suggestion); + } + return sb.toString(); + } + + private String renderInline() { + StringBuilder sb = new StringBuilder(); + sb.append(sourceClass).append("#").append(sourceMethod) + .append(" -> ").append(referencedMember) + .append(" (").append(sourcePath).append(")"); + if (suggestion != null && !suggestion.isEmpty()) { + sb.append(" Suggestion: ").append(suggestion); + } + return sb.toString(); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ClassClosureVerifier.java b/maven/build-engine/src/main/java/com/codename1/maven/ClassClosureVerifier.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ClassClosureVerifier.java rename to maven/build-engine/src/main/java/com/codename1/maven/ClassClosureVerifier.java diff --git a/maven/build-engine/src/main/java/com/codename1/maven/Cn1TestRunner.java b/maven/build-engine/src/main/java/com/codename1/maven/Cn1TestRunner.java new file mode 100644 index 00000000000..c21f7808451 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/Cn1TestRunner.java @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2012, 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.maven; + +import com.codename1.build.AntSupport; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.Log; +import org.apache.tools.ant.Project; +import org.apache.tools.ant.taskdefs.Java; +import org.apache.tools.ant.types.Path; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileFilter; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Modifier; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/// Runs a project's Codename One unit tests -- the classes implementing +/// `com.codename1.testing.UnitTest` -- in the JavaSE port's `TestRunner`. +/// +/// The body of the Maven plugin's `test` goal and the Gradle plugin's `cn1Test` +/// task: find the tests, record them in `tests.dat` beside the test classes +/// (where the runner reads them from the classpath), and fork the runner. +public final class Cn1TestRunner { + private static final int VERSION = 1; + /// The runner's main class in the JavaSE port. + public static final String RUNNER_MAIN_CLASS = "com.codename1.impl.javase.TestRunner"; + + private final Log log; + + /// A runner logging to `log`. + public Cn1TestRunner(Log log) { + this.log = log; + } + + /// Finds the tests under `testClassesDir` and writes `tests.dat` there. + /// + /// @param classpath everything the test classes need to load + /// @return false when there are no tests + public boolean prepare(File testClassesDir, List classpath) throws BuildExecutionException { + return prepare(java.util.Collections.singletonList(testClassesDir), testClassesDir, classpath); + } + + /// Finds the tests under every one of `testClassesDirs` -- Gradle compiles + /// Java and Kotlin tests into separate directories -- and writes `tests.dat` + /// into `metadataDir`, which must be on the runner's classpath. + /// + /// @param classpath everything the test classes need to load + /// @return false when there are no tests + public boolean prepare(List testClassesDirs, File metadataDir, List classpath) + throws BuildExecutionException { + try { + List paths = new ArrayList(testClassesDirs); + paths.addAll(classpath); + Class[] testCases = findTestCases(testClassesDirs, paths.toArray(new File[paths.size()])); + if (testCases.length == 0) { + return false; + } + File metadataFile = new File(metadataDir, "tests.dat"); + metadataFile.getParentFile().mkdirs(); + DataOutputStream fo = new DataOutputStream(new FileOutputStream(metadataFile)); + try { + Arrays.sort(testCases, new Comparator() { + @Override + public int compare(Class t, Class t1) { + return String.CASE_INSENSITIVE_ORDER.compare(t.getName(), t1.getName()); + } + }); + fo.writeInt(VERSION); + fo.writeInt(testCases.length); + for (Class c : testCases) { + fo.writeUTF(c.getName()); + } + } finally { + fo.close(); + } + return true; + } catch (Exception ex) { + throw new BuildExecutionException("Failed to prepare tests", ex); + } + } + + /// Forks the test runner. + /// + /// @param java the task to fork with, already set up for logging + /// @param classpath the test classes, the classes, and every runtime jar + /// including the JavaSE port + /// @param mainClass the application's main class, fully qualified + /// @param reportsDir where the JUnit XML reports go + /// @return the runner's exit status + public static int run(Project antProject, Java java, List classpath, String mainClass, File reportsDir) { + Path cp = java.createClasspath(); + for (File f : classpath) { + cp.add(new Path(antProject, f.getAbsolutePath())); + } + java.setClassname(RUNNER_MAIN_CLASS); + java.createArg().setValue(mainClass); + java.createArg().setValue("-junitXML"); + reportsDir.mkdirs(); + java.setDir(reportsDir); + java.setFork(true); + return java.executeJava(); + } + + /// [run(Project, Java, List, String, File)] with a task logging to `log`. + public int run(File baseDir, List classpath, String mainClass, File reportsDir) { + Project ant = AntSupport.newProject(baseDir); + return run(ant, AntSupport.createJava(ant, log, AntSupport.LEVEL_INFO), classpath, mainClass, reportsDir); + } + + private Class[] findTestCases(List testDirectories, File... classesDirectories) + throws MalformedURLException, IOException { + URL[] urls = new URL[classesDirectories.length]; + for(int iter = 0 ; iter < urls.length ; iter++) { + try { + urls[iter] = classesDirectories[iter].toURI().toURL(); + } catch (RuntimeException ex) { + log.error("Failed to add class directory "+iter+" in directory list: "+Arrays.toString(urls)); + throw ex; + } + } + URLClassLoader cl = new URLClassLoader(urls); + + List classList = new ArrayList(); + for (File testDirectory : testDirectories) { + findTestCasesInDir(testDirectory.getAbsolutePath(), testDirectory, cl, classList); + } + + Class[] arr = new Class[classList.size()]; + classList.toArray(arr); + return arr; + } + + private void findTestCasesInDir(String baseDir, File directory, URLClassLoader cl, List classList) throws IOException { + File[] files = directory.listFiles(new FileFilter() { + @Override + public boolean accept(File file) { + return file.isDirectory() || (file.getName().endsWith(".class") && file.getName().indexOf('$') < 0) + || file.getName().endsWith(".jar"); + } + }); + if (files == null) { + return; + } + for(File f : files) { + if(f.isDirectory()) { + findTestCasesInDir(baseDir, f, cl, classList); + } else { + String fileName = f.getAbsolutePath(); + if(fileName.endsWith(".jar")) { + FileInputStream zipFile = new FileInputStream(fileName); + ZipInputStream zip = new ZipInputStream(zipFile); + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + //System.out.println("Extracting: " +entry); + if (entry.isDirectory()) { + continue; + } + + String entryName = entry.getName(); + if (entryName.endsWith(".class") && entryName.indexOf('$') < 0) { + String className = entryName.substring(baseDir.length() + 1, entryName.length() - 6); + className = className.replace('/', '.'); + isTestCase(cl, className, classList); + } + } + zip.close(); + } else { + String className = fileName.substring(baseDir.length() + 1, fileName.length() - 6); + className = className.replace(File.separatorChar, '.'); + isTestCase(cl, className, classList); + } + } + } + } + private boolean impl(Class cls) { + for(Class current : cls.getInterfaces()) { + if(current.getName().equals("com.codename1.testing.UnitTest")) { + return true; + } + } + Class parent = cls.getSuperclass(); + if(parent == Object.class || parent == null) { + return false; + } + return impl(parent); + } + + private boolean isTestCase(ClassLoader cl, String className, List classList) { + try { + Class cls = cl.loadClass(className); + if(Modifier.isAbstract(cls.getModifiers())) { + return false; + } + if(impl(cls)) { + classList.add(cls); + return true; + } + } catch(Throwable t) { + } + return false; + } + + +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/Cn1libPom.java b/maven/build-engine/src/main/java/com/codename1/maven/Cn1libPom.java new file mode 100644 index 00000000000..d9f9041012f --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/Cn1libPom.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2012, 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.maven; + +import java.util.Collection; + +/// The `-lib` pom a cn1lib is consumed through. +/// +/// The shape `cn1lib-archetype` publishes: `-lib`, packaging `pom`, +/// depending on `-common` and its `cn1css` bundle, with one profile per +/// platform -- activated by `-Dcodename1.platform=` -- adding +/// `-`. A Maven application picks the platform's jar through +/// the profile; the Gradle plugin reads the same profiles +/// ([Cn1libPomProfiles]). The Gradle plugin writes this for a cn1lib built +/// with Gradle, so either build tool can consume a library built by either. +/// +/// Unlike the archetype's, the coordinates are written literally rather than as +/// `${project.groupId}` expressions against a parent, so the pom stands alone. +public final class Cn1libPom { + private Cn1libPom() { + } + + /// The pom. + /// + /// @param name the library's base name; artifacts are `-common`, + /// `-` and `-lib` + /// @param platforms the platforms the library ships a jar for + /// @param hasCss whether it ships a `cn1css` bundle + public static String render(String groupId, String name, String version, Collection platforms, + boolean hasCss) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\n"); + sb.append(" 4.0.0\n"); + element(sb, " ", "groupId", groupId); + element(sb, " ", "artifactId", name + "-lib"); + element(sb, " ", "version", version); + element(sb, " ", "packaging", "pom"); + element(sb, " ", "name", name + "-lib"); + sb.append(" \n"); + dependency(sb, " ", groupId, name + "-common", version, null, null); + if (hasCss) { + dependency(sb, " ", groupId, name + "-common", version, "cn1css", "zip"); + } + sb.append(" \n"); + if (!platforms.isEmpty()) { + sb.append(" \n"); + for (String platform : platforms) { + sb.append(" \n"); + element(sb, " ", "id", platform); + sb.append(" \n \n"); + element(sb, " ", "name", "codename1.platform"); + element(sb, " ", "value", platform); + sb.append(" \n \n"); + sb.append(" \n"); + dependency(sb, " ", groupId, name + "-" + platform, version, null, null); + sb.append(" \n"); + sb.append(" \n"); + } + sb.append(" \n"); + } + sb.append("\n"); + return sb.toString(); + } + + private static void dependency(StringBuilder sb, String indent, String groupId, String artifactId, + String version, String classifier, String type) { + sb.append(indent).append("\n"); + element(sb, indent + " ", "groupId", groupId); + element(sb, indent + " ", "artifactId", artifactId); + element(sb, indent + " ", "version", version); + if (classifier != null) { + element(sb, indent + " ", "classifier", classifier); + } + if (type != null) { + element(sb, indent + " ", "type", type); + } + sb.append(indent).append("\n"); + } + + private static void element(StringBuilder sb, String indent, String name, String value) { + sb.append(indent).append('<').append(name).append('>').append(escape(value)).append("\n"); + } + + private static String escape(String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/Cn1libPomProfiles.java b/maven/build-engine/src/main/java/com/codename1/maven/Cn1libPomProfiles.java new file mode 100644 index 00000000000..24b7c233ed2 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/Cn1libPomProfiles.java @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2012, 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.maven; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// Reads which artifacts a Maven-published cn1lib contributes to each platform. +/// +/// A cn1lib's `-lib` pom selects its per-platform jars with profiles activated +/// by `-Dcodename1.platform=` (see `cn1lib-archetype`'s `lib/pom.xml`). +/// Maven honours those; Gradle does not activate profiles on a property at all. +/// This reads the same profiles so the Gradle plugin can add exactly what Maven +/// would, from the same metadata, without guessing artifact names. +/// +/// A deployed pom is not interpolated, so its profile dependencies still say +/// `${project.groupId}`, `${cn1lib.name}-android` and `${project.version}`. They +/// are resolved against the pom's own coordinates and properties and those of +/// its parent, which is where the cn1lib archetype declares `cn1lib.name`. +public final class Cn1libPomProfiles { + /// Fetches a parent pom's text, or returns null when it cannot. + public interface ParentResolver { + /// The pom text of `groupId:artifactId:version`, or null. + String pom(String groupId, String artifactId, String version); + } + + /// One dependency a profile adds. + public static final class Coordinate { + /// The group id. + public final String groupId; + /// The artifact id. + public final String artifactId; + /// The version, or null when the pom leaves it to dependency management. + public final String version; + /// The classifier, or null. + public final String classifier; + /// The type (`jar` unless the pom says otherwise). + public final String type; + private final List exclusions; + + Coordinate(String groupId, String artifactId, String version, String classifier, String type) { + this(groupId, artifactId, version, classifier, type, null); + } + + Coordinate(String groupId, String artifactId, String version, String classifier, String type, + List exclusions) { + this.groupId = groupId; + this.artifactId = artifactId; + this.version = version; + this.classifier = classifier; + this.type = type == null ? "jar" : type; + this.exclusions = exclusions == null ? java.util.Collections.emptyList() + : java.util.Collections.unmodifiableList(new ArrayList(exclusions)); + } + + /// The dependency's ``, each {groupId, artifactId}, either + /// of which may be Maven's `*` wildcard. + public List exclusions() { + return exclusions; + } + + /// `group:artifact:version[:classifier][@type]`, Gradle's dependency + /// notation. + public String toNotation() { + StringBuilder sb = new StringBuilder(groupId).append(':').append(artifactId); + if (version != null) { + sb.append(':').append(version); + } + if (classifier != null) { + sb.append(':').append(classifier); + } + if (!"jar".equals(type)) { + sb.append('@').append(type); + } + return sb.toString(); + } + + @Override + public String toString() { + return toNotation(); + } + } + + private static final Pattern PROPERTY = Pattern.compile("\\$\\{([^}]+)\\}"); + + private Cn1libPomProfiles() { + } + + /// The dependencies each `codename1.platform` profile of `pomText` adds, + /// keyed by platform (`android`, `ios`, `javase`, ...). Empty when the pom + /// is not a cn1lib's or cannot be parsed. + public static Map> read(String pomText, ParentResolver parents) { + Map> out = new LinkedHashMap>(); + Element project = parse(pomText); + if (project == null) { + return out; + } + Map props = properties(project, parents, 0); + Map managed = managedVersions(project, parents, 0, props); + for (Element profile : children(child(project, "profiles"), "profile")) { + Element activation = child(profile, "activation"); + Element property = child(activation, "property"); + if (property == null || !"codename1.platform".equals(text(child(property, "name")))) { + continue; + } + String platform = text(child(property, "value")); + if (platform == null) { + continue; + } + List deps = shipped(child(profile, "dependencies"), props, managed); + List existing = out.get(platform); + if (existing == null) { + out.put(platform, deps); + } else { + existing.addAll(deps); + } + } + return out; + } + + /// The dependencies `pomText` declares outside any profile, in the scopes + /// that reach a build (not test, provided or system). A cn1lib's common + /// module lists the cn1libs it uses here, as `pom`-type dependencies on + /// their `-lib` artifacts -- which is how a consumer finds the libraries it + /// uses indirectly, whose platform profiles Maven activates too. + public static List dependencies(String pomText, ParentResolver parents) { + Element project = parse(pomText); + if (project == null) { + return new ArrayList(); + } + Map props = properties(project, parents, 0); + return shipped(child(project, "dependencies"), props, managedVersions(project, parents, 0, props)); + } + + /// The versions `` gives, in the pom and its parents + /// (nearest wins), keyed group:artifact. A dependency the pom declares + /// without a version takes its version from here, as Maven does; without it + /// a third-party platform dependency would reach Gradle versionless. + private static Map managedVersions(Element project, ParentResolver parents, int depth, + Map props) { + Map out = new HashMap(); + Element parent = child(project, "parent"); + String parentGroup = text(child(parent, "groupId")); + String parentArtifact = text(child(parent, "artifactId")); + String parentVersion = text(child(parent, "version")); + if (parent != null && parents != null && depth < 8 && parentGroup != null && parentArtifact != null + && parentVersion != null) { + Element parentProject = parse(parents.pom(parentGroup, parentArtifact, parentVersion)); + if (parentProject != null) { + out.putAll(managedVersions(parentProject, parents, depth + 1, props)); + } + } + for (Element dep : children(child(child(project, "dependencyManagement"), "dependencies"), "dependency")) { + String g = interpolate(text(child(dep, "groupId")), props); + String a = interpolate(text(child(dep, "artifactId")), props); + String v = interpolate(text(child(dep, "version")), props); + if (g != null && a != null && v != null) { + out.put(g + ":" + a, v); + } + } + return out; + } + + private static List shipped(Element dependencies, Map props, + Map managed) { + List deps = new ArrayList(); + for (Element dep : children(dependencies, "dependency")) { + String scope = interpolate(text(child(dep, "scope")), props); + if ("test".equals(scope) || "provided".equals(scope) || "system".equals(scope)) { + continue; + } + List exclusions = new ArrayList(); + for (Element ex : children(child(dep, "exclusions"), "exclusion")) { + exclusions.add(new String[] {interpolate(text(child(ex, "groupId")), props), + interpolate(text(child(ex, "artifactId")), props)}); + } + String group = interpolate(text(child(dep, "groupId")), props); + String artifact = interpolate(text(child(dep, "artifactId")), props); + String version = interpolate(text(child(dep, "version")), props); + if (version == null) { + version = managed.get(group + ":" + artifact); + } + deps.add(new Coordinate(group, artifact, version, + interpolate(text(child(dep, "classifier")), props), + interpolate(text(child(dep, "type")), props), exclusions)); + } + return deps; + } + + /// The properties `${...}` expressions in the pom can name: its own + /// ``, its parent's (recursively, nearest wins), and the + /// `project.*` coordinates. + private static Map properties(Element project, ParentResolver parents, int depth) { + Map props = new HashMap(); + Element parent = child(project, "parent"); + String parentGroup = text(child(parent, "groupId")); + String parentArtifact = text(child(parent, "artifactId")); + String parentVersion = text(child(parent, "version")); + if (parent != null && parents != null && depth < 8 && parentGroup != null && parentArtifact != null + && parentVersion != null) { + Element parentProject = parse(parents.pom(parentGroup, parentArtifact, parentVersion)); + if (parentProject != null) { + props.putAll(properties(parentProject, parents, depth + 1)); + } + } + for (Element p : children(child(project, "properties"), null)) { + String value = text(p); + props.put(p.getTagName(), value == null ? "" : value); + } + String groupId = text(child(project, "groupId")); + String version = text(child(project, "version")); + props.put("project.groupId", groupId != null ? groupId : parentGroup); + props.put("project.version", version != null ? version : parentVersion); + props.put("project.artifactId", text(child(project, "artifactId"))); + if (parentGroup != null) { + props.put("project.parent.groupId", parentGroup); + } + if (parentVersion != null) { + props.put("project.parent.version", parentVersion); + } + props.put("groupId", props.get("project.groupId")); + props.put("version", props.get("project.version")); + return props; + } + + static String interpolate(String value, Map props) { + if (value == null) { + return null; + } + String current = value; + // A few passes, so a property defined in terms of another resolves; bounded + // so a self-referencing one cannot loop. + for (int pass = 0; pass < 5 && current.indexOf("${") >= 0; pass++) { + Matcher m = PROPERTY.matcher(current); + StringBuffer sb = new StringBuffer(); + boolean changed = false; + while (m.find()) { + String replacement = props.get(m.group(1)); + if (replacement == null) { + m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0))); + } else { + changed = true; + m.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + } + m.appendTail(sb); + current = sb.toString(); + if (!changed) { + break; + } + } + return current.trim(); + } + + private static Element parse(String text) { + if (text == null || text.trim().isEmpty()) { + return null; + } + try { + DocumentBuilderFactory f = DocumentBuilderFactory.newInstance(); + f.setNamespaceAware(false); + f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + f.setExpandEntityReferences(false); + DocumentBuilder b = f.newDocumentBuilder(); + Document d = b.parse(new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8))); + return d.getDocumentElement(); + } catch (Exception ex) { + return null; + } + } + + private static Element child(Element parent, String name) { + if (parent == null) { + return null; + } + for (Node n = parent.getFirstChild(); n != null; n = n.getNextSibling()) { + if (n instanceof Element && ((Element) n).getTagName().equals(name)) { + return (Element) n; + } + } + return null; + } + + private static List children(Element parent, String name) { + if (parent == null) { + return Collections.emptyList(); + } + List out = new ArrayList(); + NodeList list = parent.getChildNodes(); + for (int i = 0; i < list.getLength(); i++) { + Node n = list.item(i); + if (n instanceof Element && (name == null || ((Element) n).getTagName().equals(name))) { + out.add((Element) n); + } + } + return out; + } + + private static String text(Element e) { + if (e == null) { + return null; + } + String t = e.getTextContent(); + return t == null ? null : t.trim(); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/CodenameOneLogin.java b/maven/build-engine/src/main/java/com/codename1/maven/CodenameOneLogin.java new file mode 100644 index 00000000000..341e45c27fb --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/CodenameOneLogin.java @@ -0,0 +1,254 @@ +/* + * 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.maven; + +import com.codename1.build.Log; +import org.apache.commons.io.IOUtils; + +import java.awt.Desktop; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.UUID; +import java.util.prefs.Preferences; + +/// Finds the Codename One signing API token the Certificate Wizard runs with: +/// the one given explicitly, else the one cached from an earlier sign-in, else +/// a browser sign-in when that is allowed. Shared by the Maven +/// `cn1:certificatewizard` goal and the Gradle `certificateWizard` task. +public final class CodenameOneLogin { + private final Log log; + + /// A login helper logging to `log`. + public CodenameOneLogin(Log log) { + this.log = log; + } + + /// The token and user to run with. Caches a fresh sign-in the same way the + /// desktop tools do, in the `/com/codename1/ui` preferences node. + /// + /// @param token an explicit token, or null + /// @param user an explicit user, or null + /// @param login whether a browser sign-in may be started + public LoginResult resolve(String token, String user, boolean login, int loginTimeoutSeconds, String baseUrl) { + Preferences prefs = Preferences.userRoot().node("/com/codename1/ui"); + String effectiveToken = firstNonEmpty(token, ""); + String effectiveUser = firstNonEmpty(user, prefs.get("user", null), ""); + String cachedToken = firstNonEmpty(prefs.get("token", null), ""); + if (effectiveToken.length() == 0 && isUsableJwt(cachedToken)) { + effectiveToken = cachedToken; + log.debug("Using cached Codename One signing API JWT"); + } + if (login && effectiveToken.length() == 0) { + LoginResult result = interactiveLogin(baseUrl, loginTimeoutSeconds); + if (result != null) { + effectiveToken = result.token; + effectiveUser = firstNonEmpty(user, result.user, effectiveUser); + prefs.put("token", effectiveToken); + if (effectiveUser.length() > 0) { + prefs.put("user", effectiveUser); + } + } + } + return new LoginResult(effectiveToken, effectiveUser); + } + + private LoginResult interactiveLogin(String baseUrl, int loginTimeoutSeconds) { + if (loginTimeoutSeconds <= 0) { + return null; + } + String key = UUID.randomUUID().toString(); + long deadline = System.currentTimeMillis() + loginTimeoutSeconds * 1000L; + try { + String root = normalizeBaseUrl(baseUrl); + String redirect = root + "/loggedIn.html"; + String loginUrl = root + "/appsec/7.0/set-user?redirect=" + enc(redirect) + + "&loginKey=" + enc(key); + log.info("Opening Codename One sign-in for certificate wizard authentication"); + log.info(loginUrl); + openBrowser(loginUrl); + while (System.currentTimeMillis() < deadline) { + LoginResult result = pollLogin(root, key); + if (result != null) { + log.info("Received Codename One signing API token for " + result.user); + return result; + } + try { + Thread.sleep(2000L); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return null; + } + } + log.warn("Timed out waiting for Codename One browser login"); + } catch (Exception ex) { + log.warn("Unable to complete browser login: " + ex.getMessage()); + } + return null; + } + + private LoginResult pollLogin(String root, String key) throws IOException { + URL url = new URL(root + "/poll-user?ver=2&loginKey=" + enc(key)); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + try { + con.setConnectTimeout(10000); + con.setReadTimeout(10000); + con.setRequestMethod("GET"); + int code = con.getResponseCode(); + if (code == 404) { + return null; + } + if (code != 200) { + log.debug("Codename One login poll returned HTTP " + code); + return null; + } + String body = IOUtils.toString(con.getInputStream(), StandardCharsets.UTF_8).trim(); + if (body.length() == 0) { + return null; + } + String[] lines = body.split("\\r?\\n", 2); + String receivedToken = lines[0].trim(); + if (receivedToken.length() == 0) { + return null; + } + String receivedUser = lines.length > 1 ? lines[1].trim() : ""; + return new LoginResult(receivedToken, receivedUser); + } finally { + con.disconnect(); + } + } + + private void openBrowser(String url) throws Exception { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(new URI(url)); + } else { + log.warn("Desktop browsing is not available. Open the URL above in a browser to continue."); + } + } + + private static String firstNonEmpty(String a, String b, String c) { + if (a != null && a.trim().length() > 0) { + return a.trim(); + } + if (b != null && b.trim().length() > 0) { + return b.trim(); + } + return c == null ? "" : c; + } + + private static String firstNonEmpty(String a, String b) { + return firstNonEmpty(a, b, ""); + } + + private static String normalizeBaseUrl(String url) { + String out = url == null || url.trim().length() == 0 + ? "https://cloud.codenameone.com" : url.trim(); + while (out.endsWith("/")) { + out = out.substring(0, out.length() - 1); + } + return out; + } + + private static String enc(String s) throws IOException { + return URLEncoder.encode(s, "UTF-8"); + } + + public static boolean isUsableJwt(String token) { + long expiresAt = jwtExpiresAt(token); + return expiresAt > System.currentTimeMillis() + 120000L; + } + + static long jwtExpiresAt(String token) { + if (token == null) { + return -1L; + } + String[] parts = token.split("\\.", -1); + if (parts.length < 2) { + return -1L; + } + try { + byte[] decoded = Base64.getUrlDecoder().decode(padBase64(parts[1])); + String payload = new String(decoded, StandardCharsets.UTF_8); + long exp = numericJsonClaim(payload, "exp"); + return exp <= 0L ? -1L : exp * 1000L; + } catch (RuntimeException ex) { + return -1L; + } + } + + private static long numericJsonClaim(String json, String name) { + String quoted = "\"" + name + "\""; + int idx = json.indexOf(quoted); + if (idx < 0) { + return -1L; + } + int colon = json.indexOf(':', idx + quoted.length()); + if (colon < 0) { + return -1L; + } + int start = colon + 1; + while (start < json.length() && Character.isWhitespace(json.charAt(start))) { + start++; + } + int end = start; + while (end < json.length() && Character.isDigit(json.charAt(end))) { + end++; + } + if (end == start) { + return -1L; + } + try { + return Long.parseLong(json.substring(start, end)); + } catch (NumberFormatException ex) { + return -1L; + } + } + + private static String padBase64(String value) { + int remainder = value.length() % 4; + if (remainder == 0) { + return value; + } + StringBuilder out = new StringBuilder(value); + for (int i = remainder; i < 4; i++) { + out.append('='); + } + return out.toString(); + } + + public static final class LoginResult { + /// The bearer token. + public final String token; + /// The account it belongs to, or empty. + public final String user; + + LoginResult(String token, String user) { + this.token = token; + this.user = user == null ? "" : user; + } + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/CssCompiler.java b/maven/build-engine/src/main/java/com/codename1/maven/CssCompiler.java new file mode 100644 index 00000000000..760f365c15f --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/CssCompiler.java @@ -0,0 +1,301 @@ +/* + * 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.maven; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; +import org.apache.commons.io.FileUtils; +import org.apache.tools.ant.Project; +import org.apache.tools.ant.taskdefs.Expand; +import org.apache.tools.ant.taskdefs.Java; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static com.codename1.maven.PathUtil.path; + +/// Compiles a project's `theme.css` (merged with the CSS of its cn1libs) into +/// `theme.res`, by forking the Codename One CSS compiler CLI. +/// +/// The body of the Maven plugin's `css` goal, shared with the Gradle plugin's +/// `cn1Css` task. The build tool supplies the library CSS bundles it resolved +/// and the CLI's classpath; everything else -- the merge order, the up-to-date +/// check, the arguments -- is decided here. +public final class CssCompiler { + /// The CSS compiler's entry point in `codenameone-css-cli`. + public static final String CSS_CLI_MAIN_CLASS = "com.codename1.designer.css.CN1CSSCLI"; + + /// Makes the `java` task the compiler is forked with. The Maven plugin + /// passes its own so its tests can substitute a recording task. + public interface JavaFactory { + /// A fresh task. + Java create(); + } + + /// A cn1lib's `cn1css` zip. + public static final class LibraryCss { + final String groupId; + final String artifactId; + final File zip; + final boolean snapshot; + final long lastModified; + final File extractTo; + + /// @param extractTo where to unpack the zip; its CSS is then under + /// `META-INF/codenameone///css` + /// @param snapshot whether the bundle can change without its version + /// changing, in which case a stale extraction is replaced + /// @param lastModified when the bundle last changed + public LibraryCss(String groupId, String artifactId, File zip, File extractTo, boolean snapshot, + long lastModified) { + this.groupId = groupId; + this.artifactId = artifactId; + this.zip = zip; + this.extractTo = extractTo; + this.snapshot = snapshot; + this.lastModified = lastModified; + } + } + + private final Log log; + private final Project antProject; + private final JavaFactory javaFactory; + + /// A compiler logging to `log`, unzipping through `antProject`, forking + /// through tasks from `javaFactory`. + private File buildDirectory; + + /// The project's build directory, when the build has moved it from the + /// conventional one; passed to the compiler process. Null (the default) + /// lets the compiler find it from the project layout. + public CssCompiler buildDirectory(File dir) { + this.buildDirectory = dir; + return this; + } + + public CssCompiler(Log log, Project antProject, JavaFactory javaFactory) { + this.log = log; + this.antProject = antProject; + this.javaFactory = javaFactory; + } + + /// Compiles `theme.css`. + /// + /// @param cssDirectory the project's CSS directory + /// @param classesOutputDir where `theme.res` is written + /// @param cssBuildDir where the merged stylesheet is written + /// @param libraries the cn1libs' CSS bundles, merged BEFORE the project's + /// own stylesheet so the application can override them + /// @param localizationDir the l10n directory bundled into the theme, or null + /// @param cliClasspath the CSS compiler CLI's classpath + /// @param workingDir the directory the compiler runs in (the one holding + /// `codenameone_settings.properties`) + /// @param sourcesModified the newest modification time among the CSS and + /// localization sources; an output newer than it is left alone + /// @return false when there was nothing to compile or it was up to date + public boolean compile(String themePrefix, File cssDirectory, File classesOutputDir, File cssBuildDir, + List libraries, File localizationDir, String cliClasspath, + File workingDir, long sourcesModified) throws BuildExecutionException { + File themeResOutput = new File(classesOutputDir, themePrefix + "theme.res"); + cssBuildDir.mkdirs(); + File mergeFile = new File(cssBuildDir, themePrefix + "theme.css"); + if (themeResOutput.exists() && sourcesModified < themeResOutput.lastModified()) { + log.info("CSS sources unchanged since last compile. Skipping CSS compilation"); + return false; + } + + // A comma-delimited list of the CSS files the compiler merges: every + // cn1lib's theme.css first, then the project's own last so the + // application can override its libraries. + StringBuilder inputs = new StringBuilder(); + for (LibraryCss lib : libraries) { + File theme = extractedTheme(lib, themePrefix); + if (theme != null) { + if (inputs.length() > 0) { + inputs.append(","); + } + inputs.append(theme.getAbsolutePath()); + } + } + + File cssTheme = new File(cssDirectory, themePrefix + "theme.css"); + if (cssTheme.exists()) { + if (inputs.length() > 0) { + inputs.append(","); + } + inputs.append(cssTheme.getAbsolutePath()); + } else { + if (themePrefix.isEmpty() && inputs.length() > 0) { + throw new BuildFailureException("Cannot compile CSS for this project. The project does not include a " + + themePrefix + "-theme.css file in " + cssTheme + ", but it includes dependencies that require CSS. " + + "Please add a CSS file at " + cssTheme); + } + log.info("Skipping CSS compilation for because " + themePrefix + cssTheme + " does not exist"); + return false; + } + + // The CLI runs on a resolved classpath rather than `java -jar` against the + // Designer's shaded artifact: it needs codenameone-javase for CEF + // rasterization, but nothing needs a 43MB shaded copy of it. + Java java = javaFactory.create(); + java.setDir(workingDir); + java.setClasspath(new org.apache.tools.ant.types.Path(antProject, cliClasspath)); + java.setClassname(CSS_CLI_MAIN_CLASS); + java.setFork(true); + java.setFailonerror(true); + java.createJvmarg().setValue("-Dcli=true"); + if (buildDirectory != null) { + // Where the compiler keeps its checksums and other work files: the + // build's own directory, which it cannot find for itself once moved. + java.createJvmarg().setValue("-Dcn1.buildDir=" + buildDirectory.getAbsolutePath()); + } + java.createArg().setValue("-css"); + java.createArg().setValue("-input"); + java.createArg().setValue(inputs.toString()); + java.createArg().setValue("-output"); + java.createArg().setFile(themeResOutput); + java.createArg().setValue("-merge"); + java.createArg().setFile(mergeFile); + if (localizationDir != null) { + java.createArg().setValue("-l"); + java.createArg().setFile(localizationDir); + } + int res = java.executeJava(); + if (res != 0) { + throw new BuildExecutionException("An error occurred while compiling the CSS files. Inputs: " + inputs + + ", output: " + themeResOutput + ", merge file: " + mergeFile); + } + return true; + } + + /// The comma-separated compiler inputs for `theme.css`: every + /// library's stylesheet, then `projectThemeCss` last when it exists. The + /// simulator's live CSS reload is given the same list the build compiles. + public String inputs(List libraries, String themePrefix, File projectThemeCss) { + StringBuilder inputs = new StringBuilder(); + for (LibraryCss lib : libraries) { + File theme = extractedTheme(lib, themePrefix); + if (theme != null) { + if (inputs.length() > 0) { + inputs.append(","); + } + inputs.append(theme.getAbsolutePath()); + } + } + if (projectThemeCss != null && projectThemeCss.exists()) { + if (inputs.length() > 0) { + inputs.append(","); + } + inputs.append(projectThemeCss.getAbsolutePath()); + } + return inputs.toString(); + } + + private File extractedTheme(LibraryCss lib, String themePrefix) { + if (lib.zip == null || !lib.zip.exists()) { + return null; + } + File extracted = lib.extractTo; + log.debug("Checking for extracted CSS bundle " + extracted); + if (extracted.exists() && lib.snapshot && lib.lastModified > extracted.lastModified()) { + try { + FileUtils.deleteDirectory(extracted); + } catch (IOException ex) { + log.error(ex); + } + } + if (!extracted.exists()) { + log.debug("CSS bundle " + lib.zip + " not extracted yet. Extracting to " + extracted); + Expand expand = (Expand) antProject.createTask("unzip"); + expand.setSrc(lib.zip); + expand.setDest(extracted); + expand.execute(); + // An entry keeps the time recorded in the zip, which a reproducible + // archive sets to 1980 -- Gradle's Zip does by default. The compiler + // merges only inputs newer than the merged stylesheet, so a library + // added to an existing project was silently left out. Freshly extracted + // is what "changed" means here. + touch(extracted, System.currentTimeMillis()); + } + if (!extracted.exists()) { + log.debug("CSS bundle extraction must have failed for " + lib.zip + + " because after extraction it still doesn't exist at " + extracted); + return null; + } + File extractedCssDir = new File(extracted, path("META-INF", "codenameone", lib.groupId, lib.artifactId, "css")); + File theme = new File(extractedCssDir, themePrefix + "theme.css"); + return theme.exists() ? theme : null; + } + + private void touch(File f, long time) { + if (f.isDirectory()) { + File[] children = f.listFiles(); + if (children != null) { + for (File c : children) { + touch(c, time); + } + } + } + if (!f.setLastModified(time)) { + // Best effort: only the merge's freshness check reads the time. + log.debug("Could not set the modification time of " + f); + } + } + + /// The localization directory beside a source root's parent: `l10n`, else + /// `i18n`, else null. + public static File localizationSibling(File parent) { + if (parent == null) { + return null; + } + File l10n = new File(parent, "l10n"); + if (l10n.isDirectory()) { + return l10n; + } + File i18n = new File(parent, "i18n"); + if (i18n.isDirectory()) { + return i18n; + } + return null; + } + + /// Every `*theme.css` prefix in `cssDirectory`: "" for `theme.css`, `dark` + /// for `darktheme.css`, and so on. + public static List themePrefixes(File cssDirectory) { + List out = new ArrayList(); + File[] files = cssDirectory == null ? null : cssDirectory.listFiles(); + if (files == null) { + return out; + } + for (File file : files) { + String name = file.getName(); + if (name.endsWith("theme.css")) { + out.add(name.substring(0, name.length() - "theme.css".length())); + } + } + return out; + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/DesktopTool.java b/maven/build-engine/src/main/java/com/codename1/maven/DesktopTool.java new file mode 100644 index 00000000000..42fe7196113 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/DesktopTool.java @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2012, 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.maven; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.Log; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/// One of the Codename One desktop tools -- Settings, the GUI Builder, the Game +/// Builder, the Certificate Wizard -- and how to start it bound to a project. +/// +/// Each tool is a Codename One application run on the JavaSE port. It reads the +/// project it edits from a binding file named by a system property +/// (`settings.input`, ...), in the [com.codename1.project.ProjectDescriptor] +/// format. The Maven goals and the Gradle tasks both launch the tools through +/// this class; only how the tool's jars are resolved differs between them. +public final class DesktopTool { + /// `mvn cn1:settings` / `./gradlew settings`. + public static final DesktopTool SETTINGS = new DesktopTool("codenameone-settings", + "com.codename1.settings.CodenameOneSettingsLauncher", "Codename One Settings", "CodenameOneSettings", + "settings", ".codenameoneSettings", "settings-icon.png", true, true); + /// `mvn cn1:guibuilder` / `./gradlew guibuilder`. + public static final DesktopTool GUI_BUILDER = new DesktopTool("codenameone-guibuilder", + "com.codename1.guibuilder.CodenameOneGUIBuilderLauncher", "Codename One GUI Builder", + "CodenameOneGUIBuilder", "guibuilder", ".codenameoneGUIBuilder", null, false, true); + /// `mvn cn1:gamebuilder` / `./gradlew gameBuilder`. + public static final DesktopTool GAME_BUILDER = new DesktopTool("codenameone-gamebuilder", + "com.codename1.gamebuilder.GameBuilderStub", "Codename One Game Builder", "CodenameOneGameBuilder", + "gamebuilder", ".gameBuilder", null, false, false); + /// `mvn cn1:certificatewizard` / `./gradlew certificateWizard`. + public static final DesktopTool CERTIFICATE_WIZARD = new DesktopTool("codenameone-certificatewizard", + "com.codename1.certificatewizard.CertificateWizardLauncher", "Certificate Wizard", "CertificateWizard", + "certificatewizard", ".certificateWizard", "certificate-wizard-icon.png", true, true); + + private final String artifactId; + private final String mainClass; + private final String displayName; + private final String windowClass; + private final String propertyPrefix; + private final String runtimeDirName; + private final String iconFileName; + private final boolean namedLauncher; + private final boolean identity; + + private DesktopTool(String artifactId, String mainClass, String displayName, String windowClass, + String propertyPrefix, String runtimeDirName, String iconFileName, boolean namedLauncher, + boolean identity) { + this.artifactId = artifactId; + this.mainClass = mainClass; + this.displayName = displayName; + this.windowClass = windowClass; + this.propertyPrefix = propertyPrefix; + this.runtimeDirName = runtimeDirName; + this.iconFileName = iconFileName; + this.namedLauncher = namedLauncher; + this.identity = identity; + } + + /// The tool's artifact, `com.codenameone:`, published with the + /// plugins at the same version. + public String artifactId() { + return artifactId; + } + + /// The tool's main class. + public String mainClass() { + return mainClass; + } + + /// The name shown in the dock, taskbar and window manager. + public String displayName() { + return displayName; + } + + /// The system property the tool reads its binding file's path from. + public String inputProperty() { + return propertyPrefix + ".input"; + } + + /// `~/.`, where bindings, logs and the named launcher live. + public File runtimeDir() { + File dir = new File(System.getProperty("user.home"), runtimeDirName); + dir.mkdirs(); + return dir; + } + + /// Writes `content` to a fresh binding file in [runtimeDir()]. + public File writeBinding(String content) throws IOException { + File input = new File(runtimeDir(), propertyPrefix + "-" + java.util.UUID.randomUUID() + ".input"); + Files.write(input.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return input; + } + + /// The `.*` system properties of this JVM, as `-D` arguments for the + /// tool's (screenshot capture, a forced section, dark mode, an MCP port...), + /// minus the binding and the spawn flag, which the launcher owns. + public List forwardedProperties() { + List args = new ArrayList(); + for (String key : System.getProperties().stringPropertyNames()) { + if (key.startsWith(propertyPrefix + ".") + && !key.equals(propertyPrefix + ".input") + && !key.equals(propertyPrefix + ".spawn")) { + args.add("-D" + key + "=" + System.getProperty(key)); + } + } + return args; + } + + /// Names the process for the dock, taskbar and window manager, and opens the + /// JDK packages the JavaSE port needs on Java 9 and newer. + /// + /// @param primaryJar the tool's own jar, where a dock icon is read from; may be null + public List identityArgs(File primaryJar, File runtimeDir, Log log) { + List args = new ArrayList(); + if (!identity) { + return args; + } + args.add("-Dapple.awt.application.name=" + displayName); + args.add("-Dcom.apple.mrj.application.apple.menu.about.name=" + displayName); + args.add("-Dsun.awt.application.name=" + displayName); + args.add("-Dsun.awt.X11.XWMClass=" + windowClass); + if (isJava9OrNewer()) { + args.add("--add-exports=java.desktop/com.apple.eawt.event=ALL-UNNAMED"); + args.add("--add-exports=java.desktop/com.apple.eawt=ALL-UNNAMED"); + } + if (isMacOs()) { + args.add("-Xdock:name=" + displayName); + if (iconFileName != null && primaryJar != null) { + File icon = extractIcon(primaryJar, runtimeDir, log); + if (icon != null && icon.isFile()) { + args.add("-Xdock:icon=" + icon.getAbsolutePath()); + } + } + } + return args; + } + + /// The `icon.png` in the tool's jar, extracted for the dock, or null. + public File extractIcon(File jar, File runtimeDir, Log log) { + File iconFile = new File(runtimeDir, iconFileName == null ? artifactId + "-icon.png" : iconFileName); + try (JarFile jf = new JarFile(jar)) { + JarEntry entry = jf.getJarEntry("icon.png"); + if (entry == null) { + return null; + } + try (InputStream in = jf.getInputStream(entry); OutputStream out = new FileOutputStream(iconFile)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + return iconFile; + } catch (IOException ex) { + log.debug("Unable to extract the " + displayName + " dock icon: " + ex.getMessage()); + return null; + } + } + + /// The Java launcher to start the tool with: a symbolic link named after the + /// tool, so the process list shows its name, except on Windows. + /// + /// On Windows the real `javaw.exe` is used, never a copy: a copied launcher + /// loses the JDK's bin directory as its DLL search anchor, so dependent + /// native libraries can bind to wrong DLLs and break rendering (issue #5443). + public File javaLauncher(File runtimeDir, Log log) { + File java = new File(javaExecutable()); + if (!namedLauncher || isWindows()) { + return java; + } + File launcher = new File(runtimeDir, displayName); + try { + Files.deleteIfExists(launcher.toPath()); + Files.createSymbolicLink(launcher.toPath(), java.toPath()); + return launcher; + } catch (IOException | UnsupportedOperationException | SecurityException ex) { + log.debug("Unable to create the " + displayName + " launcher symlink: " + ex.getMessage()); + return java; + } + } + + /// The full command line that starts the tool bound to `input`. + /// + /// @param extraJvmArgs tool-specific `-D` options (a scene to open, ...) + public List command(List classpath, File primaryJar, File input, List extraJvmArgs, + Log log) { + File runtimeDir = runtimeDir(); + List command = new ArrayList(); + command.add(javaLauncher(runtimeDir, log).getAbsolutePath()); + command.addAll(identityArgs(primaryJar, runtimeDir, log)); + command.add("-D" + inputProperty() + "=" + input.getAbsolutePath()); + command.addAll(forwardedProperties()); + if (extraJvmArgs != null) { + command.addAll(extraJvmArgs); + } + command.add("-cp"); + command.add(join(classpath)); + command.add(mainClass); + return command; + } + + /// Starts the tool. Detached (the default) returns at once and logs to + /// `/.log`; attached waits for the window to close. + public void launch(List classpath, File primaryJar, File input, File workingDir, boolean detached, + List extraJvmArgs, Log log) throws BuildExecutionException { + List command = command(classpath, primaryJar, input, extraJvmArgs, log); + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(workingDir); + pb.redirectErrorStream(true); + File logFile = new File(runtimeDir(), propertyPrefix + ".log"); + if (detached) { + pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile)); + } else { + pb.inheritIO(); + } + try { + Process p = pb.start(); + if (detached) { + log.info(displayName + " launched in the background. Log: " + logFile.getAbsolutePath()); + return; + } + int status = p.waitFor(); + if (status != 0) { + throw new BuildExecutionException(displayName + " exited with status " + status); + } + } catch (IOException ex) { + throw new BuildExecutionException("Failed to launch " + displayName, ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new BuildExecutionException("Interrupted while running " + displayName, ex); + } + } + + /// The jar of this tool among `classpath`, or null. + public File primaryJar(Iterable classpath) { + for (File f : classpath) { + if (f.getName().startsWith(artifactId + "-") && f.getName().endsWith(".jar")) { + return f; + } + } + return null; + } + + private static String join(List files) { + StringBuilder out = new StringBuilder(); + for (File file : files) { + if (out.length() > 0) { + out.append(File.pathSeparator); + } + out.append(file.getAbsolutePath()); + } + return out.toString(); + } + + private static String javaExecutable() { + String executable = isWindows() ? "javaw.exe" : "java"; + return new File(new File(System.getProperty("java.home"), "bin"), executable).getAbsolutePath(); + } + + static boolean isMacOs() { + return System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).contains("mac"); + } + + static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).contains("win"); + } + + static boolean isJava9OrNewer() { + String version = System.getProperty("java.specification.version", ""); + return version.length() > 0 && !version.startsWith("1."); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java b/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java new file mode 100644 index 00000000000..cf6a2f26e49 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java @@ -0,0 +1,1807 @@ +/* + * Copyright (c) 2012, 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.maven; + +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; +import com.codename1.project.BuildSystem; +import com.codename1.project.NativePlatform; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/// Turns an existing Maven or Ant Codename One application into the Gradle +/// layout, in a new directory. +/// +/// Every location comes from the two [ProjectLayout]s, so the rules are the +/// same ones the build tools use: sources and resources to `src/main/...`, +/// each platform's native-interface implementations to `src//`, +/// a backend module to `backend/`. The project's own dependencies become +/// `build.gradle.kts` lines. What Gradle deliberately does not support is +/// refused rather than silently dropped: legacy `.cn1lib` files, and a Java 8 +/// target. +/// +/// Used by `mvn cn1:convert-to-gradle`, and by `cn1:generate-app-project +/// -Dcn1.buildTool=gradle`, which generates the Maven project and converts it. +public final class GradleConversion { + /// The Kotlin Gradle plugin a converted Kotlin project applies. The same + /// version as the initializr's Gradle Kotlin template + /// (`GeneratorModel.KOTLIN_VERSION`), which was verified against the wrapper + /// the template pins; the Maven projects' kotlin-maven-plugin version is not + /// tied to Gradle and does not carry over. + public static final String KOTLIN_VERSION = "2.2.10"; + + private final Log log; + + private boolean includeUntouchedBackend; + + /// A converter logging to `log`. + public GradleConversion(Log log) { + this.log = log; + } + + /// Whether a backend module nobody has changed from the archetype's skeleton + /// is carried over. Off by default: every Maven project has that module, and + /// the point of the Gradle layout is to have a backend only when there is one. + /// `./gradlew addBackend` creates the same skeleton later. + public GradleConversion includeUntouchedBackend(boolean include) { + this.includeUntouchedBackend = include; + return this; + } + + /// Whether `backend` is exactly the generated skeleton: the generated `Api` + /// controller with its routes, the `Greeter` service it is given, and nothing + /// else. + static boolean isUntouchedSkeleton(File backend) { + // The generated sources and nothing else: Api.java and Greeter.java side by + // side, no Kotlin, no resources, no extra profiles. + List sources = new ArrayList(); + collectFiles(new File(backend, "src"), sources); + Map byName = new HashMap(); + for (File f : sources) { + byName.put(f.getName(), f); + } + File api = byName.get("Api.java"); + File greeter = byName.get("Greeter.java"); + if (sources.size() != 2 || api == null || greeter == null + || !api.getParentFile().equals(greeter.getParentFile())) { + return false; + } + String[] profiles = backend.list((d, n) -> n.startsWith("application-") && n.endsWith(".properties") + && !"application-dev.properties".equals(n)); + if (profiles != null && profiles.length > 0) { + return false; + } + if (!pomAsGenerated(new File(backend, "pom.xml"))) { + return false; + } + try { + // Compared as code, not by counting routes: a backend that kept the two + // generated routes but changed what they do, secured them or added a + // helper is someone's work, and leaving it behind would lose it. + if (!sameCode(api, "backend/Api.java.txt") || !sameCode(greeter, "backend/Greeter.java.txt")) { + return false; + } + return sameSettings(new File(backend, "application.properties"), "backend/application.properties.txt") + && sameSettings(new File(backend, "application-dev.properties"), + "backend/application-dev.properties.txt"); + } catch (IOException ex) { + return false; + } + } + + /// The dependencies and build plugins the archetype's backend pom declares, + /// as group:artifact. Kept in step with cn1app-archetype's backend/pom.xml. + private static final java.util.Set GENERATED_BACKEND_DEPENDENCIES = new java.util.HashSet( + java.util.Arrays.asList("com.codenameone:codenameone-backend", "org.xerial:sqlite-jdbc")); + private static final java.util.Set GENERATED_BACKEND_PLUGINS = new java.util.HashSet( + java.util.Arrays.asList("com.codenameone:codenameone-maven-plugin")); + + /// Whether a backend's pom is still what the archetype wrote, as far as the + /// conversion carries anything over: no dependency, build plugin, + /// repository or profile beyond the generated ones. A missing pom counts + /// as generated. A changed one is someone's work, and the backend is kept. + static boolean pomAsGenerated(File pom) { + if (!pom.isFile()) { + return true; + } + Element project = parsePomOrNull(pom); + if (project == null || child(project, "repositories") != null || child(project, "profiles") != null) { + return false; + } + Element deps = child(project, "dependencies"); + for (Element d : deps == null ? java.util.Collections.emptyList() : children(deps, "dependency")) { + if (!GENERATED_BACKEND_DEPENDENCIES.contains(text(d, "groupId") + ":" + text(d, "artifactId"))) { + return false; + } + } + Element build = child(project, "build"); + Element plugins = build == null ? null : child(build, "plugins"); + for (Element p : plugins == null ? java.util.Collections.emptyList() : children(plugins, "plugin")) { + String group = text(p, "groupId"); + if (!GENERATED_BACKEND_PLUGINS.contains((group == null ? "org.apache.maven.plugins" : group) + ":" + + text(p, "artifactId"))) { + return false; + } + } + return true; + } + + /// Whether `source` is the template's code, whatever its comments and package. + private static boolean sameCode(File source, String template) throws IOException { + String code = new String(Files.readAllBytes(source.toPath()), StandardCharsets.UTF_8); + return javaCode(code).equals(javaCode(GradleProjectTemplate.text(template))); + } + + /// Java source reduced to its code: comments, whitespace, the package line + /// and the template placeholders gone, so the generated file and the + /// template compare equal whatever package they are in. + /// + /// The regexes do not know about string literals, deliberately. They can only + /// hide an edit made entirely of comment syntax typed inside a literal of the + /// generated starter (`"ok"` changed to `"ok/*x*/"`), and nothing else: any + /// other change still differs. That is not a case worth a Java lexer here, + /// and its cost is bounded -- the Maven project is never modified, the + /// conversion says it left the backend behind, and `-Dcn1.includeBackend` + /// carries it over. + static String javaCode(String source) { + String t = source.replaceAll("(?s)/\\*.*?\\*/", "").replaceAll("//[^\n]*", ""); + t = t.replaceAll("(?m)^\\s*package\\s+[^;]+;", "").replace("__BACKEND__", ""); + return t.replaceAll("\\s+", ""); + } + + /// Whether `file` holds the template's settings (comments and blank lines + /// aside). A missing file counts as unchanged. + private static boolean sameSettings(File file, String template) throws IOException { + if (!file.isFile()) { + return true; + } + return settingLines(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)) + .equals(settingLines(GradleProjectTemplate.text(template))); + } + + private static List settingLines(String text) { + List out = new ArrayList(); + for (String line : text.split("\\r?\\n")) { + String t = line.trim(); + if (!t.isEmpty() && !t.startsWith("#") && !t.startsWith("!")) { + out.add(t); + } + } + return out; + } + + private static void collectFiles(File dir, List out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File f : children) { + if (f.isDirectory()) { + collectFiles(f, out); + } else if (!".gitignore".equals(f.getName())) { + out.add(f); + } + } + } + + /// Converts the project containing `sourceDir` into `targetDir`, which must + /// not exist or be empty. + /// + /// @param cn1Version the plugin version to declare + /// @return the converted project's layout + public ProjectLayout convert(File sourceDir, File targetDir, String cn1Version) throws BuildFailureException { + ProjectLayout from = ProjectLayouts.detect(sourceDir); + if (from == null || from.kind() != ProjectKind.APP) { + throw new BuildFailureException(sourceDir + " is not a Codename One application project"); + } + if (from.buildSystem() == BuildSystem.GRADLE) { + throw new BuildFailureException(sourceDir + " already builds with Gradle"); + } + String[] children = targetDir.list(); + if (children != null && children.length > 0) { + throw new BuildFailureException(targetDir + " is not empty"); + } + refuseLegacyCn1libs(from); + Properties settings = read(from.settingsFile()); + String javaVersion = settings.getProperty("codename1.arg.java.version", "8").trim(); + if (!"17".equals(javaVersion) && !javaVersion.startsWith("2")) { + log.warn("codename1.arg.java.version is " + javaVersion + "; Gradle projects compile for Java 17, " + + "so the converted settings say 17. Check that nothing relied on Java 8."); + } + + ProjectLayout to = ProjectLayouts.of(BuildSystem.GRADLE, ProjectKind.APP, targetDir, targetDir); + try { + copyTree(from.settingsFile(), to.settingsFile()); + // 17, whatever the source project said: Codename One builds Java 17 + // bytecode only -- Android runs nothing newer, and the compliance check + // transpiles newer class files down to 17 on purpose. A higher release + // is not a setting to carry over. + setJava17(to.settingsFile()); + copyTree(from.iconFile(), to.iconFile()); + // The icon the settings name, when it is not icon.png: the setting is kept, + // so the file it names must come along, at the same relative path. + String icon = settings.getProperty("codename1.icon"); + if (icon != null && icon.trim().length() > 0 && !new File(icon.trim()).isAbsolute()) { + copyTree(new File(from.projectDir(), icon.trim()), new File(to.projectDir(), icon.trim())); + } + if (from.buildSystem() == BuildSystem.ANT) { + convertAntSources(from, to); + if ("true".equals(settings.getProperty("codename1.cssTheme", "false"))) { + // Ant saved the compiled CSS theme into src/; a Gradle build + // compiles it, and a stale copy on the resources path would + // shadow the fresh one. + File stale = new File(to.resourcesDir(), "theme.res"); + if (stale.isFile() && !stale.delete()) { + throw new IOException("Could not delete " + stale); + } + } + } else { + // Everything under common/src moves as it stands: java, kotlin, + // resources, css, l10n, guibuilder, rad, test. + copyTree(new File(from.projectDir(), "src"), new File(to.projectDir(), "src")); + // Plus any root the module's pom compiles from elsewhere. + moveMavenExtraRoots(from, to); + } + // An Ant project keeps the iOS app extensions and strings inside + // native/ios; they have places of their own in the Gradle layout (which + // AppBuilder reads), so the native copy leaves them out and they move + // below -- nested under src/ios/objectivec, the build would lose them. + java.util.Set extras = new java.util.HashSet(java.util.Arrays.asList( + from.iosAppExtensionsDir().getAbsoluteFile(), from.iosStringsDir().getAbsoluteFile())); + // Ant's simulator loads the jars dropped into native/javase; as sources + // under src/javase/java they would be ignored. They become + // javaseImplementation files below instead. + extras.addAll(antJavaseArchives(from)); + for (NativePlatform p : NativePlatform.values()) { + File dir = from.nativeSourceDir(p); + if (hasFiles(dir)) { + copyTreeExcept(dir, to.nativeSourceDir(p), extras); + } + } + copyIosExtras(from, to); + if (from.buildSystem() == BuildSystem.MAVEN) { + copyPlatformResources(from, to); + } + File backend = from.backendDir(); + boolean hasBackend = backend != null && new File(backend, ProjectLayout.BACKEND_SETTINGS_FILE).isFile() + && (includeUntouchedBackend || !isUntouchedSkeleton(backend)); + if (!hasBackend && backend != null && new File(backend, ProjectLayout.BACKEND_SETTINGS_FILE).isFile()) { + log.info("Left " + backend + " behind: it is still the generated skeleton. ./gradlew addBackend " + + "creates it again, and -Dcn1.includeBackend=true carries it over."); + } + if (hasBackend) { + File target = new File(to.rootDir(), "backend"); + copyTree(new File(backend, "src"), new File(target, "src")); + for (String name : new String[] {"application.properties", "application-dev.properties"}) { + copyTree(new File(backend, name), new File(target, name)); + } + File[] profiles = backend.listFiles((d, n) -> n.startsWith("application-") && n.endsWith(".properties")); + if (profiles != null) { + for (File f : profiles) { + copyTree(f, new File(target, f.getName())); + } + } + // The backend's own libraries, or its code no longer compiles: the + // plugin supplies the backend runtime and nothing else. + File script = new File(target, "build.gradle.kts"); + Files.write(script.toPath(), GradleProjectTemplate.text("backend/build.gradle.kts.txt") + .getBytes(StandardCharsets.UTF_8)); + List backendDeps = dependencyLines(new File(backend, "pom.xml"), target, + hasSuffix(new File(target, "src"), ".kt")); + writeRepositories(script, repositoryUrls(new File(backend, "pom.xml"))); + if (!backendDeps.isEmpty()) { + warnUnresolved(backendDeps, "backend/pom.xml"); + writeDependencies(script, backendDeps, "backend/pom.xml"); + } + // Gradle plugins are per project: the application's Kotlin plugin + // does not compile the backend's Kotlin, so it would be dropped. + if (hasSuffix(new File(target, "src"), ".kt")) { + // Without a version when the application declares one: Gradle + // refuses a second version of a plugin already on the classpath. + addKotlinPlugin(script, !hasSuffix(new File(targetDir, "src"), ".kt")); + } + } + GradleProjectTemplate.writeScaffolding(targetDir, projectName(from, settings), cn1Version, + GradleProjectTemplate.Shape.APP); + if (from.buildSystem() == BuildSystem.MAVEN) { + List deps = dependencyLines(from.dependencyFile(), targetDir, + hasSuffix(new File(targetDir, "src"), ".kt")); + // The JavaSE module's repositories too: its dependencies are converted + // as javaseImplementation below and must resolve from the same places. + List repositories = new ArrayList(repositoryUrls(from.dependencyFile())); + File javasePom = new File(from.rootDir(), NativePlatform.JAVASE.id() + File.separator + "pom.xml"); + if (javasePom.isFile()) { + for (String url : repositoryUrls(javasePom)) { + if (!repositories.contains(url)) { + repositories.add(url); + } + } + } + writeRepositories(new File(targetDir, "build.gradle.kts"), repositories); + if (!deps.isEmpty()) { + warnUnresolved(deps, "common/pom.xml"); + writeDependencies(new File(targetDir, "build.gradle.kts"), deps, "common/pom.xml"); + } + for (NativePlatform p : NativePlatform.values()) { + File platformPom = new File(from.rootDir(), p.id() + File.separator + "pom.xml"); + List platformDeps = platformDependencyLines(p, platformPom, from.dependencyFile(), + deps, targetDir, hasSuffix(new File(targetDir, "src"), ".kt")); + if (!platformDeps.isEmpty()) { + warnUnresolved(platformDeps, p.id() + "/pom.xml"); + writeDependencies(new File(targetDir, "build.gradle.kts"), platformDeps, + p.id() + "/pom.xml"); + } + } + } else { + List jars = copyAntLibraryJars(from, targetDir); + if (!jars.isEmpty()) { + writeDependencies(new File(targetDir, "build.gradle.kts"), jars, "Ant project's lib/"); + } + } + if (hasSuffix(new File(targetDir, "src"), ".kt")) { + // Without the Kotlin plugin Gradle compiles none of it and says + // nothing: the app would build, minus its Kotlin classes. + addKotlinPlugin(new File(targetDir, "build.gradle.kts"), true); + } + } catch (IOException ex) { + throw new BuildFailureException("Could not convert " + sourceDir + ": " + ex.getMessage(), ex); + } + log.info("Converted " + from.rootDir() + " to a Gradle project at " + targetDir); + return to; + } + + private static String projectName(ProjectLayout from, Properties settings) { + String main = settings.getProperty("codename1.mainName"); + if (main != null && main.trim().length() > 0) { + return main.trim(); + } + return from.rootDir().getName(); + } + + private void refuseLegacyCn1libs(ProjectLayout from) throws BuildFailureException { + File legacy = from.legacyCn1libDir(); + List found = new ArrayList(); + collectCn1libs(legacy, found); + if (!found.isEmpty()) { + StringBuilder sb = new StringBuilder("This project uses legacy .cn1lib files, which Gradle projects " + + "do not support:\n"); + for (String f : found) { + sb.append(" - ").append(f).append('\n'); + } + sb.append("Depend on each library's published Maven coordinates instead -- cn1lib(\"group:name-lib:version\") " + + "in build.gradle.kts -- or republish it as a Gradle cn1lib, then convert again."); + throw new BuildFailureException(sb.toString()); + } + } + + private static void collectCn1libs(File dir, List out) { + File[] children = dir == null ? null : dir.listFiles(); + if (children == null) { + return; + } + for (File f : children) { + if (f.isDirectory()) { + // The Maven cn1libs/ module keeps an installed library in a directory + // of its own, with the .cn1lib's parts under jars/. + if (new File(f, "jars").isDirectory()) { + out.add(f.getName()); + } + } else if (f.getName().endsWith(".cn1lib")) { + out.add(f.getName()); + } + } + } + + /// Ant keeps Java, Kotlin and resources together in `src/`, CSS in `css/` and + /// tests in `test/`. + private void convertAntSources(ProjectLayout from, ProjectLayout to) throws IOException { + File src = new File(from.projectDir(), "src"); + splitSources(src, src, new File(to.projectDir(), "src" + File.separator + "main")); + copyTree(from.cssDir(), to.cssDir()); + // The legacy GUI builder's XML and CodeRAD's view templates: what the + // generated Java was made from, and what the next edit regenerates it from. + copyTree(from.guiBuilderDir(), to.guiBuilderDir()); + copyTree(from.radViewsDir(), to.radViewsDir()); + File test = new File(from.projectDir(), "test"); + splitSources(test, test, new File(to.projectDir(), "src" + File.separator + "test")); + } + + /// The framework jars an Ant project keeps in `lib/`, which the plugin + /// supplies (or no longer needs), as opposed to the application's own. + private static final java.util.Set ANT_FRAMEWORK_JARS = new java.util.HashSet( + java.util.Arrays.asList("CodenameOne.jar", "CLDC11.jar", "JavaSE.jar", "CodeNameOneBuildClient.jar", + "UpdateCodenameOne.jar", "designer_1.jar", "CodenameOneDesigner.jar")); + + /// Copies the application's own jars from the Ant project's `lib/` into + /// `libs/` and returns their `implementation(files(...))` lines. Left + /// behind, the converted project would not compile against them. `.cn1lib` + /// files were refused earlier, and `lib/impl` is their extracted content. + private List copyAntLibraryJars(ProjectLayout from, File targetDir) throws IOException { + List lines = new ArrayList(); + File[] jars = new File(from.projectDir(), "lib").listFiles( + (d, n) -> n.endsWith(".jar") && !ANT_FRAMEWORK_JARS.contains(n)); + if (jars == null) { + return lines; + } + java.util.Arrays.sort(jars); + for (File jar : jars) { + copyTree(jar, new File(targetDir, "libs" + File.separator + jar.getName())); + lines.add(" implementation(files(\"libs/" + jar.getName() + "\"))"); + log.info("Carried over " + jar.getName() + " as libs/" + jar.getName()); + } + // The simulator-only archives: on the javase source set's classpath, which + // the simulator runs with and the JavaSE upload carries. + for (File archive : antJavaseArchives(from)) { + copyTree(archive, new File(targetDir, "libs" + File.separator + "javase" + File.separator + + archive.getName())); + lines.add(" javaseImplementation(files(\"libs/javase/" + archive.getName() + "\"))"); + log.info("Carried over native/javase/" + archive.getName() + " as libs/javase/" + archive.getName()); + } + return lines; + } + + /// The prebuilt archives an Ant project keeps in `native/javase`, which its + /// simulator puts on the classpath: the directory's own `.jar` and `.zip` + /// files, as `CN1Bootstrap` lists them. + private static List antJavaseArchives(ProjectLayout from) { + List out = new ArrayList(); + if (from.buildSystem() != BuildSystem.ANT) { + return out; + } + File[] archives = from.nativeSourceDir(NativePlatform.JAVASE).listFiles( + (d, n) -> n.endsWith(".jar") || n.endsWith(".zip")); + if (archives != null) { + java.util.Arrays.sort(archives); + for (File f : archives) { + if (f.isFile()) { + out.add(f.getAbsoluteFile()); + } + } + } + return out; + } + + /// The source roots a Maven module compiles by convention, relative to it. + private static final java.util.Set CONVENTIONAL_ROOTS = new java.util.HashSet( + java.util.Arrays.asList("src/main/java", "src/main/kotlin", "src/main/resources", + "src/test/java", "src/test/kotlin", "src/test/resources")); + + /// The source roots `pom` adds beyond Maven's conventional ones: + /// ``, `` and build-helper's + /// add-source and add-test-source. Keyed "main" or "test"; each root is + /// absolute. Resources, which carry include and exclude rules, are + /// [#resourceSpecs(File)]'s. + static java.util.Map> extraRoots(File pom) { + java.util.Map> out = new java.util.LinkedHashMap>(); + out.put("main", new ArrayList()); + out.put("test", new ArrayList()); + Element project = parsePomOrNull(pom); + Element build = project == null ? null : effectiveBuild(pom, project); + if (build == null) { + return out; + } + File base = pom.getParentFile(); + // The pom's own properties first: a root may be named through one + // (${generated.sources}). + java.util.Map props = new java.util.HashMap(pomProperties(pom, 0)); + props.put("basedir", base.getAbsolutePath()); + props.put("project.basedir", base.getAbsolutePath()); + addRoot(out.get("main"), base, interpolate(text(build, "sourceDirectory"), props)); + addRoot(out.get("test"), base, interpolate(text(build, "testSourceDirectory"), props)); + Element plugins = child(build, "plugins"); + for (Element plugin : plugins == null ? java.util.Collections.emptyList() + : children(plugins, "plugin")) { + if (!"build-helper-maven-plugin".equals(text(plugin, "artifactId"))) { + continue; + } + Element executions = child(plugin, "executions"); + for (Element ex : executions == null ? java.util.Collections.emptyList() + : children(executions, "execution")) { + Element goals = child(ex, "goals"); + Element config = child(ex, "configuration"); + if (goals == null || config == null) { + continue; + } + for (Element goal : children(goals, "goal")) { + String g = goal.getTextContent().trim(); + String target = g.contains("test") ? "test" : "main"; + if ("add-source".equals(g) || "add-test-source".equals(g)) { + Element sources = child(config, "sources"); + for (Element src : sources == null ? java.util.Collections.emptyList() + : children(sources, "source")) { + addRoot(out.get(target), base, interpolate(src.getTextContent().trim(), props)); + } + } + } + } + } + return out; + } + + /// One `` a pom declares: its directory (absolute), where it goes + /// ("main" or "test"), and its include and exclude patterns and filtering. + static final class ResourceSpec { + final File dir; + final String target; + final List includes = new ArrayList(); + final List excludes = new ArrayList(); + final boolean filtering; + /// Where under the classpath Maven puts the files (``), + /// '/'-separated, or null for the root. + final String targetPath; + + ResourceSpec(File dir, String target, boolean filtering, String targetPath) { + this.dir = dir; + this.target = target; + this.filtering = filtering; + String t = targetPath == null ? null : targetPath.trim().replace('\\', '/'); + while (t != null && t.startsWith("/")) { + t = t.substring(1); + } + while (t != null && t.endsWith("/")) { + t = t.substring(0, t.length() - 1); + } + this.targetPath = t == null || t.isEmpty() ? null : t; + } + + /// Where a file at `rel` under [#dir] lands under the resources root. + String destination(String rel) { + return targetPath == null ? rel : targetPath + "/" + rel; + } + + /// Whether `rel` (a '/'-separated path under [#dir]) is packaged: it + /// matches an include (every file when there are none) and no exclude. + boolean packages(String rel) { + boolean in = includes.isEmpty(); + for (String p : includes) { + in |= antMatches(p, rel); + } + if (!in) { + return false; + } + for (String p : excludes) { + if (antMatches(p, rel)) { + return false; + } + } + return true; + } + } + + /// Every ``/`` of `pom`'s build, and build-helper's + /// add-resource and add-test-resource ones. + static List resourceSpecs(File pom) { + List out = new ArrayList(); + Element project = parsePomOrNull(pom); + Element build = project == null ? null : effectiveBuild(pom, project); + if (build == null) { + return out; + } + File base = pom.getParentFile(); + // The pom's own properties first: a root may be named through one + // (${generated.sources}). + java.util.Map props = new java.util.HashMap(pomProperties(pom, 0)); + props.put("basedir", base.getAbsolutePath()); + props.put("project.basedir", base.getAbsolutePath()); + for (String[] kind : new String[][] {{"resources", "resource", "main"}, + {"testResources", "testResource", "test"}}) { + Element list = child(build, kind[0]); + for (Element r : list == null ? java.util.Collections.emptyList() : children(list, kind[1])) { + addSpec(out, base, r, kind[2], props); + } + } + Element plugins = child(build, "plugins"); + for (Element plugin : plugins == null ? java.util.Collections.emptyList() + : children(plugins, "plugin")) { + if (!"build-helper-maven-plugin".equals(text(plugin, "artifactId"))) { + continue; + } + Element executions = child(plugin, "executions"); + for (Element ex : executions == null ? java.util.Collections.emptyList() + : children(executions, "execution")) { + Element goals = child(ex, "goals"); + Element config = child(ex, "configuration"); + if (goals == null || config == null) { + continue; + } + for (Element goal : children(goals, "goal")) { + String g = goal.getTextContent().trim(); + if ("add-resource".equals(g) || "add-test-resource".equals(g)) { + Element resources = child(config, "resources"); + for (Element r : resources == null ? java.util.Collections.emptyList() + : children(resources, "resource")) { + addSpec(out, base, r, g.contains("test") ? "test" : "main", props); + } + } + } + } + } + return out; + } + + /// The conventional roots `pom` replaces: `` and + /// `` stand in for `src/main/java` and `src/test/java`, + /// and declaring `` (``) packages + /// `src/main/resources` (`src/test/resources`) only when it is one of them. + static List replacedConventionalRoots(File pom) { + List out = new ArrayList(); + Element project = parsePomOrNull(pom); + Element build = project == null ? null : effectiveBuild(pom, project); + if (build == null) { + return out; + } + File base = pom.getParentFile().getAbsoluteFile(); + // The pom's own properties first: a root may be named through one + // (${generated.sources}). + java.util.Map props = new java.util.HashMap(pomProperties(pom, 0)); + props.put("basedir", base.getAbsolutePath()); + props.put("project.basedir", base.getAbsolutePath()); + String[][] kinds = {{"sourceDirectory", null, null, "src/main/java"}, + {"testSourceDirectory", null, null, "src/test/java"}, + {null, "resources", "resource", "src/main/resources"}, + {null, "testResources", "testResource", "src/test/resources"}}; + for (String[] k : kinds) { + List declared = new ArrayList(); + if (k[0] != null) { + String dir = interpolate(text(build, k[0]), props); + if (dir == null) { + continue; + } + declared.add(dir); + } else { + Element list = child(build, k[1]); + if (list == null) { + continue; + } + for (Element r : children(list, k[2])) { + String dir = interpolate(text(r, "directory"), props); + if (dir != null) { + declared.add(dir); + } + } + } + boolean kept = false; + for (String dir : declared) { + if (dir.contains("${")) { + // Not resolvable here: where Maven compiles from is unknown, so + // the conventional tree is kept rather than deleted on a guess. + kept = true; + continue; + } + File f = new File(dir); + File abs = (f.isAbsolute() ? f : new File(base, dir)).getAbsoluteFile(); + String rel = base.toURI().relativize(abs.toURI()).getPath(); + kept |= k[3].equals(rel.endsWith("/") ? rel.substring(0, rel.length() - 1) : rel); + } + if (!kept) { + out.add(k[3]); + } + } + return out; + } + + private static void addSpec(List out, File base, Element r, String target, + java.util.Map props) { + String path = interpolate(text(r, "directory"), props); + if (path == null || path.length() == 0 || path.contains("${")) { + return; + } + File f = new File(path); + ResourceSpec spec = new ResourceSpec((f.isAbsolute() ? f : new File(base, path)).getAbsoluteFile(), target, + "true".equals(text(r, "filtering")), interpolate(text(r, "targetPath"), props)); + for (String[] list : new String[][] {{"includes", "include"}, {"excludes", "exclude"}}) { + Element patterns = child(r, list[0]); + for (Element p : patterns == null ? java.util.Collections.emptyList() + : children(patterns, list[1])) { + ("includes".equals(list[0]) ? spec.includes : spec.excludes).add(p.getTextContent().trim()); + } + } + out.add(spec); + } + + /// Maven's (Ant's) path pattern match: `**` spans directories, `*` and `?` + /// stay within one, and a pattern ending in `/` means everything below. + static boolean antMatches(String pattern, String path) { + String p = pattern.replace('\\', '/'); + if (p.endsWith("/")) { + p = p + "**"; + } + StringBuilder re = new StringBuilder(); + for (int i = 0; i < p.length(); i++) { + char c = p.charAt(i); + if (c == '*' && i + 1 < p.length() && p.charAt(i + 1) == '*') { + boolean slash = i + 2 < p.length() && p.charAt(i + 2) == '/'; + re.append(slash ? "(?:.*/)?" : ".*"); + i += slash ? 2 : 1; + } else if (c == '*') { + re.append("[^/]*"); + } else if (c == '?') { + re.append("[^/]"); + } else { + re.append(java.util.regex.Pattern.quote(String.valueOf(c))); + } + } + return path.matches(re.toString()); + } + + /// `pom`'s `` as Maven sees it with its local parents: the nearest + /// ``, ``, `` and + /// `` declared in the chain (a child's replaces its + /// parent's), and the plugins of every pom in the chain, since a parent's + /// build-helper executions run in the child. Paths stay as written; they + /// resolve against the child, as Maven interpolates `${project.basedir}`. + /// Null when no pom in the chain has a ``. + static Element effectiveBuild(File pom, Element project) { + List chain = new ArrayList(); + Element current = project; + File currentPom = pom; + for (int depth = 0; current != null && depth < 8; depth++) { + Element build = child(current, "build"); + if (build != null) { + chain.add(build); + } + Element parent = child(current, "parent"); + File parentPom = parent == null ? null : parentPom(currentPom, parent); + current = parentPom == null ? null : parsePomOrNull(parentPom); + currentPom = parentPom; + } + if (chain.isEmpty()) { + return null; + } + org.w3c.dom.Document doc = project.getOwnerDocument(); + Element merged = doc.createElement("build"); + for (String name : new String[] {"sourceDirectory", "testSourceDirectory", "resources", "testResources"}) { + for (Element build : chain) { + Element declared = child(build, name); + if (declared != null) { + merged.appendChild(doc.importNode(declared, true)); + break; + } + } + } + Element plugins = doc.createElement("plugins"); + for (Element build : chain) { + Element declared = child(build, "plugins"); + for (Element plugin : declared == null ? java.util.Collections.emptyList() + : children(declared, "plugin")) { + plugins.appendChild(doc.importNode(plugin, true)); + } + } + merged.appendChild(plugins); + return merged; + } + + private static void addRoot(List out, File base, String path) { + if (path == null || path.length() == 0 || path.contains("${")) { + return; + } + File f = new File(path); + File abs = (f.isAbsolute() ? f : new File(base, path)).getAbsoluteFile(); + String rel = base.getAbsoluteFile().toURI().relativize(abs.toURI()).getPath(); + if (rel.endsWith("/")) { + rel = rel.substring(0, rel.length() - 1); + } + if (!CONVENTIONAL_ROOTS.contains(rel) && !out.contains(abs)) { + out.add(abs); + } + } + + private static List children(Element parent, String name) { + List out = new ArrayList(); + for (Node n = parent.getFirstChild(); n != null; n = n.getNextSibling()) { + if (n instanceof Element && name.equals(((Element) n).getTagName())) { + out.add((Element) n); + } + } + return out; + } + + /// Folds the roots [#extraRoots(File)] finds into the Gradle layout's + /// conventional ones, where the converted build compiles them. Without + /// this a module compiling from, say, `src/java` or a build-helper tree + /// lost those classes. A root inside `src/` was already copied as it + /// stands, where Gradle would not compile it, so that copy is removed. + private void moveMavenExtraRoots(ProjectLayout from, ProjectLayout to) throws IOException { + java.util.Map> roots = extraRoots(from.dependencyFile()); + File src = new File(from.projectDir(), "src").getAbsoluteFile(); + // First, what the pom replaces rather than adds to, which was copied with + // the rest of src/: Maven compiles none of it, so neither may Gradle. + for (String replaced : replacedConventionalRoots(from.dependencyFile())) { + File copied = new File(to.projectDir(), replaced.replace('/', File.separatorChar)); + if (copied.exists()) { + org.apache.commons.io.FileUtils.deleteDirectory(copied); + log.info("Left out " + replaced + ": the pom builds from elsewhere instead"); + } + } + // Resources, with the pom's rules: a file Maven leaves out -- an + // environment file, a secret -- is not packaged by the converted build + // either, whether its directory is the conventional one or not. + // Several entries may name one directory; Maven packages a + // file any of them includes. + java.util.Map> byDir = new java.util.LinkedHashMap>(); + for (ResourceSpec spec : resourceSpecs(from.dependencyFile())) { + String key = spec.target + "|" + spec.dir.getAbsolutePath(); + if (!byDir.containsKey(key)) { + byDir.put(key, new ArrayList()); + } + byDir.get(key).add(spec); + } + for (List group : byDir.values()) { + ResourceSpec spec = group.get(0); + if (!spec.dir.isDirectory()) { + continue; + } + File resources = new File(to.projectDir(), "src" + File.separator + spec.target + File.separator + + "resources"); + String rel = from.projectDir().getAbsoluteFile().toURI().relativize(spec.dir.toURI()).getPath(); + boolean conventional = ("src/" + spec.target + "/resources/").equals(rel); + copyResources(group, spec.dir, resources, conventional); + if (!conventional && spec.dir.getAbsolutePath().startsWith(src.getAbsolutePath() + File.separator)) { + File copied = new File(new File(to.projectDir(), "src"), + spec.dir.getAbsolutePath().substring(src.getAbsolutePath().length() + 1)); + if (copied.exists()) { + org.apache.commons.io.FileUtils.deleteDirectory(copied); + } + } + if (!conventional) { + log.info("Moved " + spec.dir + ", which the pom packages as resources, into src/" + spec.target + + "/resources"); + } + boolean filtered = false; + for (ResourceSpec s : group) { + filtered |= s.filtering; + } + if (filtered) { + log.warn("The pom filters " + spec.dir + " (${...} expanded while packaging); the converted " + + "resources are copied as they are. Add filtering to processResources in " + + "build.gradle.kts if they rely on it."); + } + } + for (java.util.Map.Entry> e : roots.entrySet()) { + File out = new File(to.projectDir(), "src" + File.separator + e.getKey()); + for (File root : e.getValue()) { + if (!root.isDirectory()) { + continue; + } + splitSources(root, root, out); + log.info("Moved " + root + ", which the pom compiles from, into src/" + e.getKey()); + if (root.getAbsolutePath().startsWith(src.getAbsolutePath() + File.separator)) { + File copied = new File(new File(to.projectDir(), "src"), + root.getAbsolutePath().substring(src.getAbsolutePath().length() + 1)); + if (copied.exists()) { + org.apache.commons.io.FileUtils.deleteDirectory(copied); + } + } + } + } + } + + /// Brings `dir` (a resource root, or a directory below it) in line with the + /// root's specs under `out`: a conventional root was copied already, so + /// what no spec packages is deleted there; any other root's files that a + /// spec packages are copied in. + private void copyResources(List group, File dir, File out, boolean conventional) + throws IOException { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File f : children) { + if (f.isDirectory()) { + copyResources(group, f, out, conventional); + continue; + } + String rel = group.get(0).dir.toURI().relativize(f.toURI()).getPath(); + // Each entry that packages the file puts it under its own targetPath. + java.util.Set destinations = new java.util.LinkedHashSet(); + for (ResourceSpec spec : group) { + if (spec.packages(rel)) { + destinations.add(spec.destination(rel)); + } + } + if (conventional) { + // Copied already, at rel: kept there only if an entry puts it there. + File copied = new File(out, rel); + if (!destinations.remove(rel) && copied.isFile() && !copied.delete()) { + throw new IOException("Could not delete " + copied); + } + } + for (String destination : destinations) { + copyTree(f, new File(out, destination)); + } + } + } + + /// Sorts one Ant source tree into `java/`, `kotlin/` and `resources/` under + /// `out`, the way the Maven migration sorts it into `common/src/main`. + private void splitSources(File root, File dir, File out) throws IOException { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File f : children) { + if (f.isDirectory()) { + splitSources(root, f, out); + continue; + } + String rel = root.toURI().relativize(f.toURI()).getPath(); + String name = f.getName(); + if (name.endsWith(".mirah")) { + log.warn("Skipped " + f + ": Gradle projects do not build Mirah"); + continue; + } + String kind = name.endsWith(".java") ? "java" : name.endsWith(".kt") ? "kotlin" : "resources"; + copyTree(f, new File(new File(out, kind), rel)); + } + } + + private static boolean hasSuffix(File dir, String suffix) { + File[] children = dir.listFiles(); + if (children == null) { + return false; + } + for (File c : children) { + if (c.isDirectory() ? hasSuffix(c, suffix) : c.getName().endsWith(suffix)) { + return true; + } + } + return false; + } + + /// `plugins {}` must be the first statement of a Kotlin build script, so it + /// goes after the leading comment, before `dependencies {}`, as the + /// initializr writes it. + static void addKotlinPlugin(File buildScript, boolean withVersion) throws IOException { + String text = new String(Files.readAllBytes(buildScript.toPath()), StandardCharsets.UTF_8); + if (text.contains("kotlin(\"jvm\")")) { + return; + } + String plugins = "plugins {\n kotlin(\"jvm\")" + (withVersion ? " version \"" + KOTLIN_VERSION + "\"" : "") + + "\n}\n\n"; + // Before the first block: plugins {} must lead the script. + int deps = firstOf(text, "repositories {", "dependencies {"); + String updated = deps < 0 ? plugins + text : text.substring(0, deps) + plugins + text.substring(deps); + Files.write(buildScript.toPath(), updated.getBytes(StandardCharsets.UTF_8)); + } + + /// The iOS app extensions and localized strings, from wherever the source + /// build tool keeps them to the Gradle layout's own directories. + private static void copyIosExtras(ProjectLayout from, ProjectLayout to) throws IOException { + if (hasFiles(from.iosAppExtensionsDir())) { + copyTree(from.iosAppExtensionsDir(), to.iosAppExtensionsDir()); + } + if (hasFiles(from.iosStringsDir())) { + copyTree(from.iosStringsDir(), to.iosStringsDir()); + } + } + + /// A Maven platform module's own src/main/resources. + private static void copyPlatformResources(ProjectLayout from, ProjectLayout to) throws IOException { + for (NativePlatform p : NativePlatform.values()) { + File resources = new File(from.rootDir(), p.id() + File.separator + "src" + File.separator + "main" + + File.separator + "resources"); + if (hasFiles(resources)) { + copyTree(resources, new File(to.projectDir(), "src" + File.separator + p.id() + File.separator + + "resources")); + } + } + } + + /// The com.codenameone artifacts the Gradle plugin adds by itself. Only these + /// are dropped: the group also publishes cn1libs and add-ons (googlemaps-lib, + /// ...), which a converted project still needs declared. + static final java.util.Set PLUGIN_SUPPLIED = new java.util.HashSet(java.util.Arrays.asList( + "codenameone-core", "codenameone-javase", "java-runtime", "codenameone-backend", "cn1-binaries-javase", + "codenameone-css-cli", "codenameone-android", "codenameone-ios", "codenameone-javascript", + "codenameone-maven-plugin")); + + /// `build.gradle.kts` lines for the dependencies a module's pom declares + /// itself, leaving out the framework, which the plugin adds. A dependency of + /// type `pom` is a cn1lib; a test-scoped one is `testImplementation`. A version that is still a `${...}` expression is + /// written as it stands, with a comment, rather than guessed. + static List dependencyLines(File pom) { + return dependencyLines(pom, null, true); + } + + /// The dependencies a Maven platform module (`javase/pom.xml` and the like) + /// declares beyond the framework and the application's own common module. + /// JavaSE's become the `javase` source set's (`javaseImplementation`, + /// `javaseRuntimeOnly`, `javaseCompileOnly`), which the simulator runs with + /// and the JavaSE upload carries. A Gradle project has no per-platform + /// compile configuration for the device targets, so theirs are written + /// commented out under a note naming the module rather than dropped + /// silently. Test-scoped entries are left out: the platform modules' + /// tests are not converted. + List platformDependencyLines(NativePlatform platform, File pom, File commonPom, List commonLines, + File targetDir, boolean kotlinPlugin) { + List out = new ArrayList(); + if (!pom.isFile()) { + return out; + } + Element common = parsePomOrNull(commonPom); + String commonArtifact = common == null ? null : text(common, "artifactId"); + boolean javase = platform == NativePlatform.JAVASE; + boolean noted = false; + for (String entry : dependencyLines(pom, targetDir, kotlinPlugin)) { + String trimmed = entry.trim(); + if (trimmed.startsWith("testImplementation(") || trimmed.startsWith("// testImplementation(")) { + continue; + } + for (String pair : new String[] {"\n testImplementation(", "\n testCompileOnly(", + "\n // testImplementation(", "\n // testCompileOnly("}) { + int testPair = entry.indexOf(pair); + if (testPair >= 0) { + entry = entry.substring(0, testPair); + } + } + if (commonArtifact != null && (entry.contains(":" + commonArtifact + ":") + || entry.contains(":" + commonArtifact + "\""))) { + continue; + } + // Inherited from the parent pom, which common/pom.xml inherits too. + String coordinate = firstQuoted(entry); + boolean inCommon = false; + for (String line : commonLines) { + inCommon |= coordinate != null && line.contains(coordinate); + } + if (inCommon) { + continue; + } + if (javase) { + out.add(entry.replaceFirst("^(\\s*(?:// )?)implementation\\(", "$1javaseImplementation(") + .replaceFirst("^(\\s*(?:// )?)runtimeOnly\\(", "$1javaseRuntimeOnly(") + .replaceFirst("^(\\s*(?:// )?)compileOnly\\(", "$1javaseCompileOnly(")); + continue; + } + if (!noted) { + out.add(" // " + platform.id() + "/pom.xml declared these for the " + platform.id() + + " build only; a Gradle project has no such configuration, so add any it needs above:"); + log.warn(platform.id() + "/pom.xml declares dependencies for that platform alone; they are " + + "written commented out in build.gradle.kts for you to place."); + noted = true; + } + for (String line : entry.split("\n")) { + out.add(line.trim().startsWith("//") ? line : " // " + line.trim()); + } + } + return out; + } + + /// The first double-quoted literal in `text`, quotes included, or null. + private static String firstQuoted(String text) { + int open = text.indexOf('"'); + int close = open < 0 ? -1 : text.indexOf('"', open + 1); + return close < 0 ? null : text.substring(open, close + 1); + } + + /// The Kotlin artifacts the Kotlin Gradle plugin supplies itself. Others in + /// the group (kotlin-reflect, and org.jetbrains:annotations) are ordinary + /// dependencies and are kept. + private static final java.util.Set KOTLIN_SUPPLIED = new java.util.HashSet(java.util.Arrays.asList( + "kotlin-stdlib", "kotlin-stdlib-jdk7", "kotlin-stdlib-jdk8", "kotlin-stdlib-common")); + + /// As [#dependencyLines(File)], copying system-scoped jars into + /// `targetDir/libs` and declaring them as files; with a null `targetDir` + /// they are written commented out. + /// + /// The dependencies are the effective ones Maven sees: the pom's own plus + /// those its parents declare (nearest wins), with versions from properties + /// and ``, and each dependency's classifier, type and + /// exclusions kept. A profile's dependencies count when the profile is on for + /// a plain build -- `activeByDefault`, or a file activation that holds for + /// this project. One switched on by a property, JDK or OS is not decided + /// here, so its dependencies are written commented out under a note naming + /// the profile rather than dropped without a word. + /// + /// `kotlinPlugin` says whether the converted script applies the Kotlin + /// plugin, which then supplies the standard library itself; a Java project + /// that calls the Kotlin runtime keeps its explicit dependency. + static List dependencyLines(File pom, File targetDir, boolean kotlinPlugin) { + List out = new ArrayList(); + if (pom == null || !pom.isFile()) { + return out; + } + try { + java.util.Map properties = pomProperties(pom, 0); + java.util.Map managed = managedVersions(pom, 0); + java.util.Map declared = new java.util.LinkedHashMap(); + java.util.Map conditional = new java.util.LinkedHashMap(); + java.util.Map profileOf = new java.util.HashMap(); + collectDependencies(pom, 0, declared, properties, conditional, profileOf); + for (Element d : declared.values()) { + if (kotlinPlugin && "org.jetbrains.kotlin".equals(interpolate(text(d, "groupId"), properties)) + && KOTLIN_SUPPLIED.contains(interpolate(text(d, "artifactId"), properties))) { + continue; + } + String line = dependencyLine(d, properties, managed, pom.getParentFile(), targetDir); + if (line != null) { + out.add(line); + } + } + for (java.util.Map.Entry e : conditional.entrySet()) { + if (declared.containsKey(e.getKey())) { + continue; + } + String line = dependencyLine(e.getValue(), properties, managed, pom.getParentFile(), null); + if (line == null) { + continue; + } + out.add(" // Only with the Maven profile " + profileOf.get(e.getKey()) + + " active; add it if this build relied on that profile:"); + for (String l : line.split("\n")) { + out.add(l.trim().startsWith("//") ? l : " // " + l.trim()); + } + } + } catch (Exception ex) { + out.add(" // Could not read the dependencies of " + pom + ": " + ex.getMessage()); + } + return out; + } + + /// The dependencies of `pom` and its parents on disk, keyed by + /// group:artifact:classifier:type; a child's replaces its parent's. + private static void collectDependencies(File pom, int depth, java.util.Map out, + java.util.Map properties, + java.util.Map conditional, + java.util.Map profileOf) throws Exception { + Element project = parsePom(pom); + Element parent = child(project, "parent"); + if (parent != null && depth < 8) { + File parentPom = parentPom(pom, parent); + if (parentPom != null) { + collectDependencies(parentPom, depth + 1, out, properties, conditional, profileOf); + } + } + addDependencies(child(project, "dependencies"), out, properties); + java.util.Map profiles = profileStates(project, pom, properties); + for (java.util.Map.Entry e : profiles.entrySet()) { + Element profile = e.getKey(); + Activation active = e.getValue(); + if (active == Activation.ON) { + addDependencies(child(profile, "dependencies"), out, properties); + } else if (active == Activation.UNDECIDED) { + java.util.Map deps = new java.util.LinkedHashMap(); + addDependencies(child(profile, "dependencies"), deps, properties); + for (String key : deps.keySet()) { + profileOf.put(key, "'" + text(profile, "id") + "' of " + pom.getName()); + } + conditional.putAll(deps); + } + } + } + + /// Every profile of `project` with whether a plain build turns it on, + /// decided as a group as Maven does: an activeByDefault profile is on only + /// while no other profile of the same pom is. In declaration order. + static java.util.Map profileStates(Element project, File pom, + java.util.Map properties) { + java.util.Map out = new java.util.LinkedHashMap(); + Element profiles = child(project, "profiles"); + if (profiles == null) { + return out; + } + boolean anotherIsOn = false; + for (Node n = profiles.getFirstChild(); n != null; n = n.getNextSibling()) { + if (n instanceof Element && "profile".equals(((Element) n).getTagName())) { + Element profile = (Element) n; + Activation state = activeInAPlainBuild(profile, pom.getParentFile(), properties); + out.put(profile, state); + anotherIsOn |= state == Activation.ON && !byDefault(profile); + } + } + if (anotherIsOn) { + for (java.util.Map.Entry e : out.entrySet()) { + if (e.getValue() == Activation.ON && byDefault(e.getKey())) { + e.setValue(Activation.OFF); + } + } + } + return out; + } + + private static boolean byDefault(Element profile) { + Element activation = child(profile, "activation"); + return activation != null && "true".equals(text(activation, "activeByDefault")); + } + + /// Whether Maven turns a profile on for a plain build. + enum Activation { + ON, OFF, UNDECIDED + } + + /// Whether Maven turns `profile` on for a plain build of the pom in `baseDir`: + /// on for `activeByDefault` and for a file activation that holds, off for one + /// that does not, and undecided for an activation by property, JDK or OS, + /// which depends on how the build is invoked. + static Activation activeInAPlainBuild(Element profile, File baseDir, java.util.Map properties) { + Element activation = child(profile, "activation"); + if (activation == null) { + return Activation.UNDECIDED; + } + if ("true".equals(text(activation, "activeByDefault"))) { + return Activation.ON; + } + Element file = child(activation, "file"); + if (file != null && child(activation, "property") == null && child(activation, "jdk") == null + && child(activation, "os") == null) { + java.util.Map withBase = new java.util.HashMap(properties); + withBase.put("basedir", baseDir.getAbsolutePath()); + withBase.put("project.basedir", baseDir.getAbsolutePath()); + withBase.put("user.home", System.getProperty("user.home")); + String exists = interpolate(text(file, "exists"), withBase); + String missing = interpolate(text(file, "missing"), withBase); + if (exists != null && !exists.contains("${")) { + return resolve(baseDir, exists).exists() ? Activation.ON : Activation.OFF; + } + if (missing != null && !missing.contains("${")) { + return resolve(baseDir, missing).exists() ? Activation.OFF : Activation.ON; + } + } + return Activation.UNDECIDED; + } + + private static File resolve(File baseDir, String path) { + File f = new File(path); + return f.isAbsolute() ? f : new File(baseDir, path); + } + + /// The `` children of `deps`, keyed as [#collectDependencies] + /// keys them; a later declaration replaces an earlier one. + private static void addDependencies(Element deps, java.util.Map out, + java.util.Map properties) { + if (deps == null) { + return; + } + for (Node n = deps.getFirstChild(); n != null; n = n.getNextSibling()) { + if (n instanceof Element && "dependency".equals(((Element) n).getTagName())) { + Element d = (Element) n; + String key = interpolate(text(d, "groupId"), properties) + ":" + + interpolate(text(d, "artifactId"), properties) + ":" + text(d, "classifier") + ":" + + interpolate(text(d, "type"), properties); + out.remove(key); + out.put(key, d); + } + } + } + + /// The parent pom on disk, as Maven finds it: `relativePath`, `../pom.xml` + /// when the element is absent, and none at all when it is present but empty + /// -- `` tells Maven to take the parent from a repository, and + /// inheriting from whatever pom happens to sit in `..` would give the + /// conversion a different dependency model from the build it converts. + private static File parentPom(File pom, Element parent) { + if (child(parent, "relativePath") != null && text(parent, "relativePath") == null) { + return null; + } + String relative = text(parent, "relativePath"); + File parentPom = new File(pom.getParentFile(), relative == null ? "../pom.xml" : relative); + if (parentPom.isDirectory()) { + parentPom = new File(parentPom, "pom.xml"); + } + return parentPom.isFile() ? parentPom : null; + } + + /// The Gradle declaration(s) for one pom dependency, or null. A `provided` + /// one is two: `compileOnly` keeps it out of the application, as Maven does, + /// and `testImplementation` gives the tests what Maven's test classpath gives + /// them, which `compileOnly` alone does not. A `runtime` one is likewise + /// `runtimeOnly` plus `testCompileOnly`: Maven compiles the tests against it. + private static String dependencyLine(Element d, java.util.Map properties, + java.util.Map managed, File pomDir, File targetDir) + throws IOException { + String line = declaration(d, properties, managed, pomDir, targetDir); + // Scope and type through the pom's properties, as Maven's effective model + // has them: a ${...} resolving to test must not ship. + String scope = effectiveScope(d, properties, managed); + if (line != null && "runtime".equals(scope)) { + // Maven's test compile sees runtime dependencies; Gradle's does not. + return line + "\n" + line.replace("runtimeOnly(", "testCompileOnly("); + } + if (line == null || !"provided".equals(scope)) { + return line; + } + return line + "\n" + line.replace("compileOnly(", "testImplementation("); + } + + private static String declaration(Element d, java.util.Map properties, + java.util.Map managed, File pomDir, File targetDir) + throws IOException { + String g = interpolate(text(d, "groupId"), properties); + String a = interpolate(text(d, "artifactId"), properties); + String v = text(d, "version"); + String scope = effectiveScope(d, properties, managed); + String type = interpolate(text(d, "type"), properties); + String classifier = interpolate(text(d, "classifier"), properties); + if (g == null || a == null || "com.codenameone".equals(g) && PLUGIN_SUPPLIED.contains(a)) { + return null; + } + if ("pom".equals(type) && "test".equals(scope)) { + // cn1lib is part of implementation, so it would ship a library Maven + // gives the tests alone; Gradle has no test-only cn1lib configuration. + return " // cn1lib(\"" + g + ":" + a + (v == null ? "" : ":" + v) + "\") -- test-scoped in the pom; " + + "a cn1lib is an application dependency under Gradle, so add it only if the app may ship it"; + } + // Test-scoped libraries go with the test sources the conversion copies, + // or those tests stop compiling. + String config = "pom".equals(type) ? "cn1lib" : "test".equals(scope) ? "testImplementation" + : "provided".equals(scope) ? "compileOnly" + : "runtime".equals(scope) ? "runtimeOnly" : "implementation"; + if ("system".equals(scope)) { + // A file on disk, usually because it is published nowhere: carried into + // libs/ and declared as a file, not as coordinates no repository has. + java.util.Map withBase = new java.util.HashMap(properties); + withBase.put("basedir", pomDir.getAbsolutePath()); + withBase.put("project.basedir", pomDir.getAbsolutePath()); + String path = interpolate(text(d, "systemPath"), withBase); + File jar = path == null ? null : new File(path); + if (jar == null || !jar.isFile() || targetDir == null) { + return " // implementation(files(\"libs/" + (jar == null ? a + ".jar" : jar.getName()) + + "\")) -- add this file; the pom's system-scoped " + g + ":" + a + " points at " + path + + ", which was not found"; + } + copyTree(jar, new File(targetDir, "libs" + File.separator + jar.getName())); + return " implementation(files(\"libs/" + jar.getName() + "\"))"; + } + if (v == null) { + // Left to here or in a parent. + v = managed.get(g + ":" + a); + } + if (v != null && v.contains("${")) { + v = interpolate(v, properties); + } + if (v == null || v.contains("${")) { + // Without a version Gradle cannot resolve it (only com.codenameone + // modules get one from the plugin), and a ${...} left as it stands + // would be a Kotlin string template naming a variable the script + // lacks. Either way the line is written commented out. + return " // " + config + "(\"" + g + ":" + a + ":VERSION\") -- set the version; the pom " + + (v == null ? "leaves it to dependency management it could not resolve (a BOM?)" + : "gave it as " + v + ", which no pom property defines"); + } + String line = " " + config + "(\"" + notation(g, a, v, classifier, type) + "\")"; + List exclusions = exclusions(d, properties); + if (exclusions.isEmpty()) { + return line; + } + // Maven dropped these transitives on purpose; Gradle would bring them back. + StringBuilder sb = new StringBuilder(line).append(" {\n"); + for (String e : exclusions) { + sb.append(" ").append(e).append('\n'); + } + return sb.append(" }").toString(); + } + + /// Gradle statements for a dependency's ``. + private static List exclusions(Element d, java.util.Map properties) { + List out = new ArrayList(); + Element list = child(d, "exclusions"); + if (list == null) { + return out; + } + for (Node n = list.getFirstChild(); n != null; n = n.getNextSibling()) { + if (!(n instanceof Element) || !"exclusion".equals(((Element) n).getTagName())) { + continue; + } + String g = interpolate(text((Element) n, "groupId"), properties); + String a = interpolate(text((Element) n, "artifactId"), properties); + boolean anyGroup = g == null || "*".equals(g); + boolean anyArtifact = a == null || "*".equals(a); + if (anyGroup && anyArtifact) { + out.clear(); + out.add("isTransitive = false"); + return out; + } + out.add(anyArtifact ? "exclude(group = \"" + g + "\")" + : anyGroup ? "exclude(module = \"" + a + "\")" + : "exclude(group = \"" + g + "\", module = \"" + a + "\")"); + } + return out; + } + + /// The repositories the pom and its parents declare, other than Maven + /// Central and the Codename One repository, which the plugin adds. + static List repositoryUrls(File pom) { + List out = new ArrayList(); + collectRepositories(pom, 0, out); + return out; + } + + private static void collectRepositories(File pom, int depth, List out) { + Element project = parsePomOrNull(pom); + if (project == null) { + // Unreadable: it declares no repository this conversion can see, and + // reading its dependencies reports the same pom as unreadable. + return; + } + java.util.Map properties = pomProperties(pom, 0); + addRepositories(child(project, "repositories"), properties, out); + // The profiles whose dependencies the conversion keeps bring their + // repositories too, or those dependencies would not resolve. + for (java.util.Map.Entry e : profileStates(project, pom, properties).entrySet()) { + if (e.getValue() == Activation.ON) { + addRepositories(child(e.getKey(), "repositories"), properties, out); + } + } + Element parent = child(project, "parent"); + if (parent != null && depth < 8) { + File parentPom = parentPom(pom, parent); + if (parentPom != null) { + collectRepositories(parentPom, depth + 1, out); + } + } + } + + private static void addRepositories(Element repos, java.util.Map properties, List out) { + if (repos == null) { + return; + } + for (Node n = repos.getFirstChild(); n != null; n = n.getNextSibling()) { + if (n instanceof Element && "repository".equals(((Element) n).getTagName())) { + String url = interpolate(text((Element) n, "url"), properties); + if (url != null && !url.contains("${") && !url.contains("repo.maven.apache.org") + && !url.contains("repo1.maven.org") && !url.contains("repo.codenameone.com") + && !out.contains(url)) { + out.add(url); + } + } + } + } + + /// Says which dependencies were written commented out for want of a version. + private void warnUnresolved(List lines, String from) { + for (String line : lines) { + if (line.trim().startsWith("//") && (line.contains("-- set the version") + || line.contains("-- add this file") || line.contains("-- test-scoped in the pom"))) { + log.warn("A dependency in " + from + " could not be converted as it stands; it is commented " + + "out in the build script until you complete it:" + line.trim().substring(2)); + } + } + } + + /// Gradle notation for a Maven dependency, keeping the classifier and the + /// artifact type: without them Gradle resolves the module's main jar, not the + /// artifact Maven compiled against. `test-jar` is Maven's name for the + /// `tests` classifier; `pom` (a cn1lib) and `jar` need no extension. + static String notation(String g, String a, String v, String classifier, String type) { + String c = classifier; + String ext = type; + if ("test-jar".equals(type)) { + ext = "jar"; + if (c == null) { + c = "tests"; + } + } + StringBuilder sb = new StringBuilder(g).append(':').append(a).append(':').append(v); + if (c != null && !c.isEmpty()) { + sb.append(':').append(c); + } + if (ext != null && !"jar".equals(ext) && !"pom".equals(ext)) { + sb.append('@').append(ext); + } + return sb.toString(); + } + + /// The versions `` gives, as `group:artifact`, from + /// the pom and its parents on disk (nearest wins). Imported BOMs are not + /// followed. + /// The prefix [#managedVersions(File, int)] keys a managed scope under, beside + /// the group:artifact keys of the managed versions. + static final String SCOPE_KEY = "scope|"; + + /// A dependency's scope as Maven's effective model has it: its own, through + /// the pom's properties, else the one dependency management gives it. + private static String effectiveScope(Element d, java.util.Map properties, + java.util.Map managed) { + String scope = interpolate(text(d, "scope"), properties); + if (scope != null) { + return scope; + } + return managed.get(SCOPE_KEY + interpolate(text(d, "groupId"), properties) + ":" + + interpolate(text(d, "artifactId"), properties)); + } + + static java.util.Map managedVersions(File pom, int depth) { + java.util.Map out = new java.util.HashMap(); + Element project; + try { + project = parsePom(pom); + } catch (Exception ex) { + return out; + } + Element parent = child(project, "parent"); + if (parent != null && depth < 8) { + File parentPom = parentPom(pom, parent); + if (parentPom != null) { + out.putAll(managedVersions(parentPom, depth + 1)); + } + } + Element management = child(project, "dependencyManagement"); + Element deps = management == null ? null : child(management, "dependencies"); + if (deps != null) { + java.util.Map props = pomProperties(pom, 0); + for (Node n = deps.getFirstChild(); n != null; n = n.getNextSibling()) { + if (n instanceof Element && "dependency".equals(((Element) n).getTagName())) { + Element d = (Element) n; + String key = interpolate(text(d, "groupId"), props) + ":" + + interpolate(text(d, "artifactId"), props); + String version = interpolate(text(d, "version"), props); + if (version != null && !version.contains("${")) { + out.put(key, version); + } + // A managed scope too, which Maven injects into a dependency that + // declares none (kept under its own key; see managedScope). + String scope = interpolate(text(d, "scope"), props); + if (scope != null && !scope.contains("${") && !"import".equals(scope)) { + out.put(SCOPE_KEY + key, scope); + } + } + } + } + return out; + } + + private static Element parsePomOrNull(File pom) { + try { + return parsePom(pom); + } catch (Exception ex) { + return null; + } + } + + private static Element parsePom(File pom) throws Exception { + DocumentBuilderFactory f = DocumentBuilderFactory.newInstance(); + f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + try (InputStream in = new FileInputStream(pom)) { + return f.newDocumentBuilder().parse(in).getDocumentElement(); + } + } + + /// The properties `${...}` in `pom` can name: its parents' (found on disk by + /// `relativePath`, `../pom.xml` by default; nearest wins), its own + /// ``, and the `project.*` coordinates. + static java.util.Map pomProperties(File pom, int depth) { + java.util.Map props = new java.util.HashMap(); + Element project; + try { + project = parsePom(pom); + } catch (Exception ex) { + return props; + } + Element parent = child(project, "parent"); + if (parent != null && depth < 8) { + File parentPom = parentPom(pom, parent); + if (parentPom != null) { + props.putAll(pomProperties(parentPom, depth + 1)); + } + } + putProperties(child(project, "properties"), props); + // Then the profiles a plain build turns on, which override the pom's own + // properties as Maven's effective model does: a dependency versioned by a + // property an activeByDefault profile defines resolves. + for (java.util.Map.Entry e : profileStates(project, pom, props).entrySet()) { + if (e.getValue() == Activation.ON) { + putProperties(child(e.getKey(), "properties"), props); + } + } + String groupId = text(project, "groupId"); + String version = text(project, "version"); + if (parent != null) { + if (text(parent, "groupId") != null) { + props.put("project.parent.groupId", text(parent, "groupId")); + } + if (text(parent, "version") != null) { + props.put("project.parent.version", text(parent, "version")); + } + } + props.put("project.groupId", groupId != null ? groupId : text(parent == null ? project : parent, "groupId")); + props.put("project.version", version != null ? version : text(parent == null ? project : parent, "version")); + props.put("project.artifactId", text(project, "artifactId")); + return props; + } + + private static void putProperties(Element properties, java.util.Map props) { + if (properties == null) { + return; + } + for (Node n = properties.getFirstChild(); n != null; n = n.getNextSibling()) { + if (n instanceof Element) { + String value = n.getTextContent(); + props.put(((Element) n).getTagName(), value == null ? "" : value.trim()); + } + } + } + + private static String interpolate(String value, java.util.Map properties) { + return Cn1libPomProfiles.interpolate(value, properties); + } + + private static int firstOf(String text, String... markers) { + int best = -1; + for (String m : markers) { + int i = text.indexOf(m); + if (i >= 0 && (best < 0 || i < best)) { + best = i; + } + } + return best; + } + + /// Declares the pom's own repositories, which the dependencies may only be + /// found in, as a `repositories {}` block before `dependencies {}`. + private static void writeRepositories(File buildScript, List urls) throws IOException { + if (urls.isEmpty() || !buildScript.isFile()) { + return; + } + String text = new String(Files.readAllBytes(buildScript.toPath()), StandardCharsets.UTF_8); + int deps = text.indexOf("dependencies {"); + StringBuilder sb = new StringBuilder("// From the Maven project's :\nrepositories {\n"); + for (String url : urls) { + sb.append(" maven(url = uri(\"").append(url.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("$", "\\$")).append("\"))\n"); + } + sb.append("}\n\n"); + String updated = deps < 0 ? text + "\n" + sb : text.substring(0, deps) + sb + text.substring(deps); + Files.write(buildScript.toPath(), updated.getBytes(StandardCharsets.UTF_8)); + } + + private static void writeDependencies(File buildScript, List lines, String from) throws IOException { + String text = new String(Files.readAllBytes(buildScript.toPath()), StandardCharsets.UTF_8); + int open = text.indexOf("dependencies {"); + if (open < 0) { + return; + } + int insert = text.indexOf('\n', open) + 1; + StringBuilder sb = new StringBuilder(); + sb.append(" // From the Maven project's ").append(from).append(":\n"); + for (String l : lines) { + sb.append(l).append('\n'); + } + String updated = text.substring(0, insert) + sb + text.substring(insert); + Files.write(buildScript.toPath(), updated.getBytes(StandardCharsets.UTF_8)); + } + + private static void setJava17(File settings) throws IOException { + String text = new String(Files.readAllBytes(settings.toPath()), StandardCharsets.ISO_8859_1); + String updated = text.replaceAll("(?m)^codename1\\.arg\\.java\\.version=.*$", "codename1.arg.java.version=17"); + if (!updated.contains("codename1.arg.java.version=")) { + updated = updated + (updated.endsWith("\n") ? "" : "\n") + "codename1.arg.java.version=17\n"; + } + Files.write(settings.toPath(), updated.getBytes(StandardCharsets.ISO_8859_1)); + } + + private static Properties read(File f) { + Properties p = new Properties(); + if (f.isFile()) { + try (InputStream in = new FileInputStream(f)) { + p.load(in); + } catch (IOException ignored) { + // An unreadable settings file converts as an empty one. + } + } + return p; + } + + private static boolean hasFiles(File dir) { + if (dir == null || !dir.isDirectory()) { + return false; + } + File[] children = dir.listFiles(); + if (children == null) { + return false; + } + for (File c : children) { + if (c.isFile() && !".gitignore".equals(c.getName())) { + return true; + } + if (c.isDirectory() && hasFiles(c)) { + return true; + } + } + return false; + } + + /// [#copyTree(File, File)], leaving out the directories in `except`. + private static void copyTreeExcept(File from, File to, java.util.Set except) throws IOException { + if (except.contains(from.getAbsoluteFile())) { + return; + } + if (from.isDirectory()) { + File[] children = from.listFiles(); + if (children != null) { + for (File c : children) { + copyTreeExcept(c, new File(to, c.getName()), except); + } + } + return; + } + copyTree(from, to); + } + + private static void copyTree(File from, File to) throws IOException { + if (from == null || !from.exists()) { + return; + } + if (from.isDirectory()) { + File[] children = from.listFiles(); + if (children == null) { + return; + } + for (File c : children) { + copyTree(c, new File(to, c.getName())); + } + return; + } + File parent = to.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Could not create " + parent); + } + try (InputStream in = new FileInputStream(from); OutputStream out = new FileOutputStream(to)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + if (from.canExecute() && !to.setExecutable(true, false)) { + throw new IOException("Could not keep " + to + " executable"); + } + } + + private static Element child(Element parent, String name) { + for (Node n = parent.getFirstChild(); n != null; n = n.getNextSibling()) { + if (n instanceof Element && ((Element) n).getTagName().equals(name)) { + return (Element) n; + } + } + return null; + } + + private static String text(Element parent, String name) { + Element e = child(parent, name); + if (e == null) { + return null; + } + String t = e.getTextContent(); + return t == null || t.trim().isEmpty() ? null : t.trim(); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/GradleProjectTemplate.java b/maven/build-engine/src/main/java/com/codename1/maven/GradleProjectTemplate.java new file mode 100644 index 00000000000..e4c03d6ab76 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/GradleProjectTemplate.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2012, 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.maven; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +/// The files that make a directory a Codename One Gradle project, from the one +/// template every generator shares. +/// +/// The templates live beside this class under +/// `com/codename1/project/templates/gradle`. The Maven plugin writes them when +/// it generates or migrates a project with `-Dcn1.buildTool=gradle`, the Gradle +/// plugin writes the backend skeleton for `addBackend`, and the initializr +/// packages the same directory -- so a project looks the same whichever of them +/// made it. Placeholders are `__NAME__` tokens rather than `${...}`, which +/// Kotlin build scripts already use. +public final class GradleProjectTemplate { + /// What the project is. + public enum Shape { + /// An application. + APP, + /// An application with a `backend/` subproject. + APP_WITH_BACKEND, + /// A backend with no client, at the root. + BACKEND_ONLY + } + + private static final String ROOT = "/com/codename1/project/templates/gradle/"; + + private GradleProjectTemplate() { + } + + /// The template at `path` (relative to the template root), as text. + public static String text(String path) throws IOException { + return new String(bytes(path), StandardCharsets.UTF_8); + } + + private static byte[] bytes(String path) throws IOException { + InputStream in = GradleProjectTemplate.class.getResourceAsStream(ROOT + path); + if (in == null) { + throw new IOException("Missing Gradle project template " + path); + } + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } finally { + in.close(); + } + } + + /// Writes the build scaffolding into `dir`: the settings script, the build + /// script, the Gradle wrapper, `gradle.properties` and `.gitignore`. Sources, + /// `codenameone_settings.properties` and the icon are the caller's business. + /// An existing file is left alone, so this can run over a project that has + /// some of them already. + /// + /// @param projectName the root project's name + /// @param cn1Version the plugin (and framework) version to declare + /// `value` as the inside of a Kotlin string literal: a main class may hold a + /// `$` (`Price$Tracker`), which Kotlin would read as a template. + static String kotlinString(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("$", "\\$"); + } + + public static void writeScaffolding(File dir, String projectName, String cn1Version, Shape shape) + throws IOException { + write(new File(dir, "settings.gradle.kts"), text("settings.gradle.kts.txt") + .replace("__CN1_VERSION__", cn1Version).replace("__PROJECT_NAME__", kotlinString(projectName))); + write(new File(dir, "build.gradle.kts"), + text(shape == Shape.BACKEND_ONLY ? "backend/build.gradle.kts.txt" : "app/build.gradle.kts.txt")); + write(new File(dir, "gradle.properties"), text("gradle.properties.txt")); + write(new File(dir, ".gitignore"), text("gitignore.txt")); + writeBinary(new File(dir, "gradlew"), bytes("gradlew")); + writeBinary(new File(dir, "gradlew.bat"), bytes("gradlew.bat")); + writeBinary(new File(dir, "gradle/wrapper/gradle-wrapper.jar"), bytes("gradle/wrapper/gradle-wrapper.jar")); + writeBinary(new File(dir, "gradle/wrapper/gradle-wrapper.properties"), + bytes("gradle/wrapper/gradle-wrapper.properties")); + File gradlew = new File(dir, "gradlew"); + if (!gradlew.setExecutable(true, false) && !gradlew.canExecute()) { + throw new IOException("Could not make " + gradlew + " executable"); + } + if (shape == Shape.BACKEND_ONLY) { + writeBackendFiles(dir, null, ""); + } + } + + /// Writes a backend skeleton into `backendDir`: its `application*.properties` + /// and, when `packageName` is not null, an `Api` controller and the `Greeter` + /// service it is given, in that package. + /// + /// @param taskPrefix how the backend's tasks are addressed from the root: + /// `:backend:` for a subproject, empty for a backend-only project + public static void writeBackendFiles(File backendDir, String packageName, String taskPrefix) throws IOException { + write(new File(backendDir, "application.properties"), + text("backend/application.properties.txt").replace("__BACKEND__", taskPrefix)); + write(new File(backendDir, "application-dev.properties"), + text("backend/application-dev.properties.txt").replace("__BACKEND__", taskPrefix)); + if (packageName != null) { + write(new File(backendDir, "src/main/java/" + packageName.replace('.', '/') + "/Api.java"), + text("backend/Api.java.txt").replace("${package}", packageName) + .replace("__BACKEND__", taskPrefix)); + write(new File(backendDir, "src/main/java/" + packageName.replace('.', '/') + "/Greeter.java"), + text("backend/Greeter.java.txt").replace("${package}", packageName)); + } + } + + private static void write(File f, String text) throws IOException { + writeBinary(f, text.getBytes(StandardCharsets.UTF_8)); + } + + private static void writeBinary(File f, byte[] content) throws IOException { + if (f.exists()) { + return; + } + File parent = f.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs() && !parent.isDirectory()) { + throw new IOException("Could not create " + parent); + } + OutputStream out = new FileOutputStream(f); + try { + out.write(content); + } finally { + out.close(); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLOperationModel.java b/maven/build-engine/src/main/java/com/codename1/maven/GraphQLOperationModel.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLOperationModel.java rename to maven/build-engine/src/main/java/com/codename1/maven/GraphQLOperationModel.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLSchemaModel.java b/maven/build-engine/src/main/java/com/codename1/maven/GraphQLSchemaModel.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GraphQLSchemaModel.java rename to maven/build-engine/src/main/java/com/codename1/maven/GraphQLSchemaModel.java diff --git a/maven/build-engine/src/main/java/com/codename1/maven/GuiSourcesGenerator.java b/maven/build-engine/src/main/java/com/codename1/maven/GuiSourcesGenerator.java new file mode 100644 index 00000000000..4a81c96d25d --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/GuiSourcesGenerator.java @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2012, 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.maven; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; +import org.apache.commons.io.FileUtils; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.StringWriter; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.LinkedList; +import java.util.function.Function; +import org.apache.commons.text.StringEscapeUtils; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import static com.codename1.maven.PathUtil.path; + +/// Generates Java sources from a project's visual definitions: the legacy GUI +/// builder's XML (`src/main/guibuilder`, through the build client) and CodeRAD +/// view templates (`src/main/rad/views`, into an `Abstract` class each). +/// +/// The body of the Maven plugin's `generate-gui-sources` goal, shared with the +/// Gradle plugin's `generateGuiSources` task. +public final class GuiSourcesGenerator { + private final Log log; + private final File projectDir; + private final File radViewsDir; + private final File radGeneratedSourcesDir; + private final File generatedSourcesDir; + + /// @param projectDir the directory holding the settings file + /// @param radViewsDir the CodeRAD view templates + /// @param radGeneratedSourcesDir where the view classes go; a source root + /// @param generatedSourcesDir where the XML schemas the templates reference go + public GuiSourcesGenerator(Log log, File projectDir, File radViewsDir, File radGeneratedSourcesDir, + File generatedSourcesDir) { + this.log = log; + this.projectDir = projectDir; + this.radViewsDir = radViewsDir; + this.radGeneratedSourcesDir = radGeneratedSourcesDir; + this.generatedSourcesDir = generatedSourcesDir; + } + + /// Runs the build client's legacy GUI builder generator over `guiDir`, + /// writing into `srcDir`. + public void generateLegacyGui(File buildClientJar, File srcDir, File guiDir) throws BuildExecutionException { + System.setProperty("javax.xml.bind.context.factory", "com.sun.xml.bind.v2.ContextFactory"); + try (URLClassLoader classLoader = new URLClassLoader(new URL[]{buildClientJar.toURI().toURL()}, + GuiSourcesGenerator.class.getClassLoader())) { + Class clazz = classLoader.loadClass("com.codename1.build.client.GenerateGuiSources"); + Object g = clazz.getDeclaredConstructor().newInstance(); + clazz.getMethod("setSrcDir", File.class).invoke(g, srcDir); + clazz.getMethod("setGuiDir", File.class).invoke(g, guiDir); + clazz.getMethod("execute").invoke(g); + } catch (Exception e) { + throw new BuildExecutionException("Failed to load and execute GenerateGuiSources", e); + } + } + + /// Generates the view classes for every template under the views directory. + /// + /// @return whether there is a views directory (and so a generated source root) + public boolean generateRadViews() throws BuildFailureException { + File radViews = getRADViewsDirectory(); + log.debug("Looking for views in " + radViews); + if (!radViews.isDirectory()) { + return false; + } + Exception res = forEach(radViews, child -> { + if (!child.getName().endsWith(".xml")) { + return null; + } + File destClassFile = getDestClassForRADView(child); + log.debug("Found view " + child + ". Checking against " + destClassFile); + if (!destClassFile.exists() || child.lastModified() > destClassFile.lastModified()) { + try { + generateRADViewClass(child); + } catch (IOException ex) { + return new BuildFailureException("Failed to generate class for RAD fragment XML file " + child, ex); + } + } + return null; + }); + if (res != null) { + throw new BuildFailureException("Failed to compile RAD views:" + res.getMessage(), res); + } + return true; + } + + private File getRADViewsDirectory() { + return radViewsDir; + } + + private File getRADGeneratedSourcesDirectory() { + return radGeneratedSourcesDir; + } + + private static Exception forEach(File root, Function callback) { + Exception res = callback.apply(root); + if (res != null) return res; + if (root.isDirectory()) { + for (File child : root.listFiles()) { + res = forEach(child, callback); + if (res != null) return res; + } + } + return null; + + } + + private File getDestClassForRADView(File viewXMLFile) { + String ext = viewXMLFile.getName().substring(viewXMLFile.getName().lastIndexOf(".")); + String base = viewXMLFile.getName().substring(0, viewXMLFile.getName().lastIndexOf(".")); + File viewsDirectory = getRADViewsDirectory(); + + int levels = 0; + LinkedList pathParts = new LinkedList(); + File f = viewXMLFile.getParentFile(); + while (f != null && !f.equals(viewsDirectory)) { + pathParts.addFirst(f.getName()); + f = f.getParentFile(); + } + StringBuilder pathSb = new StringBuilder(); + for (String part : pathParts) { + pathSb.append(part).append(File.separator); + } + + File genSrcDir = new File(getRADGeneratedSourcesDirectory(), pathSb.substring(0, pathSb.length()-1)); + + File out = new File(genSrcDir, base + ".java"); + out = new File(out.getParentFile(), "Abstract" + out.getName()); + return out; + + } + + private String getPackageForRADView(File viewXMLFile) { + String ext = viewXMLFile.getName().substring(viewXMLFile.getName().lastIndexOf(".")); + String base = viewXMLFile.getName().substring(0, viewXMLFile.getName().lastIndexOf(".")); + File viewsDirectory = getRADViewsDirectory(); + + int levels = 0; + LinkedList pathParts = new LinkedList(); + File f = viewXMLFile.getParentFile(); + while (f != null && !f.equals(viewsDirectory)) { + pathParts.addFirst(f.getName()); + f = f.getParentFile(); + } + StringBuilder pathSb = new StringBuilder(); + for (String part : pathParts) { + pathSb.append(part).append("."); + } + return pathSb.substring(0, pathSb.length()-1); + } + + private void generateRADViewClass(File xmlViewFile) throws IOException { + parentEntityViewClass = "AbstractEntityView"; + viewModelType = "Entity"; + log.debug("Generating RAD View for XML template "+xmlViewFile); + StringBuilder sb = new StringBuilder(); + + String packageName = getPackageForRADView(xmlViewFile); + String className = xmlViewFile.getName().substring(0, xmlViewFile.getName().indexOf(".")); + + className = "Abstract" + className; + + String radViewString = FileUtils.readFileToString(xmlViewFile, "utf-8"); + generateSchemaFor(xmlViewFile, radViewString); + radViewString = addElementIdentifiersToXML(radViewString); + + if (!packageName.isEmpty()) { + sb.append("package ").append(packageName).append(";\n"); + } + sb.append("import com.codename1.rad.annotations.RAD;\n"); + sb.append("import com.codename1.rad.ui.AbstractEntityView;\n"); + sb.append("import com.codename1.rad.ui.EntityView;\n"); + sb.append("import com.codename1.rad.models.Entity;\n"); + sb.append("import com.codename1.rad.nodes.Node;\n"); + sb.append("import com.codename1.io.CharArrayReader;\n"); + sb.append("import com.codename1.rad.ui.ViewContext;\n"); + sb.append(importStatements); + sb.append("@RAD\n"); + String parentClassName = parentEntityViewClass; + if (parentClassName.equals("AbstractEntityView")) { + parentClassName += ""; + } + sb.append("public abstract class ").append(className).append(" extends ").append(parentClassName).append(" {\n"); + sb.append(" private static final String FRAGMENT_XML=\""); + sb.append(StringEscapeUtils.escapeJava(radViewString)); + sb.append("\";\n"); + sb.append(" public ").append(className).append("(ViewContext context) {\n"); + sb.append(" super(context);\n"); + sb.append(" }\n\n"); + + sb.append("}\n"); + + + + + + + File destFile = getDestClassForRADView(xmlViewFile); + if (!destFile.getParentFile().exists()) { + destFile.getParentFile().mkdirs(); + } + log.debug("Updating "+destFile); + FileUtils.writeStringToFile(destFile, sb.toString(), "utf-8"); + } + + private static final String RAD_XML_NAMESPACE = "http://www.codenameone.com/rad"; + + private String parentEntityViewClass = "AbstractEntityView"; + private String viewModelType = "Entity"; + + + private void generateSchemaFor(File xmlViewFile, String contents) throws IOException { + File generatedSources = generatedSourcesDir; + File xmlSchemasDirectory = new File(generatedSources, "rad" + File.separator + "xmlSchemas"); + String packageName = getPackageForRADView(xmlViewFile); + String baseName = xmlViewFile.getName(); + baseName = baseName.substring(0, baseName.lastIndexOf(".")); + File actualXsdFile = new File(xmlSchemasDirectory, packageName.replace('.', File.separatorChar) + File.separator + baseName + ".xsd"); + File xsdAliasFile = new File(xmlViewFile.getParentFile(), baseName + ".xsd"); + if (!xsdAliasFile.exists()) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\n"); + sb.append(" \n"); + sb.append("\n"); + log.info("Writing XSD alias file at "+xsdAliasFile); + FileUtils.writeStringToFile(xsdAliasFile, sb.toString(), "UTF-8"); + + } + if (!contents.contains("xsi:noNamespaceSchemaLocation=\"") || !contents.contains("xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"")) { + int rootTagStart = contents.indexOf("?>"); + if (rootTagStart < 0) { + log.info("Not adding schema declaration to "+xmlViewFile+" because it failed to find the root element. The file may be malformed."); + return; + } + rootTagStart = contents.indexOf("<", rootTagStart); + if (rootTagStart < 0) { + log.info("Not adding schema declaration to "+xmlViewFile+" because it failed to find the root element. The file may be malformed."); + return; + } + int rootTagEnd = contents.indexOf(">", rootTagStart); + if (rootTagEnd < 0) { + log.info("Not adding schema declaration to "+xmlViewFile+" because it failed to find the close of the root element. The file may be malformed."); + } + String toInject = !contents.contains("xsi:noNamespaceSchemaLocation=\"") ? " xsi:noNamespaceSchemaLocation=\"" + baseName + ".xsd\"" : ""; + if (!contents.contains("xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"")) { + toInject += " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\""; + } + if (!contents.substring(0, rootTagEnd).contains("xsi:xsi:noNamespaceSchemaLocation") || !contents.substring(0, rootTagEnd).contains("xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"")) { + contents = contents.substring(0, rootTagEnd) + toInject + contents.substring(rootTagEnd); + FileUtils.writeStringToFile(xmlViewFile, contents, "utf-8"); + log.info("Injected schema declaration into document element of " + xmlViewFile); + } + } + + + + + + } + + private StringBuilder importStatements = new StringBuilder(); + + private String addElementIdentifiersToXML(String xml) throws IOException { + try { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + //an instance of builder to parse the specified xml file + DocumentBuilder db = dbf.newDocumentBuilder(); + Document doc = db.parse(new ByteArrayInputStream(xml.getBytes("utf-8"))); + class Context { + int index=0; + void crawl(org.w3c.dom.Element el) { + if (index == 0) { + if (el.hasAttribute("rad-extends")) { + parentEntityViewClass = el.getAttribute("rad-extends"); + } + if (el.hasAttribute("rad-model")) { + viewModelType = el.getAttribute("rad-model"); + } + + } + if (el.getTagName().equalsIgnoreCase("import")) { + importStatements.append(el.getTextContent()).append("\n"); + } + el.setAttribute("rad-id", String.valueOf(index++)); + NodeList children = el.getChildNodes(); + int len = children.getLength(); + for (int i=0; i 0) { @@ -46,13 +72,13 @@ static int getRuntimeMajorVersion(int defaultValue) { } /** - * Aborts the current Maven goal with a friendly, actionable message when the JVM + * Aborts the current goal or task with a friendly, actionable message when the JVM * Maven is running on is older than the supplied minimum. * * @param minimumMajorVersion smallest acceptable major version (e.g. 11) * @param operationLabel short description of what the user was trying to do (used in the error message) */ - static void requireRuntimeJavaVersion(int minimumMajorVersion, String operationLabel) throws MojoFailureException { + public static void requireRuntimeJavaVersion(int minimumMajorVersion, String operationLabel) throws BuildFailureException { int current = getRuntimeMajorVersion(-1); if (current >= minimumMajorVersion) { return; @@ -72,6 +98,6 @@ static void requireRuntimeJavaVersion(int minimumMajorVersion, String operationL msg.append(".\n\n"); msg.append("Install JDK ").append(minimumMajorVersion).append(" or newer (e.g. Eclipse Temurin\n") .append("from https://adoptium.net), point JAVA_HOME at it, and re-run this goal.\n"); - throw new MojoFailureException(msg.toString()); + throw new BuildFailureException(msg.toString()); } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/LibraryHintMerger.java b/maven/build-engine/src/main/java/com/codename1/maven/LibraryHintMerger.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/LibraryHintMerger.java rename to maven/build-engine/src/main/java/com/codename1/maven/LibraryHintMerger.java diff --git a/maven/build-engine/src/main/java/com/codename1/maven/NativeInterfaces.java b/maven/build-engine/src/main/java/com/codename1/maven/NativeInterfaces.java new file mode 100644 index 00000000000..c83749b95ab --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/NativeInterfaces.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2012, 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.maven; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; +import com.codename1.project.NativePlatform; +import com.codename1.project.ProjectLayout; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Finds a project's native interfaces and generates or checks their platform +/// implementations. +/// +/// Where the implementations go is the project layout's business: the platform +/// modules of a Maven project, `src//` in a Gradle one. Nothing +/// here creates a platform directory until a stub is written into it, so a +/// project that never declares a native interface never gets one. +public final class NativeInterfaces { + private static final String NATIVE_INTERFACE = "com/codename1/system/NativeInterface"; + + private final Log log; + private final List classesDirs; + private final List classpath; + + /// @param classesDir the project's compiled classes + /// @param classpath the compile classpath the interfaces are loaded with + public NativeInterfaces(Log log, File classesDir, List classpath) { + this(log, Collections.singletonList(classesDir), classpath); + } + + /// @param classesDirs every directory of the project's compiled classes -- + /// Gradle compiles Java and Kotlin into separate ones + /// @param classpath the compile classpath the interfaces are loaded with + public NativeInterfaces(Log log, List classesDirs, List classpath) { + this.log = log; + this.classesDirs = new ArrayList(classesDirs); + this.classpath = classpath == null ? Collections.emptyList() : classpath; + } + + /// The binary names of every type in the classes directory that directly + /// extends `NativeInterface`. + public List find() throws IOException { + List out = new ArrayList(); + for (File dir : classesDirs) { + scan(dir, out); + } + Collections.sort(out); + return out; + } + + private static void scan(File dir, final List out) throws IOException { + File[] list = dir.listFiles(); + if (list == null) { + return; + } + for (File current : list) { + if (current.isDirectory()) { + scan(current, out); + continue; + } + if (!current.getName().endsWith(".class")) { + continue; + } + InputStream is = new FileInputStream(current); + try { + new ClassReader(is).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public void visit(int version, int access, String name, String signature, String superName, + String[] interfaces) { + boolean found = NATIVE_INTERFACE.equals(superName); + if (interfaces != null) { + for (String s : interfaces) { + found |= NATIVE_INTERFACE.equals(s); + } + } + if (found) { + out.add(name.replace('/', '.')); + } + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } finally { + is.close(); + } + } + } + + private URLClassLoader loader() throws IOException { + List urls = new ArrayList(); + for (File dir : classesDirs) { + urls.add(dir.toURI().toURL()); + } + for (String element : classpath) { + File f = new File(element); + if (f.exists() && !classesDirs.contains(f)) { + urls.add(f.toURI().toURL()); + } + } + return new URLClassLoader(urls.toArray(new URL[0]), NativeInterfaces.class.getClassLoader()); + } + + private StubGenerator generatorFor(ClassLoader cl, String name, boolean swift, boolean kotlin) + throws BuildExecutionException { + Class c; + try { + c = cl.loadClass(name); + } catch (ClassNotFoundException | LinkageError ex) { + throw new BuildExecutionException("Could not load native interface " + name + + ". Compile the project first.", ex); + } + StubGenerator g = StubGenerator.create(log, c, swift, kotlin); + String problem = g.verify(); + if (problem != null) { + throw new BuildFailureException("Native interface " + name + " is not valid: " + problem); + } + return g; + } + + /// Writes stubs for every native interface, or only `only` (a simple or + /// binary name) when it is not null, into `layout`. + /// + /// An interface whose implementation files already exist is left alone + /// unless `overwrite` is set, as the Maven goal always did. + /// + /// @param overwriteHint how to ask for an overwrite, for the message + /// @return the files written + public List generate(ProjectLayout layout, String only, boolean swift, boolean kotlin, boolean overwrite, + String overwriteHint) throws BuildExecutionException { + List written = new ArrayList(); + List names; + try { + names = find(); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to scan " + classesDirs + " for native interfaces", ex); + } + boolean matched = false; + URLClassLoader cl = null; + try { + cl = loader(); + for (String name : names) { + if (only != null && !only.equals(name) && !name.endsWith("." + only)) { + continue; + } + matched = true; + StubGenerator g = generatorFor(cl, name, swift, kotlin); + List before = g.getExistingFiles(layout); + if (!overwrite && !before.isEmpty()) { + log.warn("Native interface files already exist for " + name + ". Skipping generation."); + for (File f : before) { + log.warn("Existing file: " + f.getAbsolutePath()); + } + log.warn("To overwrite these files, run: " + overwriteHint); + continue; + } + g.generateCode(layout, overwrite); + for (File f : g.getExistingFiles(layout)) { + if (overwrite || !before.contains(f)) { + written.add(f); + } + } + } + } catch (IOException ex) { + throw new BuildExecutionException("Failed to generate native interface stubs", ex); + } finally { + closeQuietly(cl); + } + if (only != null && !matched) { + throw new BuildFailureException("No native interface named " + only + " was found in " + classesDirs + + ". Native interfaces found: " + names); + } + if (names.isEmpty()) { + log.info("No native interfaces found in " + classesDirs + ". Declare one by extending " + + "com.codename1.system.NativeInterface, compile, and run this again."); + } + return written; + } + + /// The implementation files `platform` is missing, one entry per native + /// interface that has none, naming the file to create. Empty when every + /// interface is implemented (or there are none). + public List missingImplementations(ProjectLayout layout, NativePlatform platform) + throws BuildExecutionException { + List out = new ArrayList(); + List names; + try { + names = find(); + } catch (IOException ex) { + throw new BuildExecutionException("Failed to scan " + classesDirs + " for native interfaces", ex); + } + if (names.isEmpty()) { + return out; + } + URLClassLoader cl = null; + try { + cl = loader(); + for (String name : names) { + StubGenerator g = generatorFor(cl, name, false, false); + if (!g.hasImplementation(layout, platform)) { + StringBuilder sb = new StringBuilder(name).append(" has no ").append(platform.id()) + .append(" implementation; missing "); + boolean first = true; + for (File f : g.requiredFiles(layout, platform)) { + if (f.exists()) { + continue; + } + if (!first) { + sb.append(" and "); + } + first = false; + sb.append(f.getPath()); + } + out.add(sb.toString()); + } + } + } catch (IOException ex) { + throw new BuildExecutionException("Failed to load native interfaces", ex); + } finally { + closeQuietly(cl); + } + return out; + } + + private static void closeQuietly(URLClassLoader cl) { + if (cl == null) { + return; + } + try { + cl.close(); + } catch (IOException ignored) { + // A class loader over local files; nothing to recover. + } + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/PathUtil.java b/maven/build-engine/src/main/java/com/codename1/maven/PathUtil.java new file mode 100644 index 00000000000..abfa3e22ae4 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/PathUtil.java @@ -0,0 +1,41 @@ +/* + * 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.maven; + +import java.io.File; + +/** + * Utility for creatings file paths. + */ +public class PathUtil { + public static String path(String... parts) { + StringBuilder sb = new StringBuilder(); + for (String part : parts) { + if (sb.length() > 0) { + sb.append(File.separator); + } + sb.append(part); + } + return sb.toString(); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProjectTemplate.java b/maven/build-engine/src/main/java/com/codename1/maven/ProjectTemplate.java similarity index 90% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProjectTemplate.java rename to maven/build-engine/src/main/java/com/codename1/maven/ProjectTemplate.java index 655bc0d65de..627f889ddd5 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProjectTemplate.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/ProjectTemplate.java @@ -1,3 +1,25 @@ +/* + * 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.maven; import com.codename1.ant.SortedProperties; diff --git a/maven/build-engine/src/main/java/com/codename1/maven/SimulatorSupport.java b/maven/build-engine/src/main/java/com/codename1/maven/SimulatorSupport.java new file mode 100644 index 00000000000..e56c4bb71d4 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/SimulatorSupport.java @@ -0,0 +1,154 @@ +/* + * 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.maven; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Properties; + +/// What the simulator needs from the build that launches it, shared by the Maven +/// plugin's `prepare-simulator-classpath` goal and the Gradle plugin's `run` +/// task. +/// +/// The simulator reads `simulator.properties` from the build directory's +/// `codenameone` folder at startup and copies every key into the system +/// properties, before the annotated build hints are published. +public final class SimulatorSupport { + /// The simulator's main class. + public static final String SIMULATOR_MAIN_CLASS = "com.codename1.impl.javase.Simulator"; + /// The CSS compiler CLI classpath live CSS reload forks with. + public static final String CSS_CLI_CLASSPATH_PROPERTY = "cn1.css.cli.classpath"; + /// The compile classpath hot reload recompiles against. The name predates + /// Gradle support and is kept because the simulator reads it. + public static final String COMPILE_CLASSPATH_PROPERTY = "cn1.maven.compileClasspathElements"; + /// The source directories hot reload watches, path-separated, when the build + /// knows better than the conventional layout (a Gradle `sourceSets` block). + /// The simulator reads the literal names; see SourceChangeWatcher. + public static final String SOURCE_ROOTS_PROPERTY = "cn1.hotReload.sourceRoots"; + /// Where javac writes the main classes, for hot reload's direct recompile. + public static final String JAVA_CLASSES_PROPERTY = "cn1.hotReload.javaClasses"; + /// Where the Kotlin compiler writes the main classes. See [JAVA_CLASSES_PROPERTY]. + public static final String KOTLIN_CLASSES_PROPERTY = "cn1.hotReload.kotlinClasses"; + /// The test source set's Java directory, where the test recorder saves. + public static final String TEST_ROOT_PROPERTY = "cn1.hotReload.testRoot"; + /// The encoding compileJava reads sources in, which the direct recompile uses. + public static final String ENCODING_PROPERTY = "cn1.hotReload.encoding"; + /// The live CSS reload inputs, outputs and merge file. + public static final String CSS_INPUT_PROPERTY = "codename1.css.compiler.args.input"; + /// See [CSS_INPUT_PROPERTY]. + public static final String CSS_OUTPUT_PROPERTY = "codename1.css.compiler.args.output"; + /// See [CSS_INPUT_PROPERTY]. + public static final String CSS_MERGE_PROPERTY = "codename1.css.compiler.args.merge"; + + private SimulatorSupport() { + } + + /// The file the simulator reads, under the build directory of the module + /// holding `codenameone_settings.properties`. + public static File simulatorPropertiesFile(File buildDir) { + return new File(new File(buildDir, "codenameone"), "simulator.properties"); + } + + /// The `codename1.arg.*` entries of `userProperties`, which is what the + /// command line actually passed. + /// + /// Only those, never every hint in the settings file. The simulator reads a + /// system property before the file, so publishing the file's own hints that + /// way would outrank the file itself and hide the both-declared conflict + /// the simulator is supposed to report. + public static Properties commandLineBuildHints(Properties userProperties) { + Properties out = new Properties(); + if (userProperties == null) { + return out; + } + for (String key : userProperties.stringPropertyNames()) { + if (key.startsWith("codename1.arg.")) { + out.setProperty(key, userProperties.getProperty(key)); + } + } + return out; + } + + /// Puts the command line's build hints and the effective entry point into + /// the simulator's properties. + /// + /// The simulator is started with a fixed argument list, so no build hint + /// given on the command line reaches it any other way. It publishes an + /// annotated hint only where no system property already claims the key, so + /// this is what keeps a command-line hint winning locally the way it does in + /// a device build. + /// + /// The entry point travels too: it is what annotation processing stamped the + /// build hint manifest with, and a simulator reading `codename1.mainName` + /// out of the settings file disagreed with that stamp on an overridden build. + public static void addCommandLineOverrides(Properties simulatorProperties, + Properties userProperties, Properties effective) { + Properties commandLine = commandLineBuildHints(userProperties); + for (String key : commandLine.stringPropertyNames()) { + simulatorProperties.setProperty(key, commandLine.getProperty(key)); + } + copyEffective(simulatorProperties, effective, "codename1.mainName"); + copyEffective(simulatorProperties, effective, "codename1.packageName"); + } + + private static void copyEffective(Properties simulatorProperties, Properties effective, String key) { + String value = effective == null ? null : effective.getProperty(key); + if (value != null && value.trim().length() > 0) { + simulatorProperties.setProperty(key, value.trim()); + } + } + + /// Writes `simulator.properties`. + /// + /// @param compileClasspath the compile classpath, path-separated, or null + /// @param cssCliClasspath the CSS compiler CLI classpath, or null + /// @param userProperties the command-line properties, or null + /// @param effective the effective settings (file plus command line), or null + public static void writeSimulatorProperties(File file, String compileClasspath, String cssCliClasspath, + Properties userProperties, Properties effective) + throws IOException { + Properties simulatorProperties = new Properties(); + if (compileClasspath != null) { + simulatorProperties.setProperty(COMPILE_CLASSPATH_PROPERTY, compileClasspath); + } + if (cssCliClasspath != null) { + simulatorProperties.setProperty(CSS_CLI_CLASSPATH_PROPERTY, cssCliClasspath); + } + addCommandLineOverrides(simulatorProperties, userProperties, effective); + if (System.getProperty("ffmpeg.dir") != null) { + simulatorProperties.setProperty("ffmpeg.dir", System.getProperty("ffmpeg.dir")); + } + File dir = file.getParentFile(); + if (dir != null && !dir.isDirectory() && !dir.mkdirs() && !dir.isDirectory()) { + throw new IOException("Could not create " + dir); + } + OutputStream fos = new FileOutputStream(file); + try { + simulatorProperties.store(fos, "Updated simulator properties by the Codename One build"); + } finally { + fos.close(); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/StubGenerator.java b/maven/build-engine/src/main/java/com/codename1/maven/StubGenerator.java similarity index 84% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/StubGenerator.java rename to maven/build-engine/src/main/java/com/codename1/maven/StubGenerator.java index e306598295f..313cfb25943 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/StubGenerator.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/StubGenerator.java @@ -22,7 +22,7 @@ */ package com.codename1.maven; -import org.apache.maven.plugin.logging.Log; +import com.codename1.build.Log; import java.io.File; import java.io.FileOutputStream; @@ -142,33 +142,64 @@ private boolean checkDuplicateName(Method[] mtds, int offset) { return true; } - private void initFileNames(File destination) { + /// Where each platform's implementation goes: the Maven module layout + /// (`//src/main/`) when given a directory, or the + /// project's own [com.codename1.project.ProjectLayout] -- so a Gradle + /// project gets `src//`. + private interface NativeDirs { + File dir(com.codename1.project.NativePlatform platform); + } + + private static NativeDirs mavenModules(final File destination) { + return new NativeDirs() { + @Override + public File dir(com.codename1.project.NativePlatform platform) { + return new File(path(destination.getAbsolutePath(), platform.id(), "src", "main", platform.language())); + } + }; + } + + private static NativeDirs layoutDirs(final com.codename1.project.ProjectLayout layout) { + return new NativeDirs() { + @Override + public File dir(com.codename1.project.NativePlatform platform) { + return layout.nativeSourceDir(platform); + } + }; + } + + // Names only; nothing is created until a file is written, so asking which + // files exist never leaves empty platform directories behind. + private void initFileNames(NativeDirs dirs) { String className = nativeInterface.getName().replace('.', File.separatorChar) + "Impl.java"; - androidFile = new File(path(destination.getAbsolutePath(), "android", "src", "main", "java", className)); - androidFile.getParentFile().mkdirs(); - javaseFile = new File(path(destination.getAbsolutePath(), "javase", "src", "main", "java", className)); - javaseFile.getParentFile().mkdirs(); + File android = dirs.dir(com.codename1.project.NativePlatform.ANDROID); + androidFile = new File(android, className); + javaseFile = new File(dirs.dir(com.codename1.project.NativePlatform.JAVASE), className); // Native C (ParparVM "clean" target) implementation stubs for the no-JVM - // desktop ports: native Windows (win32 -> win/src/main/c) and native Linux - // (linux/src/main/c). These are plain C: the + // desktop ports: native Windows and native Linux. These are plain C: the // translator emits a C function per native method (the mangled name the // generated XxxImplCodenameOne produces) which the developer defines here. String cFileName = nativeInterface.getName().replace('.', File.separatorChar) + "ImplCodenameOne.c"; - winCFile = new File(path(destination.getAbsolutePath(), "win", "src", "main", "c", cFileName)); - winCFile.getParentFile().mkdirs(); - linuxCFile = new File(path(destination.getAbsolutePath(), "linux", "src", "main", "c", cFileName)); - linuxCFile.getParentFile().mkdirs(); + winCFile = new File(dirs.dir(com.codename1.project.NativePlatform.WIN), cFileName); + linuxCFile = new File(dirs.dir(com.codename1.project.NativePlatform.LINUX), cFileName); String iosFilename = nativeInterface.getName().replace('.', '_') + "Impl."; - iosHFile = new File(path(destination.getAbsolutePath(), "ios", "src", "main", "objectivec", iosFilename+"h")); - iosMFile = new File(path(destination.getAbsolutePath(), "ios", "src", "main", "objectivec", iosFilename+"m")); - iosSwiftFile = new File(path(destination.getAbsolutePath(), "ios", "src", "main", "objectivec", iosFilename+"swift")); - iosMFile.getParentFile().mkdirs(); - androidKotlinFile = new File(path(destination.getAbsolutePath(), "android", "src", "main", "java", nativeInterface.getName().replace('.', File.separatorChar) + "Impl.kt")); + File ios = dirs.dir(com.codename1.project.NativePlatform.IOS); + iosHFile = new File(ios, iosFilename + "h"); + iosMFile = new File(ios, iosFilename + "m"); + iosSwiftFile = new File(ios, iosFilename + "swift"); + androidKotlinFile = new File(android, nativeInterface.getName().replace('.', File.separatorChar) + "Impl.kt"); + + jsFile = new File(dirs.dir(com.codename1.project.NativePlatform.JAVASCRIPT), + nativeInterface.getName().replace('.', '_') + ".js"); + } - jsFile = new File(path(destination.getAbsolutePath(), "javascript", "src", "main", "javascript", nativeInterface.getName().replace('.', '_') + ".js")); - jsFile.getParentFile().mkdirs(); + private static void mkParent(File f) { + File parent = f.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } } /** @@ -178,11 +209,75 @@ public boolean isFilesExist(File destination) { return !getExistingFiles(destination).isEmpty(); } + /// The implementation files that already exist in `layout`. + public List getExistingFiles(com.codename1.project.ProjectLayout layout) { + return existingFiles(layoutDirs(layout)); + } + + /// Generates into `layout`'s native source directories. + public void generateCode(com.codename1.project.ProjectLayout layout, boolean overwrite) throws IOException { + generateCode(layoutDirs(layout), overwrite); + } + + /// Every implementation file this interface needs for `platform` in + /// `layout`, whether or not it exists. Swift and Kotlin are alternatives to + /// the Objective-C and Java files, so they are not required. + public List requiredFiles(com.codename1.project.ProjectLayout layout, + com.codename1.project.NativePlatform platform) { + initFileNames(layoutDirs(layout)); + List out = new ArrayList<>(); + switch (platform) { + case ANDROID: + out.add(androidFile); + break; + case JAVASE: + out.add(javaseFile); + break; + case IOS: + out.add(iosHFile); + out.add(iosMFile); + break; + case JAVASCRIPT: + out.add(jsFile); + break; + case WIN: + out.add(winCFile); + break; + default: + out.add(linuxCFile); + break; + } + return out; + } + + /// Whether `platform` has an implementation in `layout`: the required files, + /// or for iOS and Android the Swift or Kotlin alternative. + public boolean hasImplementation(com.codename1.project.ProjectLayout layout, + com.codename1.project.NativePlatform platform) { + initFileNames(layoutDirs(layout)); + if (platform == com.codename1.project.NativePlatform.IOS && iosSwiftFile.exists()) { + return true; + } + if (platform == com.codename1.project.NativePlatform.ANDROID && androidKotlinFile.exists()) { + return true; + } + for (File f : requiredFiles(layout, platform)) { + if (!f.exists()) { + return false; + } + } + return true; + } + /** * Returns all existing native implementation files for this interface. */ public List getExistingFiles(File destination) { - initFileNames(destination); + return existingFiles(mavenModules(destination)); + } + + private List existingFiles(NativeDirs dirs) { + initFileNames(dirs); List out = new ArrayList<>(); if (androidFile.exists()) { out.add(androidFile); @@ -219,15 +314,21 @@ public List getExistingFiles(File destination) { * Generates the code to the given base directory */ public void generateCode(File destination, boolean overwrite) throws IOException { - initFileNames(destination); + generateCode(mavenModules(destination), overwrite); + } + + private void generateCode(NativeDirs dirs, boolean overwrite) throws IOException { + initFileNames(dirs); if(overwrite || !androidFile.exists()) { log.info("Writing "+androidFile); + mkParent(androidFile); generateJavaFile(androidFile, "android.view.View", false); } else { log.debug(androidFile+" already exists. Skipping"); } if(overwrite || !javaseFile.exists()) { log.info("Writing "+javaseFile); + mkParent(javaseFile); generateJavaFile(javaseFile, "com.codename1.ui.PeerComponent", true); } else { log.debug(javaseFile+" already exists. Skipping"); @@ -238,12 +339,14 @@ public void generateCode(File destination, boolean overwrite) throws IOException // implements the same C functions; only the directory differs. if(overwrite || !winCFile.exists()) { log.info("Writing "+winCFile); + mkParent(winCFile); generateCleanCFile(winCFile); } else { log.debug(winCFile+" already exists. Skipping"); } if(overwrite || !linuxCFile.exists()) { log.info("Writing "+linuxCFile); + mkParent(linuxCFile); generateCleanCFile(linuxCFile); } else { log.debug(linuxCFile+" already exists. Skipping"); @@ -252,6 +355,7 @@ public void generateCode(File destination, boolean overwrite) throws IOException if(overwrite || !(iosHFile.exists() || iosMFile.exists())) { log.info("Writing "+iosHFile); log.info("Writing "+iosMFile); + mkParent(iosHFile); generateIOSFiles(); } else { log.debug(iosHFile+" already exists. Skipping"); @@ -259,7 +363,8 @@ public void generateCode(File destination, boolean overwrite) throws IOException if(generateIosSwift) { if(overwrite || !iosSwiftFile.exists()) { log.info("Writing " + iosSwiftFile); - generateIOSSwiftFile(); + mkParent(iosSwiftFile); + generateIOSSwiftFile(); } else { log.debug(iosSwiftFile + " already exists. Skipping"); } @@ -267,13 +372,15 @@ public void generateCode(File destination, boolean overwrite) throws IOException if(generateAndroidKotlin) { if(overwrite || !androidKotlinFile.exists()) { log.info("Writing " + androidKotlinFile); - generateAndroidKotlinFile(); + mkParent(androidKotlinFile); + generateAndroidKotlinFile(); } else { log.debug(androidKotlinFile + " already exists. Skipping"); } } if(overwrite || !(jsFile.exists())) { log.info("Writing "+jsFile); + mkParent(jsFile); generateJavaScriptFile(); } else { log.debug(jsFile+" already exists. Skipping"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SvgTranscodeRunner.java b/maven/build-engine/src/main/java/com/codename1/maven/SvgTranscodeRunner.java similarity index 96% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SvgTranscodeRunner.java rename to maven/build-engine/src/main/java/com/codename1/maven/SvgTranscodeRunner.java index aa8f838a6fc..99f9cd00b19 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SvgTranscodeRunner.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/SvgTranscodeRunner.java @@ -27,8 +27,8 @@ import com.codename1.svg.transcoder.SVGTranscoder; import com.codename1.svg.transcoder.SVGTranscoder.GeneratedClass; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugin.logging.Log; +import com.codename1.builders.BuildException; +import com.codename1.build.Log; import java.io.BufferedWriter; import java.io.File; @@ -191,7 +191,7 @@ public boolean hasVectorSources() { * not a matching source file exists. This is what the build has to warn * about when no registry was generated: each one is a 1x1 transparent * placeholder in the compiled theme, which renders as nothing. */ - public Set cssReferencedVectorNames() throws MojoExecutionException { + public Set cssReferencedVectorNames() throws BuildException { return scanCssHints().keySet(); } @@ -202,7 +202,7 @@ public static File registrySourceFile(File outputDir, String svgPackage) { } /** Transcode every vector source, emit the registry and the CSS placeholders. */ - public void run() throws MojoExecutionException { + public void run() throws BuildException { transcodedCount = 0; List svgs = locateSvgs(); Map cssHints = scanCssHints(); @@ -252,7 +252,7 @@ public void run() throws MojoExecutionException { try { transcodeByFormat(fmt, svg, svgPackage, className, outFile); } catch (Exception ex) { - throw new MojoExecutionException("Failed to transcode " + svg, ex); + throw new BuildException("Failed to transcode " + svg, ex); } } transcodedCount++; @@ -335,7 +335,7 @@ private static final class CssHint { * filename -> CssHint. The CSS hint vocabulary is the SVG transcoder's * -- the same {@code cn1-svg-width} property sizes Lottie outputs too * because both share the {@code GeneratedSVGImage} base. */ - private Map scanCssHints() throws MojoExecutionException { + private Map scanCssHints() throws BuildException { Map result = new HashMap(); File cssDir = new File(basedir, "src/main/css"); if (!cssDir.isDirectory()) { @@ -361,7 +361,7 @@ private Map scanCssHints() throws MojoExecutionException { try { content = readFile(css); } catch (IOException ex) { - throw new MojoExecutionException("Failed to read CSS " + css, ex); + throw new BuildException("Failed to read CSS " + css, ex); } Matcher blocks = blockPattern.matcher(content); while (blocks.find()) { @@ -404,7 +404,7 @@ private static float parsePositiveFloat(String s) { * compiler's url(...) resolver succeeds. We also put the original * SVG-named copy alongside in case the compiler insists on the .svg * extension. */ - private void emitPlaceholders(Set names) throws MojoExecutionException { + private void emitPlaceholders(Set names) throws BuildException { if (names.isEmpty()) { return; } @@ -418,7 +418,7 @@ private void emitPlaceholders(Set names) throws MojoExecutionException { try (FileOutputStream fos = new FileOutputStream(out)) { fos.write(PLACEHOLDER_PNG); } catch (IOException ex) { - throw new MojoExecutionException("Failed to write placeholder " + out, ex); + throw new BuildException("Failed to write placeholder " + out, ex); } } } @@ -426,7 +426,7 @@ private void emitPlaceholders(Set names) throws MojoExecutionException { } private void emitRegistry(File packageDir, List generated, long mtime) - throws MojoExecutionException { + throws BuildException { File registryFile = new File(packageDir, REGISTRY_CLASS_NAME + ".java"); if (registryFile.exists() && registryFile.lastModified() >= mtime) { log.debug("SVG registry up-to-date."); @@ -435,7 +435,7 @@ private void emitRegistry(File packageDir, List generated, long try (Writer w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(registryFile), "UTF-8"))) { SVGTranscoder.writeRegistry(svgPackage, REGISTRY_CLASS_NAME, generated, w); } catch (IOException ex) { - throw new MojoExecutionException("Failed to write SVG registry", ex); + throw new BuildException("Failed to write SVG registry", ex); } log.info("Wrote SVG registry " + registryFile.getName() + " with " + generated.size() + " image(s)"); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AbstractAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/AbstractAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AbstractAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/annotations/AbstractAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java rename to maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotationValues.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotationValues.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotationValues.java rename to maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotationValues.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ClassScanner.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java rename to maven/build-engine/src/main/java/com/codename1/maven/annotations/ClassScanner.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/FieldInfo.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java rename to maven/build-engine/src/main/java/com/codename1/maven/annotations/FieldInfo.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java similarity index 89% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java rename to maven/build-engine/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java index d4a31874bbd..8dd3f45e9a2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java @@ -50,6 +50,26 @@ public final class JavaSourceCompiler { private JavaSourceCompiler() { } + /// The project's compile classpath while [com.codename1.maven.AnnotationProcessing] + /// runs, appended to every compile below. Processors compile their generated + /// sources against the output directory, which under Maven holds every + /// project class. Under Gradle it does not: Kotlin and Java compile into + /// directories of their own, so a Kotlin `@RestClient` returning a Java type + /// generated code naming a class the compile could not see. Set here, at the + /// one call every processor makes, rather than in each processor. + private static final ThreadLocal> PROJECT_CLASSPATH = new ThreadLocal>(); + + /// Makes `classpath` part of every compile on this thread until + /// [#clearProjectClasspath()]. + public static void setProjectClasspath(List classpath) { + PROJECT_CLASSPATH.set(new ArrayList(classpath)); + } + + /// Undoes [#setProjectClasspath(List)]. + public static void clearProjectClasspath() { + PROJECT_CLASSPATH.remove(); + } + /// Compiles the given `fullyQualifiedName -> source` map into `.class` files /// rooted at `outputClassDir`. Adds `extraClasspath` (typically the plugin's /// own test-classes directory so the @Route + Form + Router stubs resolve). @@ -138,6 +158,10 @@ public static void compile(Map sources, File outputClassDir, } } if (extraClasspath != null) cp.addAll(extraClasspath); + List project = PROJECT_CLASSPATH.get(); + if (project != null) { + cp.addAll(project); + } fm.setLocation(StandardLocation.CLASS_PATH, cp); List compilationUnits = new ArrayList(); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/MethodInfo.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/MethodInfo.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/MethodInfo.java rename to maven/build-engine/src/main/java/com/codename1/maven/annotations/MethodInfo.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessingException.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessingException.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessingException.java rename to maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessingException.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessorContext.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessorContext.java similarity index 99% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessorContext.java rename to maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessorContext.java index 9402f8c976c..f350934201a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessorContext.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessorContext.java @@ -30,7 +30,7 @@ import java.util.Map; import java.util.Properties; -import org.apache.maven.plugin.logging.Log; +import com.codename1.build.Log; /// Shared state passed to every `AnnotationProcessor`. /// diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/AppIntentAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/AppIntentAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/AppIntentAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/AppIntentAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeanAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeanAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeanAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeanAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeans.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSettings.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSettings.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSettings.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSettings.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWeaver.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWeaver.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWeaver.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWeaver.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BuildHintAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BuildHintAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BuildHintAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/BuildHintAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/CronCompiler.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/CronCompiler.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/CronCompiler.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/CronCompiler.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GrpcClientAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/GrpcClientAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/GrpcClientAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/GrpcClientAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmEnhancer.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmEnhancer.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmEnhancer.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/OrmEnhancer.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestServerAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestServerAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestServerAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/RestServerAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RouteAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RouteAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RouteAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/RouteAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/TelemetryAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/TelemetryAnnotationProcessor.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/TelemetryAnnotationProcessor.java rename to maven/build-engine/src/main/java/com/codename1/maven/processors/TelemetryAnnotationProcessor.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSAppIntentsBuilder.java b/maven/build-engine/src/main/java/com/codename1/util/IOSAppIntentsBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSAppIntentsBuilder.java rename to maven/build-engine/src/main/java/com/codename1/util/IOSAppIntentsBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSCallDirectoryExtensionBuilder.java b/maven/build-engine/src/main/java/com/codename1/util/IOSCallDirectoryExtensionBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSCallDirectoryExtensionBuilder.java rename to maven/build-engine/src/main/java/com/codename1/util/IOSCallDirectoryExtensionBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSDocumentProviderExtensionBuilder.java b/maven/build-engine/src/main/java/com/codename1/util/IOSDocumentProviderExtensionBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSDocumentProviderExtensionBuilder.java rename to maven/build-engine/src/main/java/com/codename1/util/IOSDocumentProviderExtensionBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSNotificationContentExtensionBuilder.java b/maven/build-engine/src/main/java/com/codename1/util/IOSNotificationContentExtensionBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSNotificationContentExtensionBuilder.java rename to maven/build-engine/src/main/java/com/codename1/util/IOSNotificationContentExtensionBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java b/maven/build-engine/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java rename to maven/build-engine/src/main/java/com/codename1/util/IOSShareExtensionBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSVpnTunnelExtensionBuilder.java b/maven/build-engine/src/main/java/com/codename1/util/IOSVpnTunnelExtensionBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSVpnTunnelExtensionBuilder.java rename to maven/build-engine/src/main/java/com/codename1/util/IOSVpnTunnelExtensionBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWalletExtensionBuilder.java b/maven/build-engine/src/main/java/com/codename1/util/IOSWalletExtensionBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWalletExtensionBuilder.java rename to maven/build-engine/src/main/java/com/codename1/util/IOSWalletExtensionBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java b/maven/build-engine/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java rename to maven/build-engine/src/main/java/com/codename1/util/IOSWidgetExtensionBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java b/maven/build-engine/src/main/java/com/codename1/util/InviteAppClipBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java rename to maven/build-engine/src/main/java/com/codename1/util/InviteAppClipBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/MatterExtensionBuilder.java b/maven/build-engine/src/main/java/com/codename1/util/MatterExtensionBuilder.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/MatterExtensionBuilder.java rename to maven/build-engine/src/main/java/com/codename1/util/MatterExtensionBuilder.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/RichPropertiesReader.java b/maven/build-engine/src/main/java/com/codename1/util/RichPropertiesReader.java similarity index 77% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/RichPropertiesReader.java rename to maven/build-engine/src/main/java/com/codename1/util/RichPropertiesReader.java index 62b6a7c9284..5ae9667602a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/RichPropertiesReader.java +++ b/maven/build-engine/src/main/java/com/codename1/util/RichPropertiesReader.java @@ -1,6 +1,27 @@ +/* + * 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.util; -import com.codename1.maven.GenerateAppProjectMojo; import java.io.File; import java.io.FileInputStream; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/SurfaceKindFamilies.java b/maven/build-engine/src/main/java/com/codename1/util/SurfaceKindFamilies.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/com/codename1/util/SurfaceKindFamilies.java rename to maven/build-engine/src/main/java/com/codename1/util/SurfaceKindFamilies.java diff --git a/maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/Octal.java b/maven/build-engine/src/main/java/org/xeustechnologies/jtar/Octal.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/Octal.java rename to maven/build-engine/src/main/java/org/xeustechnologies/jtar/Octal.java diff --git a/maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarConstants.java b/maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarConstants.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarConstants.java rename to maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarConstants.java diff --git a/maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarEntry.java b/maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarEntry.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarEntry.java rename to maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarEntry.java diff --git a/maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarHeader.java b/maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarHeader.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarHeader.java rename to maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarHeader.java diff --git a/maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarInputStream.java b/maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarInputStream.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarInputStream.java rename to maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarInputStream.java diff --git a/maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarOutputStream.java b/maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarOutputStream.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarOutputStream.java rename to maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarOutputStream.java diff --git a/maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarUtils.java b/maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarUtils.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarUtils.java rename to maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarUtils.java diff --git a/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor b/maven/build-engine/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor rename to maven/build-engine/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/AndroidStudioProjectTemplate.zip b/maven/build-engine/src/main/resources/com/codename1/builders/AndroidStudioProjectTemplate.zip similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/AndroidStudioProjectTemplate.zip rename to maven/build-engine/src/main/resources/com/codename1/builders/AndroidStudioProjectTemplate.zip diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/CN1FirebaseMessagingService.javas b/maven/build-engine/src/main/resources/com/codename1/builders/CN1FirebaseMessagingService.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/CN1FirebaseMessagingService.javas rename to maven/build-engine/src/main/resources/com/codename1/builders/CN1FirebaseMessagingService.javas diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/CN1FirebaseMessagingService7.javas b/maven/build-engine/src/main/resources/com/codename1/builders/CN1FirebaseMessagingService7.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/CN1FirebaseMessagingService7.javas rename to maven/build-engine/src/main/resources/com/codename1/builders/CN1FirebaseMessagingService7.javas diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/androidx-artifact-mapping.csv b/maven/build-engine/src/main/resources/com/codename1/builders/androidx-artifact-mapping.csv similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/androidx-artifact-mapping.csv rename to maven/build-engine/src/main/resources/com/codename1/builders/androidx-artifact-mapping.csv diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/androidx-class-mapping.csv b/maven/build-engine/src/main/resources/com/codename1/builders/androidx-class-mapping.csv similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/androidx-class-mapping.csv rename to maven/build-engine/src/main/resources/com/codename1/builders/androidx-class-mapping.csv diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/car/CN1AndroidAutoBridge.java b/maven/build-engine/src/main/resources/com/codename1/builders/car/CN1AndroidAutoBridge.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/car/CN1AndroidAutoBridge.java rename to maven/build-engine/src/main/resources/com/codename1/builders/car/CN1AndroidAutoBridge.java diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/car/CN1CarAppService.java b/maven/build-engine/src/main/resources/com/codename1/builders/car/CN1CarAppService.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/car/CN1CarAppService.java rename to maven/build-engine/src/main/resources/com/codename1/builders/car/CN1CarAppService.java diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/car/CN1CarScreen.java b/maven/build-engine/src/main/resources/com/codename1/builders/car/CN1CarScreen.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/car/CN1CarScreen.java rename to maven/build-engine/src/main/resources/com/codename1/builders/car/CN1CarScreen.java diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/car/CN1CarSession.java b/maven/build-engine/src/main/resources/com/codename1/builders/car/CN1CarSession.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/car/CN1CarSession.java rename to maven/build-engine/src/main/resources/com/codename1/builders/car/CN1CarSession.java diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentEnumerator.swift b/maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentEnumerator.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentEnumerator.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentEnumerator.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentIndex.swift b/maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentIndex.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentIndex.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentIndex.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentItem.swift b/maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentItem.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentItem.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentItem.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentRemote.swift b/maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentRemote.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentRemote.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1DocumentRemote.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1FileProviderClassic.swift b/maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1FileProviderClassic.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1FileProviderClassic.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1FileProviderClassic.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1FileProviderExtension.swift b/maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1FileProviderExtension.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/documents/ios/CN1FileProviderExtension.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/documents/ios/CN1FileProviderExtension.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/health/CN1HealthConnectBridge.kt b/maven/build-engine/src/main/resources/com/codename1/builders/health/CN1HealthConnectBridge.kt similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/health/CN1HealthConnectBridge.kt rename to maven/build-engine/src/main/resources/com/codename1/builders/health/CN1HealthConnectBridge.kt diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/home/CN1MatterCommissioning.swifts b/maven/build-engine/src/main/resources/com/codename1/builders/home/CN1MatterCommissioning.swifts similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/home/CN1MatterCommissioning.swifts rename to maven/build-engine/src/main/resources/com/codename1/builders/home/CN1MatterCommissioning.swifts diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/home/MatterCommissioningBridge.javas b/maven/build-engine/src/main/resources/com/codename1/builders/home/MatterCommissioningBridge.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/home/MatterCommissioningBridge.javas rename to maven/build-engine/src/main/resources/com/codename1/builders/home/MatterCommissioningBridge.javas diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/intents/ios/CN1IntentBridge.swift b/maven/build-engine/src/main/resources/com/codename1/builders/intents/ios/CN1IntentBridge.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/intents/ios/CN1IntentBridge.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/intents/ios/CN1IntentBridge.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/intents/ios/CN1IntentHost.h b/maven/build-engine/src/main/resources/com/codename1/builders/intents/ios/CN1IntentHost.h similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/intents/ios/CN1IntentHost.h rename to maven/build-engine/src/main/resources/com/codename1/builders/intents/ios/CN1IntentHost.h diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/intents/ios/CN1IntentHost.m b/maven/build-engine/src/main/resources/com/codename1/builders/intents/ios/CN1IntentHost.m similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/intents/ios/CN1IntentHost.m rename to maven/build-engine/src/main/resources/com/codename1/builders/intents/ios/CN1IntentHost.m diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/intents/ios/CN1IntentSnippetView.swift b/maven/build-engine/src/main/resources/com/codename1/builders/intents/ios/CN1IntentSnippetView.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/intents/ios/CN1IntentSnippetView.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/intents/ios/CN1IntentSnippetView.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.javas b/maven/build-engine/src/main/resources/com/codename1/builders/maps/AppleMapProvider.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.javas rename to maven/build-engine/src/main/resources/com/codename1/builders/maps/AppleMapProvider.javas diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m b/maven/build-engine/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m rename to maven/build-engine/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/GoogleMapProvider.javas b/maven/build-engine/src/main/resources/com/codename1/builders/maps/GoogleMapProvider.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/GoogleMapProvider.javas rename to maven/build-engine/src/main/resources/com/codename1/builders/maps/GoogleMapProvider.javas diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/HuaweiMapProvider.javas b/maven/build-engine/src/main/resources/com/codename1/builders/maps/HuaweiMapProvider.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/HuaweiMapProvider.javas rename to maven/build-engine/src/main/resources/com/codename1/builders/maps/HuaweiMapProvider.javas diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/drawable/cn1_surface_rounded.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/drawable/cn1_surface_rounded.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/drawable/cn1_surface_rounded.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/drawable/cn1_surface_rounded.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_box.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_box.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_box.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_box.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_h.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_h.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_h.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_h.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_v.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_v.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_v.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_v.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_h.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_h.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_h.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_h.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_v.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_v.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_v.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_cell_weight1_v.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_chronometer.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_chronometer.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_chronometer.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_chronometer.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_column.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_column.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_column.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_column.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_center.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_center.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_center.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_center.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_fill.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_fill.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_fill.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_image_fill.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress_circular.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress_circular.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress_circular.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_progress_circular.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_row.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_row.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_row.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_row.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_spacer.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_spacer.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_spacer.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_spacer.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_text.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_text.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_text.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_text.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_textclock.xml b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_textclock.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_textclock.xml rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/android/layout/cn1_surface_textclock.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1DescriptorWidget.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1LiveActivityWidget.swift b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1LiveActivityWidget.swift similarity index 78% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1LiveActivityWidget.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1LiveActivityWidget.swift index f9f10cafd4b..0c23c4a42ee 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1LiveActivityWidget.swift +++ b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1LiveActivityWidget.swift @@ -1,3 +1,25 @@ +/* + * 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. + */ // Auto-generated by Codename One from the com.codename1.surfaces framework. // Compiled ONLY into the CN1Widgets extension target (iOS 16.1+), and only when // surfaces.json enables live activities. Renders the lock screen / banner presentation diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceAttributes.swift b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceAttributes.swift similarity index 56% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceAttributes.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceAttributes.swift index 8a10fa8b048..c8beae57892 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceAttributes.swift +++ b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceAttributes.swift @@ -1,3 +1,25 @@ +/* + * 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. + */ // Auto-generated by Codename One from the com.codename1.surfaces framework. // Compiled into BOTH the app target and the CN1Widgets extension target: ActivityKit // matches a live activity between the requesting app and the rendering extension by the diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceBridge.swift b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceBridge.swift similarity index 88% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceBridge.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceBridge.swift index 9f5870875a5..4334ad7ecf3 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceBridge.swift +++ b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceBridge.swift @@ -1,3 +1,25 @@ +/* + * 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. + */ // Auto-generated by Codename One from the com.codename1.surfaces framework. // Compiled into the MAIN APP target (copied into -src by the build). // WidgetKit and ActivityKit are Swift-only frameworks, so the Objective-C surfaces diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceModel.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1SurfaceRenderer.swift diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1WidgetProvider.swift b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1WidgetProvider.swift similarity index 73% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1WidgetProvider.swift rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1WidgetProvider.swift index e4bc56e7c2b..341c3f5105b 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/ios/CN1WidgetProvider.swift +++ b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/ios/CN1WidgetProvider.swift @@ -1,3 +1,25 @@ +/* + * 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. + */ // Auto-generated by Codename One from the com.codename1.surfaces framework. // Compiled ONLY into the CN1Widgets extension target (iOS 16.1+). TimelineProvider that // reads the timeline document the app published into the App Group container diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1ComplicationDataSource.java b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/wear/CN1ComplicationDataSource.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1ComplicationDataSource.java rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/wear/CN1ComplicationDataSource.java diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java b/maven/build-engine/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java rename to maven/build-engine/src/main/resources/com/codename1/builders/surfaces/wear/CN1SurfaceTileService.java diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java b/maven/build-engine/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java rename to maven/build-engine/src/main/resources/com/codename1/builders/wearable/CN1WearableBridge.java diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java b/maven/build-engine/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java rename to maven/build-engine/src/main/resources/com/codename1/builders/wearable/CN1WearableListenerService.java diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/buildxml-template.xml b/maven/build-engine/src/main/resources/com/codename1/maven/buildxml-template.xml similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/buildxml-template.xml rename to maven/build-engine/src/main/resources/com/codename1/maven/buildxml-template.xml diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/codenameone-icon.png b/maven/build-engine/src/main/resources/com/codename1/maven/codenameone-icon.png similarity index 100% rename from maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/codenameone-icon.png rename to maven/build-engine/src/main/resources/com/codename1/maven/codenameone-icon.png diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/app/build.gradle.kts.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/app/build.gradle.kts.txt new file mode 100644 index 00000000000..9bb4841c615 --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/app/build.gradle.kts.txt @@ -0,0 +1,14 @@ +// Optional: the plugin is applied from settings.gradle.kts. This file is where +// the application's own dependencies go. +// +// ./gradlew run run in the simulator +// ./gradlew buildAndroid send an Android build to the build server +// ./gradlew buildIos send an iOS build to the build server +// ./gradlew tasks everything else +dependencies { + // Codename One libraries published to a Maven repository: + // cn1lib("com.codenameone:googlemaps-lib:1.0") + + // Plain Java libraries, if they only use APIs Codename One supports: + // implementation("org.example:library:1.0") +} diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/Api.java.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/Api.java.txt new file mode 100644 index 00000000000..d637f70b646 --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/Api.java.txt @@ -0,0 +1,66 @@ +package ${package}; + +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PathVariable; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The server side of this app. + * + * Routes are methods. The build turns the annotations into a router and the + * main that serves them, so there is no server lifecycle to write here. + * + * While developing, run it on this JVM, which starts in a couple of seconds: + * + * ./gradlew __BACKEND__runBackend + * + * and build a single native binary, with no JVM to install beneath it, with + * + * ./gradlew __BACKEND__backendPackage + * + * Settings come from application.properties beside this project's build files, + * overridden by application-.properties (CN1_PROFILE=dev) and then by + * the environment. + * + * A controller is a bean like any other, so its constructor says what it needs + * and the generated entry point passes it in: another bean such as the Greeter + * below, a com.codename1.backend.DataSource for SQL, or a + * com.codename1.backend.orm.EntityManager for the daos generated from the + * project's @Entity classes. + * + * On the dev profile the running server also answers MCP at /mcp, with tools + * that list its routes and beans, call its endpoints and query its database -- + * see the backend reference in this project's agent skill. + */ +@RestController +public class Api { + private final Greeter greeter; + + public Api(Greeter greeter) { + this.greeter = greeter; + } + + /** What a load balancer polls. A String answer is sent as text. */ + @GetMapping("/healthz") + public String health() { + return "ok"; + } + + /** Delegates to the injected service. */ + @GetMapping("/greet/{name}") + public String greet(@PathVariable("name") String name) { + return greeter.greet(name); + } + + /** Anything that is not a String is sent as JSON. */ + @GetMapping("/echo") + public Map echo(@RequestParam(value = "say", defaultValue = "hello") String say) { + Map out = new LinkedHashMap(); + out.put("say", say); + return out; + } +} diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/Greeter.java.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/Greeter.java.txt new file mode 100644 index 00000000000..b1a86333b24 --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/Greeter.java.txt @@ -0,0 +1,25 @@ +package ${package}; + +import com.codename1.backend.annotations.Service; + +/** + * A service: business logic with no HTTP in it, which the controller is given. + * + * The build finds every @Service, @Component and @Repository, works out + * what each one's constructor needs, and writes the `new` calls into the entry + * point it generates -- so this reads like Spring, and there is no container, no + * reflection and no start-up scan once it runs. A missing or ambiguous dependency + * is a build error that names the injection point. + * + * The same pass handles @Autowired fields, @Value configuration + * settings, @Transactional and @Async methods, @Scheduled jobs and + * @McpTool methods an agent can call. See the Backend chapter of the developer + * guide, or the backend reference in this project's agent skill. + */ +@Service +public class Greeter { + /** Greets someone. */ + public String greet(String name) { + return "Hello, " + name; + } +} diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/application-dev.properties.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/application-dev.properties.txt new file mode 100644 index 00000000000..f7f0da3ebf7 --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/application-dev.properties.txt @@ -0,0 +1,19 @@ +# The development profile, chosen with CN1_PROFILE=dev. +# +# An in-memory SQLite database, so the server starts with nothing installed and +# the @Entity classes get their tables created at start-up. Point this at a file +# to keep the data between runs: +# +# cn1.datasource.url=./build/app.db +# +# Production points cn1.datasource.url at the real database instead -- a +# postgres:// or mysql:// URL -- and the same code runs against it. +cn1.datasource.url=:memory: + +# On this profile the server also serves, without a token: +# +# /manage/health, /manage/metrics, /manage/prometheus, /manage/jobs +# /mcp -- the development MCP tools, for an agent working on this backend +# +# Neither is served outside a development profile unless it is turned on and +# given a token (cn1.management.token, cn1.mcp.token). diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/application.properties.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/application.properties.txt new file mode 100644 index 00000000000..63f5a930bb0 --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/application.properties.txt @@ -0,0 +1,24 @@ +# The settings this server reads at start-up, and the layer with the lowest +# precedence. +# +# It sits in the module root on purpose. This runtime has no classpath to load a +# resource from, so Config opens application*.properties as FILES relative to the +# working directory: the backend directory under `runBackend`, and wherever the +# binary is started once it is packaged. A copy under src/main/resources would be +# collected into target/classes and never read. A system property of the same name wins over anything here, then +# the environment variable the key maps to (cn1.datasource.url is read from +# CN1_DATASOURCE_URL, and also from DATABASE_URL, which is what a platform sets +# for you), then application-.properties, then this file. +# +# Choose a profile with CN1_PROFILE. The one beside this file is `dev`: +# +# CN1_PROFILE=dev ./gradlew __BACKEND__runBackend +# +# ${DATABASE_URL} is resolved when the value is READ, so this line is fine in a +# checkout where that variable is unset: the dev profile overrides the key, and +# nothing reads this one. +cn1.datasource.url=${DATABASE_URL} + +# The port. A platform that sets PORT wins over this, which is what lets the same +# build deploy anywhere. +cn1.server.port=8080 diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/build.gradle.kts.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/build.gradle.kts.txt new file mode 100644 index 00000000000..1d3b7b6cd9b --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/build.gradle.kts.txt @@ -0,0 +1,7 @@ +// Optional: the plugin is applied from settings.gradle.kts. +// +// ./gradlew runBackend run on this JVM (starts in seconds) +// ./gradlew backendPackage build a single native binary +dependencies { + // implementation("org.example:library:1.0") +} diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gitignore.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gitignore.txt new file mode 100644 index 00000000000..f9f0c8eb09d --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gitignore.txt @@ -0,0 +1,8 @@ +.gradle/ +build/ +backend/build/ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.DS_Store +local.properties diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradle.properties.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradle.properties.txt new file mode 100644 index 00000000000..01bba8dbe13 --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradle.properties.txt @@ -0,0 +1,4 @@ +# Gradle caches the configuration of this build, which is what makes the second +# ./gradlew run start in about a second. +org.gradle.configuration-cache=true +org.gradle.caching=true diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradle/wrapper/gradle-wrapper.jar b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..b1b8ef56b44 Binary files /dev/null and b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradle/wrapper/gradle-wrapper.jar differ diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradle/wrapper/gradle-wrapper.properties b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..eb84db68da7 --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradlew b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradlew new file mode 100755 index 00000000000..249efbb032c --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradlew.bat b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradlew.bat new file mode 100644 index 00000000000..8508ef684d4 --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/settings.gradle.kts.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/settings.gradle.kts.txt new file mode 100644 index 00000000000..e342543a89c --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/settings.gradle.kts.txt @@ -0,0 +1,16 @@ +// The only Codename One line a Gradle project needs. The plugin adds the +// repository the framework is published to, applies itself to this project +// (and to backend/, when there is one), and builds against its own version. +// Bump the version here, or run ./gradlew cn1Update. +pluginManagement { + repositories { + maven("https://repo.codenameone.com/maven2") + gradlePluginPortal() + } +} + +plugins { + id("com.codenameone") version "__CN1_VERSION__" +} + +rootProject.name = "__PROJECT_NAME__" diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/ant/AntExecutorTest.java b/maven/build-engine/src/test/java/com/codename1/ant/AntExecutorTest.java similarity index 64% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/ant/AntExecutorTest.java rename to maven/build-engine/src/test/java/com/codename1/ant/AntExecutorTest.java index 0197d338568..1fb56b2afa3 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/ant/AntExecutorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/ant/AntExecutorTest.java @@ -1,3 +1,25 @@ +/* + * 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.ant; import org.junit.jupiter.api.Test; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidAdaptiveIconBackgroundTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidAdaptiveIconBackgroundTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidAdaptiveIconBackgroundTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidAdaptiveIconBackgroundTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidAiSourceSelectionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidAiSourceSelectionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidAiSourceSelectionTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidAiSourceSelectionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidAttrJar.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidAttrJar.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidAttrJar.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidAttrJar.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidBiometricSourceSelectionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidBiometricSourceSelectionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidBiometricSourceSelectionTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidBiometricSourceSelectionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderSdkVersionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidGradleBuilderSdkVersionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderSdkVersionTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidGradleBuilderSdkVersionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleDepSeparatorTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidGradleDepSeparatorTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidGradleDepSeparatorTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidGradleDepSeparatorTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidIntentShortcutsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidIntentShortcutsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidIntentShortcutsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidIntentShortcutsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidLegacyWearHintTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLocationButtonPermissionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidLocationButtonPermissionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidLocationButtonPermissionTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidLocationButtonPermissionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidMinorPlatformVersionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidMinorPlatformVersionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidMinorPlatformVersionTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidMinorPlatformVersionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidSurfaceRemoteViewsResourceTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidSurfaceRemoteViewsResourceTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidSurfaceRemoteViewsResourceTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidSurfaceRemoteViewsResourceTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidTextScriptParityTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidTextScriptParityTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidTextScriptParityTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidTextScriptParityTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidWatchSurfaceCodegenTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidWatchSurfaceCodegenTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidWatchSurfaceCodegenTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AndroidWatchSurfaceCodegenTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionPlistFileTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AppExtensionPlistFileTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionPlistFileTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AppExtensionPlistFileTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionResourcesTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AppExtensionResourcesTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionResourcesTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AppExtensionResourcesTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AppExtensionStagingTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AppExtensionStagingTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppleSdkFloorTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AppleSdkFloorTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppleSdkFloorTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/AppleSdkFloorTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/BluetoothManifestFragmentsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/BluetoothManifestFragmentsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/BluetoothManifestFragmentsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/BluetoothManifestFragmentsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/CallDirectoryExtensionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/CallDirectoryExtensionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/CallDirectoryExtensionTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/CallDirectoryExtensionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/CallManifestFragmentsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/CallManifestFragmentsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/CallManifestFragmentsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/CallManifestFragmentsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/CallPrivacyStringsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/CallPrivacyStringsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/CallPrivacyStringsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/CallPrivacyStringsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/CallVpnLibraryPrefixParityTest.java b/maven/build-engine/src/test/java/com/codename1/builders/CallVpnLibraryPrefixParityTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/CallVpnLibraryPrefixParityTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/CallVpnLibraryPrefixParityTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ContactsPermissionScanTest.java b/maven/build-engine/src/test/java/com/codename1/builders/ContactsPermissionScanTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ContactsPermissionScanTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/ContactsPermissionScanTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/CryptoGcmDecisionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/CryptoGcmDecisionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/CryptoGcmDecisionTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/CryptoGcmDecisionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DatabaseLegacyDefaultTest.java b/maven/build-engine/src/test/java/com/codename1/builders/DatabaseLegacyDefaultTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DatabaseLegacyDefaultTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/DatabaseLegacyDefaultTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DatabaseUsageScanTest.java b/maven/build-engine/src/test/java/com/codename1/builders/DatabaseUsageScanTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DatabaseUsageScanTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/DatabaseUsageScanTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DescriptorNameTest.java b/maven/build-engine/src/test/java/com/codename1/builders/DescriptorNameTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DescriptorNameTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/DescriptorNameTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DesktopTitleBarStubPropertyTest.java b/maven/build-engine/src/test/java/com/codename1/builders/DesktopTitleBarStubPropertyTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DesktopTitleBarStubPropertyTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/DesktopTitleBarStubPropertyTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DeveloperDirPrecedenceTest.java b/maven/build-engine/src/test/java/com/codename1/builders/DeveloperDirPrecedenceTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DeveloperDirPrecedenceTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/DeveloperDirPrecedenceTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorArchiveExtractionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/ExecutorArchiveExtractionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorArchiveExtractionTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/ExecutorArchiveExtractionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorDefineMarkerTest.java b/maven/build-engine/src/test/java/com/codename1/builders/ExecutorDefineMarkerTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorDefineMarkerTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/ExecutorDefineMarkerTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java b/maven/build-engine/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorRedactionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/ExecutorRedactionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorRedactionTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/ExecutorRedactionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExtensionDeploymentFloorScriptTest.java b/maven/build-engine/src/test/java/com/codename1/builders/ExtensionDeploymentFloorScriptTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExtensionDeploymentFloorScriptTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/ExtensionDeploymentFloorScriptTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java b/maven/build-engine/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/HardeningBooleanArgTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthBridgeTokenTableTest.java b/maven/build-engine/src/test/java/com/codename1/builders/HealthBridgeTokenTableTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthBridgeTokenTableTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/HealthBridgeTokenTableTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerScanTest.java b/maven/build-engine/src/test/java/com/codename1/builders/HealthListenerScanTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerScanTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/HealthListenerScanTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthManifestFragmentsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/HealthManifestFragmentsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthManifestFragmentsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/HealthManifestFragmentsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthPolicyUrlTest.java b/maven/build-engine/src/test/java/com/codename1/builders/HealthPolicyUrlTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthPolicyUrlTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/HealthPolicyUrlTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthScannerParityTest.java b/maven/build-engine/src/test/java/com/codename1/builders/HealthScannerParityTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthScannerParityTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/HealthScannerParityTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HighLevelVisionDependencyTest.java b/maven/build-engine/src/test/java/com/codename1/builders/HighLevelVisionDependencyTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HighLevelVisionDependencyTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/HighLevelVisionDependencyTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderCallPlistValueTypeTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderCallPlistValueTypeTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderCallPlistValueTypeTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderCallPlistValueTypeTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderDependencyConfigTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderDependencyConfigTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderDependencyConfigTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderDependencyConfigTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderDeploymentFloorPinTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderDeploymentFloorPinTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderDeploymentFloorPinTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderDeploymentFloorPinTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderExtensionProductNameTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderExtensionProductNameTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderExtensionProductNameTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderExtensionProductNameTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderHardeningOptOutTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderIntentsDeploymentTargetTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderIntentsDeploymentTargetTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderIntentsDeploymentTargetTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderIntentsDeploymentTargetTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderJailbreakSchemesTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderJailbreakSchemesTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderJailbreakSchemesTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderJailbreakSchemesTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderLaunchMetadataTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderLaunchMetadataTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderLaunchMetadataTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderLaunchMetadataTest.java diff --git a/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderLldbConfigTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderLldbConfigTest.java new file mode 100644 index 00000000000..55bb2ebaf6f --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderLldbConfigTest.java @@ -0,0 +1,40 @@ +/* + * 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; + +class IPhoneBuilderLldbConfigTest { + + @Test + void generatedSchemesIgnoreParparVmGcStopSignal() { + String script = IPhoneBuilder.createLldbSchemeSetupScript(); + + assertTrue(script.contains("process handle -s false -n false -p true SIGUSR2")); + assertTrue(script.contains("scheme.launch_action.xml_element.attributes['customLLDBInitFile']")); + assertTrue(script.contains("scheme.test_action.xml_element.attributes['customLLDBInitFile']")); + assertTrue(script.contains("$(SRCROOT)/cn1.lldbinit")); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderVoipModesTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderVoipModesTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderVoipModesTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderVoipModesTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderWatchKeepTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhonePushHintTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhonePushHintTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhonePushHintTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/IPhonePushHintTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java b/maven/build-engine/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java b/maven/build-engine/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteBuildHintsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/InviteBuildHintsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteBuildHintsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/InviteBuildHintsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptBuilderBuildArtifactsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuilderBuildArtifactsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptBuilderBuildArtifactsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuilderBuildArtifactsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptBuilderNativeInterfaceTest.java b/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuilderNativeInterfaceTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptBuilderNativeInterfaceTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuilderNativeInterfaceTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptProxyPackagerTest.java b/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptProxyPackagerTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptProxyPackagerTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/JavaScriptProxyPackagerTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/build-engine/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/LibraryClassPrefixScanReferencesTest.java b/maven/build-engine/src/test/java/com/codename1/builders/LibraryClassPrefixScanReferencesTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/LibraryClassPrefixScanReferencesTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/LibraryClassPrefixScanReferencesTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/LibraryClassPrefixScanTest.java b/maven/build-engine/src/test/java/com/codename1/builders/LibraryClassPrefixScanTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/LibraryClassPrefixScanTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/LibraryClassPrefixScanTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSNativeBuilderDeleteTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderDeleteTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSNativeBuilderDeleteTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderDeleteTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderScanTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSNativeBuilderXcodebuildOnPathTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderXcodebuildOnPathTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSNativeBuilderXcodebuildOnPathTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/MacOSNativeBuilderXcodebuildOnPathTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSStubThemeTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSStubThemeTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSStubThemeTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/MacOSStubThemeTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSXcodeProjectTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSXcodeProjectTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacOSXcodeProjectTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/MacOSXcodeProjectTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MatterCommissioningBridgeCompilesTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MatterCommissioningBridgeCompilesTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MatterCommissioningBridgeCompilesTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/MatterCommissioningBridgeCompilesTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MatterExtensionBuilderTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MatterExtensionBuilderTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MatterExtensionBuilderTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/MatterExtensionBuilderTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MediaPlaybackPermissionScanTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MediaPlaybackPermissionScanTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MediaPlaybackPermissionScanTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/MediaPlaybackPermissionScanTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java b/maven/build-engine/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/NearbyBonjourMergeTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java b/maven/build-engine/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/NearbyBonjourServiceTypeTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java b/maven/build-engine/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/NearbyLibraryScanTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/NearbyManifestFragmentsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java b/maven/build-engine/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/NearbyPresenceScanTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/PlayBillingVersionsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/PlayBillingVersionsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/PlayBillingVersionsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/PlayBillingVersionsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ScannerBooleanArgumentTest.java b/maven/build-engine/src/test/java/com/codename1/builders/ScannerBooleanArgumentTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ScannerBooleanArgumentTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/ScannerBooleanArgumentTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/SmartHomeManifestFragmentsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/SmartHomeManifestFragmentsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/SmartHomeManifestFragmentsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/SmartHomeManifestFragmentsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/SmartHomeScannerParityTest.java b/maven/build-engine/src/test/java/com/codename1/builders/SmartHomeScannerParityTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/SmartHomeScannerParityTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/SmartHomeScannerParityTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/StubLifecycleCastTest.java b/maven/build-engine/src/test/java/com/codename1/builders/StubLifecycleCastTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/StubLifecycleCastTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/StubLifecycleCastTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TelemetryBootstrapInstallTest.java b/maven/build-engine/src/test/java/com/codename1/builders/TelemetryBootstrapInstallTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TelemetryBootstrapInstallTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/TelemetryBootstrapInstallTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TranslatorHeapTest.java b/maven/build-engine/src/test/java/com/codename1/builders/TranslatorHeapTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TranslatorHeapTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/TranslatorHeapTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java b/maven/build-engine/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/TvNativeBuilderNearbyTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/VpnManifestFragmentsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/VpnManifestFragmentsTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/VpnManifestFragmentsTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/VpnManifestFragmentsTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/VpnTunnelNativeBuilderTest.java b/maven/build-engine/src/test/java/com/codename1/builders/VpnTunnelNativeBuilderTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/VpnTunnelNativeBuilderTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/VpnTunnelNativeBuilderTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchHealthEntitlementTest.java b/maven/build-engine/src/test/java/com/codename1/builders/WatchHealthEntitlementTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchHealthEntitlementTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/WatchHealthEntitlementTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java b/maven/build-engine/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/WatchNativeBuilderTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchWidgetExtensionTargetTest.java b/maven/build-engine/src/test/java/com/codename1/builders/WatchWidgetExtensionTargetTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchWidgetExtensionTargetTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/WatchWidgetExtensionTargetTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearGlueCompilesTest.java b/maven/build-engine/src/test/java/com/codename1/builders/WearGlueCompilesTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearGlueCompilesTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/WearGlueCompilesTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearModuleGradleTest.java b/maven/build-engine/src/test/java/com/codename1/builders/WearModuleGradleTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearModuleGradleTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/WearModuleGradleTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java b/maven/build-engine/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/WearableGlueCompilesTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WindowsNativeBuilderArchTest.java b/maven/build-engine/src/test/java/com/codename1/builders/WindowsNativeBuilderArchTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WindowsNativeBuilderArchTest.java rename to maven/build-engine/src/test/java/com/codename1/builders/WindowsNativeBuilderArchTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/GrpcWebTest.java b/maven/build-engine/src/test/java/com/codename1/io/grpc/GrpcWebTest.java similarity index 83% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/GrpcWebTest.java rename to maven/build-engine/src/test/java/com/codename1/io/grpc/GrpcWebTest.java index 71bf5101d49..c045a7b4e0e 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/GrpcWebTest.java +++ b/maven/build-engine/src/test/java/com/codename1/io/grpc/GrpcWebTest.java @@ -1,5 +1,24 @@ /* * 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.io.grpc; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/ProtoCodecTest.java b/maven/build-engine/src/test/java/com/codename1/io/grpc/ProtoCodecTest.java similarity index 87% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/ProtoCodecTest.java rename to maven/build-engine/src/test/java/com/codename1/io/grpc/ProtoCodecTest.java index 10c7bd06618..ba08030f2a0 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/io/grpc/ProtoCodecTest.java +++ b/maven/build-engine/src/test/java/com/codename1/io/grpc/ProtoCodecTest.java @@ -1,5 +1,24 @@ /* * 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.io.grpc; diff --git a/maven/build-engine/src/test/java/com/codename1/maven/BackendMainClassTest.java b/maven/build-engine/src/test/java/com/codename1/maven/BackendMainClassTest.java new file mode 100644 index 00000000000..4fd0e442dfa --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/BackendMainClassTest.java @@ -0,0 +1,91 @@ +/* + * 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.maven; + +import com.codename1.build.BuildFailureException; +import com.codename1.build.SystemStreamLog; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BackendMainClassTest { + @TempDir + Path tmp; + + /// Writes `name` (internal form) into `dir`, with a `main` when asked. + private static void writeClass(File dir, String name, boolean withMain) throws IOException { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + if (withMain) { + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "main", + "([Ljava/lang/String;)V", null, null); + mv.visitCode(); + mv.visitInsn(Opcodes.RETURN); + mv.visitMaxs(0, 1); + mv.visitEnd(); + } + cw.visitEnd(); + File f = new File(dir, name + ".class"); + f.getParentFile().mkdirs(); + Files.write(f.toPath(), cw.toByteArray()); + } + + private static BackendMainClass finder() { + return new BackendMainClass(new SystemStreamLog(), "-Pcn1.backend.mainClass"); + } + + /// Gradle compiles Java and Kotlin into separate directories: one main + /// between them is found, and one in each is as ambiguous as two in one. + @Test + void theMainClassIsFoundAcrossClassDirectoriesAndAmbiguityIsReported() throws Exception { + File java = new File(tmp.toFile(), "java"); + File kotlin = new File(tmp.toFile(), "kotlin"); + writeClass(java, "a/Helper", false); + writeClass(kotlin, "a/Server", true); + assertEquals("a.Server", finder().findMainClass(Arrays.asList(java, kotlin))); + + writeClass(java, "a/Other", true); + BuildFailureException ex = assertThrows(BuildFailureException.class, + () -> finder().findMainClass(Arrays.asList(java, kotlin))); + assertTrue(ex.getMessage().contains("a.Server") && ex.getMessage().contains("a.Other"), ex.getMessage()); + } + + @Test + void noMainAnywhereSaysHowToChooseOne() { + BuildFailureException ex = assertThrows(BuildFailureException.class, + () -> finder().findMainClass(Arrays.asList(new File(tmp.toFile(), "none")))); + assertTrue(ex.getMessage().contains("-Pcn1.backend.mainClass"), ex.getMessage()); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BytecodeComplianceMojoTest.java b/maven/build-engine/src/test/java/com/codename1/maven/BytecodeComplianceTest.java similarity index 88% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BytecodeComplianceMojoTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/BytecodeComplianceTest.java index cb5bcf545a1..0ca1bcf0627 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BytecodeComplianceMojoTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/BytecodeComplianceTest.java @@ -24,9 +24,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.apache.maven.artifact.DefaultArtifact; -import org.apache.maven.artifact.handler.DefaultArtifactHandler; -import org.apache.maven.project.MavenProject; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter; @@ -50,7 +47,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertFalse; -class BytecodeComplianceMojoTest { +class BytecodeComplianceTest { @Test void detectsForbiddenMethodReferenceWithSourceDetails(@TempDir Path tempDir) throws Exception { Path outputDir = tempDir.resolve("classes"); @@ -61,7 +58,7 @@ void detectsForbiddenMethodReferenceWithSourceDetails(@TempDir Path tempDir) thr Files.createDirectories(runtimeDir); writeJavaLangObject(runtimeDir); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map runtimeIndex = buildClassIndex(mojo, Collections.singletonList(runtimeDir.toFile())); List violations = scanProjectClasses(mojo, outputDir, runtimeIndex, Collections.emptyMap()); @@ -83,7 +80,7 @@ void allowsMethodReferenceWhenPresentInAllowedApiIndex(@TempDir Path tempDir) th writeApiClass(allowedDir, "allowed/Api", "m", "()V"); writeJavaLangObject(allowedDir); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map allowedIndex = buildClassIndex(mojo, Collections.singletonList(allowedDir.toFile())); List violations = scanProjectClasses(mojo, outputDir, allowedIndex, Collections.emptyMap()); @@ -101,7 +98,7 @@ void allowsMethodReferenceWhenPresentInProjectDependencyIndex(@TempDir Path temp writeApiClass(dependencyDir, "dep/Helper", "ok", "()V"); writeJavaLangObject(dependencyDir); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map dependencyIndex = buildClassIndex(mojo, Collections.singletonList(dependencyDir.toFile())); List violations = scanProjectClasses(mojo, outputDir, Collections.emptyMap(), dependencyIndex); @@ -109,6 +106,30 @@ void allowsMethodReferenceWhenPresentInProjectDependencyIndex(@TempDir Path temp } + /// Gradle's Kotlin pass runs before javac, so the project's Java classes are + /// known only by their sources; a reference to one, or to a class nested in + /// one, is the project's own. Anything else is still checked. + @Test + void pendingProjectClassesAreTheProjectsOwn(@TempDir Path tempDir) throws Exception { + Path outputDir = tempDir.resolve("classes"); + Files.createDirectories(outputDir); + writeClass(outputDir, "app/KotlinCaller", "app/JavaHelper", "help", "()V"); + writeClass(outputDir, "app/KotlinNested", "app/JavaHelper$Inner", "help", "()V"); + writeClass(outputDir, "app/KotlinForbidden", "forbidden/Api", "m", "()V"); + + Path runtimeDir = tempDir.resolve("runtime"); + Files.createDirectories(runtimeDir); + writeJavaLangObject(runtimeDir); + + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()) + .pendingProjectClasses(Collections.singleton("app/JavaHelper")); + Map runtimeIndex = buildClassIndex(mojo, Collections.singletonList(runtimeDir.toFile())); + List violations = scanProjectClasses(mojo, outputDir, runtimeIndex, Collections.emptyMap()); + + assertEquals(1, violations.size(), String.valueOf(violations)); + assertEquals("forbidden/Api#m()V", field(violations.get(0), "referencedMember")); + } + @Test void allowsInheritedMethodAcrossProjectAndAllowedIndexes(@TempDir Path tempDir) throws Exception { Path outputDir = tempDir.resolve("classes"); @@ -123,7 +144,7 @@ void allowsInheritedMethodAcrossProjectAndAllowedIndexes(@TempDir Path tempDir) writeJavaLangObject(allowedDir); writeSubclass(dependencyDir, "dep/Sub", "allowed/Base"); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map allowedIndex = buildClassIndex(mojo, Collections.singletonList(allowedDir.toFile())); Map dependencyIndex = buildClassIndex(mojo, Collections.singletonList(dependencyDir.toFile())); @@ -138,7 +159,7 @@ void rewritesClassMajorVersionAboveJava17(@TempDir Path tempDir) throws Exceptio Files.createDirectories(outputDir); Path classFile = writeClassWithVersion(outputDir, "app/TooNew", Opcodes.V17 + 1); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); int rewritten = enforceMaxClassVersion(mojo, outputDir.toFile(), Opcodes.V17); assertEquals(1, rewritten, "Expected one class to be rewritten"); @@ -151,7 +172,7 @@ void rewritesStringSplitInvocations(@TempDir Path tempDir) throws Exception { Files.createDirectories(outputDir); Path classFile = writeStringApiUsageClass(outputDir, "app/StringApiUser"); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); applyInvocationRewrites(mojo, outputDir.toFile()); byte[] rewritten = Files.readAllBytes(classFile); @@ -164,7 +185,7 @@ void rewritesStringReplaceAllInvocations(@TempDir Path tempDir) throws Exception Files.createDirectories(outputDir); Path classFile = writeStringRegexInvocationClass(outputDir, "app/StringReplaceAllUser", "replaceAll"); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); applyInvocationRewrites(mojo, outputDir.toFile()); byte[] rewritten = Files.readAllBytes(classFile); @@ -177,7 +198,7 @@ void rewritesStringReplaceFirstInvocations(@TempDir Path tempDir) throws Excepti Files.createDirectories(outputDir); Path classFile = writeStringRegexInvocationClass(outputDir, "app/StringReplaceFirstUser", "replaceFirst"); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); applyInvocationRewrites(mojo, outputDir.toFile()); byte[] rewritten = Files.readAllBytes(classFile); @@ -190,7 +211,7 @@ void allowsRewriteHelperCallsAfterSplitRewrite(@TempDir Path tempDir) throws Exc Files.createDirectories(outputDir); writeStringApiUsageClass(outputDir, "app/StringApiUser"); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); applyInvocationRewrites(mojo, outputDir.toFile()); List violations = scanProjectClasses(mojo, outputDir, Collections.emptyMap(), Collections.emptyMap()); @@ -206,7 +227,7 @@ void skipsModuleInfoAndMultiReleaseJarEntries(@TempDir Path tempDir) throws Exce new JarEntryBytes("META-INF/versions/21/bad/TooNew.class", new byte[]{0x4, 0x5, 0x6}), new JarEntryBytes("good/Api.class", classBytes("good/Api"))); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map index = buildClassIndex(mojo, Collections.singletonList(jarFile.toFile())); assertEquals(1, index.size(), "Expected only the real API class to be indexed"); @@ -220,7 +241,7 @@ void skipsUnreadableJarClassEntriesInsteadOfFailing(@TempDir Path tempDir) throw new JarEntryBytes("good/Api.class", classBytes("good/Api")), new JarEntryBytes("broken/Broken.class", new byte[]{0x1, 0x2, 0x3, 0x4})); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map index = buildClassIndex(mojo, Collections.singletonList(jarFile.toFile())); assertTrue(index.containsKey("good/Api"), "Expected valid class to be indexed"); @@ -233,7 +254,7 @@ void buildFailureSummaryUsesReadableBulletFormat(@TempDir Path tempDir) throws E Files.createDirectories(reportFile.getParent()); Files.write(reportFile, new byte[0]); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); setField(mojo, "complianceOutputFile", reportFile.toFile()); List violations = new ArrayList(); violations.add(newViolation("app/Caller", "run()V", "forbidden/Api#m()V", null, "app/Caller.class")); @@ -250,10 +271,9 @@ void includesProvidedScopeDependenciesInComplianceScan(@TempDir Path tempDir) th Path providedJar = tempDir.resolve("provided.jar"); writeJar(providedJar, new JarEntryBytes("dep/ProvidedApi.class", classBytes("dep/ProvidedApi"))); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); - MavenProject project = new MavenProject(); - project.setArtifacts(new HashSet(Collections.singleton(newArtifact("dep-provided", "provided", providedJar.toFile())))); - mojo.project = project; + TestProjectHost host = TestProjectHost.empty(); + host.artifacts.add(TestProjectHost.artifact("dep-provided", "provided", providedJar.toFile())); + BytecodeCompliance mojo = new BytecodeCompliance(host); List jars = getDependencyJarsForScanning(mojo); assertEquals(1, jars.size(), "Expected provided-scope jar to be included for compliance scanning"); @@ -264,10 +284,9 @@ void includesCn1libArtifactsInComplianceScan(@TempDir Path tempDir) throws Excep Path cn1lib = tempDir.resolve("maps.cn1lib"); writeJar(cn1lib, new JarEntryBytes("dep/MapContainer.class", classBytes("dep/MapContainer"))); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); - MavenProject project = new MavenProject(); - project.setArtifacts(new HashSet(Collections.singleton(newArtifact("maps-lib", "compile", cn1lib.toFile())))); - mojo.project = project; + TestProjectHost host = TestProjectHost.empty(); + host.artifacts.add(TestProjectHost.artifact("maps-lib", "compile", cn1lib.toFile())); + BytecodeCompliance mojo = new BytecodeCompliance(host); List jars = getDependencyJarsForScanning(mojo); assertEquals(1, jars.size(), "Expected .cn1lib artifact to be included for compliance scanning"); @@ -279,7 +298,7 @@ void indexesClassesInsideNestedArchivesInCn1lib(@TempDir Path tempDir) throws Ex byte[] nestedZipBytes = zipBytes(new JarEntryBytes("dep/NestedApi.class", classBytes("dep/NestedApi"))); writeJar(cn1lib, new JarEntryBytes("META-INF/cn1lib/nativejavase.zip", nestedZipBytes)); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map index = buildClassIndex(mojo, Collections.singletonList(cn1lib.toFile())); assertTrue(index.containsKey("dep/NestedApi"), "Expected class in nested cn1lib archive to be indexed"); } @@ -295,7 +314,7 @@ void allowsSimdAllocaValuePassedToSimdMethod(@TempDir Path tempDir) throws Excep writeSimdApi(allowedDir); writeAllocaCaller(outputDir, "app/AllocaOk", AllocaUsage.SIMD_METHOD); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map allowedIndex = buildClassIndex(mojo, Collections.singletonList(allowedDir.toFile())); List violations = scanProjectClasses(mojo, outputDir, allowedIndex, Collections.emptyMap()); @@ -315,7 +334,7 @@ void rejectsSimdAllocaValuePassedToNonSimdMethod(@TempDir Path tempDir) throws E writeAllocaCaller(outputDir, "app/AllocaBadCall", AllocaUsage.NON_SIMD_METHOD); writeByteArrayConsumer(outputDir, "app/Helper"); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map allowedIndex = buildClassIndex(mojo, Collections.singletonList(allowedDir.toFile())); Map projectIndex = buildClassIndex(mojo, Collections.singletonList(outputDir.toFile())); List violations = scanProjectClasses(mojo, outputDir, allowedIndex, projectIndex); @@ -335,7 +354,7 @@ void rejectsSimdAllocaValueReturnedFromMethod(@TempDir Path tempDir) throws Exce writeSimdApi(allowedDir); writeAllocaCaller(outputDir, "app/AllocaBadReturn", AllocaUsage.RETURN_VALUE); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map allowedIndex = buildClassIndex(mojo, Collections.singletonList(allowedDir.toFile())); List violations = scanProjectClasses(mojo, outputDir, allowedIndex, Collections.emptyMap()); @@ -383,7 +402,7 @@ void rejectsSynchronizationOnEveryPrimitiveWrapper(@TempDir Path tempDir) throws writePrimitiveWrapperSynchronizedClass(outputDir, "app/" + simpleName + "LockUser", owner, "valueOf", descriptor); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map allowedIndex = buildClassIndex(mojo, Collections.singletonList(allowedDir.toFile())); List violations = scanProjectClasses(mojo, outputDir, allowedIndex, @@ -405,7 +424,7 @@ void allowsSynchronizationOnDedicatedObjectLock(@TempDir Path tempDir) throws Ex writeJavaLangObject(allowedDir); writeObjectSynchronizedClass(outputDir, "app/ObjectLockUser"); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); Map allowedIndex = buildClassIndex(mojo, Collections.singletonList(allowedDir.toFile())); List violations = scanProjectClasses(mojo, outputDir, allowedIndex, Collections.emptyMap()); @@ -415,7 +434,7 @@ void allowsSynchronizationOnDedicatedObjectLock(@TempDir Path tempDir) throws Ex @Test void recognizesAllSimdAllocaHelperNames() throws Exception { - Method method = BytecodeComplianceMojo.class.getDeclaredMethod("isSimdAllocaMethod", String.class, String.class, String.class); + Method method = BytecodeCompliance.class.getDeclaredMethod("isSimdAllocaMethod", String.class, String.class, String.class); method.setAccessible(true); assertTrue(((Boolean) method.invoke(null, "com/codename1/util/Simd", "allocaByteZeroed", "(I)[B")).booleanValue()); @@ -433,7 +452,7 @@ void recognizesAllSimdAllocaHelperNames() throws Exception { @Test void rerunsComplianceCheckWhenClassesRecompiledWithoutSourceChange(@TempDir Path tempDir) throws Exception { - BytecodeComplianceMojo mojo = newMojoForSkipCheck(tempDir); + BytecodeCompliance mojo = newMojoForSkipCheck(tempDir); long markerTime = markerFile(mojo).lastModified(); Path classFile = writeClassWithVersion(outputDir(mojo), "app/Stable", Opcodes.V1_8); @@ -451,7 +470,7 @@ void rerunsComplianceCheckWhenClassesRecompiledWithoutSourceChange(@TempDir Path @Test void rerunsComplianceCheckWhenKotlinIncrementalClassesAreNewer(@TempDir Path tempDir) throws Exception { - BytecodeComplianceMojo mojo = newMojoForSkipCheck(tempDir); + BytecodeCompliance mojo = newMojoForSkipCheck(tempDir); long markerTime = markerFile(mojo).lastModified(); Path kotlinIcDir = tempDir.resolve("ios").resolve("target") @@ -465,7 +484,7 @@ void rerunsComplianceCheckWhenKotlinIncrementalClassesAreNewer(@TempDir Path tem @Test void rerunsComplianceCheckAfterPreviousFailureReport(@TempDir Path tempDir) throws Exception { - BytecodeComplianceMojo mojo = newMojoForSkipCheck(tempDir); + BytecodeCompliance mojo = newMojoForSkipCheck(tempDir); java.io.File marker = markerFile(mojo); long markerTime = marker.lastModified(); Files.write(marker.toPath(), @@ -476,7 +495,7 @@ void rerunsComplianceCheckAfterPreviousFailureReport(@TempDir Path tempDir) thro "Expected re-run when the previous check recorded violations, even with nothing newer"); } - private BytecodeComplianceMojo newMojoForSkipCheck(Path tempDir) throws Exception { + private BytecodeCompliance newMojoForSkipCheck(Path tempDir) throws Exception { Path iosDir = tempDir.resolve("ios"); Path commonDir = tempDir.resolve("common"); Files.createDirectories(iosDir.resolve("target").resolve("codenameone")); @@ -491,43 +510,41 @@ private BytecodeComplianceMojo newMojoForSkipCheck(Path tempDir) throws Exceptio Files.write(marker, "Completed compliance check on test\n".getBytes("UTF-8")); assertTrue(marker.toFile().setLastModified(base)); - MavenProject project = new MavenProject(); - project.setFile(iosDir.resolve("pom.xml").toFile()); - org.apache.maven.model.Build build = new org.apache.maven.model.Build(); - build.setDirectory(iosDir.resolve("target").toString()); - build.setOutputDirectory(iosDir.resolve("target").resolve("classes").toString()); - project.setBuild(build); - - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); - mojo.project = project; + // What the Maven plugin answers for the ios module of this layout: its own + // target/, and the common module's sources (the settings file here). + TestProjectHost host = TestProjectHost.empty(); + host.buildDir = iosDir.resolve("target").toFile(); + host.outputDir = iosDir.resolve("target").resolve("classes").toFile(); + host.sourcesModified = settings.toFile().lastModified(); + BytecodeCompliance mojo = new BytecodeCompliance(host); setField(mojo, "complianceOutputFile", marker.toFile()); return mojo; } - private java.io.File markerFile(BytecodeComplianceMojo mojo) throws Exception { + private java.io.File markerFile(BytecodeCompliance mojo) throws Exception { return (java.io.File) field(mojo, "complianceOutputFile"); } - private Path outputDir(BytecodeComplianceMojo mojo) { - return java.nio.file.Paths.get(mojo.project.getBuild().getOutputDirectory()); + private Path outputDir(BytecodeCompliance mojo) throws Exception { + return java.nio.file.Paths.get(((TestProjectHost) field(mojo, "host")).outputDir.getPath()); } - private boolean hasChangedSinceLastCheck(BytecodeComplianceMojo mojo) throws Exception { - Method method = BytecodeComplianceMojo.class.getDeclaredMethod("hasChangedSinceLastCheck"); + private boolean hasChangedSinceLastCheck(BytecodeCompliance mojo) throws Exception { + Method method = BytecodeCompliance.class.getDeclaredMethod("hasChangedSinceLastCheck"); method.setAccessible(true); return ((Boolean) method.invoke(mojo)).booleanValue(); } @SuppressWarnings("unchecked") - private Map buildClassIndex(BytecodeComplianceMojo mojo, List roots) throws Exception { - Method method = BytecodeComplianceMojo.class.getDeclaredMethod("buildClassIndex", List.class); + private Map buildClassIndex(BytecodeCompliance mojo, List roots) throws Exception { + Method method = BytecodeCompliance.class.getDeclaredMethod("buildClassIndex", List.class); method.setAccessible(true); return (Map) method.invoke(mojo, roots); } @SuppressWarnings("unchecked") - private List scanProjectClasses(BytecodeComplianceMojo mojo, Path outputDir, Map allowedIndex, Map projectAndDependencyIndex) throws Exception { - Method method = BytecodeComplianceMojo.class.getDeclaredMethod("scanProjectClasses", java.io.File.class, Map.class, Map.class); + private List scanProjectClasses(BytecodeCompliance mojo, Path outputDir, Map allowedIndex, Map projectAndDependencyIndex) throws Exception { + Method method = BytecodeCompliance.class.getDeclaredMethod("scanProjectClasses", java.io.File.class, Map.class, Map.class); method.setAccessible(true); return (List) method.invoke(mojo, outputDir.toFile(), allowedIndex, projectAndDependencyIndex); } @@ -554,28 +571,21 @@ private void setField(Object target, String name, Object value) throws Exception field.set(target, value); } - private String buildFailureSummary(BytecodeComplianceMojo mojo, List violations) throws Exception { - Method method = BytecodeComplianceMojo.class.getDeclaredMethod("buildFailureSummary", List.class); + private String buildFailureSummary(BytecodeCompliance mojo, List violations) throws Exception { + Method method = BytecodeCompliance.class.getDeclaredMethod("buildFailureSummary", List.class); method.setAccessible(true); return (String) method.invoke(mojo, violations); } @SuppressWarnings("unchecked") - private List getDependencyJarsForScanning(BytecodeComplianceMojo mojo) throws Exception { - Method method = BytecodeComplianceMojo.class.getDeclaredMethod("getDependencyJarsForScanning"); + private List getDependencyJarsForScanning(BytecodeCompliance mojo) throws Exception { + Method method = BytecodeCompliance.class.getDeclaredMethod("getDependencyJarsForScanning"); method.setAccessible(true); return (List) method.invoke(mojo); } - private org.apache.maven.artifact.Artifact newArtifact(String artifactId, String scope, java.io.File file) { - DefaultArtifact artifact = new DefaultArtifact("com.example", artifactId, "1.0", scope, "jar", null, new DefaultArtifactHandler("jar")); - artifact.setFile(file); - artifact.setScope(scope); - return artifact; - } - private Object newViolation(String sourceClass, String sourceMethod, String referencedMember, String suggestion, String sourcePath) throws Exception { - Class violationClass = Class.forName("com.codename1.maven.BytecodeComplianceMojo$Violation"); + Class violationClass = Class.forName("com.codename1.maven.BytecodeCompliance$Violation"); java.lang.reflect.Constructor ctor = violationClass.getDeclaredConstructor(String.class, String.class, String.class, String.class, String.class); ctor.setAccessible(true); return ctor.newInstance(sourceClass, sourceMethod, referencedMember, suggestion, sourcePath); @@ -610,7 +620,7 @@ void rewritingAClassInvalidatesTheBuildHintStampUntilItIsTakenAgain(@TempDir Pat String before = digestIn(manifest); assertEquals(sha256Of(classFile.toFile()), before, "the stamp should start out correct"); - BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); assertEquals(1, enforceMaxClassVersion(mojo, outputDir.toFile(), Opcodes.V17)); assertFalse(sha256Of(classFile.toFile()).equals(before), @@ -648,14 +658,14 @@ private static String sha256Of(java.io.File f) throws Exception { return hex.toString(); } - private int enforceMaxClassVersion(BytecodeComplianceMojo mojo, java.io.File outputDir, int maxVersion) throws Exception { - Method method = BytecodeComplianceMojo.class.getDeclaredMethod("enforceMaxClassVersion", java.io.File.class, int.class); + private int enforceMaxClassVersion(BytecodeCompliance mojo, java.io.File outputDir, int maxVersion) throws Exception { + Method method = BytecodeCompliance.class.getDeclaredMethod("enforceMaxClassVersion", java.io.File.class, int.class); method.setAccessible(true); return ((Integer) method.invoke(mojo, outputDir, maxVersion)).intValue(); } - private void applyInvocationRewrites(BytecodeComplianceMojo mojo, java.io.File outputDir) throws Exception { - Method method = BytecodeComplianceMojo.class.getDeclaredMethod("applyInvocationRewrites", java.io.File.class); + private void applyInvocationRewrites(BytecodeCompliance mojo, java.io.File outputDir) throws Exception { + Method method = BytecodeCompliance.class.getDeclaredMethod("applyInvocationRewrites", java.io.File.class); method.setAccessible(true); method.invoke(mojo, outputDir); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/ClassClosureVerifierTest.java b/maven/build-engine/src/test/java/com/codename1/maven/ClassClosureVerifierTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/ClassClosureVerifierTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/ClassClosureVerifierTest.java diff --git a/maven/build-engine/src/test/java/com/codename1/maven/Cn1libPomProfilesTest.java b/maven/build-engine/src/test/java/com/codename1/maven/Cn1libPomProfilesTest.java new file mode 100644 index 00000000000..8c9e7c4200f --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/Cn1libPomProfilesTest.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2012, 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.maven; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Cn1libPomProfilesTest { + /// Shaped exactly like a deployed cn1lib-archetype `lib/pom.xml`: nothing + /// interpolated, `cn1lib.name` defined only in the parent. + private static final String LIB_POM = "\n" + + " com.acmemaps1.2\n" + + " com.acmemaps-lib1.2\n" + + " pom\n" + + " \n" + + " ${project.groupId}${cn1lib.name}-common" + + "${project.version}\n" + + " \n" + + " \n" + + " androidcodename1.platform" + + "android\n" + + " ${project.groupId}" + + "${cn1lib.name}-android${project.version}" + + "\n" + + " ioscodename1.platform" + + "ios\n" + + " ${project.groupId}" + + "${cn1lib.name}-ios${project.version}" + + "junitjunit4" + + "test\n" + + " releasetrue" + + "\n" + + " \n" + + "\n"; + + private static final String PARENT_POM = "com.acmemaps" + + "1.2maps"; + + @Test + void readsEachPlatformProfileWithTheParentsProperties() { + Map> byPlatform = Cn1libPomProfiles.read(LIB_POM, + (g, a, v) -> "com.acme".equals(g) && "maps".equals(a) && "1.2".equals(v) ? PARENT_POM : null); + assertEquals(2, byPlatform.size()); + assertEquals("com.acme:maps-android:1.2", byPlatform.get("android").get(0).toNotation()); + List ios = byPlatform.get("ios"); + assertEquals(1, ios.size(), "test-scoped dependencies are not part of the platform"); + assertEquals("com.acme:maps-ios:1.2", ios.get(0).toNotation()); + } + + @Test + void anUnresolvablePropertyIsLeftVisibleRatherThanDropped() { + Map> byPlatform = Cn1libPomProfiles.read(LIB_POM, (g, a, v) -> null); + assertEquals("${cn1lib.name}-android", byPlatform.get("android").get(0).artifactId); + } + + @Test + void aProfileDependencysExclusionsAreKept() { + String pom = "codename1.platform" + + "android" + + "com.acmemaps-android1" + + "com.otherdup" + + "**" + + ""; + List deps = Cn1libPomProfiles.read(pom, (g, a, v) -> null).get("android"); + assertEquals(2, deps.get(0).exclusions().size()); + assertEquals("com.other", deps.get(0).exclusions().get(0)[0]); + assertEquals("dup", deps.get(0).exclusions().get(0)[1]); + assertEquals("*", deps.get(0).exclusions().get(1)[0]); + } + + @Test + void aVersionLeftToDependencyManagementIsResolved() { + String parent = "org.example" + + "sdk4.2" + + ""; + String pom = "com.acmeparent" + + "1" + + "codename1.platformandroid" + + "org.examplesdk" + + ""; + List deps = Cn1libPomProfiles.read(pom, + (g, a, v) -> "parent".equals(a) ? parent : null).get("android"); + assertEquals("org.example:sdk:4.2", deps.get(0).toNotation()); + } + + @Test + void notACn1libOrNotXml() { + assertTrue(Cn1libPomProfiles.read("a", null).isEmpty()); + assertTrue(Cn1libPomProfiles.read("not xml", null).isEmpty()); + assertTrue(Cn1libPomProfiles.read(null, null).isEmpty()); + } + + @Test + void classifierAndTypeBecomeGradleNotation() { + String pom = "ga1" + + "codename1.platformjavase" + + "gx" + + "${project.version}nativeszip" + + ""; + assertEquals("g:x:1:natives@zip", Cn1libPomProfiles.read(pom, null).get("javase").get(0).toNotation()); + } + + /// A cn1lib's common module names the cn1libs it uses as pom-type + /// dependencies; that is how a consumer reaches their platform profiles. + @Test + void mainDependenciesExposeTheCn1libsALibraryUses() { + String common = "com.acmemaps-common" + + "1.23.0" + + "com.acmegeo-lib" + + "${geo.version}pom" + + "junitjunit4" + + "test" + + "codename1.platform" + + "iosx" + + "y1" + + ""; + List deps = Cn1libPomProfiles.dependencies(common, null); + assertEquals(1, deps.size(), "test scope and profile dependencies are not main dependencies"); + assertEquals("com.acme:geo-lib:3.0@pom", deps.get(0).toNotation()); + assertTrue(Cn1libPomProfiles.dependencies("not xml", null).isEmpty()); + } + + /// A library built with Gradle is consumed through the same pom shape, so what + /// the Gradle plugin writes must read back as the Maven archetype's does. + @Test + void theRenderedLibPomReadsBackPlatformByPlatform() { + String pom = Cn1libPom.render("com.acme", "maps", "2.0", + java.util.Arrays.asList("android", "ios", "javase"), true); + Map> byPlatform = Cn1libPomProfiles.read(pom, null); + assertEquals(3, byPlatform.size()); + assertEquals("com.acme:maps-ios:2.0", byPlatform.get("ios").get(0).toNotation()); + assertTrue(pom.contains("maps-lib")); + assertTrue(pom.contains("cn1css")); + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/maven/GradleConversionTest.java b/maven/build-engine/src/test/java/com/codename1/maven/GradleConversionTest.java new file mode 100644 index 00000000000..ec26639dcf3 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/GradleConversionTest.java @@ -0,0 +1,813 @@ +/* + * 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.maven; + +import com.codename1.build.BuildFailureException; +import com.codename1.build.SystemStreamLog; +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GradleConversionTest { + /// The backend Api the archetype and the Gradle template generate, in package + /// a.backend: the one a conversion may leave behind as untouched. + private static final String UNTOUCHED_API = template("backend/Api.java.txt") + .replace("${package}", "a.backend").replace("__BACKEND__", ":backend:"); + + /// The service the generated Api is given, as the archetype writes it. + private static final String UNTOUCHED_GREETER = template("backend/Greeter.java.txt") + .replace("${package}", "a.backend"); + + private static final String GENERATED_BACKEND_POM = "" + + "com.codenameonecodenameone-backend" + + "" + + "org.xerialsqlite-jdbc3" + + "" + + "com.codenameone" + + "codenameone-maven-plugin"; + + /// [#GENERATED_BACKEND_POM] plus a library of the developer's own. + private static final String BACKEND_POM_WITH_PAYMENTS = GENERATED_BACKEND_POM.replace("", + "org.examplepayments3.1" + + ""); + + private static String template(String path) { + try { + return GradleProjectTemplate.text(path); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + @TempDir + Path tmp; + + private static File touch(File base, String path, String content) throws IOException { + File f = new File(base, path); + f.getParentFile().mkdirs(); + Files.write(f.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return f; + } + + private static String read(File f) throws IOException { + return new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + } + + private static GradleConversion converter() { + return new GradleConversion(new SystemStreamLog()); + } + + private File antApp() throws IOException { + File ant = new File(tmp.toFile(), "AntApp"); + touch(ant, "build.xml", ""); + touch(ant, "codenameone_settings.properties", "codename1.mainName=AntApp\ncodename1.packageName=a\n" + + "codename1.cssTheme=true\ncodename1.arg.java.version=8\n"); + touch(ant, "icon.png", "png"); + touch(ant, "src/a/AntApp.java", "package a; public class AntApp {}"); + touch(ant, "src/a/Helper.kt", "package a\nclass Helper"); + touch(ant, "src/a/Old.mirah", "class Old; end"); + touch(ant, "src/theme.res", "stale"); + touch(ant, "src/messages.properties", "hello=Hello"); + touch(ant, "css/theme.css", "Form { color: red; }"); + touch(ant, "test/a/AntAppTest.java", "package a; public class AntAppTest {}"); + touch(ant, "native/android/a/MyNativeImpl.java", "package a; public class MyNativeImpl {}"); + touch(ant, "native/ios/a_MyNativeImpl.m", "// ios"); + touch(ant, "lib/CodenameOne.jar", "framework"); + touch(ant, "lib/json-helper.jar", "the app's own"); + touch(ant, "lib/impl/cls/a/FromCn1lib.class", "extracted"); + return ant; + } + + private File mavenApp(String backendApi) throws IOException { + File mvn = new File(tmp.toFile(), "mvnapp"); + touch(mvn, "pom.xml", "com.acmemvnapp" + + "1.01.2" + + "org.example" + + "managed5.0" + + ""); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0" + + "com.codenameonecodenameone-core" + + "com.acmemaps-lib" + + "${maps.version}pom" + + "com.codenameonegooglemaps-lib" + + "1.0pom" + + "org.exampleutil2.0" + + "" + + "org.exampleapi${api.version}" + + "provided" + + "junitjunit4" + + "test" + + "org.examplemanaged" + + "org.exampleunmanaged" + + "org.examplenatives2" + + "linuxzip" + + "org.examplefixtures3" + + "test-jartest" + + ""); + touch(mvn, "common/codenameone_settings.properties", "codename1.mainName=MvnApp\ncodename1.arg.java.version=17\n"); + touch(mvn, "common/icon.png", "png"); + touch(mvn, "common/src/main/java/a/MvnApp.java", "package a; public class MvnApp {}"); + touch(mvn, "common/src/main/css/theme.css", "Form {}"); + touch(mvn, "android/pom.xml", ""); + touch(mvn, "android/src/main/java/a/MyNativeImpl.java", "package a; public class MyNativeImpl {}"); + touch(mvn, "android/src/main/resources/android.png", "png"); + touch(mvn, "ios/src/main/objectivec/.gitignore", ""); + // As the archetype writes it: the runtime and the SQLite driver. + touch(mvn, "backend/pom.xml", GENERATED_BACKEND_POM); + touch(mvn, "backend/application.properties", template("backend/application.properties.txt")); + touch(mvn, "backend/application-dev.properties", template("backend/application-dev.properties.txt")); + touch(mvn, "backend/src/main/java/a/backend/Api.java", backendApi); + touch(mvn, "backend/src/main/java/a/backend/Greeter.java", UNTOUCHED_GREETER); + return mvn; + } + + @Test + void anAntProjectIsSortedIntoTheGradleLayout() throws Exception { + File out = new File(tmp.toFile(), "out"); + ProjectLayout to = converter().convert(antApp(), out, "9.9.9"); + + assertEquals(BuildSystem.GRADLE, to.buildSystem()); + assertTrue(new File(out, "src/main/java/a/AntApp.java").isFile()); + assertTrue(new File(out, "src/main/kotlin/a/Helper.kt").isFile(), "Kotlin is not a resource"); + assertFalse(new File(out, "src/main/resources/a/Helper.kt").exists()); + assertTrue(new File(out, "src/main/resources/messages.properties").isFile()); + assertFalse(new File(out, "src/main/resources/theme.res").exists(), + "a CSS project's saved theme.res would shadow the compiled one"); + assertFalse(new File(out, "src/main/resources/a/Old.mirah").exists()); + assertTrue(new File(out, "src/main/css/theme.css").isFile()); + assertTrue(new File(out, "src/test/java/a/AntAppTest.java").isFile()); + assertTrue(new File(out, "src/android/java/a/MyNativeImpl.java").isFile()); + assertTrue(new File(out, "src/ios/objectivec/a_MyNativeImpl.m").isFile()); + assertFalse(new File(out, "src/javase").exists(), "only the platforms that have code get a directory"); + assertTrue(new File(out, "icon.png").isFile()); + + String settings = read(new File(out, "codenameone_settings.properties")); + assertTrue(settings.contains("codename1.arg.java.version=17"), settings); + assertFalse(settings.contains("java.version=8"), settings); + assertTrue(read(new File(out, "settings.gradle.kts")).contains("id(\"com.codenameone\") version \"9.9.9\"")); + assertTrue(read(new File(out, "settings.gradle.kts")).contains("rootProject.name = \"AntApp\"")); + String build = read(new File(out, "build.gradle.kts")); + int plugins = build.indexOf("kotlin(\"jvm\") version \"" + GradleConversion.KOTLIN_VERSION + "\""); + assertTrue(plugins > 0 && plugins < build.indexOf("dependencies {"), + "a Kotlin project needs the Kotlin plugin, first in the script: " + build); + assertTrue(new File(out, "gradlew").canExecute()); + assertTrue(new File(out, "libs/json-helper.jar").isFile(), "the app's own jar comes along"); + assertFalse(new File(out, "libs/CodenameOne.jar").exists(), "the plugin supplies the framework"); + assertTrue(build.contains("implementation(files(\"libs/json-helper.jar\"))"), build); + assertFalse(build.contains("CodenameOne.jar"), build); + + ProjectLayout detected = ProjectLayouts.detect(out); + assertEquals(BuildSystem.GRADLE, detected.buildSystem()); + assertEquals(ProjectKind.APP, detected.kind()); + } + + @Test + void anAntProjectsIosExtrasGoWhereTheGradleBuildReadsThem() throws Exception { + File ant = antApp(); + touch(ant, "native/ios/app_extensions/Widget.zip", "zip"); + touch(ant, "native/ios/strings/fr.lproj/Localizable.strings", "\"a\" = \"b\";"); + File out = new File(tmp.toFile(), "out"); + converter().convert(ant, out, "1.0"); + assertTrue(new File(out, "src/ios/app_extensions/Widget.zip").isFile()); + assertTrue(new File(out, "src/ios/strings/fr.lproj/Localizable.strings").isFile()); + assertFalse(new File(out, "src/ios/objectivec/app_extensions").exists(), "not as native sources"); + assertFalse(new File(out, "src/ios/objectivec/strings").exists(), "not as native sources"); + assertTrue(new File(out, "src/ios/objectivec/a_MyNativeImpl.m").isFile()); + } + + @Test + void anEmptyRelativePathTakesNothingFromTheDirectoryAbove() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "pom.xml", "com.acmemvnapp" + + "1.0org.example" + + "inherited7"); + touch(mvn, "common/pom.xml", "com.acmecorporate" + + "1.0" + + "org.exampleutil2.0" + + ""); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("implementation(\"org.example:util:2.0\")"), build); + assertFalse(build.contains("inherited"), "the parent comes from a repository, not ..: " + build); + } + + @Test + void anAntProjectsGuiBuilderAndViewSourcesComeAlong() throws Exception { + File ant = antApp(); + touch(ant, "res/guibuilder/a/Main.gui", ""); + touch(ant, "rad/views/a/MyView.xml", ""); + File out = new File(tmp.toFile(), "out"); + converter().convert(ant, out, "1.0"); + assertTrue(new File(out, "src/main/guibuilder/a/Main.gui").isFile()); + assertTrue(new File(out, "src/main/rad/views/a/MyView.xml").isFile()); + } + + @Test + void aProvidedDependencyIsOnTheTestClasspathToo() throws Exception { + File out = new File(tmp.toFile(), "out"); + converter().convert(mavenApp(UNTOUCHED_API), out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("compileOnly(\"org.example:api:"), build); + assertTrue(build.contains("testImplementation(\"org.example:api:"), build); + assertFalse(build.contains("implementation(\"org.example:api:"), "never in the application: " + build); + } + + @Test + void profileDependenciesFollowWhatAPlainMavenBuildActivates() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/src/main/extra/marker", ""); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0" + + "org.exampleutil2.0" + + "" + + "present${basedir}/src/main/extra" + + "org.examplepresent" + + "1" + + "absent${basedir}/nowhere" + + "org.exampleabsent" + + "1" + + "cici" + + "org.exampleci-only" + + "1" + + ""); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("\n implementation(\"org.example:present:1\")"), build); + assertFalse(build.contains("org.example:absent"), "a file activation that fails is off: " + build); + assertTrue(build.contains("profile 'ci' of pom.xml"), build); + assertTrue(build.contains(" // implementation(\"org.example:ci-only:1\")"), + "undecided: written, but commented out: " + build); + } + + @Test + void anActivatedProfileSwitchesTheDefaultOneOff() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/production.marker", ""); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0" + + "devtrue" + + "org.exampledev-db" + + "1" + + "prodproduction.marker" + + "org.exampleprod-db" + + "1" + + ""); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("implementation(\"org.example:prod-db:1\")"), build); + assertFalse(build.contains("dev-db"), "Maven drops the default profile once another is on: " + build); + + // With nothing else on, the default profile is. + if (!new File(mvn, "common/production.marker").delete()) { + throw new IOException("could not delete the marker"); + } + File again = new File(tmp.toFile(), "again"); + converter().convert(mvn, again, "1.0"); + String dev = read(new File(again, "build.gradle.kts")); + assertTrue(dev.contains("implementation(\"org.example:dev-db:1\")"), dev); + assertFalse(dev.contains("prod-db"), dev); + } + + @Test + void anActiveProfilesRepositoryComesAlongAndATestCn1libIsNotShipped() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0" + + "com.acmefixtures-lib1" + + "pomtest" + + "vendortrue" + + "vhttps://maven.vendor.example/repo" + + "com.vendorsdk" + + "2" + + "offx" + + "ohttps://maven.off.example/repo" + + "" + + ""); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("implementation(\"com.vendor:sdk:2\")"), build); + assertTrue(build.contains("maven(url = uri(\"https://maven.vendor.example/repo\"))"), build); + assertFalse(build.contains("maven.off.example"), "an undecided profile's repository stays out: " + build); + assertFalse(build.contains("\n cn1lib(\"com.acme:fixtures-lib"), "a test cn1lib is not shipped: " + build); + assertTrue(build.contains("// cn1lib(\"com.acme:fixtures-lib:1\") -- test-scoped"), build); + } + + @Test + void anAntProjectsSimulatorJarsBecomeJavaseDependencies() throws Exception { + File ant = antApp(); + touch(ant, "native/javase/vendor-sim.jar", "jar"); + touch(ant, "native/javase/a/MyNativeImpl.java", "package a; public class MyNativeImpl {}"); + File out = new File(tmp.toFile(), "out"); + converter().convert(ant, out, "1.0"); + assertTrue(new File(out, "libs/javase/vendor-sim.jar").isFile()); + assertFalse(new File(out, "src/javase/java/vendor-sim.jar").exists(), "not a source file"); + assertTrue(new File(out, "src/javase/java/a/MyNativeImpl.java").isFile()); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("javaseImplementation(files(\"libs/javase/vendor-sim.jar\"))"), build); + } + + @Test + void aJavaProjectCallingTheKotlinRuntimeKeepsTheStdlib() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0" + + "org.jetbrains.kotlinkotlin-stdlib" + + "2.0"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("implementation(\"org.jetbrains.kotlin:kotlin-stdlib:2.0\")"), + "no Kotlin plugin supplies it here: " + build); + } + + @Test + void sourceRootsThePomAddsAreCompiledAfterConversion() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0src/java" + + "${project.basedir}/assets" + + "org.codehaus.mojobuild-helper-maven-plugin" + + "add-source" + + "generated-by-hand" + + "add-test-source" + + "it/java" + + ""); + touch(mvn, "common/src/java/a/Moved.java", "package a; public class Moved {}"); + touch(mvn, "common/assets/logo.txt", "logo"); + touch(mvn, "common/generated-by-hand/a/Helper.java", "package a; public class Helper {}"); + touch(mvn, "common/it/java/a/ItTest.java", "package a; public class ItTest {}"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + assertTrue(new File(out, "src/main/java/a/Moved.java").isFile()); + assertFalse(new File(out, "src/java").exists(), "the uncompiled copy under src/ is gone"); + assertTrue(new File(out, "src/main/resources/logo.txt").isFile()); + assertTrue(new File(out, "src/main/java/a/Helper.java").isFile()); + assertTrue(new File(out, "src/test/java/a/ItTest.java").isFile()); + assertFalse(new File(out, "src/main/java/a/MvnApp.java").exists(), + " replaces src/main/java in Maven, so it is not compiled here either"); + } + + @Test + void resourcesMavenLeavesOutAreNotPackagedEither() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0" + + "src/main/resources**/*.env" + + "" + + "src/main/resourceskeep/prod.env" + + "" + + "extra**/*.json" + + "true" + + ""); + touch(mvn, "common/src/main/resources/app.properties", "a=b"); + touch(mvn, "common/src/main/resources/secrets/dev.env", "TOKEN=x"); + touch(mvn, "common/src/main/resources/keep/prod.env", "kept by the second entry"); + touch(mvn, "common/extra/data/config.json", "{}"); + touch(mvn, "common/extra/data/notes.txt", "not included"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + assertTrue(new File(out, "src/main/resources/app.properties").isFile()); + assertFalse(new File(out, "src/main/resources/secrets/dev.env").exists(), "excluded by the pom"); + assertTrue(new File(out, "src/main/resources/keep/prod.env").isFile(), "another entry includes it"); + assertTrue(new File(out, "src/main/resources/data/config.json").isFile()); + assertFalse(new File(out, "src/main/resources/data/notes.txt").exists(), "not among the includes"); + } + + @Test + void mavenPathPatternsMatchAsMavenDoes() { + assertTrue(GradleConversion.antMatches("**/*.env", "a/b/c.env")); + assertTrue(GradleConversion.antMatches("**/*.env", "c.env")); + assertFalse(GradleConversion.antMatches("*.env", "a/c.env")); + assertTrue(GradleConversion.antMatches("secrets/", "secrets/x/y.txt")); + assertTrue(GradleConversion.antMatches("a?c.txt", "abc.txt")); + assertFalse(GradleConversion.antMatches("a?c.txt", "a/c.txt")); + } + + @Test + void platformModuleDependenciesAreNotDroppedSilently() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0mvnapp-common" + + "org.exampleutil2.0" + + ""); + touch(mvn, "javase/pom.xml", "com.acmemvnapp" + + "1.0desk" + + "https://maven.desktop.example/repo" + + "com.acmemvnapp-common1.0" + + "" + + "com.codenameonecodenameone-javase" + + "1provided" + + "org.junit.jupiterjunit-jupiter" + + "5test" + + "org.exampledesktop-only3" + + "" + + "org.exampleutil2.0" + + ""); + touch(mvn, "android/pom.xml", "" + + "org.exampleandroid-sdk-glue" + + "4"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains(" javaseImplementation(\"org.example:desktop-only:3\")"), build); + assertTrue(build.contains("maven(url = uri(\"https://maven.desktop.example/repo\"))"), + "where the JavaSE dependency resolves from: " + build); + assertFalse(build.contains("mvnapp-common"), "the application's own module is not a dependency: " + build); + assertFalse(build.contains("junit-jupiter"), "the platform modules' tests are not converted: " + build); + assertFalse(build.contains("javaseImplementation(\"org.example:util"), "already common's: " + build); + assertTrue(build.contains(" // implementation(\"org.example:android-sdk-glue:4\")"), build); + assertTrue(build.contains("android/pom.xml declared these for the android build only"), build); + } + + @Test + void aBackendWhosePomWasChangedIsKeptEvenWithTheGeneratedCode() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "backend/pom.xml", BACKEND_POM_WITH_PAYMENTS); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + assertTrue(new File(out, "backend/src/main/java/a/backend/Api.java").isFile(), + "a library added to the pom is someone's work"); + assertTrue(read(new File(out, "backend/build.gradle.kts")).contains("org.example:payments:3.1")); + assertTrue(GradleConversion.pomAsGenerated(new File(mavenApp(UNTOUCHED_API), "backend/pom.xml")), + "the archetype's own pom is still the skeleton"); + } + + @Test + void whatThePomReplacesIsLeftOutAndTargetPathsAreKept() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0" + + "org.exampledriver9" + + "runtime" + + "src/app" + + "metaMETA-INF/services" + + ""); + touch(mvn, "common/src/app/a/Real.java", "package a; public class Real {}"); + touch(mvn, "common/src/main/resources/stale.properties", "x=y"); + touch(mvn, "common/meta/com.example.Spi", "a.Real"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + assertTrue(new File(out, "src/main/java/a/Real.java").isFile()); + assertFalse(new File(out, "src/main/java/a/MvnApp.java").exists(), + "Maven compiled src/app instead of src/main/java"); + assertFalse(new File(out, "src/main/resources/stale.properties").exists(), + "declaring leaves src/main/resources out"); + assertTrue(new File(out, "src/main/resources/META-INF/services/com.example.Spi").isFile(), + "the resource keeps its targetPath"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("runtimeOnly(\"org.example:driver:9\")"), build); + assertTrue(build.contains("testCompileOnly(\"org.example:driver:9\")"), + "Maven compiles the tests against runtime dependencies: " + build); + } + + @Test + void theIconTheSettingsNameComesAlong() throws Exception { + File ant = antApp(); + touch(ant, "codenameone_settings.properties", "codename1.mainName=AntApp\ncodename1.packageName=a\n" + + "codename1.icon=branding/app.png\n"); + touch(ant, "branding/app.png", "png"); + File out = new File(tmp.toFile(), "out"); + converter().convert(ant, out, "1.0"); + assertTrue(new File(out, "branding/app.png").isFile()); + assertTrue(read(new File(out, "codenameone_settings.properties")).contains("codename1.icon=branding/app.png")); + } + + @Test + void aScopeGivenByAPropertyIsTheScopeMavenUses() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0test" + + "org.examplefixtures" + + "1${fixtures.scope}"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("testImplementation(\"org.example:fixtures:1\")"), build); + assertFalse(build.contains("\n implementation(\"org.example:fixtures"), "not shipped: " + build); + } + + @Test + void aPropertyAnActiveProfileDefinesResolves() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0org.example" + + "driver${driver.version}" + + "defaultstrue" + + "3.4" + + ""); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("implementation(\"org.example:driver:3.4\")"), build); + } + + @Test + void aMainClassWithADollarStaysALiteralName() throws Exception { + assertEquals("Price\\$Tracker", GradleProjectTemplate.kotlinString("Price$Tracker")); + File ant = antApp(); + touch(ant, "codenameone_settings.properties", "codename1.mainName=Price$Tracker\ncodename1.packageName=a\n"); + File out = new File(tmp.toFile(), "out"); + converter().convert(ant, out, "1.0"); + assertTrue(read(new File(out, "settings.gradle.kts")).contains("rootProject.name = \"Price\\$Tracker\"")); + } + + @Test + void rootsNamedThroughPropertiesResolveAndAnUnknownOneDeletesNothing() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0generated-by-hand" + + "${gen}"); + touch(mvn, "common/generated-by-hand/a/Gen.java", "package a; public class Gen {}"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + assertTrue(new File(out, "src/main/java/a/Gen.java").isFile(), "the property resolves"); + + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0${undefined.dir}" + + ""); + File again = new File(tmp.toFile(), "again"); + converter().convert(mvn, again, "1.0"); + assertTrue(new File(again, "src/main/java/a/MvnApp.java").isFile(), + "a root that cannot be resolved does not cost the conventional sources"); + } + + @Test + void aScopeFromDependencyManagementApplies() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "pom.xml", "com.acmemvnapp" + + "1.0" + + "org.exampleservlet4" + + "provided"); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0org.example" + + "servlet"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("compileOnly(\"org.example:servlet:4\")"), build); + assertFalse(build.contains("\n implementation(\"org.example:servlet"), "not shipped: " + build); + } + + @Test + void sourceRootsAParentPomDeclaresApplyToTheChild() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "pom.xml", "com.acmemvnapp" + + "1.0src/app" + + "org.codehaus.mojobuild-helper-maven-plugin" + + "add-source" + + "extra" + + ""); + touch(mvn, "common/src/app/a/Inherited.java", "package a; public class Inherited {}"); + touch(mvn, "common/extra/a/Helper.java", "package a; public class Helper {}"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + assertTrue(new File(out, "src/main/java/a/Inherited.java").isFile(), "the parent's sourceDirectory"); + assertTrue(new File(out, "src/main/java/a/Helper.java").isFile(), "the parent's build-helper source"); + assertFalse(new File(out, "src/main/java/a/MvnApp.java").exists(), + "the inherited sourceDirectory replaces src/main/java"); + } + + @Test + void aJavaOnlyProjectGetsNoKotlinPlugin() throws Exception { + File ant = antApp(); + assertTrue(new File(ant, "src/a/Helper.kt").delete()); + File out = new File(tmp.toFile(), "out"); + converter().convert(ant, out, "1.0"); + assertFalse(read(new File(out, "build.gradle.kts")).contains("kotlin(")); + } + + @Test + void legacyCn1libsAreRefusedByName() throws Exception { + File ant = antApp(); + touch(ant, "lib/Maps.cn1lib", "zip"); + File out = new File(tmp.toFile(), "out"); + BuildFailureException ex = assertThrows(BuildFailureException.class, + () -> converter().convert(ant, out, "1.0")); + assertTrue(ex.getMessage().contains("Maps.cn1lib"), ex.getMessage()); + assertTrue(ex.getMessage().contains("cn1lib(\""), "the refusal says what to do instead"); + assertFalse(new File(out, "settings.gradle.kts").exists(), "nothing is half converted"); + } + + @Test + void aMavenProjectsCn1libsModuleIsRefusedToo() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "cn1libs/Maps/jars/main.zip", "zip"); + BuildFailureException ex = assertThrows(BuildFailureException.class, + () -> converter().convert(mvn, new File(tmp.toFile(), "out"), "1.0")); + assertTrue(ex.getMessage().contains("Maps"), ex.getMessage()); + } + + @Test + void aMavenProjectKeepsItsCodeAndDependencies() throws Exception { + File out = new File(tmp.toFile(), "out"); + converter().convert(mavenApp(UNTOUCHED_API), out, "1.0"); + + assertTrue(new File(out, "src/main/java/a/MvnApp.java").isFile()); + assertTrue(new File(out, "src/main/css/theme.css").isFile()); + assertTrue(new File(out, "src/android/java/a/MyNativeImpl.java").isFile()); + assertTrue(new File(out, "src/android/resources/android.png").isFile()); + assertFalse(new File(out, "src/ios").exists(), "an empty native tree is not carried over"); + assertFalse(new File(out, "backend").exists(), "the untouched skeleton is left behind"); + assertFalse(new File(out, "pom.xml").exists()); + assertFalse(new File(out, "common").exists()); + + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("cn1lib(\"com.acme:maps-lib:1.2\")"), + "a property version resolves through the parent pom: " + build); + assertTrue(build.contains("cn1lib(\"com.codenameone:googlemaps-lib:1.0\")"), + "a cn1lib in the com.codenameone group is not the framework: " + build); + assertTrue(build.contains("implementation(\"org.example:util:2.0\")"), build); + assertTrue(build.contains("// compileOnly(\"org.example:api:VERSION\") -- set the version"), + "a version no property defines is commented out, not written as a Kotlin template: " + build); + assertFalse(build.contains("\"org.example:api:${"), build); + assertTrue(build.contains("implementation(\"org.example:managed:5.0\")"), + "a version left to the parent's dependencyManagement is resolved: " + build); + assertTrue(build.contains("// implementation(\"org.example:unmanaged:VERSION\")"), + "an unresolvable managed version is commented out, not left versionless: " + build); + assertTrue(build.contains("implementation(\"org.example:natives:2:linux@zip\")"), + "the classifier and type select the same artifact Maven used: " + build); + assertTrue(build.contains("testImplementation(\"org.example:fixtures:3:tests\")"), build); + assertFalse(build.contains("codenameone-core"), "the plugin adds the framework: " + build); + assertTrue(build.contains("testImplementation(\"junit:junit:4\")"), + "the copied tests keep their libraries: " + build); + } + + @Test + void aBackendWithCodeOfItsOwnIsConverted() throws Exception { + File out = new File(tmp.toFile(), "out"); + int end = UNTOUCHED_API.lastIndexOf('}'); + String api = UNTOUCHED_API.substring(0, end) + + " @GetMapping(\"/orders\") public String o() { return \"\"; }\n}\n"; + File mvn = mavenApp(api); + touch(mvn, "backend/pom.xml", BACKEND_POM_WITH_PAYMENTS); + touch(mvn, "backend/application-prod.properties", "cn1.server.port=80\n"); + converter().convert(mvn, out, "1.0"); + assertTrue(new File(out, "backend/src/main/java/a/backend/Api.java").isFile()); + assertTrue(new File(out, "backend/application.properties").isFile()); + assertTrue(new File(out, "backend/application-prod.properties").isFile()); + assertFalse(new File(out, "backend/pom.xml").exists()); + String backendBuild = read(new File(out, "backend/build.gradle.kts")); + assertTrue(backendBuild.contains("implementation(\"org.example:payments:3.1\")"), + "the backend keeps its own libraries: " + backendBuild); + assertFalse(backendBuild.contains("codenameone-backend"), "the plugin adds the runtime: " + backendBuild); + assertFalse(backendBuild.contains("kotlin("), "a Java backend needs no Kotlin plugin: " + backendBuild); + } + + @Test + void aBackendWhoseServiceChangedIsConverted() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "backend/src/main/java/a/backend/Greeter.java", + UNTOUCHED_GREETER.replace("\"Hello, \"", "\"Welcome, \"")); + assertFalse(GradleConversion.isUntouchedSkeleton(new File(mvn, "backend"))); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + assertTrue(read(new File(out, "backend/src/main/java/a/backend/Greeter.java")).contains("Welcome, "), + "the changed service is someone's work and is kept"); + } + + @Test + void anOlderSkeletonWithoutItsServiceIsNotUntouched() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + new File(mvn, "backend/src/main/java/a/backend/Greeter.java").delete(); + assertFalse(GradleConversion.isUntouchedSkeleton(new File(mvn, "backend")), + "an Api that names a Greeter nobody declares is not the generated skeleton"); + } + + @Test + void aKotlinBackendGetsTheKotlinPluginInItsOwnScript() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "backend/src/main/kotlin/a/backend/Orders.kt", "package a.backend\nclass Orders"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + assertTrue(read(new File(out, "backend/build.gradle.kts")).contains("kotlin(\"jvm\")")); + assertFalse(read(new File(out, "build.gradle.kts")).contains("kotlin(\"jvm\")"), + "the application has no Kotlin of its own"); + } + + /// Gradle refuses a version for a plugin the root project already put on + /// the classpath, so a Kotlin backend under a Kotlin application names none. + @Test + void aKotlinBackendUnderAKotlinAppDeclaresNoSecondVersion() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "common/src/main/kotlin/a/Helper.kt", "package a\nclass Helper"); + touch(mvn, "backend/src/main/kotlin/a/backend/Orders.kt", "package a.backend\nclass Orders"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + assertTrue(read(new File(out, "build.gradle.kts")).contains("kotlin(\"jvm\") version")); + String backend = read(new File(out, "backend/build.gradle.kts")); + assertTrue(backend.contains("kotlin(\"jvm\")") && !backend.contains("kotlin(\"jvm\") version"), backend); + } + + @Test + void theUntouchedSkeletonIsKeptOnRequest() throws Exception { + File out = new File(tmp.toFile(), "out"); + converter().includeUntouchedBackend(true).convert(mavenApp(UNTOUCHED_API), out, "1.0"); + assertTrue(new File(out, "backend/application.properties").isFile()); + } + + @Test + void refusesANonEmptyTargetAndAGradleSource() throws Exception { + File ant = antApp(); + File out = new File(tmp.toFile(), "out"); + touch(out, "keep.txt", "x"); + assertThrows(BuildFailureException.class, () -> converter().convert(ant, out, "1.0")); + + File converted = new File(tmp.toFile(), "converted"); + converter().convert(ant, converted, "1.0"); + assertThrows(BuildFailureException.class, + () -> converter().convert(converted, new File(tmp.toFile(), "again"), "1.0")); + } + + /// The effective dependencies, as Maven sees them: inherited ones, the + /// exclusions, a system jar, the pom's repositories, and only the Kotlin + /// artifacts the Kotlin plugin supplies left out. + @Test + void dependenciesConvertAsMavenResolvesThem() throws Exception { + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "pom.xml", "com.acmemvnapp" + + "1.01.2" + + "acmehttps://maven.acme.example/releases" + + "centralhttps://repo.maven.apache.org/maven2" + + "" + + "org.exampleinherited" + + "7"); + touch(mvn, "common/lib/vendor.jar", "jar"); + touch(mvn, "common/pom.xml", "com.acmemvnapp" + + "1.0" + + "org.exampleheavy1" + + "commons-loggingcommons-logging" + + "" + + "com.vendorvendor1" + + "system${basedir}/lib/vendor.jar" + + "org.jetbrains.kotlinkotlin-stdlib" + + "2.0" + + "org.jetbrains.kotlinkotlin-reflect" + + "2.0" + + ""); + // Kotlin sources: the Kotlin plugin is applied and supplies the stdlib. + touch(mvn, "common/src/main/kotlin/a/K.kt", "package a\nclass K"); + File out = new File(tmp.toFile(), "out"); + converter().convert(mvn, out, "1.0"); + String build = read(new File(out, "build.gradle.kts")); + assertTrue(build.contains("implementation(\"org.example:inherited:7\")"), "inherited from the parent: " + build); + assertTrue(build.contains("implementation(\"org.example:heavy:1\") {\n" + + " exclude(group = \"commons-logging\", module = \"commons-logging\")\n }"), build); + assertTrue(build.contains("implementation(files(\"libs/vendor.jar\"))"), build); + assertTrue(new File(out, "libs/vendor.jar").isFile(), "the system jar is carried over"); + assertTrue(build.contains("kotlin-reflect:2.0"), "kotlin-reflect is not the plugin's to supply: " + build); + assertFalse(build.contains("kotlin-stdlib"), build); + assertTrue(build.contains("maven(url = uri(\"https://maven.acme.example/releases\"))"), build); + assertFalse(build.contains("repo.maven.apache.org"), "Central needs no declaration: " + build); + assertTrue(build.indexOf("repositories {") < build.indexOf("dependencies {"), build); + } + + /// Keeping the two generated routes is not the same as being untouched. + @Test + void aBackendWithCustomizedGeneratedRoutesIsKept() throws Exception { + File out = new File(tmp.toFile(), "out"); + converter().convert(mavenApp(UNTOUCHED_API.replace("return \"ok\";", "return audit(\"ok\");") + .replace("public class Api {", "public class Api {\n String audit(String s) { return s; }")), out, + "1.0"); + assertTrue(new File(out, "backend/src/main/java/a/backend/Api.java").isFile(), + "a changed route body is someone's work, and is converted"); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java b/maven/build-engine/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSProvisioningPreflightTest.java b/maven/build-engine/src/test/java/com/codename1/maven/IOSProvisioningPreflightTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSProvisioningPreflightTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/IOSProvisioningPreflightTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/LibraryHintMergerTest.java b/maven/build-engine/src/test/java/com/codename1/maven/LibraryHintMergerTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/LibraryHintMergerTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/LibraryHintMergerTest.java diff --git a/maven/build-engine/src/test/java/com/codename1/maven/NativeInterfacesTest.java b/maven/build-engine/src/test/java/com/codename1/maven/NativeInterfacesTest.java new file mode 100644 index 00000000000..adb6a372fc5 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/NativeInterfacesTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2012, 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.maven; + +import com.codename1.build.BuildFailureException; +import com.codename1.build.SystemStreamLog; +import com.codename1.maven.stubgen.TestNativeInterface; +import com.codename1.project.BuildSystem; +import com.codename1.project.NativePlatform; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The native-interface lifecycle on a Gradle-shaped project that starts with +/// no platform directories at all. +class NativeInterfacesTest { + @TempDir + File tmp; + + /// A classes directory holding only the fixture interface, so the scan + /// finds exactly one. + private File classes() throws Exception { + File dir = new File(tmp, "classes"); + String path = TestNativeInterface.class.getName().replace('.', '/') + ".class"; + File out = new File(dir, path); + out.getParentFile().mkdirs(); + try (InputStream in = TestNativeInterface.class.getClassLoader().getResourceAsStream(path)) { + Files.copy(in, out.toPath()); + } + return dir; + } + + private ProjectLayout gradle() { + File root = new File(tmp, "app"); + root.mkdirs(); + return ProjectLayouts.of(BuildSystem.GRADLE, ProjectKind.APP, root, root); + } + + @Test + void generatesOnlyIntoTheLayoutsNativeDirectories() throws Exception { + ProjectLayout layout = gradle(); + NativeInterfaces ni = new NativeInterfaces(new SystemStreamLog(), classes(), Collections.emptyList()); + assertEquals(Collections.singletonList(TestNativeInterface.class.getName()), ni.find()); + assertTrue(layout.existingNativePlatforms().isEmpty(), "nothing exists before generation"); + + List written = ni.generate(layout, null, false, false, false, "overwrite"); + assertFalse(written.isEmpty()); + String javase = TestNativeInterface.class.getName().replace('.', File.separatorChar) + "Impl.java"; + assertTrue(new File(layout.nativeSourceDir(NativePlatform.JAVASE), javase).isFile()); + assertTrue(new File(layout.nativeSourceDir(NativePlatform.ANDROID), javase).isFile()); + assertTrue(new File(new File(layout.rootDir(), "src/ios/objectivec"), + TestNativeInterface.class.getName().replace('.', '_') + "Impl.m").isFile()); + // No Maven-style module directories. + assertFalse(new File(layout.rootDir(), "javase").exists()); + assertFalse(new File(layout.rootDir(), "android").exists()); + } + + @Test + void doesNotOverwriteUnlessAsked() throws Exception { + ProjectLayout layout = gradle(); + NativeInterfaces ni = new NativeInterfaces(new SystemStreamLog(), classes(), Collections.emptyList()); + ni.generate(layout, null, false, false, false, "overwrite"); + File impl = new File(layout.nativeSourceDir(NativePlatform.JAVASE), + TestNativeInterface.class.getName().replace('.', File.separatorChar) + "Impl.java"); + Files.write(impl.toPath(), "// mine".getBytes("UTF-8")); + + assertTrue(ni.generate(layout, null, false, false, false, "overwrite").isEmpty()); + assertEquals("// mine", new String(Files.readAllBytes(impl.toPath()), "UTF-8")); + + ni.generate(layout, null, false, false, true, "overwrite"); + assertFalse("// mine".equals(new String(Files.readAllBytes(impl.toPath()), "UTF-8"))); + } + + @Test + void reportsAMissingImplementationWithTheFileToCreate() throws Exception { + ProjectLayout layout = gradle(); + NativeInterfaces ni = new NativeInterfaces(new SystemStreamLog(), classes(), Collections.emptyList()); + assertEquals(1, ni.missingImplementations(layout, NativePlatform.IOS).size()); + + ni.generate(layout, null, false, false, false, "overwrite"); + assertTrue(ni.missingImplementations(layout, NativePlatform.IOS).isEmpty()); + assertTrue(ni.missingImplementations(layout, NativePlatform.JAVASE).isEmpty()); + + File impl = new File(layout.nativeSourceDir(NativePlatform.JAVASE), + TestNativeInterface.class.getName().replace('.', File.separatorChar) + "Impl.java"); + assertTrue(impl.delete()); + List missing = ni.missingImplementations(layout, NativePlatform.JAVASE); + assertEquals(1, missing.size()); + assertTrue(missing.get(0).contains(impl.getPath()), missing.get(0)); + } + + @Test + void anUnknownInterfaceNameIsRefused() throws Exception { + NativeInterfaces ni = new NativeInterfaces(new SystemStreamLog(), classes(), Collections.emptyList()); + assertThrows(BuildFailureException.class, + () -> ni.generate(gradle(), "NoSuchThing", false, false, false, "overwrite")); + ni.generate(gradle(), "TestNativeInterface", false, false, false, "overwrite"); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/StubGeneratorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/StubGeneratorTest.java similarity index 78% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/StubGeneratorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/StubGeneratorTest.java index 42b6b1d676a..90cb26d3932 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/StubGeneratorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/StubGeneratorTest.java @@ -1,7 +1,29 @@ +/* + * 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.maven; import com.codename1.maven.stubgen.TestNativeInterface; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.jupiter.api.Test; import java.io.File; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/SvgTranscodeRunnerScreeningTest.java b/maven/build-engine/src/test/java/com/codename1/maven/SvgTranscodeRunnerScreeningTest.java similarity index 97% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/SvgTranscodeRunnerScreeningTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/SvgTranscodeRunnerScreeningTest.java index d4f67f46728..fc55af70ece 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/SvgTranscodeRunnerScreeningTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/SvgTranscodeRunnerScreeningTest.java @@ -22,8 +22,8 @@ */ package com.codename1.maven; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.builders.BuildException; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -65,7 +65,7 @@ public void runFailsOnAnUnreadableSource() throws Exception { try { runner(basedir).run(); fail("the transcoder must report a source it cannot read"); - } catch (MojoExecutionException expected) { + } catch (BuildException expected) { assertTrue("names the offending file: " + expected.getMessage(), expected.getMessage().contains("anim.lottie")); } diff --git a/maven/build-engine/src/test/java/com/codename1/maven/TestProjectHost.java b/maven/build-engine/src/test/java/com/codename1/maven/TestProjectHost.java new file mode 100644 index 00000000000..a431b132290 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/TestProjectHost.java @@ -0,0 +1,179 @@ +/* + * 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.maven; + +import com.codename1.build.BuildArtifact; +import com.codename1.build.Log; +import com.codename1.build.ProjectHost; +import com.codename1.build.SystemStreamLog; +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Properties; +import java.util.Set; + +/// A [ProjectHost] for engine tests: every answer is a public field. +public class TestProjectHost implements ProjectHost { + public Log log = new SystemStreamLog(); + public File projectDir = new File("."); + public File buildDir = new File("build"); + public File outputDir = new File("build/classes"); + public String finalName = "test"; + public String groupId = "com.example"; + public Properties projectProperties = new Properties(); + public Properties userProperties = new Properties(); + public List classpath = new ArrayList(); + public List sourceRoots = new ArrayList(); + public List artifacts = new ArrayList(); + public long sourcesModified; + + /// A dependency with the given scope, as a resolved build artifact. + public static BuildArtifact artifact(String artifactId, String scope, File file) { + return new BuildArtifact("com.example", artifactId, "1.0", null, "jar", scope, file, null); + } + + @Override + public Log log() { + return log; + } + + @Override + public ProjectLayout layout() { + return ProjectLayouts.of(BuildSystem.GRADLE, ProjectKind.APP, projectDir, projectDir); + } + + @Override + public File cn1ProjectDir() { + return projectDir; + } + + @Override + public File baseDir() { + return projectDir; + } + + @Override + public File buildDirectory() { + return buildDir; + } + + @Override + public File outputDirectory() { + return outputDir; + } + + @Override + public String finalName() { + return finalName; + } + + @Override + public String groupId() { + return groupId; + } + + @Override + public Properties projectProperties() { + return projectProperties; + } + + @Override + public Properties userProperties() { + return userProperties; + } + + @Override + public List compileClasspathElements() { + return classpath; + } + + @Override + public List runtimeClasspathElements() { + return classpath; + } + + @Override + public List compileSourceRoots() { + return sourceRoots; + } + + @Override + public Collection artifacts() { + return artifacts; + } + + @Override + public Set neededWithoutDesktopRuntime() { + return null; + } + + @Override + public File getJar(BuildArtifact artifact) { + return artifact.getFile(); + } + + @Override + public File getJar(String groupId, String artifactId, String classifier) { + for (BuildArtifact a : artifacts) { + if (a.getGroupId().equals(groupId) && a.getArtifactId().equals(artifactId)) { + return a.getFile(); + } + } + return null; + } + + @Override + public long sessionStartTime() { + return Long.MAX_VALUE; + } + + @Override + public long sourcesModificationTime() { + return sourcesModified; + } + + @Override + public void attachArtifact(String type, String classifier, File file) { + } + + @Override + public String codenameOneVersion() { + return "8.0-SNAPSHOT"; + } + + @Override + public String pluginVersion() { + return "8.0-SNAPSHOT"; + } + + /// An empty host. + public static TestProjectHost empty() { + return new TestProjectHost(); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/annotations/ClassScannerTest.java b/maven/build-engine/src/test/java/com/codename1/maven/annotations/ClassScannerTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/annotations/ClassScannerTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/annotations/ClassScannerTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/AppIntentAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/AppIntentAnnotationProcessorTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/AppIntentAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/AppIntentAnnotationProcessorTest.java index 2c562843233..6a83754d137 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/AppIntentAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/AppIntentAnnotationProcessorTest.java @@ -27,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index b517b96192d..e70271c02f1 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -29,7 +29,7 @@ import com.codename1.maven.annotations.ClassScanner; import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java similarity index 95% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java index b7da665714c..95e0bebbc04 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java @@ -1,6 +1,24 @@ /* * 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.maven.processors; @@ -9,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BuildHintAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BuildHintAnnotationProcessorTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BuildHintAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/BuildHintAnnotationProcessorTest.java index af15a162fbc..e7d0618e764 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BuildHintAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BuildHintAnnotationProcessorTest.java @@ -27,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessorTest.java similarity index 89% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessorTest.java index e7bfaa87249..fbc58791448 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/GraphQLClientAnnotationProcessorTest.java @@ -1,5 +1,24 @@ /* * 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.maven.processors; @@ -8,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GrpcClientAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/GrpcClientAnnotationProcessorTest.java similarity index 90% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GrpcClientAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/GrpcClientAnnotationProcessorTest.java index e0b372e01cc..4c0ab08231b 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/GrpcClientAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/GrpcClientAnnotationProcessorTest.java @@ -1,5 +1,24 @@ /* * 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.maven.processors; @@ -8,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/JavaBeansAccessorMappingTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/JavaBeansAccessorMappingTest.java similarity index 88% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/JavaBeansAccessorMappingTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/JavaBeansAccessorMappingTest.java index ff00816daf9..04b67496073 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/JavaBeansAccessorMappingTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/JavaBeansAccessorMappingTest.java @@ -1,5 +1,24 @@ /* * 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.maven.processors; @@ -8,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java index 9b9c60b747d..168f819d10a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java @@ -27,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java index 354db7de79d..6eb715c3bb5 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java @@ -28,7 +28,7 @@ import com.codename1.maven.annotations.ProcessingException; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/OrmEnhancerTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmEnhancerTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/OrmEnhancerTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/OrmEnhancerTest.java index 9397e3966ad..a5a1e3071cd 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/OrmEnhancerTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmEnhancerTest.java @@ -23,7 +23,7 @@ package com.codename1.maven.processors; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessorTest.java similarity index 90% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessorTest.java index c1e3b0334f1..6e50ba1cec7 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/ProtoMessageAnnotationProcessorTest.java @@ -1,5 +1,24 @@ /* * 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.maven.processors; @@ -8,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RecordMappingTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RecordMappingTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RecordMappingTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/RecordMappingTest.java index 615ea242f7c..d31b9d5bd96 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RecordMappingTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RecordMappingTest.java @@ -27,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java similarity index 90% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java index 975f1314b03..0452539e4e5 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java @@ -1,5 +1,24 @@ /* * 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.maven.processors; @@ -8,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java index 1705867f932..bac4665b19c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java @@ -28,7 +28,7 @@ import com.codename1.maven.annotations.ProcessorContext; import com.codename1.backend.HttpServer; import com.codename1.backend.Json; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestServerAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestServerAnnotationProcessorTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestServerAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/RestServerAnnotationProcessorTest.java index 1f910f80205..e7384385dcd 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestServerAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestServerAnnotationProcessorTest.java @@ -26,7 +26,7 @@ import com.codename1.maven.annotations.ClassScanner; import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.After; import org.junit.Before; import org.junit.Rule; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RouteAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RouteAnnotationProcessorTest.java similarity index 92% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RouteAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/RouteAnnotationProcessorTest.java index 6aae04565f6..37a44d0909c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RouteAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RouteAnnotationProcessorTest.java @@ -1,6 +1,24 @@ /* * 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.maven.processors; @@ -9,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/TelemetryAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/TelemetryAnnotationProcessorTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/TelemetryAnnotationProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/TelemetryAnnotationProcessorTest.java index f20738e6bf8..6d048204bbc 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/TelemetryAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/TelemetryAnnotationProcessorTest.java @@ -27,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/WebSocketMappingProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/WebSocketMappingProcessorTest.java similarity index 99% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/WebSocketMappingProcessorTest.java rename to maven/build-engine/src/test/java/com/codename1/maven/processors/WebSocketMappingProcessorTest.java index 7796eeebd18..c1708c9a505 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/WebSocketMappingProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/WebSocketMappingProcessorTest.java @@ -27,7 +27,7 @@ import com.codename1.maven.annotations.JavaSourceCompiler; import com.codename1.maven.annotations.ProcessorContext; import com.codename1.backend.HttpServer; -import org.apache.maven.plugin.logging.SystemStreamLog; +import com.codename1.build.SystemStreamLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; diff --git a/maven/build-engine/src/test/java/com/codename1/maven/stubgen/TestNativeInterface.java b/maven/build-engine/src/test/java/com/codename1/maven/stubgen/TestNativeInterface.java new file mode 100644 index 00000000000..253a1b3dd33 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/stubgen/TestNativeInterface.java @@ -0,0 +1,29 @@ +/* + * 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.maven.stubgen; + +import com.codename1.system.NativeInterface; + +public interface TestNativeInterface extends NativeInterface { + String hello(String name); +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSAppIntentsBuilderTest.java b/maven/build-engine/src/test/java/com/codename1/util/IOSAppIntentsBuilderTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSAppIntentsBuilderTest.java rename to maven/build-engine/src/test/java/com/codename1/util/IOSAppIntentsBuilderTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSDocumentProviderExtensionBuilderTest.java b/maven/build-engine/src/test/java/com/codename1/util/IOSDocumentProviderExtensionBuilderTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSDocumentProviderExtensionBuilderTest.java rename to maven/build-engine/src/test/java/com/codename1/util/IOSDocumentProviderExtensionBuilderTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSNotificationContentExtensionBuilderTest.java b/maven/build-engine/src/test/java/com/codename1/util/IOSNotificationContentExtensionBuilderTest.java similarity index 76% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSNotificationContentExtensionBuilderTest.java rename to maven/build-engine/src/test/java/com/codename1/util/IOSNotificationContentExtensionBuilderTest.java index 7cc367e57de..e3e9205696f 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSNotificationContentExtensionBuilderTest.java +++ b/maven/build-engine/src/test/java/com/codename1/util/IOSNotificationContentExtensionBuilderTest.java @@ -1,3 +1,25 @@ +/* + * 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.util; import org.junit.jupiter.api.Test; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSShareExtensionBuilderTest.java b/maven/build-engine/src/test/java/com/codename1/util/IOSShareExtensionBuilderTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSShareExtensionBuilderTest.java rename to maven/build-engine/src/test/java/com/codename1/util/IOSShareExtensionBuilderTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWalletExtensionBuilderTest.java b/maven/build-engine/src/test/java/com/codename1/util/IOSWalletExtensionBuilderTest.java similarity index 87% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWalletExtensionBuilderTest.java rename to maven/build-engine/src/test/java/com/codename1/util/IOSWalletExtensionBuilderTest.java index 35c2060d5bc..9dde8dc8eea 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWalletExtensionBuilderTest.java +++ b/maven/build-engine/src/test/java/com/codename1/util/IOSWalletExtensionBuilderTest.java @@ -1,3 +1,25 @@ +/* + * 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.util; import org.junit.jupiter.api.Test; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchFamilyTest.java b/maven/build-engine/src/test/java/com/codename1/util/IOSWidgetExtensionWatchFamilyTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchFamilyTest.java rename to maven/build-engine/src/test/java/com/codename1/util/IOSWidgetExtensionWatchFamilyTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchTargetTest.java b/maven/build-engine/src/test/java/com/codename1/util/IOSWidgetExtensionWatchTargetTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/IOSWidgetExtensionWatchTargetTest.java rename to maven/build-engine/src/test/java/com/codename1/util/IOSWidgetExtensionWatchTargetTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java b/maven/build-engine/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java rename to maven/build-engine/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfaceKindFamiliesTest.java b/maven/build-engine/src/test/java/com/codename1/util/SurfaceKindFamiliesTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfaceKindFamiliesTest.java rename to maven/build-engine/src/test/java/com/codename1/util/SurfaceKindFamiliesTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfacesSwiftWatchPortabilityTest.java b/maven/build-engine/src/test/java/com/codename1/util/SurfacesSwiftWatchPortabilityTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/SurfacesSwiftWatchPortabilityTest.java rename to maven/build-engine/src/test/java/com/codename1/util/SurfacesSwiftWatchPortabilityTest.java diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/VpnTunnelExtensionTest.java b/maven/build-engine/src/test/java/com/codename1/util/VpnTunnelExtensionTest.java similarity index 100% rename from maven/codenameone-maven-plugin/src/test/java/com/codename1/util/VpnTunnelExtensionTest.java rename to maven/build-engine/src/test/java/com/codename1/util/VpnTunnelExtensionTest.java diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/README.md b/maven/build-engine/src/test/resources/matter-bridge-stubs/README.md similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/README.md rename to maven/build-engine/src/test/resources/matter-bridge-stubs/README.md diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/app/Activity.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/android/app/Activity.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/app/Activity.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/android/app/Activity.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/content/ActivityNotFoundException.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/android/content/ActivityNotFoundException.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/content/ActivityNotFoundException.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/android/content/ActivityNotFoundException.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/content/ComponentName.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/android/content/ComponentName.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/content/ComponentName.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/android/content/ComponentName.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/content/Intent.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/android/content/Intent.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/content/Intent.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/android/content/Intent.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/content/IntentSender.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/android/content/IntentSender.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/content/IntentSender.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/android/content/IntentSender.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/content/pm/PackageManager.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/android/content/pm/PackageManager.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/content/pm/PackageManager.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/android/content/pm/PackageManager.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/net/Uri.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/android/net/Uri.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/net/Uri.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/android/net/Uri.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/provider/Settings.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/android/provider/Settings.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/android/provider/Settings.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/android/provider/Settings.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/AndroidNativeUtil.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/AndroidNativeUtil.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/AndroidNativeUtil.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/AndroidNativeUtil.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/AndroidSmartHomeSupport.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/AndroidSmartHomeSupport.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/AndroidSmartHomeSupport.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/AndroidSmartHomeSupport.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/CodenameOneActivity.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/CodenameOneActivity.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/CodenameOneActivity.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/CodenameOneActivity.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/IntentResultListener.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/IntentResultListener.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/IntentResultListener.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/codename1/impl/android/IntentResultListener.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/common/ConnectionResult.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/common/ConnectionResult.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/common/ConnectionResult.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/common/ConnectionResult.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/common/GoogleApiAvailability.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/common/GoogleApiAvailability.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/common/GoogleApiAvailability.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/common/GoogleApiAvailability.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/Matter.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/Matter.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/Matter.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/Matter.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/commissioning/CommissioningRequest.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/commissioning/CommissioningRequest.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/commissioning/CommissioningRequest.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/commissioning/CommissioningRequest.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/commissioning/CommissioningResult.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/commissioning/CommissioningResult.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/commissioning/CommissioningResult.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/home/matter/commissioning/CommissioningResult.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/OnFailureListener.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/OnFailureListener.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/OnFailureListener.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/OnFailureListener.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/OnSuccessListener.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/OnSuccessListener.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/OnSuccessListener.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/OnSuccessListener.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/Task.javas b/maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/Task.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/Task.javas rename to maven/build-engine/src/test/resources/matter-bridge-stubs/com/google/android/gms/tasks/Task.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/README.md b/maven/build-engine/src/test/resources/wear-surface-stubs/README.md similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/README.md rename to maven/build-engine/src/test/resources/wear-surface-stubs/README.md diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/app/PendingIntent.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/app/PendingIntent.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/app/PendingIntent.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/app/PendingIntent.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Context.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/content/Context.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Context.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/content/Context.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Intent.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/content/Intent.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/Intent.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/content/Intent.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Configuration.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/content/res/Configuration.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Configuration.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/content/res/Configuration.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Resources.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/content/res/Resources.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/content/res/Resources.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/content/res/Resources.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/Bitmap.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/graphics/Bitmap.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/Bitmap.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/graphics/Bitmap.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/drawable/Icon.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/graphics/drawable/Icon.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/graphics/drawable/Icon.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/graphics/drawable/Icon.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/net/Uri.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/net/Uri.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/net/Uri.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/net/Uri.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/os/Build.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/os/Build.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/os/Build.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/os/Build.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/DisplayMetrics.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/util/DisplayMetrics.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/DisplayMetrics.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/util/DisplayMetrics.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/Log.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/android/util/Log.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/android/util/Log.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/android/util/Log.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/concurrent/futures/CallbackToFutureAdapter.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/concurrent/futures/CallbackToFutureAdapter.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/concurrent/futures/CallbackToFutureAdapter.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/concurrent/futures/CallbackToFutureAdapter.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ActionBuilders.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ActionBuilders.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ActionBuilders.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ActionBuilders.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ColorBuilders.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ColorBuilders.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ColorBuilders.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ColorBuilders.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/DimensionBuilders.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/DimensionBuilders.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/DimensionBuilders.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/DimensionBuilders.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/LayoutElementBuilders.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/LayoutElementBuilders.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/LayoutElementBuilders.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/LayoutElementBuilders.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ModifiersBuilders.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ModifiersBuilders.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ModifiersBuilders.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ModifiersBuilders.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ResourceBuilders.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ResourceBuilders.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ResourceBuilders.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/ResourceBuilders.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/TimelineBuilders.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/TimelineBuilders.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/TimelineBuilders.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/protolayout/TimelineBuilders.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/RequestBuilders.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/tiles/RequestBuilders.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/RequestBuilders.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/tiles/RequestBuilders.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileBuilders.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileBuilders.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileBuilders.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileBuilders.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileService.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileService.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileService.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/tiles/TileService.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationData.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationData.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationData.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationData.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationText.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationText.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationText.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationText.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationType.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationType.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationType.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ComplicationType.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountDownTimeReference.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountDownTimeReference.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountDownTimeReference.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountDownTimeReference.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountUpTimeReference.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountUpTimeReference.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountUpTimeReference.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/CountUpTimeReference.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/LongTextComplicationData.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/LongTextComplicationData.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/LongTextComplicationData.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/LongTextComplicationData.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImage.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImage.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImage.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImage.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImageComplicationData.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImageComplicationData.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImageComplicationData.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/MonochromaticImageComplicationData.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/NoDataComplicationData.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/NoDataComplicationData.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/NoDataComplicationData.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/NoDataComplicationData.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/PlainComplicationText.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/PlainComplicationText.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/PlainComplicationText.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/PlainComplicationText.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/RangedValueComplicationData.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/RangedValueComplicationData.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/RangedValueComplicationData.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/RangedValueComplicationData.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ShortTextComplicationData.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ShortTextComplicationData.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ShortTextComplicationData.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/ShortTextComplicationData.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceComplicationText.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceComplicationText.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceComplicationText.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceComplicationText.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceStyle.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceStyle.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceStyle.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeDifferenceStyle.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeRange.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeRange.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeRange.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/data/TimeRange.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataTimeline.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataTimeline.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataTimeline.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationDataTimeline.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationRequest.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationRequest.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationRequest.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/ComplicationRequest.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimeInterval.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimeInterval.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimeInterval.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimeInterval.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimelineEntry.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimelineEntry.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimelineEntry.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/androidx/wear/watchface/complications/datasource/TimelineEntry.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/com/google/common/util/concurrent/ListenableFuture.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/com/google/common/util/concurrent/ListenableFuture.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/com/google/common/util/concurrent/ListenableFuture.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/com/google/common/util/concurrent/ListenableFuture.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONArray.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/org/json/JSONArray.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONArray.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/org/json/JSONArray.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas b/maven/build-engine/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas rename to maven/build-engine/src/test/resources/wear-surface-stubs/org/json/JSONObject.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/app/Service.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/app/Service.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/app/Service.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/app/Service.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Context.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/content/Context.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Context.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/content/Context.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Intent.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/content/Intent.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/Intent.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/content/Intent.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/SharedPreferences.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/content/SharedPreferences.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/SharedPreferences.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/content/SharedPreferences.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/ApplicationInfo.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/content/pm/ApplicationInfo.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/ApplicationInfo.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/content/pm/ApplicationInfo.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/PackageManager.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/content/pm/PackageManager.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/content/pm/PackageManager.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/content/pm/PackageManager.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/net/Uri.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/net/Uri.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/net/Uri.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/net/Uri.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Handler.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/os/Handler.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Handler.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/os/Handler.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Looper.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/os/Looper.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/os/Looper.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/os/Looper.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Base64.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/util/Base64.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Base64.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/util/Base64.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Log.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/android/util/Log.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/android/util/Log.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/android/util/Log.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/common/data/Freezable.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/common/data/Freezable.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/common/data/Freezable.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/common/data/Freezable.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnCompleteListener.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnCompleteListener.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnCompleteListener.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnCompleteListener.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnFailureListener.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnFailureListener.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnFailureListener.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/OnFailureListener.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Task.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Task.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Task.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Task.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Tasks.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Tasks.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Tasks.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/tasks/Tasks.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Asset.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Asset.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Asset.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Asset.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityClient.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityClient.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityClient.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityClient.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityInfo.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityInfo.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityInfo.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/CapabilityInfo.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataClient.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataClient.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataClient.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataClient.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEvent.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEvent.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEvent.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEvent.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEventBuffer.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEventBuffer.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEventBuffer.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataEventBuffer.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItem.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItem.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItem.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItem.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemAsset.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemAsset.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemAsset.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemAsset.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemBuffer.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemBuffer.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemBuffer.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataItemBuffer.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMap.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMap.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMap.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMap.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMapItem.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMapItem.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMapItem.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/DataMapItem.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageClient.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageClient.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageClient.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageClient.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageEvent.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageEvent.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageEvent.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/MessageEvent.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Node.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Node.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Node.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Node.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/NodeClient.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/NodeClient.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/NodeClient.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/NodeClient.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataMapRequest.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataMapRequest.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataMapRequest.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataMapRequest.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataRequest.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataRequest.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataRequest.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/PutDataRequest.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Wearable.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Wearable.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Wearable.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/Wearable.javas diff --git a/maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/WearableListenerService.javas b/maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/WearableListenerService.javas similarity index 100% rename from maven/codenameone-maven-plugin/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/WearableListenerService.javas rename to maven/build-engine/src/test/resources/wearable-glue-stubs/com/google/android/gms/wearable/WearableListenerService.javas diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/linux/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/linux/pom.xml index fc5669525a4..bcf2e72852b 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/linux/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/linux/pom.xml @@ -21,7 +21,17 @@ linux-device - + + + + src/main/c + + + src/main/resources + + com.codenameone diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/win/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/win/pom.xml index ae418464d6b..2adfd56631a 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/win/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/win/pom.xml @@ -21,7 +21,17 @@ windows-device - + + + + src/main/c + + + src/main/resources + + com.codenameone diff --git a/maven/codenameone-gradle-plugin-marker/pom.xml b/maven/codenameone-gradle-plugin-marker/pom.xml new file mode 100644 index 00000000000..bcaa58eaa23 --- /dev/null +++ b/maven/codenameone-gradle-plugin-marker/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + com.codenameone + codenameone + 8.0-SNAPSHOT + + + com.codenameone.gradle.plugin + pom + Codename One Gradle Plugin Marker + Plugin marker for the com.codenameone Gradle plugin + + + + ${project.groupId} + codenameone-gradle-plugin + ${project.version} + + + diff --git a/maven/codenameone-gradle-plugin/pom.xml b/maven/codenameone-gradle-plugin/pom.xml new file mode 100644 index 00000000000..5118273d596 --- /dev/null +++ b/maven/codenameone-gradle-plugin/pom.xml @@ -0,0 +1,124 @@ + + + 4.0.0 + + com.codenameone + codenameone + 8.0-SNAPSHOT + + codenameone-gradle-plugin + Codename One Gradle Plugin + The com.codenameone Gradle plugin: an optional, single-project Gradle build for Codename One applications, cn1libs and backends, running the same build engine as the Maven plugin + + + + + UTF-8 + 1.8 + 1.8 + 8.11.1 + + + + + ${project.groupId} + codenameone-build-engine + ${project.version} + + + ${project.groupId} + codenameone-project-model + ${project.version} + + + + dev.gradleplugins + gradle-api + ${gradle.api.version} + provided + + + org.codehaus.groovy + groovy-all + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + + src/main/resources + false + + com/codename1/gradle/plugin.properties + + + + + src/main/resources + true + + com/codename1/gradle/plugin.properties + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + com.github.spotbugs + spotbugs-maven-plugin + 4.7.3.4 + + Max + Low + false + true + ${project.build.directory} + ${project.basedir}/spotbugs-exclude.xml + + + + spotbugs + verify + + spotbugs + + + + + + + diff --git a/maven/codenameone-gradle-plugin/spotbugs-exclude.xml b/maven/codenameone-gradle-plugin/spotbugs-exclude.xml new file mode 100644 index 00000000000..c1fdf419c4e --- /dev/null +++ b/maven/codenameone-gradle-plugin/spotbugs-exclude.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CodenameOneDesigner/src/com/codename1/designer/MavenHelper.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSettings.java similarity index 54% rename from CodenameOneDesigner/src/com/codename1/designer/MavenHelper.java rename to maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSettings.java index 1527098bb2a..ce937f8c19c 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/MavenHelper.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSettings.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, Codename One and/or its affiliates. All rights reserved. + * 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 @@ -20,37 +20,29 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.designer; +package com.codename1.gradle; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; -public class MavenHelper { - - public static boolean isMavenProject(File inputFile) { - return new File(getProjectDir(inputFile), "pom.xml").exists(); +/// Reads `codenameone_settings.properties`. +final class AppSettings { + private AppSettings() { } - public static File getProjectDir(File start) { - File f = new File(start, "codenameone_settings.properties"); - - while (!f.exists() && f.getParentFile().getParentFile() != null) { - f = new File(f.getParentFile().getParentFile(), "codenameone_settings.properties"); - if (f.exists()) { - return f.getParentFile(); + /// The file's properties, or empty when it is missing or unreadable. + static Properties read(File settings) { + Properties p = new Properties(); + if (settings.isFile()) { + try (InputStream in = new FileInputStream(settings)) { + p.load(in); + } catch (IOException ignored) { + // Reported by whatever needed a value from it. } } - return f.exists() ? f.getParentFile() : null; - - } - - public static File getCSSDir(File start) { - File projectDir = getProjectDir(start); - if (isMavenProject(start)) { - return new File(projectDir, "src" + File.separator + "main" + File.separator + "css"); - } else { - return new File(projectDir, "css"); - } - + return p; } } diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java new file mode 100644 index 00000000000..d52be6ca9d5 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java @@ -0,0 +1,795 @@ +/* + * 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.gradle; + +import com.codename1.gradle.tasks.Cn1BuildTask; +import com.codename1.gradle.tasks.Cn1CssTask; +import com.codename1.gradle.tasks.NativeInterfacesTask; +import com.codename1.gradle.tasks.PrepareSimulatorTask; +import com.codename1.gradle.tasks.ProcessAnnotationsAction; +import com.codename1.gradle.tasks.TranscodeSvgTask; +import com.codename1.maven.SimulatorSupport; +import com.codename1.project.NativePlatform; +import com.codename1.project.ProjectLayout; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.result.ResolvedArtifactResult; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.JavaExec; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.compile.JavaCompile; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// Everything an application project gets. +final class AppSupport { + /// The task group the Codename One tasks are listed under. + static final String GROUP = "codename one"; + /// The task group the build targets are listed under. + static final String BUILD_GROUP = "codename one build"; + + /// Each named build task: task name, `codename1.platform`, + /// `codename1.buildTarget`, description. The same pairs as the Maven + /// build wrappers and the archetype's build.sh. + static final String[][] BUILD_TARGETS = { + {"buildAndroid", "android", "android-device", "Sends an Android build to the build server"}, + {"buildAndroidGradleProject", "android", "android-source", "Generates an Android Studio project locally"}, + {"buildIos", "ios", "ios-device", "Sends an iOS debug build to the build server"}, + {"buildIosRelease", "ios", "ios-device-release", "Sends an iOS App Store build to the build server"}, + {"buildIosXcodeProject", "ios", "ios-source", "Generates an Xcode project locally"}, + {"buildMacNative", "ios", "mac-os-x-native", "Sends a native macOS build to the build server"}, + {"buildMacDesktop", "javase", "mac-os-x-desktop", "Sends a macOS desktop (JVM) build to the build server"}, + {"buildWindowsDesktop", "javase", "windows-desktop", "Sends a Windows desktop (JVM) build to the build server"}, + {"buildWindowsDevice", "win", "windows-device", "Sends a native Windows build to the build server"}, + {"buildLinuxDevice", "linux", "linux-device", "Sends a native Linux build to the build server"}, + {"buildJavascript", "javascript", "javascript", "Sends a JavaScript build to the build server"}, + {"buildJavascriptLocal", "javascript", "local-javascript", "Builds the JavaScript port locally"}, + }; + + private AppSupport() { + } + + static void apply(final Project project, final ProjectLayout layout, final CodenameOneExtension ext, + final Provider> userProperties) { + final Provider version = ext.getVersion(); + SourceSetContainer sourceSets = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets(); + final SourceSet main = sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME); + + // The framework is `provided`: compiled against, supplied by the build server + // or the simulator, never part of the upload. + addFramework(project, "compileOnly", version, "codenameone-core", "java-runtime"); + addFramework(project, "testImplementation", version, "codenameone-core", "codenameone-javase"); + Configuration framework = resolvable(project, "cn1Framework", "codenameone-core and java-runtime, for local builds"); + framework.setTransitive(false); + addFramework(project, framework.getName(), version, "codenameone-core", "java-runtime"); + Configuration simulator = resolvable(project, "cn1Simulator", "The simulator: the JavaSE port and its natives"); + addFramework(project, simulator.getName(), version, "codenameone-core", "codenameone-javase"); + project.getDependencies().addProvider(simulator.getName(), + version.map(v -> PluginInfo.GROUP + ":cn1-binaries-javase:" + v)); + Configuration cssCompiler = resolvable(project, "cn1CssCompiler", "The Codename One CSS compiler"); + addFramework(project, cssCompiler.getName(), version, "codenameone-css-cli"); + + Cn1libs.configure(project); + + // The simulator's native-interface implementations: a real source set, so + // src/javase/java compiles against the application and the JavaSE port and + // lands on the simulator's classpath the next time it runs. Declared always; + // Gradle is happy with a directory that does not exist yet. + final SourceSet javase = sourceSets.create("javase", ss -> { + ss.getJava().setSrcDirs(Collections.singletonList(layout.nativeSourceDir(NativePlatform.JAVASE))); + ss.getResources().setSrcDirs(Collections.singletonList( + new File(layout.projectDir(), "src" + File.separator + "javase" + File.separator + "resources"))); + // Added to the source set's own classpath, not in place of it, so a + // javaseImplementation(...) dependency still reaches this code. + ss.setCompileClasspath(ss.getCompileClasspath().plus(main.getOutput()).plus(main.getCompileClasspath()) + .plus(simulator) + .plus(project.getConfigurations().getByName(Cn1libs.configurationName("javase")))); + }); + + TaskProvider svg = project.getTasks().register("transcodeSvg", TranscodeSvgTask.class, t -> { + common(t, project, layout, ext, userProperties); + t.setDescription("Transcodes SVG and Lottie assets into Java sources"); + // Every directory the transcoder reads, or adding an asset to one of + // them leaves the task up to date and its generated classes stale. + t.getSources().from(layout.cssDir(), new File(layout.projectDir(), "src/main/svg"), + new File(layout.projectDir(), "src/main/lottie")); + t.getOutputDirectory().set(new File(layout.buildDir(), "generated/sources/cn1-svg")); + t.getPlaceholderDirectory().set(new File(layout.buildDir(), "css-resources")); + }); + main.getJava().srcDir(svg.flatMap(TranscodeSvgTask::getOutputDirectory)); + + registerGuiSources(project, layout, ext, userProperties, main); + + TaskProvider css = project.getTasks().register("cn1Css", Cn1CssTask.class, t -> { + common(t, project, layout, ext, userProperties); + t.setDescription("Compiles src/main/css into theme.res"); + // A theme can reference transcoded SVG and Lottie assets, whose + // placeholders transcodeSvg writes; on a clean build they must exist + // before the stylesheet is compiled. + t.dependsOn(svg); + // i18n too: the CSS compiler bundles whichever of the two exists, as the + // Maven build does (CssCompiler.localizationSibling). + t.getSources().from(layout.cssDir(), layout.l10nDir(), + new File(layout.l10nDir().getParentFile(), "i18n"), layout.settingsFile()); + t.getCompilerClasspath().from(cssCompiler); + t.getOutputDirectory().set(new File(layout.buildDir(), "generated/resources/cn1-css")); + t.getWorkDirectory().set(cssWorkDir(layout)); + Provider> resolved = project.getConfigurations().getByName("compileClasspath") + .getIncoming().artifactView(v -> v.setLenient(true)).getArtifacts().getResolvedArtifacts(); + // addAll, not set: the CSS of cn1libs that are projects of this build is + // added too (Cn1libs), and whichever configures first must not be lost. + t.getLibraryCss().addAll(resolved.map(AppSupport::cssBundles)); + t.getLibraryCssFiles().from(resolved.map(set -> filesOf(set, true))); + }); + main.getResources().srcDir(css.flatMap(Cn1CssTask::getOutputDirectory)); + + final Provider> compileArtifacts = project.getConfigurations() + .getByName(main.getCompileClasspathConfigurationName()).getIncoming() + .artifactView(v -> v.setLenient(true)).getArtifacts().getResolvedArtifacts() + .map(set -> AppSupport.encode(set, "provided")); + final Map complianceProperties = new java.util.HashMap(); + // -P, or -D: BytecodeCompliance also honours the JVM system property, so + // it has to be an input too, or a -D skipped compile would satisfy the + // next ordinary build as up to date. + Object skip = skipComplianceCheck(project); + if (skip != null) { + complianceProperties.put("skipComplianceCheck", String.valueOf(skip)); + } + // The two post-compile steps depend on more than the sources: hot reload + // compiles with -PskipComplianceCheck=true, and the codename1.* overrides + // reach the annotation processors. As inputs, a change in either reruns the + // compile -- otherwise a native build after a hot-reload compile found + // compileJava up to date and uploaded classes nobody had checked. + final String skipInput = String.valueOf(skip); + project.getTasks().named(main.getCompileJavaTaskName(), JavaCompile.class, compile -> { + compile.getInputs().property("cn1SkipComplianceCheck", skipInput); + processingInputs(compile, layout, userProperties); + Provider> roots = project.provider(() -> sourceRoots(main, layout)); + // Compliance first (it caps and rewrites classes in place), then the + // annotation processors, which stamp the result -- the order the Maven + // poms bind process-classes in. + // Kotlin's classes (compiled first, into a directory of their own) are + // the Java pass's siblings, so Java calling Kotlin resolves. + compile.doLast("cn1Compliance", new com.codename1.gradle.tasks.ComplianceAction(layout.rootDir(), + layout.projectDir(), compile.getDestinationDirectory().getAsFile(), project.getName(), + main.getCompileClasspath(), compileArtifacts, complianceProperties) + .withBuildDirectory(project.getLayout().getBuildDirectory().getAsFile()) + .withSiblingClasses(main.getOutput().getClassesDirs())); + compile.doLast("processCn1Annotations", new ProcessAnnotationsAction( + compile.getDestinationDirectory().getAsFile(), stubsDir(layout), layout.projectDir(), + layout.settingsFile(), roots, "UTF-8", userProperties, + // javac's own classpath: in a mixed project it holds Kotlin's + // classes, which a Java @RestClient's Kotlin DTO resolves from. + compile.getClasspath()) + .withSourceEncoding(javaEncoding(project, main))); + // Last: in a Java and Kotlin project, both passes have run by now. + compile.doLast("cn1SplitOutputCheck", new com.codename1.gradle.tasks.SplitOutputCheck( + compile.getDestinationDirectory().getAsFile(), main.getOutput().getClassesDirs())); + }); + + // Kotlin compiles into a directory of its own, before javac. The same two + // steps run over it, so a Kotlin application is checked and processed like a + // Java one. Wired by name because the Kotlin plugin's types are not on this + // plugin's classpath. + // + // Each pass processes its own directory, so a registry built from every + // declaration -- Routes, the *Bootstrap classes, the backend's entry point -- + // is complete only when one language declares that annotation. Declared in + // both, each pass writes a partial copy at the same path, and + // cn1SplitOutputCheck fails the build naming it rather than letting one copy + // win. One pass over both directories is not an option: processing rewrites + // the classes it reads in place, so the Java pass would have to write + // Kotlin's classes, and a Kotlin-only edit leaves compileJava up to date + // with nothing reprocessed. + project.getPluginManager().withPlugin("org.jetbrains.kotlin.jvm", kotlin -> + project.getTasks().named("compileKotlin").configure(compile -> { + compile.getInputs().property("cn1SkipComplianceCheck", skipInput); + processingInputs(compile, layout, userProperties); + Provider kotlinClasses = kotlinDestinationProvider(compile, layout); + Provider> roots = project.provider(() -> sourceRoots(main, layout)); + // javac has not run yet, so the Java classes Kotlin calls are + // known by their sources. + compile.doLast("cn1Compliance", new com.codename1.gradle.tasks.ComplianceAction( + layout.rootDir(), layout.projectDir(), kotlinClasses, project.getName(), + main.getCompileClasspath(), compileArtifacts, complianceProperties) + .withBuildDirectory(project.getLayout().getBuildDirectory().getAsFile()) + .withPendingJavaSources(main.getJava().getSrcDirs())); + compile.doLast("processCn1Annotations", new ProcessAnnotationsAction(kotlinClasses, + stubsDir(layout), + layout.projectDir(), layout.settingsFile(), roots, "UTF-8", userProperties, + main.getCompileClasspath()) + .withPendingJavaSources(main.getJava().getSrcDirs()) + .withSourceEncoding(javaEncoding(project, main))); + })); + + project.getTasks().register("cn1Compile", t -> { + t.setGroup(GROUP); + t.setDescription("Compiles the application and its simulator native code (used by hot reload)"); + t.dependsOn(main.getClassesTaskName(), javase.getClassesTaskName()); + }); + + TaskProvider prepare = project.getTasks().register("prepareSimulator", + PrepareSimulatorTask.class, t -> { + common(t, project, layout, ext, userProperties); + t.setDescription("Writes the files the simulator reads at startup"); + t.getCompileClasspath().from(main.getCompileClasspath()); + t.getCssCompilerClasspath().from(cssCompiler); + t.getSimulatorProperties().set(SimulatorSupport.simulatorPropertiesFile(layout.buildDir())); + t.getDescriptor().set(layout.descriptorFile()); + // The main class and package the descriptor records come from here. + t.getInputs().files(layout.settingsFile()).withPropertyName("settingsFile"); + }); + + registerSimulator(project, "run", "Runs the application in the Codename One simulator", false, + layout, main, javase, simulator, prepare, css, ext, userProperties); + registerSimulator(project, "debug", "Runs the simulator suspended, waiting for a debugger on port 5005", true, + layout, main, javase, simulator, prepare, css, ext, userProperties); + + project.getTasks().register("generateNativeInterfaces", + NativeInterfacesTask.class, t -> { + nativeCommon(t, project, layout, ext, userProperties, main); + t.setDescription("Writes implementation stubs for every NativeInterface into src//"); + t.getOnly().set(project.getProviders().gradleProperty("cn1.nativeInterface")); + t.getSwift().set(flag(project, "cn1.swift")); + t.getKotlin().set(flag(project, "cn1.kotlin")); + t.getOverwrite().set(flag(project, "cn1.overwrite")); + t.getOutputs().upToDateWhen(x -> false); + }); + + final Provider queue = project.getGradle().getSharedServices().registerIfAbsent( + BuildQueue.NAME, BuildQueue.class, spec -> spec.getMaxParallelUsages().set(1)); + for (String[] target : BUILD_TARGETS) { + registerBuild(project, target[0], target[1], target[2], target[3], layout, main, javase, framework, ext, + userProperties, queue); + } + // The generic form, for any target: -Pcodename1.platform=... -Pcodename1.buildTarget=... + registerBuild(project, "cn1Build", null, null, + "Runs the build given by -Pcodename1.platform and -Pcodename1.buildTarget", + layout, main, javase, framework, ext, userProperties, queue); + + final SourceSet test = sourceSets.getByName(SourceSet.TEST_SOURCE_SET_NAME); + project.getTasks().register("cn1Test", com.codename1.gradle.tasks.Cn1TestTask.class, t -> { + common(t, project, layout, ext, userProperties); + t.setGroup("verification"); + t.setDescription("Runs the Codename One unit tests in the simulator's test runner"); + t.dependsOn(test.getClassesTaskName(), javase.getClassesTaskName(), css); + t.getTestClassesDirectories().from(test.getOutput().getClassesDirs()); + t.getRuntimeClasspath().from(test.getRuntimeClasspath(), javase.getRuntimeClasspath(), simulator, + project.getConfigurations().getByName(Cn1libs.configurationName("javase"))); + t.getReportsDirectory().set(new File(layout.buildDir(), "cn1-reports")); + }); + + BackendSupport.registerAddBackend(project, layout); + ToolSupport.register(project, layout, main, ext, userProperties); + UpdateSupport.register(project, layout); + } + + private static void registerSimulator(final Project project, String name, String description, final boolean debug, + final ProjectLayout layout, final SourceSet main, final SourceSet javase, + final Configuration simulator, + final TaskProvider prepare, + final TaskProvider css, final CodenameOneExtension ext, + final Provider> userProperties) { + project.getTasks().register(name, JavaExec.class, t -> { + t.setGroup(GROUP); + t.setDescription(description); + t.dependsOn(prepare, css, main.getClassesTaskName(), javase.getClassesTaskName()); + t.getMainClass().set(SimulatorSupport.SIMULATOR_MAIN_CLASS); + // javase's runtime classpath: its output and javaseRuntimeOnly dependencies. + t.setClasspath(main.getRuntimeClasspath().plus(javase.getRuntimeClasspath()).plus(simulator) + .plus(project.getConfigurations().getByName(Cn1libs.configurationName("javase")))); + t.setWorkingDir(layout.projectDir()); + t.setMaxHeapSize("1024M"); + t.setDebug(debug); + // Live CSS reload recompiles into the resources the simulator runs from. + // The cn1libs' stylesheets first, as cn1Css compiled them; a reload from + // theme.css alone would drop every library style. + t.getJvmArgumentProviders().add(new CssInputArgument( + new File(cssWorkDir(layout), Cn1CssTask.SIMULATOR_INPUTS), + layout.themeCss(), layout.projectDir())); + // Where processResources really put theme.res -- the simulator loads it + // from there, and a relocated build directory moves it. + t.getJvmArgumentProviders().add(new SystemPropertyArgument(SimulatorSupport.CSS_OUTPUT_PROPERTY, + project.provider(() -> new File(main.getOutput().getResourcesDir(), "theme.res") + .getAbsolutePath()))); + t.systemProperty(SimulatorSupport.CSS_MERGE_PROPERTY, + new File(cssWorkDir(layout), "theme.css").getAbsolutePath()); + t.getArgumentProviders().add(new MainClassArgument(layout.settingsFile(), userProperties)); + // Hot reload watches and recompiles where the build really keeps the + // sources and classes, which a sourceSets block or a relocated build + // directory moves away from the conventional layout. + t.getJvmArgumentProviders().add(new HotReloadArguments( + project.provider(() -> hotReloadRoots(main, layout)), + project.provider(() -> main.getJava().getClassesDirectory().get().getAsFile().getAbsolutePath()), + project.provider(() -> kotlinClasses(project, layout)))); + // Where the build directory really is: the simulator reads + // simulator.properties, the classes and the resources from it, and + // would otherwise look in the conventional build/. + t.getJvmArgumentProviders().add(new SystemPropertyArgument("cn1.buildDir", + project.getLayout().getBuildDirectory().getAsFile().map(File::getAbsolutePath))); + // And the project's Gradle path, which hot reload's build-tool fallback + // runs cn1Compile under; a build can decouple it from the directory. + final String gradlePath = project.getPath(); + t.getJvmArgumentProviders().add(new SystemPropertyArgument("cn1.gradleProjectPath", + project.provider(() -> gradlePath))); + // The test recorder saves into the test source set's own directory. + final SourceSet test = project.getExtensions().getByType(org.gradle.api.plugins.JavaPluginExtension.class) + .getSourceSets().getByName(SourceSet.TEST_SOURCE_SET_NAME); + t.getJvmArgumentProviders().add(new SystemPropertyArgument(SimulatorSupport.TEST_ROOT_PROPERTY, + project.provider(() -> ToolSupport.primaryRoot(test.getJava().getSrcDirs(), + layout.testSourceDir(), layout).getAbsolutePath()))); + t.getJvmArgumentProviders().add(new SystemPropertyArgument(SimulatorSupport.ENCODING_PROPERTY, + javaEncoding(project, main))); + }); + } + + /// The main source set's own source directories, generated ones under the + /// build directory left out (the watcher must not recompile on its own + /// output), plus the CodeRAD views. + static String hotReloadRoots(SourceSet main, ProjectLayout layout) { + String build = layout.buildDir().getAbsolutePath() + File.separator; + StringBuilder sb = new StringBuilder(); + for (String root : ToolSupport.sourceRoots(main)) { + if (!root.startsWith(build)) { + sb.append(sb.length() == 0 ? "" : File.pathSeparator).append(root); + } + } + return sb.append(sb.length() == 0 ? "" : File.pathSeparator) + .append(new File(layout.projectDir(), "src" + File.separator + "main" + File.separator + "rad") + .getAbsolutePath()).toString(); + } + + /// compileKotlin's destination, or the conventional one when Kotlin is not + /// applied. + static String kotlinClasses(Project project, ProjectLayout layout) { + org.gradle.api.Task compile = project.getPluginManager().hasPlugin("org.jetbrains.kotlin.jvm") + ? project.getTasks().findByName("compileKotlin") : null; + return (compile != null ? kotlinDestination(compile, layout) + : new File(layout.buildDir(), "classes" + File.separator + "kotlin" + File.separator + "main")) + .getAbsolutePath(); + } + + /// One `-Dname=value`, the value computed when the task runs. + static final class SystemPropertyArgument implements org.gradle.process.CommandLineArgumentProvider { + private final String name; + private final Provider value; + + SystemPropertyArgument(String name, Provider value) { + this.name = name; + this.value = value; + } + + @Override + public Iterable asArguments() { + return Collections.singletonList("-D" + name + "=" + value.get()); + } + } + + /// The -D arguments behind [SimulatorSupport#SOURCE_ROOTS_PROPERTY]. + static final class HotReloadArguments implements org.gradle.process.CommandLineArgumentProvider { + private final Provider roots; + private final Provider javaClasses; + private final Provider kotlinClasses; + + HotReloadArguments(Provider roots, Provider javaClasses, Provider kotlinClasses) { + this.roots = roots; + this.javaClasses = javaClasses; + this.kotlinClasses = kotlinClasses; + } + + @Override + public Iterable asArguments() { + return java.util.Arrays.asList("-D" + SimulatorSupport.SOURCE_ROOTS_PROPERTY + "=" + roots.get(), + "-D" + SimulatorSupport.JAVA_CLASSES_PROPERTY + "=" + javaClasses.get(), + "-D" + SimulatorSupport.KOTLIN_CLASSES_PROPERTY + "=" + kotlinClasses.get()); + } + } + + private static void registerBuild(final Project project, String name, final String platform, + final String buildTarget, String description, final ProjectLayout layout, + final SourceSet main, final SourceSet javase, final Configuration framework, + final CodenameOneExtension ext, + final Provider> userProperties, + final Provider queue) { + final Provider platformProvider = platform != null ? project.provider(() -> platform) + : project.getProviders().gradleProperty("codename1.platform"); + final Provider targetProvider = buildTarget != null ? project.provider(() -> buildTarget) + : project.getProviders().gradleProperty("codename1.buildTarget"); + + TaskProvider verify = project.getTasks().register("verifyNativeInterfaces" + + name.substring(0, 1).toUpperCase(java.util.Locale.ROOT) + name.substring(1), + NativeInterfacesTask.class, t -> { + nativeCommon(t, project, layout, ext, userProperties, main); + t.setGroup(null); + t.setDescription("Checks every NativeInterface is implemented for " + name); + // Never absent: an absent platform means "generate" to this task, and + // cn1Build without -Pcodename1.platform must not write stubs into + // src/ before it fails for the missing platform. An empty + // one names no platform, so nothing is checked. + t.getVerifyPlatform().set(platformProvider.map(AppSupport::nativePlatformOf).orElse("")); + t.getSwift().set(false); + t.getKotlin().set(false); + t.getOverwrite().set(false); + }); + + project.getTasks().register(name, Cn1BuildTask.class, t -> { + common(t, project, layout, ext, userProperties); + t.setGroup(BUILD_GROUP); + t.setDescription(description); + // The simulator code only for a JVM target, which is the only upload + // that carries it: a broken src/javase must not stop an iOS build. + t.dependsOn(main.getClassesTaskName(), verify, platformProvider.map( + p -> "javase".equals(p) ? javase.getClassesTaskName() : Collections.emptyList()) + .orElse(Collections.emptyList())); + t.usesService(queue); + t.getPlatform().set(platformProvider); + t.getBuildTarget().set(targetProvider); + t.getAutomated().set(project.getProviders().gradleProperty("automated").map(Boolean::parseBoolean) + .orElse(Boolean.FALSE)); + t.getStageOnly().set(project.getProviders().gradleProperty("codename1.stageOnly") + .map(Boolean::parseBoolean).orElse(Boolean.FALSE)); + t.getOpen().set(ext.getOpenGeneratedProjects()); + t.getFrameworkJars().from(framework); + t.getSourceRoots().set(Collections.singletonList(layout.javaSourceDir().getAbsolutePath())); + t.getFinalName().set(project.getName()); + t.getGroupId().set(project.provider(() -> String.valueOf(project.getGroup()))); + // A Maven platform module declares codename1.projectPlatform, and the + // build runs only in the module whose platform matches. A Gradle + // project is every platform at once, so each build task says which. + t.getProjectProperties().set(platformProvider.map( + p -> Collections.singletonMap("codename1.projectPlatform", p))); + + ConfigurableFileCollection upload = t.getUploadClasspath(); + upload.from(main.getOutput()); + upload.from(platformProvider.map(p -> platformSources(layout, p, javase))); + upload.from(project.getConfigurations().getByName(main.getRuntimeClasspathConfigurationName())); + final Configuration runtime = project.getConfigurations().getByName(main.getRuntimeClasspathConfigurationName()); + Provider> runtimeArtifacts = runtime.getIncoming() + .artifactView(v -> v.setLenient(true)).getArtifacts().getResolvedArtifacts(); + Provider> encoded = runtimeArtifacts.map(set -> encode(set, "compile")); + // Each platform's cn1lib jars, looked up here rather than inside the + // lambdas: a lambda holding the Project cannot be stored in the + // configuration cache, which has to store these unevaluated when a + // dependency is a project of this build (cn1lib(project(":maps"))). + final Map platformFiles = + new java.util.HashMap(); + final Map>> platformArtifacts = + new java.util.HashMap>>(); + for (String id : Cn1libs.PLATFORMS) { + Configuration c = project.getConfigurations().findByName(Cn1libs.configurationName(id)); + if (c != null) { + platformFiles.put(id, c.getIncoming().getFiles()); + platformArtifacts.put(id, c.getIncoming().artifactView(v -> v.setLenient(true)).getArtifacts() + .getResolvedArtifacts().map(set -> encode(set, "compile"))); + } + } + final Provider> none = project.provider(Collections::emptyList); + upload.from(platformProvider.map(p -> platformFiles.containsKey(p) + ? platformFiles.get(p) : Collections.emptyList())); + t.getArtifacts().set(encoded.zip(platformProvider.flatMap(p -> platformArtifacts.containsKey(p) + ? platformArtifacts.get(p) : none), (list, extra) -> { + List all = new ArrayList(list); + all.addAll(extra); + return all; + })); + }); + } + + /// The platform's own sources that ride the upload: the simulator's + /// compiled native code for the JVM targets, the native source directory + /// (as resources, exactly as Maven packages a platform module) otherwise. + private static Object platformSources(ProjectLayout layout, String platform, SourceSet javase) { + if ("javase".equals(platform)) { + // Its runtime classpath: the compiled simulator code and what + // javaseImplementation declares (a converted Ant project's native/javase + // jars among them). + return javase.getRuntimeClasspath(); + } + NativePlatform p = NativePlatform.fromId(platform); + if (p == null) { + return Collections.emptyList(); + } + // The directories whether or not they exist yet. Asking here would be + // answered once and cached with the configuration, so a directory that + // generateNativeInterfaces creates later would never reach the upload; the + // engine skips a classpath element that does not exist. Beside the native + // sources, the platform's resources -- a Maven platform module's + // src/main/resources, which the conversion moves to src//resources. + return java.util.Arrays.asList(layout.nativeSourceDir(p), + new File(layout.projectDir(), "src" + File.separator + p.id() + File.separator + "resources")); + } + + /// The native platform whose implementations a build platform needs; the + /// JVM desktop targets use the simulator's. + static String nativePlatformOf(String platform) { + return platform; + } + + /// The encoding the source set's javac reads sources in: its + /// `options.encoding`, which [ProjectSupport] defaults to UTF-8. + /// + /// A plain provider, not one mapped from the task: that would carry a + /// dependency on compileJava, and opening Settings must not need the + /// sources to compile. + static Provider javaEncoding(Project project, SourceSet main) { + final String name = main.getCompileJavaTaskName(); + return project.provider(() -> { + String encoding = project.getTasks().named(name, JavaCompile.class).get().getOptions().getEncoding(); + return encoding == null ? "UTF-8" : encoding; + }); + } + + /// `skipComplianceCheck` as given with -P or -D, or null. + static String skipComplianceCheck(Project project) { + Object skip = project.findProperty("skipComplianceCheck"); + return skip != null ? String.valueOf(skip) + : project.getProviders().systemProperty("skipComplianceCheck").getOrNull(); + } + + /// `generateGuiSources`, whose generated views are a main source root. An + /// application's and a cn1lib's alike: a library's own views extend the + /// `Abstract*` classes it generates, exactly as an application's do. + static void registerGuiSources(final Project project, final ProjectLayout layout, final CodenameOneExtension ext, + final Provider> userProperties, SourceSet main) { + TaskProvider gui = project.getTasks().register( + "generateGuiSources", com.codename1.gradle.tasks.GenerateGuiSourcesTask.class, t -> { + common(t, project, layout, ext, userProperties); + t.setDescription("Generates sources from GUI builder XML and CodeRAD view templates"); + t.getSources().from(layout.guiBuilderDir(), layout.radViewsDir()); + // The legacy GUI builder writes its Java into a source directory; + // the one the source set compiles, which sourceSets can move. + t.getJavaSourceDir().set(project.provider( + () -> ToolSupport.primaryJavaRoot(main, layout).getAbsolutePath())); + // The simulator's hot reload regenerates views into the same place. + t.getRadOutputDirectory().set(new File(layout.buildDir(), "generated-sources/rad-views")); + }); + main.getJava().srcDir(gui.flatMap(com.codename1.gradle.tasks.GenerateGuiSourcesTask::getRadOutputDirectory)); + } + + /// Where the annotation processors write generated stub sources. Asked for + /// inside the compile tasks' configuration, after the build script has had + /// its say about the build directory. + static File stubsDir(ProjectLayout layout) { + return new File(layout.buildDir(), "generated/sources/cn1-annotations"); + } + + static void common(com.codename1.gradle.tasks.Cn1Task t, Project project, ProjectLayout layout, + CodenameOneExtension ext, Provider> userProperties) { + if (t.getGroup() == null) { + t.setGroup(GROUP); + } + t.getRootDirectory().set(layout.rootDir()); + t.getProjectDirectory().set(layout.projectDir()); + t.getBuildDirectory().set(project.getLayout().getBuildDirectory()); + t.getKind().set(layout.kind().name()); + t.getUserProperties().set(userProperties); + t.getCodenameOneVersion().set(ext.getVersion()); + } + + private static void nativeCommon(NativeInterfacesTask t, Project project, ProjectLayout layout, + CodenameOneExtension ext, Provider> userProperties, + SourceSet main) { + common(t, project, layout, ext, userProperties); + t.dependsOn(main.getClassesTaskName()); + t.getClassesDirectories().from(main.getOutput().getClassesDirs()); + t.getCompileClasspath().from(main.getCompileClasspath()); + } + + private static Provider flag(Project project, String name) { + return project.getProviders().gradleProperty(name).map(v -> v.isEmpty() || Boolean.parseBoolean(v)) + .orElse(Boolean.FALSE); + } + + static Configuration resolvable(Project project, String name, String description) { + return project.getConfigurations().create(name, c -> { + c.setCanBeResolved(true); + c.setCanBeConsumed(false); + c.setDescription(description); + }); + } + + /// [#kotlinDestination(org.gradle.api.Task, ProjectLayout)], lazily: the task's own + /// `destinationDirectory` provider, which follows a build script that sets + /// it after the plugin configured the task. + static Provider kotlinDestinationProvider(final org.gradle.api.Task compile, final ProjectLayout layout) { + try { + Object dir = compile.getClass().getMethod("getDestinationDirectory").invoke(compile); + if (dir instanceof org.gradle.api.file.DirectoryProperty) { + return ((org.gradle.api.file.DirectoryProperty) dir).getAsFile(); + } + } catch (ReflectiveOperationException | RuntimeException ex) { + compile.getLogger().info("cn1: " + compile.getPath() + " did not report its destination: " + ex); + } + return compile.getProject().provider(() -> kotlinDestination(compile, layout)); + } + + /// Where a Kotlin compile task writes its classes: the task's own + /// `destinationDirectory`, which follows a relocated build directory, read by + /// reflection because the Kotlin plugin's types are not on this plugin's + /// classpath. The conventional path only if the task will not say. + static File kotlinDestination(org.gradle.api.Task compile, ProjectLayout layout) { + try { + Object dir = compile.getClass().getMethod("getDestinationDirectory").invoke(compile); + if (dir instanceof org.gradle.api.file.DirectoryProperty) { + return ((org.gradle.api.file.DirectoryProperty) dir).get().getAsFile(); + } + } catch (ReflectiveOperationException | RuntimeException ex) { + compile.getLogger().info("cn1: " + compile.getPath() + " did not report its destination: " + ex); + } + return new File(layout.buildDir(), "classes" + File.separator + "kotlin" + File.separator + "main"); + } + + /// What the annotation processors read besides the compiled classes: the + /// codename1.* overrides and the settings file -- its mainName and + /// packageName pick the entry point the manifest is stamped for. As inputs, + /// changing either reruns the compile and so the processing. + static void processingInputs(org.gradle.api.Task compile, ProjectLayout layout, + Provider> userProperties) { + compile.getInputs().property("cn1UserProperties", userProperties); + compile.getInputs().files(layout.settingsFile()).withPropertyName("cn1Settings") + .withPathSensitivity(org.gradle.api.tasks.PathSensitivity.RELATIVE); + } + + /// The main source set's source directories, as the build really has them: + /// Java's and, with the Kotlin plugin, Kotlin's -- including any a build + /// script added. The processors use them to tell live classes from stale + /// output, so a directory missing here makes its classes look orphaned. + static List sourceRoots(SourceSet main, ProjectLayout layout) { + java.util.LinkedHashSet roots = new java.util.LinkedHashSet(); + roots.add(layout.javaSourceDir().getAbsolutePath()); + for (File dir : main.getJava().getSrcDirs()) { + roots.add(dir.getAbsolutePath()); + } + Object kotlin = main.getExtensions().findByName("kotlin"); + if (kotlin instanceof org.gradle.api.file.SourceDirectorySet) { + for (File dir : ((org.gradle.api.file.SourceDirectorySet) kotlin).getSrcDirs()) { + roots.add(dir.getAbsolutePath()); + } + } else { + roots.add(new File(layout.projectDir(), "src/main/kotlin").getAbsolutePath()); + } + return new ArrayList(roots); + } + + static void addFramework(Project project, String configuration, Provider version, String... artifactIds) { + for (final String artifactId : artifactIds) { + project.getDependencies().addProvider(configuration, + version.map(v -> PluginInfo.GROUP + ":" + artifactId + ":" + v)); + } + } + + static List encode(Set set, String scope) { + List out = new ArrayList(); + for (ResolvedArtifactResult r : set) { + String e = GradleHostFactory.encode(r, scope); + if (e != null) { + out.add(e); + } + } + return out; + } + + private static List cssBundles(Set set) { + List out = new ArrayList(); + for (String e : encode(set, "compile")) { + com.codename1.build.BuildArtifact a = GradleHostFactory.decode(e); + if (a != null && "cn1css".equals(a.getClassifier())) { + out.add(e); + } + } + return out; + } + + private static List filesOf(Set set, boolean cssOnly) { + List out = new ArrayList(); + for (ResolvedArtifactResult r : set) { + if (!cssOnly || r.getFile().getName().endsWith("-cn1css.zip")) { + out.add(r.getFile()); + } + } + return out; + } + + /// The simulator's one argument: the application's main class, from the + /// effective settings so a `-Pcodename1.mainName` override is honoured. + /// cn1Css's work directory, which the simulator reads its CSS inputs from. + static File cssWorkDir(ProjectLayout layout) { + return new File(layout.buildDir(), "css"); + } + + /// The simulator's live CSS reload inputs: what cn1Css recorded when it ran, + /// else the application's theme.css alone (CSS switched off, so no list). + static final class CssInputArgument implements org.gradle.process.CommandLineArgumentProvider { + private final File recorded; + private final File themeCss; + private final File projectDir; + + CssInputArgument(File recorded, File themeCss, File projectDir) { + this.recorded = recorded; + this.themeCss = themeCss; + this.projectDir = projectDir; + } + + @Override + public Iterable asArguments() { + String inputs = themeCss.getAbsolutePath(); + if (recorded.isFile()) { + try { + String listed = new String(java.nio.file.Files.readAllBytes(recorded.toPath()), + java.nio.charset.StandardCharsets.UTF_8).trim(); + if (!listed.isEmpty()) { + // Recorded relative to the project (it is a cached output). + StringBuilder sb = new StringBuilder(); + for (String path : listed.split(",")) { + File f = new File(path); + sb.append(sb.length() == 0 ? "" : ",") + .append((f.isAbsolute() ? f : new File(projectDir, path)).getAbsolutePath()); + } + inputs = sb.toString(); + } + } catch (java.io.IOException ignored) { + // Falls back to the application's own stylesheet. + } + } + return Collections.singletonList("-D" + SimulatorSupport.CSS_INPUT_PROPERTY + "=" + inputs); + } + } + + static final class MainClassArgument implements org.gradle.process.CommandLineArgumentProvider { + private final File settingsFile; + private final Provider> userProperties; + + MainClassArgument(File settingsFile, Provider> userProperties) { + this.settingsFile = settingsFile; + this.userProperties = userProperties; + } + + @Override + public Iterable asArguments() { + java.util.Properties p = new java.util.Properties(); + if (settingsFile.isFile()) { + try (java.io.InputStream in = new java.io.FileInputStream(settingsFile)) { + p.load(in); + } catch (java.io.IOException ignored) { + // The simulator reports a missing main class itself. + } + } + for (Map.Entry e : userProperties.get().entrySet()) { + p.setProperty(e.getKey(), e.getValue()); + } + String main = p.getProperty("codename1.mainName", "").trim(); + String pkg = p.getProperty("codename1.packageName", "").trim(); + return Collections.singletonList(pkg.isEmpty() ? main : pkg + "." + main); + } + } + +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java new file mode 100644 index 00000000000..1b59153407a --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java @@ -0,0 +1,243 @@ +/* + * 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.gradle; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.ProjectHost; +import com.codename1.gradle.tasks.Cn1Task; +import com.codename1.maven.BackendPackager; +import com.codename1.project.ProjectLayout; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// `backendPackage`: the backend translated to C and linked into one native +/// binary, with no JVM beneath it. The same [BackendPackager] as +/// `mvn cn1:backend-package`. +final class BackendPackageSupport { + private BackendPackageSupport() { + } + + static void register(Project project, ProjectLayout layout, SourceSet main, CodenameOneExtension ext) { + final Configuration runtime = AppSupport.resolvable(project, "cn1BackendToolchain", + "The backend runtime sources and the ParparVM translator"); + runtime.setTransitive(false); + project.getDependencies().addProvider(runtime.getName(), + ext.getVersion().map(v -> PluginInfo.GROUP + ":codenameone-backend:" + v + ":parparvm-sources")); + project.getDependencies().addProvider(runtime.getName(), + ext.getVersion().map(v -> PluginInfo.GROUP + ":codenameone-parparvm:" + v + ":bundle")); + project.getTasks().register("backendPackage", PackageTask.class, t -> { + AppSupport.common(t, project, layout, ext, project.provider(Collections::emptyMap)); + t.setDescription("Builds the backend as a single native binary"); + t.dependsOn(main.getClassesTaskName()); + t.getToolchain().from(runtime); + // The compile classpath, as Maven's backend-package resolves (scope + // compile), not the runtime one: the binary is translated by ParparVM, + // which loads nothing by name (Class.forName is banned in the backend), + // so a runtimeOnly library -- a JDBC driver, say -- could not run in it. + t.getCompileClasspath().from(main.getCompileClasspath()); + t.getArtifacts().set(project.getConfigurations().getByName(main.getCompileClasspathConfigurationName()) + .getIncoming().artifactView(v -> v.setLenient(true)).getArtifacts().getResolvedArtifacts() + .map(set -> AppSupport.encode(set, "compile"))); + t.getSources().from(main.getJava().getSrcDirs()); + // What annotation processors (annotationProcessor(...)) generated: the + // packager compiles the sources again, without javac's processor path, + // so it needs their output the way runBackend has it from the classes. + t.getSources().from(project.getTasks().named(main.getCompileJavaTaskName(), + org.gradle.api.tasks.compile.JavaCompile.class) + .flatMap(c -> c.getOptions().getGeneratedSourceOutputDirectory())); + t.getSourceEncoding().set(AppSupport.javaEncoding(project, main)); + t.getProcessedResources().from(project.provider(() -> main.getOutput().getResourcesDir())); + t.getProcessedResources().builtBy(main.getProcessResourcesTaskName()); + t.getMainClass().set(project.getProviders().gradleProperty("cn1.backend.mainClass")); + t.getTarget().set(project.getProviders().gradleProperty("cn1.backend.target")); + t.getJdk().set(project.getProviders().gradleProperty("cn1.backend.jdk")); + t.getCflags().set(project.getProviders().gradleProperty("cn1.backend.cflags")); + t.getSqlite().set(project.getProviders().gradleProperty("cn1.backend.sqlite") + .map(Boolean::parseBoolean).orElse(Boolean.TRUE)); + t.getCheckedCasts().set(project.getProviders().gradleProperty("cn1.backend.checkedCasts") + .map(Boolean::parseBoolean).orElse(Boolean.TRUE)); + t.getDevTools().set(project.getProviders().gradleProperty("cn1.backend.devTools") + .map(Boolean::parseBoolean).orElse(Boolean.FALSE)); + t.getBinary().set(new File(layout.buildDir(), project.getName())); + }); + } + + /// See [BackendPackageSupport]. + @DisableCachingByDefault(because = "Runs a native toolchain") + public abstract static class PackageTask extends Cn1Task { + /// `codenameone-backend`'s sources and the ParparVM bundle. + @Classpath + public abstract ConfigurableFileCollection getToolchain(); + + /// The backend's compile classpath, which is translated with it. + @Classpath + public abstract ConfigurableFileCollection getCompileClasspath(); + + /// That classpath's resolved dependencies, encoded. + @Input + public abstract ListProperty getArtifacts(); + + /// The Java sources, recompiled against the backend class library. + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getSources(); + + /// The entry point, when not the generated one. + @Input + @Optional + public abstract Property getMainClass(); + + /// A Linux cross-compile target (not supported from this task yet). + @Input + @Optional + public abstract Property getTarget(); + + /// The JDK to compile and translate with; defaults to Gradle's. + @Input + @Optional + public abstract Property getJdk(); + + /// The encoding compileJava reads the sources in; the packager recompiles + /// the same sources and must read them the same way. + @Input + public abstract Property getSourceEncoding(); + + /// processResources' output, wherever the build directory is. + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getProcessedResources(); + + /// Extra C compiler flags. + @Input + @Optional + public abstract Property getCflags(); + + /// Link the bundled SQLite engine. + @Input + public abstract Property getSqlite(); + + /// Make a failed cast throw. + @Input + public abstract Property getCheckedCasts(); + + /// Carry the development MCP tools in the binary (`-Pcn1.backend.devTools`); + /// off by default, as for Maven's backend-package. + @Input + public abstract Property getDevTools(); + + /// The binary. + @OutputFile + public abstract RegularFileProperty getBinary(); + + /// The source directories the packager compiles: the source set's own, as + /// the build script configured them, not only the conventional one -- a + /// directory added in `sourceSets` held controllers the binary then lacked. + private List sourceRoots() { + List roots = new ArrayList(); + for (File dir : getSources().getFiles()) { + roots.add(dir.getAbsolutePath()); + } + return roots; + } + + @TaskAction + public void build() { + final ProjectLayout layout = layout(); + ProjectHost host = GradleHostFactory.create(this, log(), layout, layout.projectDir().getName(), "", + Collections.singletonMap("project.build.sourceEncoding", getSourceEncoding().get()), + Collections.emptyMap(), + getCompileClasspath(), sourceRoots(), + getArtifacts().get(), null, getCodenameOneVersion().get()); + final java.util.Set toolchain = getToolchain().getFiles(); + java.util.Iterator resources = getProcessedResources().getFiles().iterator(); + File processed = resources.hasNext() ? resources.next() : layout.resourcesOutputDir(); + BackendPackager packager = new GradleBackendPackager(host, layout, toolchain, processed); + try { + packager.mainClass(getMainClass().getOrNull()).output(getBinary().get().getAsFile()) + .target(getTarget().getOrNull()).jdk(getJdk().getOrNull(), null).cflags(getCflags().getOrNull()) + .sqlite(getSqlite().get()).checkedCasts(getCheckedCasts().get()) + .devTools(getDevTools().get()).execute(); + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + } + + /// The packager, with the Gradle project's resources and the toolchain + /// jars the task resolved in place of Maven's repository lookup. + private static final class GradleBackendPackager extends BackendPackager { + private final ProjectLayout layout; + private final java.util.Set toolchain; + private final File processedResources; + + GradleBackendPackager(ProjectHost host, ProjectLayout layout, java.util.Set toolchain, + File processedResources) { + super(host); + this.layout = layout; + this.toolchain = toolchain; + this.processedResources = processedResources; + } + + @Override + protected File processedResourcesDirectory() { + return processedResources; + } + + @Override + protected boolean declaresResources() { + return layout.resourcesDir().isDirectory(); + } + + @Override + protected File resolve(String groupId, String artifactId, String version, String classifier) + throws BuildExecutionException { + for (File f : toolchain) { + if (f.getName().startsWith(artifactId + "-") && f.getName().endsWith("-" + classifier + ".jar")) { + return f; + } + } + throw new BuildExecutionException("Could not resolve " + groupId + ":" + artifactId + ":" + + version + ":" + classifier); + } + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java new file mode 100644 index 00000000000..fdfb1a2d5d8 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java @@ -0,0 +1,225 @@ +/* + * 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.gradle; + +import com.codename1.gradle.tasks.ProcessAnnotationsAction; +import com.codename1.gradle.tasks.RunBackendTask; +import com.codename1.maven.GradleProjectTemplate; +import com.codename1.project.ProjectLayout; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.TaskAction; +import org.gradle.api.tasks.compile.JavaCompile; +import org.gradle.work.DisableCachingByDefault; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/// Backends: the `backend/` subproject of an application, or a backend-only +/// project at the root. +final class BackendSupport { + /// The SQLite driver the dev profile's in-memory database uses, as the Maven + /// backend module declares it. + static final String SQLITE_JDBC = "org.xerial:sqlite-jdbc:3.46.1.0"; + + private BackendSupport() { + } + + static void apply(final Project project, final ProjectLayout layout, final CodenameOneExtension ext, + final Provider> userProperties) { + AppSupport.addFramework(project, "implementation", ext.getVersion(), "codenameone-backend"); + project.getDependencies().add("runtimeOnly", SQLITE_JDBC); + + final SourceSet main = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets() + .getByName(SourceSet.MAIN_SOURCE_SET_NAME); + // The @RestController router and the entry point are generated here, from + // the compiled classes, exactly as the Maven module's process-annotations + // execution does. + project.getTasks().named(main.getCompileJavaTaskName(), JavaCompile.class, compile -> { + AppSupport.processingInputs(compile, layout, userProperties); + compile.doLast("processCn1Annotations", new ProcessAnnotationsAction( + compile.getDestinationDirectory().getAsFile(), AppSupport.stubsDir(layout), layout.projectDir(), + layout.settingsFile(), project.provider(() -> AppSupport.sourceRoots(main, layout)), + "UTF-8", userProperties, compile.getClasspath()) + .withSourceEncoding(AppSupport.javaEncoding(project, main))); + compile.doLast("cn1SplitOutputCheck", new com.codename1.gradle.tasks.SplitOutputCheck( + compile.getDestinationDirectory().getAsFile(), main.getOutput().getClassesDirs())); + }); + // Kotlin controllers and entities are processed like Java ones: in a pure + // Kotlin backend compileJava has no sources and would generate no router or + // entry point at all. (backendPackage compiles Java sources itself, so a + // Kotlin backend runs on the JVM with runBackend; see BackendPackager.) + project.getPluginManager().withPlugin("org.jetbrains.kotlin.jvm", kotlin -> + project.getTasks().named("compileKotlin").configure(compile -> { + AppSupport.processingInputs(compile, layout, userProperties); + compile.doLast("processCn1Annotations", new ProcessAnnotationsAction( + AppSupport.kotlinDestinationProvider(compile, layout), AppSupport.stubsDir(layout), + layout.projectDir(), layout.settingsFile(), + project.provider(() -> AppSupport.sourceRoots(main, layout)), + "UTF-8", userProperties, main.getCompileClasspath()) + .withPendingJavaSources(main.getJava().getSrcDirs()) + .withSourceEncoding(AppSupport.javaEncoding(project, main))); + })); + + project.getTasks().register("runBackend", RunBackendTask.class, t -> { + t.setGroup(AppSupport.GROUP); + t.setDescription("Runs the backend on this JVM (the fast development loop)"); + t.dependsOn(main.getClassesTaskName()); + t.getClasspath().from(main.getRuntimeClasspath()); + t.getClassesDirectories().from(main.getOutput().getClassesDirs()); + t.getMainClass().set(project.getProviders().gradleProperty("cn1.backend.mainClass")); + t.getArgs().set(project.getProviders().gradleProperty("cn1.backend.args").map(BackendSupport::split) + .orElse(Collections.emptyList())); + t.getJvmArgs().set(project.getProviders().gradleProperty("cn1.backend.jvmArgs") + .map(BackendSupport::split).orElse(Collections.emptyList())); + t.getWorkingDirectory().set(layout.projectDir()); + }); + BackendPackageSupport.register(project, layout, main, ext); + UpdateSupport.register(project, layout); + } + + static List split(String s) { + String t = s.trim(); + return t.isEmpty() ? Collections.emptyList() : new ArrayList(Arrays.asList(t.split("\\s+"))); + } + + /// `addBackend` on an application: creates the `backend/` subproject. + static void registerAddBackend(Project project, ProjectLayout layout) { + project.getTasks().register("addBackend", AddBackendTask.class, t -> { + t.setGroup(AppSupport.GROUP); + t.setDescription("Adds a backend/ subproject to this application"); + t.getRootDirectory().set(layout.rootDir()); + // Whether the settings plugin really ran, which includes backend/ and + // applies itself there: a settings script can name the plugin without + // applying it (pluginManagement { plugins { id(...) version ... } }). + org.gradle.api.plugins.ExtraPropertiesExtension extra = + project.getGradle().getExtensions().getExtraProperties(); + t.getSettingsPluginApplied().set(extra.has(SettingsSupport.APPLIED_MARKER) + && Boolean.TRUE.equals(extra.get(SettingsSupport.APPLIED_MARKER))); + t.getPackageName().set(project.provider(() -> { + String pkg = AppSettings.read(layout.settingsFile()).getProperty("codename1.packageName", "app"); + return pkg + ".backend"; + })); + }); + } + + /// Writes the backend skeleton the Maven archetype generates, minus the pom. + @DisableCachingByDefault(because = "Creates project sources once") + public abstract static class AddBackendTask extends DefaultTask { + /// The root project directory. + @Internal + public abstract DirectoryProperty getRootDirectory(); + + /// Whether `com.codenameone` was applied in the settings script. + @Input + public abstract Property getSettingsPluginApplied(); + + /// The package of the generated `Api` controller. + @Input + public abstract Property getPackageName(); + + @TaskAction + public void create() { + File backend = new File(getRootDirectory().get().getAsFile(), "backend"); + if (new File(backend, "application.properties").exists()) { + throw new GradleException(backend + " already holds a backend"); + } + String pkg = getPackageName().get(); + try { + // The same skeleton the initializr and the Maven generator write. + GradleProjectTemplate.writeBackendFiles(backend, pkg, ":backend:"); + } catch (IOException ex) { + throw new GradleException("Could not create the backend", ex); + } + getLogger().lifecycle("Created " + backend + ". Run it with ./gradlew :backend:runBackend"); + File root = getRootDirectory().get().getAsFile(); + File kts = new File(root, "settings.gradle.kts"); + File groovy = new File(root, "settings.gradle"); + File settings = kts.isFile() || !groovy.isFile() ? kts : groovy; + if (!kts.isFile() && !groovy.isFile()) { + // A project that applies the plugin from its build script needs no + // settings script until it has a second project; this is that moment. + // In the build script's language, naming the root as Gradle did. + boolean groovyBuild = new File(root, "build.gradle").isFile() + && !new File(root, "build.gradle.kts").isFile(); + settings = groovyBuild ? groovy : kts; + try { + write(settings, groovyBuild ? "rootProject.name = '" + root.getName() + "'\n" + : "rootProject.name = \"" + root.getName() + "\"\n"); + } catch (IOException ex) { + throw new GradleException("Could not create " + settings, ex); + } + } + if (settings.isFile()) { + try { + String text = new String(Files.readAllBytes(settings.toPath()), StandardCharsets.UTF_8); + if (!getSettingsPluginApplied().get()) { + // The settings plugin includes backend/ and applies itself there. + // A build that applies the plugin from the root build script has + // neither, so the backend is included here and applies the plugin + // in its own script -- without a version, since the root already + // put the plugin on the classpath. + boolean isKts = settings.getName().endsWith(".kts"); + if (!text.contains("include(\"backend\")") && !text.contains("include 'backend'") + && !text.contains("include \"backend\"")) { + write(settings, text + (text.endsWith("\n") ? "" : "\n") + + (isKts ? "include(\"backend\")\n" : "include 'backend'\n")); + } + File script = new File(backend, isKts ? "build.gradle.kts" : "build.gradle"); + if (!script.exists()) { + write(script, isKts ? "plugins {\n id(\"com.codenameone\")\n}\n\n" + + GradleProjectTemplate.text("backend/build.gradle.kts.txt").replace( + "// Optional: the plugin is applied from settings.gradle.kts.\n", "") + : "plugins {\n id 'com.codenameone'\n}\n"); + } + } + } catch (IOException ex) { + throw new GradleException("Could not update " + settings, ex); + } + } + } + + private static void write(File f, String text) throws IOException { + File parent = f.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Could not create " + parent); + } + Files.write(f.toPath(), text.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BuildQueue.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BuildQueue.java new file mode 100644 index 00000000000..b44394f6335 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BuildQueue.java @@ -0,0 +1,38 @@ +/* + * 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.gradle; + +import org.gradle.api.services.BuildService; +import org.gradle.api.services.BuildServiceParameters; + +/// One build at a time. +/// +/// Gradle runs a project's tasks in parallel when it can, but two Codename One +/// builds share things that do not take turns by themselves: the build client +/// and its updater in `~/.codenameone`, the account's build queue, and a +/// terminal the cloud build streams its log to. Every build task uses this +/// service, which allows a single user at once. +public abstract class BuildQueue implements BuildService { + /// The service's name in the build. + static final String NAME = "cn1BuildQueue"; +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/Cn1libs.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/Cn1libs.java new file mode 100644 index 00000000000..abb0205cd23 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/Cn1libs.java @@ -0,0 +1,316 @@ +/* + * 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.gradle; + +import com.codename1.maven.Cn1libPomProfiles; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/// Consuming Maven-published cn1libs. +/// +/// A project declares a cn1lib the way it would declare any library: +/// +/// ```kotlin +/// dependencies { +/// cn1lib("com.codenameone:googlemaps-lib:1.0") +/// } +/// ``` +/// +/// Its common jar and `cn1css` bundle arrive through the ordinary dependency +/// graph, because `implementation` extends `cn1lib`. Its per-platform jars do +/// not: Maven selects them with profiles activated by `codename1.platform`, +/// which Gradle never activates. So each platform gets a configuration +/// (`cn1libAndroid`, `cn1libIos`, ...) filled from those same profiles, read +/// by [Cn1libPomProfiles] -- the metadata Maven uses, not a naming guess. +final class Cn1libs { + /// The configuration a project declares cn1libs in. + static final String DECLARED = "cn1lib"; + + /// The platform ids a cn1lib's profiles are keyed by. + static final String[] PLATFORMS = {"javase", "android", "ios", "javascript", "win", "linux"}; + + private Cn1libs() { + } + + /// The name of `platform`'s configuration, e.g. `cn1libIos`. + static String configurationName(String platform) { + return DECLARED + platform.substring(0, 1).toUpperCase(Locale.ROOT) + platform.substring(1); + } + + /// Creates the `cn1lib` configuration and one resolvable configuration per + /// platform, and fills the latter once the build script has declared its + /// dependencies. + static void configure(final Project project) { + Configuration declared = project.getConfigurations().create(DECLARED, c -> { + c.setCanBeResolved(false); + c.setCanBeConsumed(false); + c.setDescription("Codename One libraries (cn1libs) published to a Maven repository"); + }); + project.getConfigurations().getByName("implementation").extendsFrom(declared); + for (String platform : PLATFORMS) { + project.getConfigurations().create(configurationName(platform), c -> { + c.setCanBeResolved(true); + c.setCanBeConsumed(false); + c.setDescription("The " + platform + " artifacts of this project's cn1libs"); + }); + } + // The CSS of cn1libs that are projects of this build, which publish no + // cn1css zip for the compile classpath scan to find. Keyed by project + // path: the "group|artifact|version|cn1css|zip|compile" each one's + // bundle is known by (artifact being the library's -common module, the + // path its zip keeps the stylesheet under). Strings only, so the CSS + // task's providers can be stored in the configuration cache. + final Map projectCss = new java.util.HashMap(); + final Configuration projectCssFiles = project.getConfigurations().create(PROJECT_CSS, c -> { + c.setCanBeResolved(true); + c.setCanBeConsumed(false); + c.setDescription("The CSS bundles of this project's cn1libs that are projects of this build"); + }); + project.getTasks().withType(com.codename1.gradle.tasks.Cn1CssTask.class).configureEach(t -> { + t.getLibraryCss().addAll(projectCssFiles.getIncoming().artifactView(v -> v.setLenient(true)) + .getArtifacts().getResolvedArtifacts().map(set -> encodeProjectCss(set, projectCss))); + t.getLibraryCssFiles().from(projectCssFiles); + }); + project.afterEvaluate(p -> { + // Maven activates codename1.platform in EVERY pom of the graph, so every + // cn1lib the application ends up with contributes its platform jars -- + // including one a cn1lib uses. Gradle ignores those profiles, so each + // module of the RESOLVED graph has its pom read for them. The resolved + // graph, not a walk of the poms: it already applies every exclusion + // (on a declaration, a configuration or a pom's own dependency), the + // versions dependency management supplies, and conflict resolution, so + // a library is packaged exactly when its classes are on the classpath. + Configuration source = p.getConfigurations().findByName("runtimeClasspath"); + if (source == null) { + return; + } + // A copy: resolving the real configuration here would freeze it for + // anything the build still wants to add. + Configuration graph = source.copyRecursive(); + final String framework = p.getExtensions().getByType(CodenameOneExtension.class).getVersion().get(); + graph.getResolutionStrategy().eachDependency(d -> { + String requested = d.getRequested().getVersion(); + if (PluginInfo.GROUP.equals(d.getRequested().getGroup()) + && (requested == null || requested.isEmpty())) { + d.useVersion(framework); + } + }); + Set components; + try { + components = graph.getIncoming().getResolutionResult().getAllComponents(); + } catch (RuntimeException ex) { + p.getLogger().info("cn1: could not resolve the cn1lib graph: " + ex.getMessage()); + return; + } + Set added = new java.util.HashSet(); + for (org.gradle.api.artifacts.result.ResolvedComponentResult component : components) { + if (component.getId() instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier) { + addProjectPlatforms(p, (org.gradle.api.artifacts.component.ProjectComponentIdentifier) + component.getId(), projectCss); + continue; + } + if (!(component.getId() instanceof ModuleComponentIdentifier)) { + continue; + } + ModuleComponentIdentifier id = (ModuleComponentIdentifier) component.getId(); + if (!mayBeCn1lib(id.getGroup(), id.getModule())) { + continue; + } + String pom = pomText(p, id.getGroup(), id.getModule(), id.getVersion()); + if (pom == null) { + continue; + } + Cn1libPomProfiles.ParentResolver parents = (g, a, v) -> pomText(p, g, a, v); + for (Map.Entry> e + : Cn1libPomProfiles.read(pom, parents).entrySet()) { + Configuration target = p.getConfigurations().findByName(configurationName(e.getKey())); + if (target == null) { + continue; + } + for (Cn1libPomProfiles.Coordinate c : e.getValue()) { + // Keyed by the exclusions too: two cn1libs may reach one + // library, only one of them excluding something under it. + // Maven keeps a transitive dependency any path leaves in, and + // so does Gradle when both edges are declared -- dropping + // the second edge would let the first one's exclusion win. + if (added.add(e.getKey() + "|" + c.toNotation() + "|" + exclusionKey(c.exclusions()))) { + target.getDependencies().add(withExclusions(p.getDependencies().create(c.toNotation()), + c.exclusions())); + } + } + } + } + }); + } + + /// The exclusions as one order-independent string, for telling apart two + /// declarations of one coordinate. + static String exclusionKey(List exclusions) { + List parts = new java.util.ArrayList(); + for (String[] x : exclusions) { + parts.add(x[0] + ":" + x[1]); + } + java.util.Collections.sort(parts); + return String.join(",", parts); + } + + /// `dependency` with the profile's `` applied, as Maven applies + /// them: a `*` group or artifact matches any, and `*:*` keeps nothing + /// transitive. + static org.gradle.api.artifacts.Dependency withExclusions(org.gradle.api.artifacts.Dependency dependency, + List exclusions) { + if (!(dependency instanceof org.gradle.api.artifacts.ModuleDependency)) { + return dependency; + } + org.gradle.api.artifacts.ModuleDependency module = (org.gradle.api.artifacts.ModuleDependency) dependency; + for (String[] x : exclusions) { + Map rule = exclusionRule(x[0], x[1]); + if (rule == null) { + module.setTransitive(false); + } else { + module.exclude(rule); + } + } + return module; + } + + /// Gradle's exclude rule for a Maven exclusion, or null for `*:*`. + static Map exclusionRule(String group, String artifact) { + Map rule = new java.util.LinkedHashMap(); + if (group != null && !"*".equals(group)) { + rule.put("group", group); + } + if (artifact != null && !"*".equals(artifact)) { + rule.put("module", artifact); + } + return rule.isEmpty() ? null : rule; + } + + /// The consumable configuration a Gradle cn1lib exposes `platform`'s jar in. + static String platformElementsName(String platform) { + return configurationName(platform) + "Elements"; + } + + /// A cn1lib of this build declared as `cn1lib(project(":maps"))`: it has no + /// published pom to read, so each platform's jar comes from the library's + /// own [#platformElementsName(String)] configuration. A project that is not + /// a Codename One library has none and adds nothing. + private static void addProjectPlatforms(Project p, + org.gradle.api.artifacts.component.ProjectComponentIdentifier id, + Map projectCss) { + Project other = p.findProject(id.getProjectPath()); + if (other == null || other == p) { + p.getLogger().warn("cn1: " + id.getDisplayName() + " is a project of another build; its platform " + + "implementations are not packaged. Publish it and depend on its coordinates instead."); + return; + } + if (!other.getState().getExecuted()) { + try { + p.evaluationDependsOn(other.getPath()); + } catch (RuntimeException ex) { + p.getLogger().warn("cn1: could not configure " + other.getPath() + " to read its platform " + + "implementations (" + ex.getMessage() + "); declare evaluationDependsOn(\"" + + other.getPath() + "\") in this build script"); + return; + } + } + for (String platform : PLATFORMS) { + String elements = platformElementsName(platform); + Configuration target = p.getConfigurations().findByName(configurationName(platform)); + if (target == null || other.getConfigurations().findByName(elements) == null) { + continue; + } + Map notation = new java.util.HashMap(); + notation.put("path", other.getPath()); + notation.put("configuration", elements); + target.getDependencies().add(p.getDependencies().project(notation)); + } + Configuration css = p.getConfigurations().findByName(PROJECT_CSS); + if (css != null && other.getConfigurations().findByName(CSS_ELEMENTS) != null + && !projectCss.containsKey(other.getPath())) { + projectCss.put(other.getPath(), other.getGroup() + "|" + other.getName() + "-common|" + + other.getVersion() + "|cn1css|zip|compile"); + Map notation = new java.util.HashMap(); + notation.put("path", other.getPath()); + notation.put("configuration", CSS_ELEMENTS); + css.getDependencies().add(p.getDependencies().project(notation)); + } + } + + /// The configuration of an application holding its project cn1libs' CSS. + static final String PROJECT_CSS = "cn1libProjectCss"; + /// The consumable configuration a Gradle cn1lib exposes its cn1css zip in. + static final String CSS_ELEMENTS = "cn1libCssElements"; + + /// The resolved project CSS bundles as [Cn1CssTask] inputs, each known by + /// the coordinates `projectCss` recorded for its project. + static List encodeProjectCss(Set set, + Map projectCss) { + List out = new java.util.ArrayList(); + for (org.gradle.api.artifacts.result.ResolvedArtifactResult r : set) { + Object id = r.getId().getComponentIdentifier(); + if (id instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier) { + String coordinates = projectCss.get( + ((org.gradle.api.artifacts.component.ProjectComponentIdentifier) id).getProjectPath()); + if (coordinates != null) { + out.add(coordinates + "|" + r.getFile().getAbsolutePath()); + } + } + } + return out; + } + + /// Whether a module's pom is worth reading for platform profiles: anything + /// but the framework's own modules, whose poms have none and which every + /// application depends on. + static boolean mayBeCn1lib(String group, String module) { + return !(PluginInfo.GROUP.equals(group) && module.startsWith("codenameone-")); + } + + /// A module's pom, fetched from the project's repositories, or null. + static String pomText(Project project, String group, String name, String version) { + Configuration c = project.getConfigurations().detachedConfiguration( + project.getDependencies().create(group + ":" + name + ":" + version + "@pom")); + c.setTransitive(false); + try { + Set files = c.resolve(); + for (File f : files) { + return new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + } + } catch (RuntimeException | IOException ex) { + project.getLogger().info("Could not read the pom of " + group + ":" + name + ":" + version + ": " + + ex.getMessage()); + } + return null; + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/CodenameOneExtension.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/CodenameOneExtension.java new file mode 100644 index 00000000000..c4e4aa16fe9 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/CodenameOneExtension.java @@ -0,0 +1,57 @@ +/* + * 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.gradle; + +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; + +/// The `codenameone { }` block. +/// +/// Everything has a sensible default, so most projects never write one: +/// +/// ```kotlin +/// codenameone { +/// version = "8.1.0" // the framework version; defaults to the plugin's +/// buildHints.put("ios.newStorageLocation", "true") +/// } +/// ``` +public abstract class CodenameOneExtension { + /// The Codename One version the project builds against. Defaults to the + /// plugin's own version, so the version in `settings.gradle.kts` is the only + /// number to bump. + public abstract Property getVersion(); + + /// The application's main class, fully qualified. Defaults to + /// `codename1.packageName` + `codename1.mainName` from + /// `codenameone_settings.properties`. + public abstract Property getMainClass(); + + /// Build hints applied to every build of this project, on top of the ones in + /// `codenameone_settings.properties` (without the `codename1.arg.` prefix). + /// A `-Pcodename1.arg.=...` on the command line still wins. + public abstract MapProperty getBuildHints(); + + /// Whether a generated Xcode or Android Studio project is opened when a + /// `*-source` build finishes. Defaults to true; CI sets `-Pcodename1.open=false`. + public abstract Property getOpenGeneratedProjects(); +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/CodenameOnePlugin.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/CodenameOnePlugin.java new file mode 100644 index 00000000000..05e1bce4ecc --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/CodenameOnePlugin.java @@ -0,0 +1,67 @@ +/* + * 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.gradle; + +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.initialization.Settings; +import org.gradle.util.GradleVersion; + +/// The `com.codenameone` Gradle plugin. +/// +/// One id for every use, applied wherever it is declared: +/// +/// - **in `settings.gradle.kts`** it adds the Codename One repository, includes +/// a `backend/` subproject when there is one, and applies itself to the root +/// project and that backend -- so a project needs no `build.gradle.kts` until +/// it has dependencies to declare; +/// - **in a project** it builds whatever the directory holds: an application +/// (`codenameone_settings.properties`), a cn1lib +/// (`codenameone_library_appended.properties`) or a backend +/// (`application.properties`). The `codename1.kind` Gradle property +/// overrides the detection. +/// +/// The work itself is the build engine the Maven plugin also runs, so the same +/// application builds the same way under either tool. +public class CodenameOnePlugin implements Plugin { + /// The oldest Gradle the plugin supports. + static final String MINIMUM_GRADLE = "8.5"; + + @Override + public void apply(Object target) { + if (GradleVersion.current().compareTo(GradleVersion.version(MINIMUM_GRADLE)) < 0) { + throw new GradleException("The Codename One Gradle plugin needs Gradle " + MINIMUM_GRADLE + + " or newer; this build runs " + GradleVersion.current().getVersion() + + ". Update the wrapper with: gradle wrapper --gradle-version " + MINIMUM_GRADLE); + } + if (target instanceof Settings) { + SettingsSupport.apply((Settings) target); + } else if (target instanceof Project) { + ProjectSupport.apply((Project) target); + } else { + throw new GradleException("The com.codenameone plugin is applied from settings.gradle.kts or from a " + + "project's build.gradle.kts, not to " + target); + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/DelegatingProjectHost.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/DelegatingProjectHost.java new file mode 100644 index 00000000000..cd252fae258 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/DelegatingProjectHost.java @@ -0,0 +1,157 @@ +/* + * 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.gradle; + +import com.codename1.build.BuildArtifact; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.Log; +import com.codename1.build.ProjectHost; +import com.codename1.project.ProjectLayout; + +import java.io.File; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Properties; +import java.util.Set; + +/// A [ProjectHost] that answers from another, for a task that needs to change +/// one answer. +public class DelegatingProjectHost implements ProjectHost { + private final ProjectHost delegate; + + /// Answers from `delegate`. + public DelegatingProjectHost(ProjectHost delegate) { + this.delegate = delegate; + } + + @Override + public Log log() { + return delegate.log(); + } + + @Override + public ProjectLayout layout() { + return delegate.layout(); + } + + @Override + public File cn1ProjectDir() { + return delegate.cn1ProjectDir(); + } + + @Override + public File baseDir() { + return delegate.baseDir(); + } + + @Override + public File buildDirectory() { + return delegate.buildDirectory(); + } + + @Override + public File outputDirectory() { + return delegate.outputDirectory(); + } + + @Override + public String finalName() { + return delegate.finalName(); + } + + @Override + public String groupId() { + return delegate.groupId(); + } + + @Override + public Properties projectProperties() { + return delegate.projectProperties(); + } + + @Override + public Properties userProperties() { + return delegate.userProperties(); + } + + @Override + public List compileClasspathElements() throws BuildExecutionException { + return delegate.compileClasspathElements(); + } + + @Override + public List runtimeClasspathElements() throws BuildExecutionException { + return delegate.runtimeClasspathElements(); + } + + @Override + public List compileSourceRoots() { + return delegate.compileSourceRoots(); + } + + @Override + public Collection artifacts() { + return delegate.artifacts(); + } + + @Override + public Set neededWithoutDesktopRuntime() { + return delegate.neededWithoutDesktopRuntime(); + } + + @Override + public File getJar(BuildArtifact artifact) { + return delegate.getJar(artifact); + } + + @Override + public File getJar(String groupId, String artifactId, String classifier) { + return delegate.getJar(groupId, artifactId, classifier); + } + + @Override + public long sessionStartTime() { + return delegate.sessionStartTime(); + } + + @Override + public long sourcesModificationTime() throws IOException { + return delegate.sourcesModificationTime(); + } + + @Override + public void attachArtifact(String type, String classifier, File file) { + delegate.attachArtifact(type, classifier, file); + } + + @Override + public String codenameOneVersion() { + return delegate.codenameOneVersion(); + } + + @Override + public String pluginVersion() { + return delegate.pluginVersion(); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/GradleHostFactory.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/GradleHostFactory.java new file mode 100644 index 00000000000..c8fdb0b29a1 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/GradleHostFactory.java @@ -0,0 +1,86 @@ +/* + * 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.gradle; + +import com.codename1.build.Log; +import com.codename1.build.ProjectHost; +import com.codename1.project.ProjectLayout; +import org.gradle.api.Task; +import org.gradle.api.file.FileCollection; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Builds a [ProjectHost] from a task's resolved inputs. +public final class GradleHostFactory { + private GradleHostFactory() { + } + + /// A resolved dependency as a task input string; see [decode(String)]. + public static String encode(org.gradle.api.artifacts.result.ResolvedArtifactResult result, String scope) { + return GradleProjectHost.encode(result, scope); + } + + /// The dependency [encode] wrote, or null. + public static com.codename1.build.BuildArtifact decode(String encoded) { + return GradleProjectHost.decode(encoded); + } + + /// A host over the given inputs. + /// + /// @param task the task the host serves (for its name in messages) + /// @param classpath the ordered classpath the build stages + /// @param frameworkJars `codenameone-core` / `java-runtime` and friends, + /// recognised by file name + public static ProjectHost create(Task task, Log log, ProjectLayout layout, String finalName, String groupId, + Map projectProperties, Map userProperties, + FileCollection classpath, List sourceRoots, List artifacts, + FileCollection frameworkJars, String codenameOneVersion) { + List elements = new ArrayList(); + for (File f : classpath) { + // A dependency declared pom -- how a cn1lib depends on + // another cn1lib's -lib -- resolves in Gradle to the .pom file itself. + // Maven never puts such a dependency on the classpath, only what it + // brings in, and the build would try to open the pom as a jar. + if (f.isFile() && f.getName().endsWith(".pom")) { + continue; + } + elements.add(f.getAbsolutePath()); + } + Map framework = new LinkedHashMap(); + if (frameworkJars != null) { + for (File f : frameworkJars) { + for (String artifactId : new String[] {"codenameone-core", "java-runtime", "codenameone-javase"}) { + if (f.getName().startsWith(artifactId + "-") && f.getName().endsWith(".jar")) { + framework.put(PluginInfo.GROUP + ":" + artifactId, f); + } + } + } + } + return new GradleProjectHost(log, layout, finalName, groupId, projectProperties, userProperties, elements, + sourceRoots, artifacts, framework, codenameOneVersion); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/GradleLog.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/GradleLog.java new file mode 100644 index 00000000000..d9282acb3c1 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/GradleLog.java @@ -0,0 +1,119 @@ +/* + * 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.gradle; + +import com.codename1.build.Log; +import org.gradle.api.logging.Logger; + +/// The build engine's [Log] over a Gradle task logger. +/// +/// Info goes to lifecycle, which Gradle shows by default, because the engine's +/// info messages (what is being built, where the output went) are the ones a +/// Maven user sees at the default level too. +public final class GradleLog implements Log { + private final Logger logger; + + public GradleLog(Logger logger) { + this.logger = logger; + } + + @Override + public boolean isDebugEnabled() { + return logger.isDebugEnabled(); + } + + @Override + public void debug(CharSequence content) { + logger.debug(String.valueOf(content)); + } + + @Override + public void debug(CharSequence content, Throwable error) { + logger.debug(String.valueOf(content), error); + } + + @Override + public void debug(Throwable error) { + logger.debug(String.valueOf(error), error); + } + + @Override + public boolean isInfoEnabled() { + return logger.isLifecycleEnabled(); + } + + @Override + public void info(CharSequence content) { + logger.lifecycle(String.valueOf(content)); + } + + @Override + public void info(CharSequence content, Throwable error) { + logger.lifecycle(String.valueOf(content), error); + } + + @Override + public void info(Throwable error) { + logger.lifecycle(String.valueOf(error), error); + } + + @Override + public boolean isWarnEnabled() { + return logger.isWarnEnabled(); + } + + @Override + public void warn(CharSequence content) { + logger.warn(String.valueOf(content)); + } + + @Override + public void warn(CharSequence content, Throwable error) { + logger.warn(String.valueOf(content), error); + } + + @Override + public void warn(Throwable error) { + logger.warn(String.valueOf(error), error); + } + + @Override + public boolean isErrorEnabled() { + return logger.isErrorEnabled(); + } + + @Override + public void error(CharSequence content) { + logger.error(String.valueOf(content)); + } + + @Override + public void error(CharSequence content, Throwable error) { + logger.error(String.valueOf(content), error); + } + + @Override + public void error(Throwable error) { + logger.error(String.valueOf(error), error); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/GradleProjectHost.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/GradleProjectHost.java new file mode 100644 index 00000000000..efde7ace619 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/GradleProjectHost.java @@ -0,0 +1,390 @@ +/* + * 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.gradle; + +import com.codename1.build.BuildArtifact; +import com.codename1.build.Log; +import com.codename1.build.ProjectHost; +import com.codename1.project.ProjectLayout; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.result.ResolvedArtifactResult; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +/// The build engine's [ProjectHost] for a Gradle project. +/// +/// Built inside a task action from the task's own inputs -- plain strings and +/// files -- never from the `Project`, so every task that uses it stays +/// compatible with the configuration cache. +final class GradleProjectHost implements ProjectHost { + private final Log log; + private final ProjectLayout layout; + private final String finalName; + private final String groupId; + private final Properties projectProperties; + private final Properties userProperties; + private final List classpath; + private final List sourceRoots; + private final List artifacts; + private final Map frameworkJars; + private final String codenameOneVersion; + + GradleProjectHost(Log log, ProjectLayout layout, String finalName, String groupId, + Map projectProperties, Map userProperties, + List classpath, List sourceRoots, List encodedArtifacts, + Map frameworkJars, String codenameOneVersion) { + this.log = log; + this.layout = layout; + this.finalName = finalName; + this.groupId = groupId; + this.projectProperties = toProperties(projectProperties); + this.userProperties = toProperties(userProperties); + this.classpath = classpath == null ? Collections.emptyList() : classpath; + this.sourceRoots = sourceRoots == null ? Collections.emptyList() : sourceRoots; + this.artifacts = new ArrayList(); + if (encodedArtifacts != null) { + for (String encoded : encodedArtifacts) { + BuildArtifact a = decode(encoded); + if (a != null) { + this.artifacts.add(a); + } + } + } + this.frameworkJars = frameworkJars == null ? Collections.emptyMap() : frameworkJars; + this.codenameOneVersion = codenameOneVersion; + } + + private static Properties toProperties(Map map) { + Properties p = new Properties(); + if (map != null) { + for (Map.Entry e : map.entrySet()) { + if (e.getKey() != null && e.getValue() != null) { + p.setProperty(e.getKey(), e.getValue()); + } + } + } + return p; + } + + /// `group|artifact|version|classifier|type|scope|path`, the form a task + /// input carries a resolved dependency in. + static String encode(ResolvedArtifactResult result, String scope) { + ComponentIdentifier id = result.getId().getComponentIdentifier(); + File file = result.getFile(); + if (!(id instanceof ModuleComponentIdentifier)) { + return null; + } + ModuleComponentIdentifier m = (ModuleComponentIdentifier) id; + String name = file.getName(); + int dot = name.lastIndexOf('.'); + String type = dot < 0 ? "jar" : name.substring(dot + 1); + String base = dot < 0 ? name : name.substring(0, dot); + String classifier = classifierOf(base, m.getModule(), m.getVersion()); + return m.getGroup() + "|" + m.getModule() + "|" + m.getVersion() + "|" + classifier + "|" + type + "|" + + scope + "|" + file.getAbsolutePath(); + } + + private static final java.util.regex.Pattern SNAPSHOT_STAMP = + java.util.regex.Pattern.compile("\\d{8}\\.\\d{6}-\\d+(?:-(.+))?"); + + /// The classifier in an artifact's file name (without its extension), or + /// "" for none. A snapshot resolved from a repository with unique snapshots + /// is named for its timestamp -- `maps-1.0-20260101.120000-3-cn1css` for + /// `1.0-SNAPSHOT` -- which the version alone does not match; missing that + /// dropped a snapshot cn1lib's CSS from the theme. + static String classifierOf(String base, String module, String version) { + String prefix = module + "-" + version; + if (base.startsWith(prefix + "-")) { + return base.substring(prefix.length() + 1); + } + if (version.endsWith("-SNAPSHOT")) { + String stem = module + "-" + version.substring(0, version.length() - "SNAPSHOT".length()); + if (base.startsWith(stem)) { + java.util.regex.Matcher stamp = SNAPSHOT_STAMP.matcher(base.substring(stem.length())); + if (stamp.matches()) { + return stamp.group(1) == null ? "" : stamp.group(1); + } + } + } + return ""; + } + + static BuildArtifact decode(String encoded) { + if (encoded == null) { + return null; + } + String[] p = encoded.split("\\|", 7); + if (p.length < 7) { + return null; + } + return new BuildArtifact(p[0], p[1], p[2], p[3].isEmpty() ? null : p[3], p[4], p[5], new File(p[6]), + null); + } + + @Override + public Log log() { + return log; + } + + @Override + public ProjectLayout layout() { + return layout; + } + + @Override + public File cn1ProjectDir() { + return layout.projectDir(); + } + + @Override + public File baseDir() { + return layout.projectDir(); + } + + @Override + public File buildDirectory() { + return layout.buildDir(); + } + + @Override + public File outputDirectory() { + return layout.classesDir(); + } + + @Override + public String finalName() { + return finalName; + } + + @Override + public String groupId() { + return groupId; + } + + @Override + public Properties projectProperties() { + return projectProperties; + } + + @Override + public Properties userProperties() { + return userProperties; + } + + @Override + public List compileClasspathElements() { + return new ArrayList(classpath); + } + + @Override + public List runtimeClasspathElements() { + return new ArrayList(classpath); + } + + @Override + public List compileSourceRoots() { + return sourceRoots; + } + + @Override + public Collection artifacts() { + return artifacts; + } + + /// Gradle keeps the desktop runtime binaries off the application's + /// classpaths entirely (only the simulator gets them), so nothing reached + /// through that aggregator is ever staged and there is nothing to decide. + @Override + public Set neededWithoutDesktopRuntime() { + return null; + } + + @Override + public File getJar(BuildArtifact artifact) { + return artifact.getFile(); + } + + @Override + public File getJar(String groupId, String artifactId, String classifier) { + File f = frameworkJars.get(groupId + ":" + artifactId + (classifier == null ? "" : ":" + classifier)); + if (f != null) { + return f; + } + for (BuildArtifact a : artifacts) { + if (a.getGroupId().equals(groupId) && a.getArtifactId().equals(artifactId) + && (classifier == null ? a.getClassifier() == null : classifier.equals(a.getClassifier()))) { + return a.getFile(); + } + } + return null; + } + + @Override + public long sessionStartTime() { + return Long.MAX_VALUE; + } + + /// The newest input a cached native output (an APK, a generated Xcode or + /// Android Studio project) was built from: the sources, the build files, every + /// file on the upload classpath, and the build hints. Files are compared by timestamp; + /// the build hints are not files -- they arrive as `codename1.*` Gradle + /// properties, from `gradle.properties`, `-P` or the build script -- so + /// their fingerprint is recorded beside the build output, and a change in it + /// counts as a change now. Without it a hint edited in gradle.properties + /// returned the artifact built with the old one. + /// + /// The classpath's PATHS are part of that fingerprint too. A dependency + /// changed in a version catalog or an applied script edits none of the files + /// checked here, and the newly selected jar, already in Gradle's cache, can + /// be older than the output -- but its path names the new version. + @Override + public long sourcesModificationTime() { + long t = lastModified(new File(layout.projectDir(), "src")); + t = Math.max(t, layout.settingsFile().lastModified()); + t = Math.max(t, layout.dependencyFile().lastModified()); + t = Math.max(t, layout.rootBuildFile().lastModified()); + // The dependencies too: a jar replaced in place (a converted Ant project's + // libs/foo.jar) edits no build file, and the cached APK or generated + // project still held the old code. + for (String element : classpath) { + t = Math.max(t, lastModified(new File(element))); + } + // The icon, which lives outside src/ and off the classpath: a new one + // must not leave the APK or generated project with the old. + t = Math.max(t, iconFile().lastModified()); + return Math.max(t, hintsChangedAt()); + } + + /// The icon the build uses: `codename1.icon` (a -P/-D override first, then + /// the settings file) relative to the project, else the layout's icon.png. + File iconFile() { + String icon = userProperties == null ? null : userProperties.getProperty("codename1.icon"); + if (icon == null && layout.settingsFile().isFile()) { + Properties settings = new Properties(); + try (java.io.InputStream in = new java.io.FileInputStream(layout.settingsFile())) { + settings.load(in); + icon = settings.getProperty("codename1.icon"); + } catch (IOException ex) { + icon = null; + } + } + if (icon == null || icon.trim().isEmpty()) { + return layout.iconFile(); + } + File f = new File(icon.trim()); + return f.isAbsolute() ? f : new File(layout.projectDir(), icon.trim()); + } + + /// When the build hints last changed, as the fingerprint file's timestamp. + private long hintsChangedAt() { + File fingerprint = new File(layout.buildDir(), "codenameone" + File.separator + "build-hints.fingerprint"); + StringBuilder inputs = new StringBuilder(hintsFingerprint(userProperties)); + for (String element : classpath) { + inputs.append('\n').append(element); + } + String current = inputs.toString(); + try { + String previous = fingerprint.isFile() + ? new String(java.nio.file.Files.readAllBytes(fingerprint.toPath()), StandardCharsets.UTF_8) + : null; + if (!current.equals(previous)) { + File dir = fingerprint.getParentFile(); + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Could not create " + dir); + } + java.nio.file.Files.write(fingerprint.toPath(), current.getBytes(StandardCharsets.UTF_8)); + } + return fingerprint.lastModified(); + } catch (IOException ex) { + // Unknown, so treat the hints as just changed: rebuilding is safe, + // reusing a stale artifact is not. + log.warn("Could not record the build hint fingerprint in " + fingerprint + ": " + ex.getMessage()); + return System.currentTimeMillis(); + } + } + + /// A SHA-256 of `properties`, sorted, so equal hints give equal digests. A + /// digest, not the text: hints carry signing passwords and tokens, which + /// have no business in the build directory. + static String hintsFingerprint(Properties properties) { + StringBuilder sb = new StringBuilder(); + for (String key : new java.util.TreeSet(properties.stringPropertyNames())) { + sb.append(key).append('=').append(properties.getProperty(key)).append('\n'); + } + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(sb.toString().getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte b : digest) { + hex.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16)); + } + return hex.toString(); + } catch (java.security.NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is required of every JVM", ex); + } + } + + private static long lastModified(File f) { + if (!f.exists()) { + return 0L; + } + if (!f.isDirectory()) { + return f.lastModified(); + } + long t = f.lastModified(); + File[] children = f.listFiles(); + if (children != null) { + for (File c : children) { + t = Math.max(t, lastModified(c)); + } + } + return t; + } + + /// Gradle has no attached-artifact concept; the file stays where the build + /// wrote it, under the build directory, and is reported. + @Override + public void attachArtifact(String type, String classifier, File file) { + log.info("Build output: " + file.getAbsolutePath()); + } + + @Override + public String codenameOneVersion() { + return codenameOneVersion; + } + + @Override + public String pluginVersion() { + return PluginInfo.version(); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/LibrarySupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/LibrarySupport.java new file mode 100644 index 00000000000..f7db8955306 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/LibrarySupport.java @@ -0,0 +1,455 @@ +/* + * 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.gradle; + +import com.codename1.gradle.tasks.ComplianceAction; +import com.codename1.gradle.tasks.NativeInterfacesTask; +import com.codename1.maven.Cn1libPom; +import com.codename1.project.NativePlatform; +import com.codename1.project.ProjectLayout; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.provider.Provider; +import org.gradle.api.publish.PublishingExtension; +import org.gradle.api.publish.maven.MavenPublication; +import org.gradle.api.publish.maven.plugins.MavenPublishPlugin; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.bundling.Jar; +import org.gradle.api.tasks.bundling.Zip; +import org.gradle.api.tasks.compile.JavaCompile; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// A cn1lib built with Gradle, published in the Maven shape `cn1lib-archetype` +/// produces, so Maven and Gradle applications can both consume it. +/// +/// For a library named `N` (the project name) in group `G`: +/// +/// | artifact | contents | +/// |---|---| +/// | `G:N-common` | the compiled library, with `codenameone_library_appended.properties` and `codenameone_library_required.properties` under `META-INF/codenameone/G/N-common/` | +/// | `G:N-common:cn1css@zip` | `src/main/css`, merged into an application's theme | +/// | `G:N-` | `src//` (compiled, for `javase`), one per platform | +/// | `G:N-lib@pom` | what applications depend on: the common jar and CSS, plus one `codename1.platform` profile per platform | +/// +/// `./gradlew publishToMavenLocal` installs it; any `publishing { repositories }` +/// the build script declares receives it with `./gradlew publish`. Applications +/// then declare `cn1lib("G:N-lib:version")` (Gradle) or depend on `N-lib` with +/// `pom` (Maven). +final class LibrarySupport { + private LibrarySupport() { + } + + private static boolean hasFiles(File dir) { + File[] children = dir.listFiles(); + if (children == null) { + return false; + } + for (File c : children) { + if (c.isFile() || c.isDirectory() && hasFiles(c)) { + return true; + } + } + return false; + } + + /// Adds `pom` to the dependencies on `cn1libs` (group:artifact). + /// + /// `cn1libs` maps each group:artifact to the artifact a consumer must depend + /// on: the same one for a published cn1lib (its `-lib`), the `-lib` of a + /// project of this build (`cn1lib(project(":maps"))`), which Gradle writes + /// under the project's name or its `-common` publication instead. + static void markCn1libDependencies(org.w3c.dom.Element project, Map cn1libs) { + org.w3c.dom.NodeList deps = project.getElementsByTagName("dependency"); + for (int i = 0; i < deps.getLength(); i++) { + org.w3c.dom.Element dep = (org.w3c.dom.Element) deps.item(i); + String key = childText(dep, "groupId") + ":" + childText(dep, "artifactId"); + String artifact = cn1libs.get(key); + if (artifact == null) { + continue; + } + if (!artifact.equals(childText(dep, "artifactId"))) { + dep.getElementsByTagName("artifactId").item(0).setTextContent(artifact); + } + if (dep.getElementsByTagName("type").getLength() > 0) { + continue; + } + // Plain createElement: Gradle's pom DOM is not namespace aware, and a + // namespaced element would serialize with a stray xmlns="". + org.w3c.dom.Element type = dep.getOwnerDocument().createElement("type"); + type.setTextContent("pom"); + dep.appendChild(type); + } + } + + /// Writes `version` into every `com.codenameone` dependency the pom declares + /// without one. The build resolves those with the framework's version + /// (ProjectSupport's resolution strategy), but the published pom would carry + /// the declaration as written, and a Maven consumer cannot resolve a + /// dependency with no version. + static void fillFrameworkVersions(org.w3c.dom.Element project, String version) { + org.w3c.dom.NodeList deps = project.getElementsByTagName("dependency"); + for (int i = 0; i < deps.getLength(); i++) { + org.w3c.dom.Element dep = (org.w3c.dom.Element) deps.item(i); + String existing = childText(dep, "version"); + if (!PluginInfo.GROUP.equals(childText(dep, "groupId")) || existing != null && !existing.isEmpty()) { + continue; + } + org.w3c.dom.NodeList old = dep.getElementsByTagName("version"); + for (int j = old.getLength() - 1; j >= 0; j--) { + dep.removeChild(old.item(j)); + } + org.w3c.dom.Element v = dep.getOwnerDocument().createElement("version"); + v.setTextContent(version); + dep.appendChild(v); + } + } + + /// One `` element, indented for the `` of a + /// platform pom. `compile` is Maven's default and is left implicit. + static String dependencyXml(String group, String artifact, String version, String scope) { + return " \n " + xmlEscape(group) + "\n " + + xmlEscape(artifact) + "\n " + xmlEscape(version) + "\n" + + ("compile".equals(scope) ? "" : " " + xmlEscape(scope) + "\n") + + " \n"; + } + + /// `dependencyXml` with the declaration's exclusions, as Maven writes them, + /// so a consumer of the pom drops what the library dropped. A + /// non-transitive declaration is the `*:*` exclusion. + static String withExclusions(String dependencyXml, org.gradle.api.artifacts.Dependency d) { + if (!(d instanceof org.gradle.api.artifacts.ModuleDependency)) { + return dependencyXml; + } + org.gradle.api.artifacts.ModuleDependency module = (org.gradle.api.artifacts.ModuleDependency) d; + List rules = new ArrayList(); + if (!module.isTransitive()) { + rules.add(new String[] {"*", "*"}); + } else { + for (org.gradle.api.artifacts.ExcludeRule r : module.getExcludeRules()) { + rules.add(new String[] {orAny(r.getGroup()), orAny(r.getModule())}); + } + } + return exclusionsXml(dependencyXml, rules); + } + + /// Maven's `*` for an exclusion part Gradle leaves out. Gradle declares the + /// getters non-null, but a group-only or module-only exclude answers null. + private static String orAny(Object part) { + return part == null ? "*" : part.toString(); + } + + /// `dependencyXml` with an `` element for `rules` ({group, + /// artifact}), placed before its closing tag; unchanged when there are none. + static String exclusionsXml(String dependencyXml, List rules) { + if (rules.isEmpty()) { + return dependencyXml; + } + StringBuilder sb = new StringBuilder(" \n"); + for (String[] r : rules) { + sb.append(" \n ").append(xmlEscape(r[0])) + .append("\n ").append(xmlEscape(r[1])) + .append("\n \n"); + } + sb.append(" \n"); + int close = dependencyXml.lastIndexOf(" "); + return dependencyXml.substring(0, close) + sb + dependencyXml.substring(close); + } + + private static String xmlEscape(String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + private static String childText(org.w3c.dom.Element parent, String name) { + org.w3c.dom.NodeList n = parent.getElementsByTagName(name); + return n.getLength() == 0 ? null : n.item(0).getTextContent().trim(); + } + + static void apply(final Project project, final ProjectLayout layout, final CodenameOneExtension ext, + final Provider> userProperties) { + project.getPluginManager().apply(MavenPublishPlugin.class); + // A library may use other cn1libs, declared the same way an application does. + // They are part of its API -- compile scope in cn1lib-archetype's poms -- so + // a consumer compiles against them too: java-library's api extends cn1lib. + project.getPluginManager().apply(org.gradle.api.plugins.JavaLibraryPlugin.class); + Cn1libs.configure(project); + project.getConfigurations().getByName("api").extendsFrom( + project.getConfigurations().getByName(Cn1libs.DECLARED)); + AppSupport.addFramework(project, "compileOnly", ext.getVersion(), "codenameone-core", "java-runtime"); + AppSupport.addFramework(project, "testImplementation", ext.getVersion(), "codenameone-core", + "codenameone-javase"); + Configuration javasePort = AppSupport.resolvable(project, "cn1Simulator", "The JavaSE port"); + AppSupport.addFramework(project, javasePort.getName(), ext.getVersion(), "codenameone-core", + "codenameone-javase"); + + SourceSetContainer sourceSets = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets(); + final SourceSet main = sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME); + final SourceSet javase = sourceSets.create("javase", ss -> { + ss.getJava().setSrcDirs(Collections.singletonList(layout.nativeSourceDir(NativePlatform.JAVASE))); + ss.getResources().setSrcDirs(Collections.singletonList( + new File(layout.projectDir(), "src" + File.separator + "javase" + File.separator + "resources"))); + // The JavaSE jars of the cn1libs this library uses, as an application's + // javase source set has them: its own JavaSE code may call theirs. + ss.setCompileClasspath(ss.getCompileClasspath().plus(main.getOutput()).plus(main.getCompileClasspath()) + .plus(javasePort) + .plus(project.getConfigurations().getByName(Cn1libs.configurationName("javase")))); + }); + + AppSupport.registerGuiSources(project, layout, ext, userProperties, main); + + final String name = project.getName(); + final Provider group = project.provider(() -> String.valueOf(project.getGroup())); + final Provider version = project.provider(() -> String.valueOf(project.getVersion())); + + // The library's properties travel inside its jar, where an application's + // build finds and merges them (see AppBuilder, LibraryHintMerger). + project.getTasks().named(main.getProcessResourcesTaskName(), org.gradle.api.tasks.Copy.class, copy -> + copy.from(layout.projectDir(), spec -> { + spec.include("codenameone_library_appended.properties", "codenameone_library_required.properties"); + spec.into(group.map(g -> "META-INF/codenameone/" + g + "/" + name + "-common")); + })); + + // The same compliance check an application's compile runs. + final Provider> compileArtifacts = project.getConfigurations() + .getByName(main.getCompileClasspathConfigurationName()).getIncoming() + .artifactView(v -> v.setLenient(true)).getArtifacts().getResolvedArtifacts() + .map(set -> AppSupport.encode(set, "provided")); + // Skipping the check is an input, as for an application: a compile made with + // it must not satisfy the next ordinary build as up to date. + final String skip = AppSupport.skipComplianceCheck(project); + final Map complianceProperties = skip == null + ? Collections.emptyMap() + : Collections.singletonMap("skipComplianceCheck", skip); + project.getTasks().named(main.getCompileJavaTaskName(), JavaCompile.class, compile -> { + compile.getInputs().property("cn1SkipComplianceCheck", String.valueOf(skip)); + compile.doLast("cn1Compliance", new ComplianceAction(layout.rootDir(), layout.projectDir(), + compile.getDestinationDirectory().getAsFile(), name, main.getCompileClasspath(), + compileArtifacts, complianceProperties) + .withBuildDirectory(project.getLayout().getBuildDirectory().getAsFile()) + .withSiblingClasses(main.getOutput().getClassesDirs())); + }); + // A Kotlin library's classes are checked too, as an application's are: in + // a pure Kotlin library compileJava has no sources and runs no action at + // all, and a mixed one would otherwise publish its Kotlin half unchecked. + project.getPluginManager().withPlugin("org.jetbrains.kotlin.jvm", kotlin -> + project.getTasks().named("compileKotlin").configure(compile -> { + compile.getInputs().property("cn1SkipComplianceCheck", String.valueOf(skip)); + compile.doLast("cn1Compliance", new ComplianceAction(layout.rootDir(), layout.projectDir(), + AppSupport.kotlinDestinationProvider(compile, layout), name, main.getCompileClasspath(), + compileArtifacts, complianceProperties) + .withBuildDirectory(project.getLayout().getBuildDirectory().getAsFile()) + .withPendingJavaSources(main.getJava().getSrcDirs())); + })); + + // A library without CSS publishes no cn1css bundle and its -lib pom names + // none: the Zip task would do nothing, and publishing then failed on the + // missing archive. Maven cn1libs without CSS have the same shape. + final boolean hasCss = hasFiles(layout.cssDir()); + final TaskProvider css = project.getTasks().register("cn1libCss", Zip.class, zip -> { + zip.setGroup(AppSupport.GROUP); + zip.setDescription("Packages src/main/css as the library's cn1css bundle"); + zip.getArchiveBaseName().set(name + "-common"); + zip.getArchiveClassifier().set("cn1css"); + zip.getArchiveExtension().set("zip"); + zip.from(layout.cssDir(), spec -> spec.into(group.map(g -> "META-INF/codenameone/" + g + "/" + + name + "-common/css"))); + }); + if (hasCss) { + // The same bundle for an application in this build that declares + // cn1lib(project(":this")), which finds no published zip to read. + Configuration cssElements = project.getConfigurations().create(Cn1libs.CSS_ELEMENTS, c -> { + c.setCanBeConsumed(true); + c.setCanBeResolved(false); + c.setDescription("The library's cn1css bundle, for applications in this build"); + }); + project.getArtifacts().add(cssElements.getName(), css); + } + + final List platforms = new ArrayList(); + final List> platformJars = new ArrayList>(); + for (final NativePlatform p : NativePlatform.values()) { + platforms.add(p.id()); + platformJars.add(project.getTasks().register("cn1lib" + + p.id().substring(0, 1).toUpperCase(java.util.Locale.ROOT) + p.id().substring(1) + "Jar", + Jar.class, jar -> { + jar.setGroup(AppSupport.GROUP); + jar.setDescription("Packages the library's " + p.id() + " implementation"); + jar.getArchiveBaseName().set(name + "-" + p.id()); + if (p == NativePlatform.JAVASE) { + jar.from(javase.getOutput()); + } else { + // Sources, not classes: the native builders compile them, as a + // Maven cn1lib's platform module packages them. + jar.from(layout.nativeSourceDir(p)); + jar.from(new File(layout.projectDir(), "src" + File.separator + p.id() + + File.separator + "resources")); + } + })); + // The same jar for an application in this build that declares + // cn1lib(project(":this")): it resolves a project, not a pom with + // profiles, so it takes each platform's jar from here. + final TaskProvider platformJar = platformJars.get(platformJars.size() - 1); + Configuration elements = project.getConfigurations().create(Cn1libs.platformElementsName(p.id()), c -> { + c.setCanBeConsumed(true); + c.setCanBeResolved(false); + c.setDescription("The " + p.id() + " implementation, for applications in this build"); + }); + if (p == NativePlatform.JAVASE) { + elements.extendsFrom(project.getConfigurations().getByName(javase.getImplementationConfigurationName()), + project.getConfigurations().getByName(javase.getRuntimeOnlyConfigurationName())); + } + project.getArtifacts().add(elements.getName(), platformJar); + } + + project.getTasks().register("generateNativeInterfaces", NativeInterfacesTask.class, t -> { + AppSupport.common(t, project, layout, ext, userProperties); + t.setDescription("Writes implementation stubs for every NativeInterface into src//"); + t.dependsOn(main.getClassesTaskName()); + t.getClassesDirectories().from(main.getOutput().getClassesDirs()); + t.getCompileClasspath().from(main.getCompileClasspath()); + t.getOnly().set(project.getProviders().gradleProperty("cn1.nativeInterface")); + t.getSwift().set(project.getProviders().gradleProperty("cn1.swift").map(Boolean::parseBoolean) + .orElse(Boolean.FALSE)); + t.getKotlin().set(project.getProviders().gradleProperty("cn1.kotlin").map(Boolean::parseBoolean) + .orElse(Boolean.FALSE)); + t.getOverwrite().set(project.getProviders().gradleProperty("cn1.overwrite").map(Boolean::parseBoolean) + .orElse(Boolean.FALSE)); + t.getOutputs().upToDateWhen(x -> false); + }); + + PublishingExtension publishing = project.getExtensions().getByType(PublishingExtension.class); + // The cn1libs this library uses, as group:artifact. Gradle publishes them as + // ordinary (jar) dependencies; Maven consumers need pom, the + // shape cn1lib-archetype's common module uses, or they look for a jar the + // -lib artifact does not have. + final Provider> usedCn1libs = project.provider(() -> { + Map out = new java.util.HashMap(); + for (org.gradle.api.artifacts.Dependency d + : project.getConfigurations().getByName(Cn1libs.DECLARED).getAllDependencies()) { + if (d instanceof org.gradle.api.artifacts.ProjectDependency) { + out.put(d.getGroup() + ":" + d.getName(), d.getName() + "-lib"); + out.put(d.getGroup() + ":" + d.getName() + "-common", d.getName() + "-lib"); + } else { + out.put(d.getGroup() + ":" + d.getName(), d.getName()); + } + } + return out; + }); + // No Gradle module metadata for the common jar. Gradle prefers a .module + // to the pom, and the one java-library writes names a project cn1lib by + // its jar (maps-common) where the pom, rewritten above, names its -lib: + // a Gradle application then got the inner library's classes but none of + // its platform jars. The pom is what Maven consumers read, so with the + // metadata gone both build tools resolve the same Maven-shaped graph. + project.getTasks().withType(org.gradle.api.publish.tasks.GenerateModuleMetadata.class) + .matching(t -> "generateMetadataFileForCn1libCommonPublication".equals(t.getName())) + .configureEach(t -> t.setEnabled(false)); + publishing.getPublications().create("cn1libCommon", MavenPublication.class, pub -> { + pub.setArtifactId(name + "-common"); + pub.from(project.getComponents().getByName("java")); + final Provider framework = ext.getVersion(); + pub.getPom().withXml(xml -> { + markCn1libDependencies(xml.asElement(), usedCn1libs.get()); + fillFrameworkVersions(xml.asElement(), framework.get()); + }); + if (!hasCss) { + return; + } + pub.artifact(css, a -> { + a.setClassifier("cn1css"); + a.setExtension("zip"); + }); + }); + // What the JavaSE code itself uses (javaseImplementation, javaseRuntimeOnly): + // the -javase pom must name it, or an application gets the jar without the + // libraries it calls and the simulator fails on a missing class. + final Provider frameworkVersion = ext.getVersion(); + final Provider> javaseDependencies = project.provider(() -> { + List out = new ArrayList(); + for (String[] cfg : new String[][] {{javase.getImplementationConfigurationName(), "compile"}, + {javase.getRuntimeOnlyConfigurationName(), "runtime"}}) { + for (org.gradle.api.artifacts.Dependency d + : project.getConfigurations().getByName(cfg[0]).getDependencies()) { + // A project of the same build (project(":desktop-helper")) is published + // too, under its group, name and version -- what Gradle reports for it + // and what java-library publishes it as. + if (!(d instanceof org.gradle.api.artifacts.ExternalModuleDependency + || d instanceof org.gradle.api.artifacts.ProjectDependency) || d.getGroup() == null) { + continue; + } + String v = d.getVersion(); + if ((v == null || v.isEmpty()) && PluginInfo.GROUP.equals(d.getGroup())) { + v = frameworkVersion.get(); + } + if (v == null || v.isEmpty() || "unspecified".equals(v)) { + project.getLogger().warn("cn1: " + d.getGroup() + ":" + d.getName() + " has no version, " + + "so the published " + name + "-javase pom cannot name it"); + continue; + } + out.add(withExclusions(dependencyXml(d.getGroup(), d.getName(), v, cfg[1]), d)); + } + } + return out; + }); + for (int i = 0; i < platforms.size(); i++) { + final String platform = platforms.get(i); + final TaskProvider jar = platformJars.get(i); + publishing.getPublications().create("cn1lib" + platform.substring(0, 1).toUpperCase(java.util.Locale.ROOT) + + platform.substring(1), MavenPublication.class, pub -> { + pub.setArtifactId(name + "-" + platform); + pub.artifact(jar); + pub.getPom().withXml(xml -> { + // Every platform jar depends on the common one, as the Maven + // platform modules do. + StringBuilder sb = xml.asString(); + int end = sb.lastIndexOf(""); + StringBuilder deps = new StringBuilder(" \n") + .append(dependencyXml(group.get(), name + "-common", version.get(), "compile")); + if ("javase".equals(platform)) { + for (String d : javaseDependencies.get()) { + deps.append(d); + } + } + sb.insert(end, deps.append(" \n").toString()); + }); + }); + } + publishing.getPublications().create("cn1libLib", MavenPublication.class, pub -> { + pub.setArtifactId(name + "-lib"); + pub.getPom().setPackaging("pom"); + pub.getPom().withXml(xml -> { + StringBuilder sb = xml.asString(); + sb.setLength(0); + sb.append(Cn1libPom.render(group.get(), name, version.get(), platforms, hasCss)); + }); + }); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/PluginInfo.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/PluginInfo.java new file mode 100644 index 00000000000..55cfdb94e9f --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/PluginInfo.java @@ -0,0 +1,67 @@ +/* + * 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.gradle; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/// Facts about this plugin build. +final class PluginInfo { + /// Where Codename One publishes, the plugin and the framework alike. + static final String REPOSITORY_URL = "https://repo.codenameone.com/maven2"; + /// The Codename One group id. + static final String GROUP = "com.codenameone"; + + private static String version; + + private PluginInfo() { + } + + /// The plugin's version, which is also the Codename One version a project + /// builds against unless it sets `codenameone { version = ... }`. + static synchronized String version() { + if (version == null) { + Properties p = new Properties(); + InputStream in = PluginInfo.class.getResourceAsStream("plugin.properties"); + try { + if (in != null) { + p.load(in); + } + } catch (IOException ignored) { + // Falls through to the unknown version below. + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignored) { + // A classpath resource; nothing to recover. + } + } + } + String v = p.getProperty("version"); + version = v == null || v.startsWith("${") ? "8.0-SNAPSHOT" : v.trim(); + } + return version; + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/ProjectSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/ProjectSupport.java new file mode 100644 index 00000000000..59a961c557f --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/ProjectSupport.java @@ -0,0 +1,234 @@ +/* + * 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.gradle; + +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.plugins.JavaPlugin; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.compile.JavaCompile; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// The plugin applied to a project: common setup, then the kind-specific part. +final class ProjectSupport { + /// The Java release every Codename One Gradle project compiles for. + static final int JAVA_RELEASE = 17; + + private ProjectSupport() { + } + + static void apply(Project project) { + if (project.getExtensions().findByName("codenameone") != null) { + return; + } + SettingsSupport.addRepositoriesIfMissing(project); + project.getPluginManager().apply(JavaPlugin.class); + + final CodenameOneExtension ext = project.getExtensions().create("codenameone", CodenameOneExtension.class); + ext.getVersion().convention(project.getProviders().gradleProperty("codename1.version") + .orElse(PluginInfo.version())); + // -Popen=false is the Maven plugin's -Dopen=false, so the switch people + // already pass to keep Xcode or Android Studio shut works here too. + ext.getOpenGeneratedProjects().convention(project.getProviders().gradleProperty("codename1.open") + .orElse(project.getProviders().gradleProperty("open")) + .map(Boolean::parseBoolean).orElse(Boolean.TRUE)); + + // A com.codenameone module declared without a version gets the framework's, + // the job ${cn1.version} does in the archetype's pom. Settings' add-on + // catalog writes its coordinates that way, and so can a build script. + final Provider frameworkVersion = ext.getVersion(); + project.getConfigurations().configureEach(c -> c.getResolutionStrategy().eachDependency(d -> { + String requested = d.getRequested().getVersion(); + if (PluginInfo.GROUP.equals(d.getRequested().getGroup()) + && (requested == null || requested.isEmpty())) { + d.useVersion(frameworkVersion.get()); + d.because("the Codename One framework version"); + } + })); + + requireJava17(project); + + ProjectKind kind = kind(project); + // The build directory as Gradle has it when asked: a build script can move + // it (layout.buildDirectory) after this plugin is applied, and every + // Codename One output must follow, or `clean` leaves them behind. + final org.gradle.api.file.DirectoryProperty buildDirectory = project.getLayout().getBuildDirectory(); + ProjectLayout layout = ProjectLayouts.of(BuildSystem.GRADLE, kind, + project.getRootDir(), project.getProjectDir()) + .withBuildDir(() -> buildDirectory.get().getAsFile()); + Provider> userProperties = userProperties(project, ext); + + switch (kind) { + case BACKEND: + BackendSupport.apply(project, layout, ext, userProperties); + break; + case LIB: + LibrarySupport.apply(project, layout, ext, userProperties); + break; + default: + AppSupport.apply(project, layout, ext, userProperties); + break; + } + } + + /// The kind from the `codename1.kind` Gradle property, else from the + /// project's files. + /// + /// The property is read for the root project only. A `-P` or root + /// `gradle.properties` value is visible in every subproject, so read there it + /// would also turn the `backend` the settings plugin includes into a second + /// application -- no `runBackend`, and app tasks pointed at the backend's + /// directory. A subproject is always told apart by its own marker file. + static ProjectKind kind(Project project) { + Object explicit = project == project.getRootProject() ? project.findProperty("codename1.kind") : null; + if (explicit != null) { + try { + return ProjectKind.valueOf(String.valueOf(explicit).trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw new GradleException("codename1.kind must be APP, LIB or BACKEND, not " + explicit, ex); + } + } + return ProjectLayouts.gradleKind(project.getProjectDir()); + } + + /// Every compile targets Java 17. A project that asks for less is refused + /// with the reason, rather than compiling classes the Codename One + /// toolchain was never verified against for a Gradle build. + private static void requireJava17(Project project) { + project.getTasks().withType(JavaCompile.class).configureEach(compile -> { + compile.getOptions().getRelease().convention(JAVA_RELEASE); + compile.getOptions().setEncoding(compile.getOptions().getEncoding() == null + ? "UTF-8" : compile.getOptions().getEncoding()); + compile.doFirst(new RequireRelease()); + }); + // Kotlin targets the JDK Gradle runs on unless told otherwise, and the + // Kotlin plugin refuses a Java and a Kotlin target that differ -- so a + // Kotlin project built on JDK 21 failed at compileKotlin ("Inconsistent + // JVM Target Compatibility"). Pinned to the same release as javac. + // The ORM enhancer keeps a bookkeeping file in each classes directory it + // processes, and a Kotlin project has two (Java's and Kotlin's), so an + // archive of the main output met the same path twice and `jar` failed. + // Only the enhancer reads the file, from the directory, never from a jar. + project.getTasks().withType(org.gradle.api.tasks.bundling.Jar.class).configureEach(jar -> + jar.filesMatching("META-INF/cn1/orm-enhanced-dependencies.list", + f -> f.setDuplicatesStrategy(org.gradle.api.file.DuplicatesStrategy.EXCLUDE))); + project.getPluginManager().withPlugin("org.jetbrains.kotlin.jvm", kotlin -> + project.getTasks().matching(t -> t.getName().startsWith("compile") + && t.getName().endsWith("Kotlin")).configureEach(ProjectSupport::pinKotlinJvmTarget)); + } + + /// The `codename1.*` properties from the command line (`-P` and `-D`) and + /// the `codenameone { buildHints }` block, as `codename1.*` keys. + /// + /// These are what the Maven plugin calls user properties: they override + /// the settings file for this build only. + static Provider> userProperties(Project project, CodenameOneExtension ext) { + Provider> gradleProps = project.getProviders().gradlePropertiesPrefixedBy("codename1."); + Provider> systemProps = project.getProviders().systemPropertiesPrefixedBy("codename1."); + // codenameone { mainClass } is the settings file's packageName + mainName, + // so it becomes those two overrides; the command line still wins. + Provider mainClass = ext.getMainClass().orElse(""); + return ext.getBuildHints().zip(mainClass, (hints, main) -> { + Map out = new LinkedHashMap(); + for (Map.Entry e : hints.entrySet()) { + out.put("codename1.arg." + e.getKey(), e.getValue()); + } + out.putAll(mainClassProperties(main)); + return out; + }).zip(gradleProps, (m, gp) -> { + Map out = new LinkedHashMap(m); + out.putAll(gp); + return out; + }).zip(systemProps, (m, sp) -> { + Map out = new LinkedHashMap(m); + out.putAll(sp); + return out; + }); + } + + /// `codename1.packageName` and `codename1.mainName` for a fully qualified + /// main class, or nothing for an empty one. + static Map mainClassProperties(String mainClass) { + Map out = new LinkedHashMap(); + String main = mainClass == null ? "" : mainClass.trim(); + if (main.isEmpty()) { + return out; + } + int dot = main.lastIndexOf('.'); + out.put("codename1.packageName", dot < 0 ? "" : main.substring(0, dot)); + out.put("codename1.mainName", dot < 0 ? main : main.substring(dot + 1)); + return out; + } + + /// Sets a Kotlin compile task's JVM target to [JAVA_RELEASE], the one level + /// Codename One builds: the build server translates Java 17 bytecode for + /// every platform, and Android runs nothing newer. Reflective, because the + /// Kotlin Gradle plugin's types are not on this plugin's classpath: + /// `compilerOptions.jvmTarget` (Kotlin 1.8 and newer), else the older + /// `kotlinOptions.jvmTarget` string. + @SuppressWarnings("unchecked") + static void pinKotlinJvmTarget(org.gradle.api.Task task) { + try { + Object options = task.getClass().getMethod("getCompilerOptions").invoke(task); + Object target = options.getClass().getMethod("getJvmTarget").invoke(options); + Class jvmTarget = Class.forName("org.jetbrains.kotlin.gradle.dsl.JvmTarget", false, + task.getClass().getClassLoader()); + ((org.gradle.api.provider.Property) target).set( + Enum.valueOf(jvmTarget.asSubclass(Enum.class), "JVM_" + JAVA_RELEASE)); + return; + } catch (ReflectiveOperationException | RuntimeException ex) { + // An older Kotlin plugin: fall through to kotlinOptions. + } + try { + Object options = task.getClass().getMethod("getKotlinOptions").invoke(task); + options.getClass().getMethod("setJvmTarget", String.class).invoke(options, String.valueOf(JAVA_RELEASE)); + } catch (ReflectiveOperationException | RuntimeException ex) { + task.getLogger().warn("cn1: could not set " + task.getPath() + "'s JVM target to " + JAVA_RELEASE + + "; set kotlin { compilerOptions { jvmTarget } } in build.gradle.kts if the build refuses " + + "the Java and Kotlin targets as inconsistent (" + ex + ")"); + } + } + + /// Fails a compile that targets less than [JAVA_RELEASE]. Codename One builds + /// Java 17 bytecode only (Android runs nothing newer); the compliance check + /// rewrites anything a newer compiler emits down to 17, so only a lower + /// release is an error here. + static final class RequireRelease implements org.gradle.api.Action { + @Override + public void execute(org.gradle.api.Task task) { + JavaCompile compile = (JavaCompile) task; + Integer release = compile.getOptions().getRelease().getOrNull(); + if (release != null && release < JAVA_RELEASE) { + throw new GradleException("Codename One Gradle projects compile for Java " + JAVA_RELEASE + + ", but " + task.getPath() + " asks for Java " + release + ". Remove the " + + "release setting, or use the Maven build for a project that must target Java 8."); + } + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/SettingsSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/SettingsSupport.java new file mode 100644 index 00000000000..9e2f5279af7 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/SettingsSupport.java @@ -0,0 +1,161 @@ +/* + * 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.gradle; + +import com.codename1.project.ProjectLayout; +import org.gradle.api.Project; +import org.gradle.api.artifacts.dsl.RepositoryHandler; +import org.gradle.api.initialization.Settings; +import org.gradle.api.provider.Provider; + +import java.io.File; +import java.net.URI; + +/// The plugin applied from `settings.gradle.kts`. +final class SettingsSupport { + /// Set on the build once the settings plugin ran, so a project does not add + /// repositories of its own on top of the settings' ones. + static final String APPLIED_MARKER = "codename1.settingsPluginApplied"; + + private SettingsSupport() { + } + + static void apply(final Settings settings) { + settings.getGradle().getExtensions().getExtraProperties().set(APPLIED_MARKER, Boolean.TRUE); + addRepositories(settings.getDependencyResolutionManagement().getRepositories(), + settings.getProviders().gradleProperty("codename1.repository")); + + // A backend/ directory beside the application is its backend subproject. + // Including it here keeps settings.gradle.kts down to the plugin line; the + // addBackend task creates the directory, and nothing else has to change. + File backend = new File(settings.getRootDir(), "backend"); + if (new File(backend, ProjectLayout.BACKEND_SETTINGS_FILE).isFile() + && settings.findProject(":backend") == null) { + settings.include("backend"); + } + + settings.getGradle().beforeProject(project -> { + if (project == project.getRootProject() || ":backend".equals(project.getPath())) { + project.getPluginManager().apply(CodenameOnePlugin.class); + } + }); + } + + /// The Codename One repository, then Maven Central for third-party + /// libraries. `codename1.repository` (a URL or a directory) replaces the + /// Codename One one, for a mirror or a locally built framework. + static void addRepositories(RepositoryHandler repositories, Provider override) { + final String url = override.isPresent() ? override.get() : PluginInfo.REPOSITORY_URL; + repositories.maven(repo -> { + repo.setName("CodenameOne"); + repo.setUrl(toUri(url)); + }); + repositories.mavenCentral(); + } + + /// Adds the repositories to a project that has none, when the plugin was + /// applied without the settings plugin. + static void addRepositoriesIfMissing(final Project project) { + Object marker = project.getGradle().getExtensions().getExtraProperties().has(APPLIED_MARKER) + ? project.getGradle().getExtensions().getExtraProperties().get(APPLIED_MARKER) : null; + org.gradle.api.initialization.resolve.DependencyResolutionManagement management = resolution(project); + org.gradle.api.initialization.resolve.RepositoriesMode mode = management == null ? null + : management.getRepositoriesMode().getOrNull(); + boolean settingsDeclare = management != null && !management.getRepositories().isEmpty(); + boolean strict = mode == org.gradle.api.initialization.resolve.RepositoriesMode.PREFER_SETTINGS + || mode == org.gradle.api.initialization.resolve.RepositoriesMode.FAIL_ON_PROJECT_REPOS; + if (!Boolean.TRUE.equals(marker) && (strict || settingsDeclare)) { + // The settings script governs resolution -- a strict mode ignores or + // forbids project repositories, and under the default mode any project + // repository would make Gradle drop the settings' ones (a corporate + // mirror with them). So none is added; if the settings name no Codename + // One repository, say what to add rather than fail later on an + // unresolvable framework. + if (!namesCodenameOneRepository(management, project.getProviders().gradleProperty("codename1.repository"))) { + project.getLogger().warn("cn1: settings.gradle(.kts) declares the repositories, and none is the " + + "Codename One repository, which publishes the framework. Add " + + "maven(\"" + PluginInfo.REPOSITORY_URL + "\") to its dependencyResolutionManagement, " + + "or apply com.codenameone in the settings plugins block, which does."); + } + return; + } + if (!Boolean.TRUE.equals(marker) && project.getRepositories().isEmpty()) { + addRepositories(project.getRepositories(), project.getProviders().gradleProperty("codename1.repository")); + return; + } + // A project that declares repositories of its own makes Gradle ignore the + // settings' ones, and the framework then cannot be found. Adding ours beside + // the project's keeps `repositories { maven(...) }` in a build script from + // being a trap. + project.afterEvaluate(p -> { + if (!p.getRepositories().isEmpty() && p.getRepositories().findByName("CodenameOne") == null) { + addRepositories(p.getRepositories(), p.getProviders().gradleProperty("codename1.repository")); + } + }); + } + + /// The settings script's `dependencyResolutionManagement`, or null. Settings + /// is not public from a Project, so it is reached through the Gradle object; + /// null when that fails, which keeps the plugin adding its repositories. + static org.gradle.api.initialization.resolve.DependencyResolutionManagement resolution(Project project) { + try { + Object settings = project.getGradle().getClass().getMethod("getSettings").invoke(project.getGradle()); + return settings instanceof org.gradle.api.initialization.Settings + ? ((org.gradle.api.initialization.Settings) settings).getDependencyResolutionManagement() : null; + } catch (ReflectiveOperationException | RuntimeException ex) { + return null; + } + } + + /// Whether the settings' repositories include the Codename One one (or the + /// `codename1.repository` override). + static boolean namesCodenameOneRepository( + org.gradle.api.initialization.resolve.DependencyResolutionManagement management, + Provider override) { + if (management == null) { + return false; + } + URI wanted = toUri(override.isPresent() ? override.get() : PluginInfo.REPOSITORY_URL); + for (org.gradle.api.artifacts.repositories.ArtifactRepository r : management.getRepositories()) { + if (r instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository + && sameRepository(((org.gradle.api.artifacts.repositories.MavenArtifactRepository) r).getUrl(), + wanted)) { + return true; + } + } + return false; + } + + private static boolean sameRepository(URI a, URI b) { + String x = a.toString(); + String y = b.toString(); + return (x.endsWith("/") ? x : x + "/").equals(y.endsWith("/") ? y : y + "/"); + } + + private static URI toUri(String url) { + if (url.contains("://")) { + return URI.create(url); + } + return new File(url).getAbsoluteFile().toURI(); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/ToolSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/ToolSupport.java new file mode 100644 index 00000000000..3b21262fd24 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/ToolSupport.java @@ -0,0 +1,287 @@ +/* + * 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.gradle; + +import com.codename1.build.BuildExecutionException; +import com.codename1.gradle.tasks.Cn1Task; +import com.codename1.maven.CodenameOneLogin; +import com.codename1.maven.DesktopTool; +import com.codename1.project.ProjectDescriptor; +import com.codename1.project.ProjectLayout; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/// `settings`, `guibuilder`, `gameBuilder` and `certificateWizard`: the desktop +/// tools, bound to this project. +/// +/// The same tools, launched the same way ([DesktopTool]), as the Maven goals. +/// The binding they read is the project descriptor with the tool's own keys +/// added, so each tool knows it is editing a Gradle project -- which is how +/// Settings, for one, adds a cn1lib to `build.gradle.kts` rather than a pom. +final class ToolSupport { + private ToolSupport() { + } + + static void register(Project project, ProjectLayout layout, SourceSet main, CodenameOneExtension ext, + Provider> userProperties) { + // Evaluated when the task runs, after the build script has had its say + // about the source directories. + Provider> roots = project.provider(() -> sourceRoots(main)); + Provider javaRoot = project.provider(() -> primaryJavaRoot(main, layout).getAbsolutePath()); + Provider encoding = AppSupport.javaEncoding(project, main); + register(project, layout, ext, userProperties, roots, javaRoot, encoding, "settings", + DesktopTool.SETTINGS, "Opens Codename One Settings for this project"); + register(project, layout, ext, userProperties, roots, javaRoot, encoding, "guibuilder", + DesktopTool.GUI_BUILDER, "Opens the Codename One GUI Builder for this project"); + register(project, layout, ext, userProperties, roots, javaRoot, encoding, "gameBuilder", + DesktopTool.GAME_BUILDER, "Opens the Codename One Game Builder for this project"); + register(project, layout, ext, userProperties, roots, javaRoot, encoding, "certificateWizard", + DesktopTool.CERTIFICATE_WIZARD, + "Opens the iOS Certificate Wizard for this project"); + } + + /// The main source set's Java and Kotlin directories as the build resolved + /// them, a `sourceSets { }` block included. Settings treats the descriptor's + /// roots as the whole compiled-source set, so the conventional layout's alone + /// would hide a relocated main class and its annotations from it. + /// Where a tool writes the Java it generates: the conventional directory while + /// the source set still compiles it, else the source set's first own Java + /// directory (not one generated under the build directory), so the output is + /// compiled. + static File primaryJavaRoot(SourceSet main, ProjectLayout layout) { + return primaryRoot(main.getJava().getSrcDirs(), layout.javaSourceDir(), layout); + } + + /// Of a source set's Java directories: `conventional` while it is one of + /// them, else the first not generated under the build directory, else + /// `conventional`. + static File primaryRoot(java.util.Set dirs, File conventionalDir, ProjectLayout layout) { + File conventional = conventionalDir.getAbsoluteFile(); + for (File dir : dirs) { + if (dir.getAbsoluteFile().equals(conventional)) { + return conventional; + } + } + String build = layout.buildDir().getAbsolutePath() + File.separator; + for (File dir : dirs) { + if (!dir.getAbsolutePath().startsWith(build)) { + return dir.getAbsoluteFile(); + } + } + return conventional; + } + + static List sourceRoots(SourceSet main) { + java.util.LinkedHashSet roots = new java.util.LinkedHashSet(); + for (File dir : main.getJava().getSrcDirs()) { + roots.add(dir.getAbsolutePath()); + } + Object kotlin = main.getExtensions().findByName("kotlin"); + if (kotlin instanceof org.gradle.api.file.SourceDirectorySet) { + for (File dir : ((org.gradle.api.file.SourceDirectorySet) kotlin).getSrcDirs()) { + roots.add(dir.getAbsolutePath()); + } + } + return new ArrayList(roots); + } + + private static void register(Project project, ProjectLayout layout, CodenameOneExtension ext, + Provider> userProperties, Provider> sourceRoots, + Provider javaRoot, Provider encoding, String name, DesktopTool tool, + String description) { + final Configuration classpath = AppSupport.resolvable(project, "cn1Tool" + + name.substring(0, 1).toUpperCase(java.util.Locale.ROOT) + name.substring(1), + "The " + tool.displayName()); + // The tools are Codename One applications, so they run on the JavaSE port, + // but their published poms carry codenameone-javase in TEST scope only. + // Maven's legacy resolver, which the cn1: goals use, ignores scopes and + // picks it up by accident; Gradle honours them and would start the tool + // without the port (NoClassDefFoundError: JavaSEPort). Declared here. + AppSupport.addFramework(project, classpath.getName(), ext.getVersion(), tool.artifactId(), + "codenameone-javase"); + project.getTasks().register(name, ToolTask.class, t -> { + AppSupport.common(t, project, layout, ext, userProperties); + t.setDescription(description); + t.getToolName().set(name); + t.getSourceRoots().set(sourceRoots); + t.getJavaSourceDir().set(javaRoot); + t.getSourceEncoding().set(encoding); + t.getToolClasspath().from(classpath); + t.getDetached().set(project.getProviders().gradleProperty("spawn").map(Boolean::parseBoolean) + .orElse(Boolean.TRUE)); + t.getInitialForm().set(project.getProviders().gradleProperty("className")); + t.getToken().set(project.getProviders().gradleProperty("token")); + t.getUser().set(project.getProviders().gradleProperty("user")); + t.getBaseUrl().set(project.getProviders().gradleProperty("baseUrl") + .orElse("https://cloud.codenameone.com")); + t.getOutputs().upToDateWhen(x -> false); + }); + } + + /// See [ToolSupport]. + @DisableCachingByDefault(because = "Opens a desktop tool") + public abstract static class ToolTask extends Cn1Task { + /// Which tool: settings, guibuilder, gameBuilder or certificateWizard. + @Input + public abstract Property getToolName(); + + /// The tool and its runtime. + @Classpath + public abstract ConfigurableFileCollection getToolClasspath(); + + /// The main source set's source directories; see [ToolSupport#sourceRoots]. + @Input + public abstract org.gradle.api.provider.ListProperty getSourceRoots(); + + /// compileJava's source encoding, which the tools read sources in. + @Input + public abstract Property getSourceEncoding(); + + /// Where the GUI and Game Builders write Java; see [ToolSupport#primaryJavaRoot]. + @Input + public abstract Property getJavaSourceDir(); + + /// Return at once (the default) rather than wait for the window to close. + @Input + public abstract Property getDetached(); + + /// The GUI Builder form, or Game Builder scene, to open first. + @Input + @Optional + public abstract Property getInitialForm(); + + /// A Codename One signing API token for the Certificate Wizard. + @Input + @Optional + public abstract Property getToken(); + + /// The account for the Certificate Wizard. + @Input + @Optional + public abstract Property getUser(); + + /// The Codename One cloud the Certificate Wizard signs in to. + @Input + public abstract Property getBaseUrl(); + + @TaskAction + public void open() { + DesktopTool tool = toolFor(getToolName().get()); + ProjectLayout layout = layout(); + List classpath = new ArrayList(getToolClasspath().getFiles()); + File primary = tool.primaryJar(classpath); + if (primary == null) { + throw new GradleException("Could not resolve " + tool.displayName() + " (com.codenameone:" + + tool.artifactId() + ":" + getCodenameOneVersion().get() + ")"); + } + Properties effective = effectiveSettings(); + ProjectDescriptor binding = ProjectDescriptor.fromLayout(layout); + if (!getSourceRoots().get().isEmpty()) { + binding.set("sourceRoot", null); + for (String root : getSourceRoots().get()) { + binding.add("sourceRoot", root); + } + } + binding.set("sourceEncoding", getSourceEncoding().get()); + binding.set("mainName", effective.getProperty("codename1.mainName")); + binding.set("packageName", effective.getProperty("codename1.packageName")); + List extra = new ArrayList(); + try { + if (tool == DesktopTool.GUI_BUILDER) { + ensureDir(layout.guiBuilderDir()); + binding.set("guiDir", layout.guiBuilderDir().getAbsolutePath()); + binding.set("sourceDir", getJavaSourceDir().get()); + binding.set("cssFile", layout.themeCss().getAbsolutePath()); + binding.set("initialForm", getInitialForm().getOrNull()); + } else if (tool == DesktopTool.GAME_BUILDER) { + ensureDir(layout.gamesDir()); + binding.set("gamesDir", layout.gamesDir().getAbsolutePath()); + binding.set("sourceDir", getJavaSourceDir().get()); + binding.set("output", new File(tool.runtimeDir(), java.util.UUID.randomUUID() + ".output") + .getAbsolutePath()); + if (getInitialForm().isPresent()) { + extra.add("-Dgamebuilder.scene=" + getInitialForm().get()); + } + } else if (tool == DesktopTool.CERTIFICATE_WIZARD) { + File certs = new File(layout.projectDir(), "iosCerts"); + ensureDir(certs); + CodenameOneLogin.LoginResult signIn = new CodenameOneLogin(log()).resolve( + getToken().getOrNull(), getUser().getOrNull(), true, 180, getBaseUrl().get()); + if (signIn.token.isEmpty()) { + getLogger().warn("No Codename One bearer token was found. The wizard opens in offline mode " + + "unless you pass -Ptoken= or sign in."); + } + binding.set("outputDir", certs.getAbsolutePath()); + binding.set("output", new File(tool.runtimeDir(), java.util.UUID.randomUUID() + ".output") + .getAbsolutePath()); + binding.set("user", signIn.user); + binding.set("token", signIn.token); + binding.set("baseUrl", getBaseUrl().get()); + } + File input = tool.writeBinding(binding.format()); + getLogger().lifecycle("Launching " + tool.displayName() + " bound to " + layout.projectDir()); + tool.launch(classpath, primary, input, layout.projectDir(), getDetached().get(), extra, log()); + } catch (IOException ex) { + throw new GradleException("Could not write the " + tool.displayName() + " binding", ex); + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex); + } + } + + private static void ensureDir(File dir) throws IOException { + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Could not create " + dir); + } + } + + private static DesktopTool toolFor(String name) { + switch (name) { + case "settings": + return DesktopTool.SETTINGS; + case "guibuilder": + return DesktopTool.GUI_BUILDER; + case "gameBuilder": + return DesktopTool.GAME_BUILDER; + default: + return DesktopTool.CERTIFICATE_WIZARD; + } + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/UpdateSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/UpdateSupport.java new file mode 100644 index 00000000000..a3b9c116758 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/UpdateSupport.java @@ -0,0 +1,172 @@ +/* + * 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.gradle; + +import com.codename1.build.AntSupport; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.CodenameOneUpdater; +import com.codename1.project.ProjectLayout; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// `cn1Update`: moves the project to the newest Codename One release. +/// +/// Under Gradle there is one version to change -- the plugin's, in +/// `settings.gradle.kts` (or wherever the build declares it) -- because the +/// framework version defaults to it. The build client in `~/.codenameone` is +/// refreshed at the same time, as `mvn cn1:update` does. +final class UpdateSupport { + /// The plugin marker's metadata, which lists every published version. + static final String METADATA_URL = PluginInfo.REPOSITORY_URL + + "/com/codenameone/com.codenameone.gradle.plugin/maven-metadata.xml"; + + /// `id("com.codenameone") version "X"`, Kotlin or Groovy DSL. + static final Pattern PLUGIN_VERSION = Pattern.compile( + "(id\\s*\\(?\\s*[\"']com\\.codenameone[\"']\\s*\\)?\\s*version\\s*\\(?\\s*[\"'])([^\"']+)([\"'])"); + + private UpdateSupport() { + } + + static void register(Project project, ProjectLayout layout) { + project.getTasks().register("cn1Update", UpdateTask.class, t -> { + t.setGroup(AppSupport.GROUP); + t.setDescription("Updates the Codename One plugin version and the build client to the latest release"); + t.getRootDirectory().set(layout.rootDir()); + t.getProjectDirectory().set(layout.projectDir()); + t.getVersion().set(project.getProviders().gradleProperty("codename1.updateTo")); + t.getMetadataUrl().set(project.getProviders().gradleProperty("cn1.metadataUrl").orElse(METADATA_URL)); + }); + } + + /// The version a metadata document names as the release, else its latest. + static String releaseOf(String metadata) { + Matcher m = Pattern.compile("([^<]+)").matcher(metadata); + if (m.find()) { + return m.group(1).trim(); + } + m = Pattern.compile("([^<]+)").matcher(metadata); + return m.find() ? m.group(1).trim() : null; + } + + /// `script` with the com.codenameone plugin version set to `version`, or + /// null when the script does not declare one. + static String withPluginVersion(String script, String version) { + Matcher m = PLUGIN_VERSION.matcher(script); + if (!m.find()) { + return null; + } + return m.replaceFirst(Matcher.quoteReplacement(m.group(1) + version + m.group(3))); + } + + /// See [UpdateSupport]. + @DisableCachingByDefault(because = "Edits the build and downloads the build client") + public abstract static class UpdateTask extends DefaultTask { + /// The root project directory. + @Internal + public abstract DirectoryProperty getRootDirectory(); + + /// This project's directory. + @Internal + public abstract DirectoryProperty getProjectDirectory(); + + /// The version to move to (`-Pcodename1.updateTo`), else the latest. + @Input + @Optional + public abstract Property getVersion(); + + /// Where the published versions are listed. + @Input + public abstract Property getMetadataUrl(); + + @TaskAction + public void update() { + String version = getVersion().getOrNull(); + if (version == null) { + try (InputStream in = new URL(getMetadataUrl().get()).openStream()) { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + version = releaseOf(new String(out.toByteArray(), StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new GradleException("Could not read the published versions from " + + getMetadataUrl().get() + "; pass -Pcodename1.updateTo=", ex); + } + } + if (version == null) { + throw new GradleException("No release is listed at " + getMetadataUrl().get()); + } + boolean changed = false; + File root = getRootDirectory().get().getAsFile(); + File project = getProjectDirectory().get().getAsFile(); + for (File script : new File[] {new File(root, "settings.gradle.kts"), new File(root, "settings.gradle"), + new File(project, "build.gradle.kts"), new File(project, "build.gradle")}) { + if (!script.isFile()) { + continue; + } + try { + String text = new String(Files.readAllBytes(script.toPath()), StandardCharsets.UTF_8); + String updated = withPluginVersion(text, version); + if (updated != null && !updated.equals(text)) { + Files.write(script.toPath(), updated.getBytes(StandardCharsets.UTF_8)); + getLogger().lifecycle("Set the Codename One plugin to " + version + " in " + script); + changed = true; + } else if (updated != null) { + changed = true; + } + } catch (IOException ex) { + throw new GradleException("Could not update " + script, ex); + } + } + if (!changed) { + getLogger().warn("No plugin declaration with a version was found; set it to " + version + + " by hand: id(\"com.codenameone\") version \"" + version + "\""); + } + try { + new CodenameOneUpdater(new GradleLog(getLogger()), AntSupport.newProject(project)).update(true, + new File(project, "build/codenameone"), project); + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex); + } + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1BuildTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1BuildTask.java new file mode 100644 index 00000000000..2da944d8d48 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1BuildTask.java @@ -0,0 +1,143 @@ +/* + * 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.gradle.tasks; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.gradle.GradleHostFactory; +import com.codename1.maven.AppBuilder; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +/// Sends a build to the Codename One build server, or runs one of the local +/// build targets. +/// +/// The same [AppBuilder] the Maven plugin's `cn1:build` runs: the staged +/// jar-with-dependencies, the class closure check, the settings merge +/// (annotations, command-line hints, cn1lib properties) and both preflights are +/// shared code. `-Pcodename1.stageOnly=true` stops once the upload jar is +/// assembled, exactly as `-Dcodename1.stageOnly` does for Maven. +@DisableCachingByDefault(because = "A build submission or a local native build is never cacheable") +public abstract class Cn1BuildTask extends Cn1Task { + /// `codename1.platform`: ios, android, javase, javascript, win, linux. + @Input + public abstract Property getPlatform(); + + /// `codename1.buildTarget`: ios-device, android-source, ... + @Input + public abstract Property getBuildTarget(); + + /// Wait for a cloud build and download its result. + @Input + public abstract Property getAutomated(); + + /// Stop once the upload jar is staged and checked. + @Input + public abstract Property getStageOnly(); + + /// Open a generated Xcode or Android Studio project. + @Input + public abstract Property getOpen(); + + /// What is uploaded, in order: the compiled classes and resources, the + /// platform's native sources, then the dependencies. + @Classpath + public abstract ConfigurableFileCollection getUploadClasspath(); + + /// The resolved dependencies behind [getUploadClasspath()], encoded, so the + /// engine can tell the server-supplied ones from the application's own. + @Input + public abstract ListProperty getArtifacts(); + + /// `codenameone-core` and `java-runtime`, which the server supplies and a + /// local build needs to translate against. + @InputFiles + @PathSensitive(PathSensitivity.NAME_ONLY) + public abstract ConfigurableFileCollection getFrameworkJars(); + + /// The source roots the project compiles. + @Input + public abstract ListProperty getSourceRoots(); + + /// The base name outputs are written under. + @Input + public abstract Property getFinalName(); + + /// The project's group; a dependency sharing it is part of the application. + @Input + @Optional + public abstract Property getGroupId(); + + /// Project-level `codename1.*` properties. + @Input + public abstract MapProperty getProjectProperties(); + + @TaskAction + public void build() { + final java.io.File work = new java.io.File(layout().buildDir(), "codenameone" + java.io.File.separator + + getName()); + AppBuilder builder = new TaskAppBuilder(GradleHostFactory.create(this, log(), layout(), + getFinalName().get(), getGroupId().getOrElse(""), getProjectProperties().get(), + getUserProperties().get(), getUploadClasspath(), getSourceRoots().get(), getArtifacts().get(), + getFrameworkJars(), getCodenameOneVersion().get()), work); + builder.platform(getPlatform().get()) + .buildTarget(getBuildTarget().get()) + .automated(getAutomated().get()) + .stageOnly(getStageOnly().get()) + .open(getOpen().get()); + try { + builder.execute(); + } catch (BuildFailureException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + } + + /// All platforms share this project's build directory, so each build task + /// keeps its own Ant project; see AppBuilder.workDirectory. + private static final class TaskAppBuilder extends AppBuilder { + private final java.io.File work; + + TaskAppBuilder(com.codename1.build.ProjectHost host, java.io.File work) { + super(host); + this.work = work; + } + + @Override + protected java.io.File workDirectory() { + return work; + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1CssTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1CssTask.java new file mode 100644 index 00000000000..480db71b051 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1CssTask.java @@ -0,0 +1,211 @@ +/* + * 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.gradle.tasks; + +import com.codename1.build.AntSupport; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; +import com.codename1.maven.CssCompiler; +import org.apache.tools.ant.Project; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.tasks.CacheableTask; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/// Compiles `src/main/css/*theme.css`, merged with every cn1lib's CSS, into +/// `theme.res`. The same [CssCompiler] as the Maven plugin's `css` goal. +@CacheableTask +public abstract class Cn1CssTask extends Cn1Task { + /// The project's CSS sources, and the l10n bundles compiled into the theme. + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getSources(); + + /// The cn1libs' `cn1css` bundles, encoded like [Cn1BuildTask#getArtifacts()]. + @Input + public abstract ListProperty getLibraryCss(); + + /// The bundle files themselves, so a changed bundle recompiles. + @InputFiles + @PathSensitive(PathSensitivity.NAME_ONLY) + public abstract ConfigurableFileCollection getLibraryCssFiles(); + + /// The CSS compiler CLI's classpath. + @Classpath + public abstract ConfigurableFileCollection getCompilerClasspath(); + + /// Where `theme.res` goes; added to the main resources. + @OutputDirectory + public abstract DirectoryProperty getOutputDirectory(); + + /// Where the merged stylesheet, the extracted cn1lib bundles and the + /// simulator's input list ([#SIMULATOR_INPUTS]) go. An output, so a build + /// cache hit restores the extracted bundles that list names. + @OutputDirectory + public abstract DirectoryProperty getWorkDirectory(); + + /// The file in [#getWorkDirectory()] holding the comma-separated stylesheets + /// the main theme is compiled from, cn1libs first. The simulator's live CSS + /// reload recompiles from the same list, so a library's styles survive an + /// edit to the application's own theme.css. + public static final String SIMULATOR_INPUTS = "simulator-css-inputs.txt"; + + /// Empties `out`. It is a main resource directory, so a theme.res left from + /// an earlier run would still be packaged after CSS was switched off. + private static void clear(File out) { + File[] children = out.listFiles(); + if (children == null) { + return; + } + for (File c : children) { + try { + org.apache.commons.io.FileUtils.forceDelete(c); + } catch (java.io.IOException ex) { + throw new GradleException("Could not delete the stale " + c, ex); + } + } + } + + /// `inputs` (comma separated) with each path under `projectDir` written + /// relative to it. The file is a cached output: absolute paths would send a + /// checkout restored from the build cache somewhere else to the old one's + /// stylesheets. The simulator resolves them against the project again. + public static String relative(String inputs, File projectDir) { + String base = projectDir.getAbsolutePath() + File.separator; + StringBuilder sb = new StringBuilder(); + for (String path : inputs.split(",")) { + if (path.isEmpty()) { + continue; + } + if (sb.length() > 0) { + sb.append(','); + } + sb.append(path.startsWith(base) ? path.substring(base.length()) : path); + } + return sb.toString(); + } + + private static void writeInputs(File to, String inputs) { + try { + java.nio.file.Files.write(to.toPath(), inputs.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } catch (java.io.IOException ex) { + throw new GradleException("Could not write " + to, ex); + } + } + + @TaskAction + public void compile() { + Log log = log(); + File out = getOutputDirectory().get().getAsFile(); + File cssDir = layout().cssDir(); + File work = getWorkDirectory().get().getAsFile(); + // Gone before any early return: with CSS switched off or theme.css removed, + // a list from an earlier run would keep the simulator's live reload merging + // stylesheets the build no longer uses. + File simulatorInputs = new File(work, SIMULATOR_INPUTS); + if (simulatorInputs.exists() && !simulatorInputs.delete()) { + throw new GradleException("Could not delete " + simulatorInputs); + } + // Effective, not the file alone: codename1.cssTheme set in gradle.properties, + // with -P/-D or in codenameone { buildHints } counts as it does for the + // simulator and the native builds. + if (effectiveSettings().getProperty("codename1.cssTheme") == null) { + log.info("CSS themes not activated for this project (codename1.cssTheme). Skipping CSS compilation"); + clear(out); + return; + } + // With no theme.css of the project's own there is nothing to compile -- + // unless a cn1lib brings CSS, which cannot be compiled alone: the compiler + // below then fails and says so, as the Maven build does, rather than the + // library's styles silently disappearing. + if (!layout().themeCss().isFile() && getLibraryCss().get().isEmpty()) { + log.warn("CSS compilation skipped because " + layout().themeCss() + " does not exist"); + clear(out); + return; + } + List libraries = new ArrayList(); + for (String encoded : getLibraryCss().get()) { + com.codename1.build.BuildArtifact a = com.codename1.gradle.GradleHostFactory.decode(encoded); + if (a == null) { + continue; + } + File extractTo = new File(work, "libs" + File.separator + a.getGroupId() + File.separator + + a.getArtifactId() + File.separator + a.getVersion()); + libraries.add(new CssCompiler.LibraryCss(a.getGroupId(), a.getArtifactId(), a.getFile(), extractTo, + a.getVersion() != null && a.getVersion().endsWith("-SNAPSHOT"), a.getFile().lastModified())); + } + final Project ant = AntSupport.newProject(layout().projectDir()); + CssCompiler compiler = new CssCompiler(log, ant, + () -> AntSupport.createJava(ant, log, AntSupport.LEVEL_INFO)).buildDirectory(layout().buildDir()); + if (!layout().themeCss().isFile()) { + // Only cn1libs bring CSS. Extracting them says whether any has a + // stylesheet; if so, Maven refuses this project, and so does this build. + if (!compiler.inputs(libraries, "", null).isEmpty()) { + throw new GradleException("Cannot compile CSS for this project. It has no " + layout().themeCss() + + ", but it uses cn1libs whose CSS needs it. Add that file (it can be empty)."); + } + log.warn("CSS compilation skipped because " + layout().themeCss() + " does not exist"); + clear(out); + return; + } + File l10n = CssCompiler.localizationSibling(layout().l10nDir().getParentFile()); + // Every current theme is compiled below, so start empty: a deleted + // darktheme.css would otherwise leave darktheme.res in a directory that is + // packaged as a main resource. + clear(out); + try { + for (String prefix : CssCompiler.themePrefixes(cssDir)) { + // Gradle has already decided this task must run; a merged stylesheet + // left from the last run would make the compiler decide otherwise. + File merged = new File(work, prefix + "theme.css"); + if (merged.exists() && !merged.delete()) { + throw new GradleException("Could not delete " + merged); + } + // Gradle decides whether this task is up to date, so the + // compiler's own timestamp check is told everything changed. + compiler.compile(prefix, cssDir, out, work, libraries, l10n != null && l10n.isDirectory() ? l10n : null, + getCompilerClasspath().getAsPath(), layout().projectDir(), Long.MAX_VALUE); + } + // After the compile, which extracted every bundle the list names. + writeInputs(simulatorInputs, relative(compiler.inputs(libraries, "", layout().themeCss()), + layout().projectDir())); + } catch (BuildFailureException ex) { + throw new GradleException(ex.getMessage(), ex); + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex); + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1Task.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1Task.java new file mode 100644 index 00000000000..7f745f7f203 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1Task.java @@ -0,0 +1,121 @@ +/* + * 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.gradle.tasks; + +import com.codename1.build.Log; +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; +import org.gradle.api.DefaultTask; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.Properties; + +/// What every Codename One task knows about the project it runs in. +/// +/// All of it is plain task input set at configuration time, so an action never +/// reaches for the `Project` and the task stays configuration-cache safe. +public abstract class Cn1Task extends DefaultTask { + /// The Gradle root project directory. + @Internal + public abstract DirectoryProperty getRootDirectory(); + + /// The project's build directory, wherever the build script put it. + @Internal + public abstract DirectoryProperty getBuildDirectory(); + + /// This project's directory. + @Internal + public abstract DirectoryProperty getProjectDirectory(); + + /// `APP`, `LIB` or `BACKEND`. + @Input + public abstract Property getKind(); + + /// The `codename1.*` properties given on the command line (`-P` or `-D`) + /// and the `codenameone { buildHints }` block, as `codename1.*` keys. + @Input + public abstract MapProperty getUserProperties(); + + /// The Codename One version the project builds against. + @Input + public abstract Property getCodenameOneVersion(); + + /// The project's layout. + protected ProjectLayout layout() { + File root = getRootDirectory().get().getAsFile(); + File dir = getProjectDirectory().get().getAsFile(); + ProjectLayout layout = ProjectLayouts.of(BuildSystem.GRADLE, ProjectKind.valueOf(getKind().get()), root, dir); + return getBuildDirectory().isPresent() ? layout.withBuildDir(getBuildDirectory().get().getAsFile()) : layout; + } + + /// The engine's logger over this task's. + protected Log log() { + return new com.codename1.gradle.GradleLog(getLogger()); + } + + /// The settings file exactly as on disk, or empty. + protected Properties rawSettings() { + Properties p = new Properties(); + File f = layout().settingsFile(); + if (f.isFile()) { + try (InputStream in = new FileInputStream(f)) { + p.load(in); + } catch (IOException ex) { + getLogger().warn("Could not read " + f + ": " + ex.getMessage()); + } + } + return p; + } + + /// The settings file with the command-line `codename1.*` overrides on top: + /// what the build actually uses. + protected Properties effectiveSettings() { + Properties p = rawSettings(); + for (Map.Entry e : getUserProperties().get().entrySet()) { + p.setProperty(e.getKey(), e.getValue()); + } + return p; + } + + /// `packageName.mainName` from the effective settings, or null. + protected String mainClass() { + Properties p = effectiveSettings(); + String main = p.getProperty("codename1.mainName"); + String pkg = p.getProperty("codename1.packageName"); + if (main == null || main.trim().isEmpty()) { + return null; + } + return pkg == null || pkg.trim().isEmpty() ? main.trim() : pkg.trim() + "." + main.trim(); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1TestTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1TestTask.java new file mode 100644 index 00000000000..1d562abd37b --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1TestTask.java @@ -0,0 +1,109 @@ +/* + * 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.gradle.tasks; + +import com.codename1.build.BuildExecutionException; +import com.codename1.maven.Cn1TestRunner; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/// Runs the project's Codename One unit tests (the classes in `src/test/java` +/// implementing `com.codename1.testing.UnitTest`) in the JavaSE port's test +/// runner, as `mvn cn1:test` does. JUnit reports go to `build/cn1-reports`. +/// +/// Not part of `check`: like the Maven goal, it runs the application's UI, and +/// a headless machine needs a virtual display (`xvfb-run ./gradlew cn1Test`). +@DisableCachingByDefault(because = "Runs the application's UI tests") +public abstract class Cn1TestTask extends Cn1Task { + /// The compiled tests: every output directory of the test source set, so + /// Kotlin tests (compiled apart from Java's) are found too. Already on the + /// runtime classpath, which is the input. + @Internal + public abstract ConfigurableFileCollection getTestClassesDirectories(); + + /// Everything the tests run against, the JavaSE port included. + @Classpath + public abstract ConfigurableFileCollection getRuntimeClasspath(); + + /// Where the JUnit XML reports go. + @OutputDirectory + public abstract DirectoryProperty getReportsDirectory(); + + @TaskAction + public void runTests() { + // Reports are this run's or none: with the last test removed, the previous + // run's TEST-*.xml would otherwise stay for a CI collector to publish as if + // those tests had just passed. + File reports = getReportsDirectory().get().getAsFile(); + try { + if (reports.isDirectory()) { + org.apache.commons.io.FileUtils.cleanDirectory(reports); + } + } catch (java.io.IOException ex) { + throw new GradleException("Could not clear " + reports, ex); + } + List testDirs = new ArrayList(getTestClassesDirectories().getFiles()); + if (testDirs.isEmpty()) { + getLogger().lifecycle("No tests were found."); + return; + } + List classpath = new ArrayList(); + for (File f : getRuntimeClasspath()) { + if (!testDirs.contains(f)) { + classpath.add(f); + } + } + Cn1TestRunner runner = new Cn1TestRunner(log()); + try { + // tests.dat goes in the first (Java's) directory, which the runner's + // classpath carries whether or not javac produced anything. + if (!runner.prepare(testDirs, testDirs.get(0), classpath)) { + getLogger().lifecycle("No tests were found."); + return; + } + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + List all = new ArrayList(); + all.addAll(testDirs); + all.addAll(classpath); + String main = mainClass(); + if (main == null) { + throw new GradleException("codenameone_settings.properties names no codename1.mainName"); + } + int result = runner.run(layout().projectDir(), all, main, getReportsDirectory().get().getAsFile()); + if (result != 0) { + throw new GradleException("Tests failed; see " + getReportsDirectory().get().getAsFile()); + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ComplianceAction.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ComplianceAction.java new file mode 100644 index 00000000000..75373159f89 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ComplianceAction.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.gradle.tasks; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.ProjectHost; +import com.codename1.gradle.GradleHostFactory; +import com.codename1.gradle.GradleLog; +import com.codename1.maven.BytecodeCompliance; +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayouts; +import org.gradle.api.Action; +import org.gradle.api.GradleException; +import org.gradle.api.Task; +import org.gradle.api.file.FileCollection; +import org.gradle.api.provider.Provider; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// Runs the bytecode compliance check over `compileJava`'s output, as the task's +/// last step before annotation processing -- the order the Maven poms bind the +/// two goals in. A `doLast` for the same reason as [ProcessAnnotationsAction]: +/// the check rewrites the compiled classes in place. +/// +/// `-PskipComplianceCheck=true` skips it, which is what hot reload's recompile +/// passes, as Maven's does with `-DskipComplianceCheck`. +public final class ComplianceAction implements Action { + private final File rootDir; + private final File projectDir; + /// Resolved when the action runs: a build may set the compile task's + /// destination after the plugin configured it. + private final Provider classesDir; + private final String projectName; + private final FileCollection compileClasspath; + private final Provider> artifacts; + private final Map projectProperties; + private final List siblingClassRoots = new ArrayList(); + private FileCollection siblingClassDirs; + private final List pendingJavaSources = new ArrayList(); + + /// @param compileClasspath the classes the check resolves references against + /// @param artifacts the same classpath's resolved dependencies, encoded as + /// `GradleProjectHost` task inputs carry them (every one in the + /// `provided` scope, which keeps the framework in the scan the way + /// Maven's provided scope does), so the check can tell + /// `codenameone-core` and `java-runtime` apart. Strings, not + /// `ResolvedArtifactResult`s: Gradle 8.5, the minimum supported, cannot + /// store those in the configuration cache. + public ComplianceAction(File rootDir, File projectDir, Provider classesDir, String projectName, + FileCollection compileClasspath, Provider> artifacts, + Map projectProperties) { + this.rootDir = rootDir; + this.projectDir = projectDir; + this.classesDir = classesDir; + this.projectName = projectName; + this.compileClasspath = compileClasspath; + this.artifacts = artifacts; + this.projectProperties = new java.util.HashMap(projectProperties); + } + + private Provider buildDirectory; + + /// The project's build directory, read when the action runs (a build + /// script can move it), for the report the check writes. + public ComplianceAction withBuildDirectory(Provider dir) { + this.buildDirectory = dir; + return this; + } + + /// Adds directories of the project's other compiled classes -- Kotlin's, + /// for the Java pass -- which the checked classes may use. + public ComplianceAction withSiblingClasses(File... roots) { + siblingClassRoots.addAll(Arrays.asList(roots)); + return this; + } + + /// Adds every classes directory of the source set except the one checked + /// here -- the real ones, wherever the build put them, not a guessed path. + public ComplianceAction withSiblingClasses(FileCollection classesDirs) { + this.siblingClassDirs = classesDirs; + return this; + } + + /// Declares the Java sources javac has not compiled yet, for the Kotlin pass, + /// which runs first; see [BytecodeCompliance#pendingProjectClasses(Set)]. + public ComplianceAction withPendingJavaSources(java.util.Collection javaSourceDirs) { + this.pendingJavaSources.addAll(javaSourceDirs); + return this; + } + + /// The internal name of every `.java` file under `root`, by path: a public + /// top-level class lives in the file named after it. + static Set javaTypesUnder(File root) { + Set out = new HashSet(); + collectJavaTypes(root, "", out); + return out; + } + + private static void collectJavaTypes(File dir, String prefix, Set out) { + File[] children = dir == null ? null : dir.listFiles(); + if (children == null) { + return; + } + for (File c : children) { + if (c.isDirectory()) { + collectJavaTypes(c, prefix + c.getName() + "/", out); + } else if (c.getName().endsWith(".java") && !"package-info.java".equals(c.getName())) { + out.add(prefix + c.getName().substring(0, c.getName().length() - ".java".length())); + } + } + } + + @Override + public void execute(Task task) { + File classesDir = this.classesDir.get(); + List encoded = artifacts.get(); + // The check indexes dependencies from the resolved module artifacts, which a + // project(":shared") or files(...) dependency is not; those are on javac's + // classpath all the same, so they are indexed too, or a reference to one + // of their classes read as an unsupported API and failed a valid build. + Set moduleFiles = new HashSet(); + for (String e : encoded) { + com.codename1.build.BuildArtifact a = GradleHostFactory.decode(e); + if (a != null && a.getFile() != null) { + moduleFiles.add(a.getFile().getAbsoluteFile()); + } + } + List siblings = new ArrayList(siblingClassRoots); + if (siblingClassDirs != null) { + for (File dir : siblingClassDirs) { + if (!dir.getAbsoluteFile().equals(classesDir.getAbsoluteFile())) { + siblings.add(dir); + } + } + } + for (File f : compileClasspath) { + if (!moduleFiles.contains(f.getAbsoluteFile())) { + siblings.add(f); + } + } + final File buildDir = buildDirectory != null ? buildDirectory.get() : new File(projectDir, "build"); + ProjectHost host = GradleHostFactory.create(task, new GradleLog(task.getLogger()), + ProjectLayouts.of(BuildSystem.GRADLE, ProjectKind.APP, rootDir, projectDir), projectName, "", + projectProperties, Collections.emptyMap(), compileClasspath, + Collections.emptyList(), encoded, null, ""); + BytecodeCompliance check = new BytecodeCompliance(new ClassesDirHost(host, classesDir, buildDir)) + .siblingClassRoots(siblings); + if (!pendingJavaSources.isEmpty()) { + Set pending = new HashSet(); + for (File root : pendingJavaSources) { + pending.addAll(javaTypesUnder(root)); + } + check.pendingProjectClasses(pending); + } + try { + check.execute(); + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + } + + /// The host with `compileJava`'s own destination as the output directory + /// (the layout's default names the main source set's, which is the same + /// directory unless a build script moved it). + private static final class ClassesDirHost extends com.codename1.gradle.DelegatingProjectHost { + private final File classesDir; + private final File buildDir; + + ClassesDirHost(ProjectHost delegate, File classesDir, File buildDir) { + super(delegate); + this.classesDir = classesDir; + this.buildDir = buildDir; + } + + @Override + public File outputDirectory() { + return classesDir; + } + + @Override + public File buildDirectory() { + return buildDir; + } + + @Override + public long sourcesModificationTime() { + return Long.MAX_VALUE; + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/GenerateGuiSourcesTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/GenerateGuiSourcesTask.java new file mode 100644 index 00000000000..5f260954c28 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/GenerateGuiSourcesTask.java @@ -0,0 +1,94 @@ +/* + * 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.gradle.tasks; + +import com.codename1.build.AntSupport; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.CodenameOneUpdater; +import com.codename1.maven.GuiSourcesGenerator; +import com.codename1.project.ProjectLayout; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.File; + +/// Generates Java sources from the legacy GUI builder's XML +/// (`src/main/guibuilder`) and from CodeRAD view templates +/// (`src/main/rad/views`), as the Maven plugin's `generate-gui-sources` goal +/// does. A project with neither does nothing here. +@DisableCachingByDefault(because = "The legacy step writes into src/main/java") +public abstract class GenerateGuiSourcesTask extends Cn1Task { + /// The GUI builder XML and the view templates. + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getSources(); + + /// The main source set's Java directory, where the legacy GUI builder writes + /// its forms. + @org.gradle.api.tasks.Input + public abstract org.gradle.api.provider.Property getJavaSourceDir(); + + /// Where the generated view classes go; a source root of the main source set. + @OutputDirectory + public abstract DirectoryProperty getRadOutputDirectory(); + + @TaskAction + public void generate() { + ProjectLayout layout = layout(); + // Every view is regenerated, so start empty: a deleted or renamed view + // would otherwise leave its Abstract*.java in a directory that is still a + // main source root, where it keeps compiling -- and can clash with its + // replacement. (This directory only; the legacy generator writes into + // src/main/java, which is the user's.) + File radOut = getRadOutputDirectory().get().getAsFile(); + try { + if (radOut.isDirectory()) { + org.apache.commons.io.FileUtils.cleanDirectory(radOut); + } + } catch (java.io.IOException ex) { + throw new GradleException("Could not clear " + radOut, ex); + } + GuiSourcesGenerator generator = new GuiSourcesGenerator(log(), layout.projectDir(), layout.radViewsDir(), + getRadOutputDirectory().get().getAsFile(), new File(layout.buildDir(), "generated-sources")); + try { + if (layout.guiBuilderDir().isDirectory()) { + // The legacy generator ships in the build client, which a cloud build + // installs anyway; fetch it now if this is the first thing to need it. + File client = CodenameOneUpdater.buildClientJar(); + new CodenameOneUpdater(log(), AntSupport.newProject(layout.projectDir())).update(false, + new File(layout.buildDir(), "codenameone"), layout.projectDir(), client); + generator.generateLegacyGui(client, new File(getJavaSourceDir().get()), layout.guiBuilderDir()); + } + generator.generateRadViews(); + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/NativeInterfacesTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/NativeInterfacesTask.java new file mode 100644 index 00000000000..9860ef46f6c --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/NativeInterfacesTask.java @@ -0,0 +1,123 @@ +/* + * 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.gradle.tasks; + +import com.codename1.build.BuildExecutionException; +import com.codename1.maven.NativeInterfaces; +import com.codename1.project.NativePlatform; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/// `generateNativeInterfaces` and `verifyNativeInterfaces`. +/// +/// Generating writes a stub for every platform into `src//`, +/// creating the directories only then; nothing in the build file changes, +/// because the platform source sets always exist. Verifying runs before every +/// platform build and fails early, naming the file to create, when an +/// interface has no implementation for that platform -- which a cloud build +/// would otherwise only report minutes later. +@DisableCachingByDefault(because = "Writes into src/, or is a check") +public abstract class NativeInterfacesTask extends Cn1Task { + /// The compiled classes to scan: every classes directory of the main source + /// set, since Gradle compiles Kotlin apart from Java. A directory that does + /// not exist (javac's, in a pure Kotlin project) is simply empty. + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getClassesDirectories(); + + /// The compile classpath the interfaces load against. + @Classpath + public abstract ConfigurableFileCollection getCompileClasspath(); + + /// When set, check this platform instead of generating. + @Input + @Optional + public abstract Property getVerifyPlatform(); + + /// Generate for this interface only (simple or binary name). + @Input + @Optional + public abstract Property getOnly(); + + /// Also write a Swift implementation for iOS. + @Input + public abstract Property getSwift(); + + /// Also write a Kotlin implementation for Android. + @Input + public abstract Property getKotlin(); + + /// Replace existing implementation files. + @Input + public abstract Property getOverwrite(); + + @TaskAction + public void run() { + List cp = new ArrayList(); + for (File f : getCompileClasspath()) { + cp.add(f.getAbsolutePath()); + } + NativeInterfaces ni = new NativeInterfaces(log(), + new ArrayList(getClassesDirectories().getFiles()), cp); + try { + if (getVerifyPlatform().isPresent()) { + NativePlatform platform = NativePlatform.fromId(getVerifyPlatform().get()); + if (platform == null) { + return; + } + List missing = ni.missingImplementations(layout(), platform); + if (!missing.isEmpty()) { + StringBuilder sb = new StringBuilder("Native interfaces are missing their ") + .append(platform.id()).append(" implementation:\n"); + for (String m : missing) { + sb.append(" - ").append(m).append('\n'); + } + sb.append("Run ./gradlew generateNativeInterfaces to write stubs, then implement them."); + throw new GradleException(sb.toString()); + } + return; + } + List written = ni.generate(layout(), getOnly().getOrNull(), getSwift().get(), getKotlin().get(), + getOverwrite().get(), "./gradlew generateNativeInterfaces -Pcn1.overwrite=true"); + for (File f : written) { + getLogger().lifecycle("Wrote " + f); + } + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex); + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/PrepareSimulatorTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/PrepareSimulatorTask.java new file mode 100644 index 00000000000..c97dbe4466b --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/PrepareSimulatorTask.java @@ -0,0 +1,112 @@ +/* + * 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.gradle.tasks; + +import com.codename1.maven.SimulatorSupport; +import com.codename1.project.ProjectDescriptor; +import com.codename1.project.ProjectLayout; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Properties; + +/// Writes what the simulator reads at startup: `simulator.properties` (the +/// classpaths hot reload and live CSS reload fork with, and the command-line +/// build hints) and the project descriptor the desktop tools read. +@DisableCachingByDefault(because = "Writes two small files; not worth caching") +public abstract class PrepareSimulatorTask extends Cn1Task { + /// The compile classpath hot reload recompiles against. + @Internal + public abstract ConfigurableFileCollection getCompileClasspath(); + + /// The CSS compiler CLI classpath live CSS reload forks with. + @Internal + public abstract ConfigurableFileCollection getCssCompilerClasspath(); + + /// The two classpaths as the path lists this task writes. The lists, not the + /// files' contents, are what the output records, so these are the inputs: + /// adding or upgrading a dependency changes them and regenerates the + /// properties -- a stale list made hot reload recompile against the old + /// classpath -- while an ordinary recompile of the project does not. + @Input + public String getClasspathPaths() { + return getCompileClasspath().getAsPath() + "\n" + getCssCompilerClasspath().getAsPath(); + } + + /// `build/codenameone/simulator.properties`. + @OutputFile + public abstract RegularFileProperty getSimulatorProperties(); + + /// `build/codenameone/project.properties`. + @OutputFile + public abstract RegularFileProperty getDescriptor(); + + @TaskAction + public void prepare() { + ProjectLayout layout = layout(); + Properties effective = effectiveSettings(); + try { + SimulatorSupport.writeSimulatorProperties(getSimulatorProperties().get().getAsFile(), + getCompileClasspath().getAsPath(), getCssCompilerClasspath().getAsPath(), + toProperties(getUserProperties().get()), effective); + writeDescriptor(layout, getDescriptor().get().getAsFile(), effective, null); + } catch (IOException ex) { + throw new GradleException("Failed to write the simulator's properties", ex); + } + } + + static Properties toProperties(java.util.Map map) { + Properties p = new Properties(); + p.putAll(map); + return p; + } + + /// Writes the project descriptor, with the resolved main class and the + /// source roots the build compiles. + static void writeDescriptor(ProjectLayout layout, File file, Properties effective, List extraRoots) + throws IOException { + ProjectDescriptor d = ProjectDescriptor.fromLayout(layout); + d.set("sourceEncoding", "UTF-8"); + if (effective.getProperty("codename1.mainName") != null) { + d.set("mainName", effective.getProperty("codename1.mainName").trim()); + } + if (effective.getProperty("codename1.packageName") != null) { + d.set("packageName", effective.getProperty("codename1.packageName").trim()); + } + if (extraRoots != null) { + for (String r : extraRoots) { + d.add("sourceRoot", r); + } + } + d.write(file); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java new file mode 100644 index 00000000000..178a7fb40d6 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java @@ -0,0 +1,211 @@ +/* + * 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.gradle.tasks; + +import com.codename1.build.BuildExecutionException; +import com.codename1.gradle.GradleLog; +import com.codename1.maven.AnnotationProcessing; +import org.gradle.api.Action; +import org.gradle.api.GradleException; +import org.gradle.api.Task; +import org.gradle.api.file.FileCollection; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/// Runs the Codename One annotation processors over `compileJava`'s output, as +/// the last step of that task. +/// +/// A `doLast` rather than a task of its own because the processors rewrite the +/// compiled classes in place: a separate task writing into `compileJava`'s +/// output directory would be an overlapping output, which defeats up-to-date +/// checks and the build cache for both. +public final class ProcessAnnotationsAction implements Action { + /// Resolved when the action runs; see [ComplianceAction]. + private final org.gradle.api.provider.Provider classesDir; + private final File stubDir; + private final File projectDir; + private final File settingsFile; + /// Resolved when the action runs: a build can change the source set's + /// directories after the compile task was configured, and the processors + /// treat a class with no source under these roots as a stale output. + private final org.gradle.api.provider.Provider> sourceRoots; + private final String encoding; + private final org.gradle.api.provider.Provider> userProperties; + private final FileCollection compileClasspath; + private final List pendingJavaSources = new ArrayList(); + private org.gradle.api.provider.Provider sourceEncoding; + + /// @param classesDir `compileJava`'s destination + /// @param stubDir where processors write generated stub sources + /// @param userProperties the command-line `codename1.*` overrides and the + /// `codenameone { }` settings, read when the action runs: a build + /// that realizes the compile task before its `codenameone { }` block + /// must not process annotations for a stale main class + public ProcessAnnotationsAction(org.gradle.api.provider.Provider classesDir, File stubDir, File projectDir, File settingsFile, + org.gradle.api.provider.Provider> sourceRoots, String encoding, + org.gradle.api.provider.Provider> userProperties, + FileCollection compileClasspath) { + this.classesDir = classesDir; + this.stubDir = stubDir; + this.projectDir = projectDir; + this.settingsFile = settingsFile; + this.sourceRoots = sourceRoots; + this.encoding = encoding; + this.userProperties = userProperties; + this.compileClasspath = compileClasspath; + } + + /// The encoding javac reads the sources in (`compileJava.options.encoding`), + /// in place of the constructor's: the processors read sources back to tell a + /// live class from a stale output, and must decode them as javac did. + public ProcessAnnotationsAction withSourceEncoding(org.gradle.api.provider.Provider encoding) { + this.sourceEncoding = encoding; + return this; + } + + /// For the Kotlin pass, which runs before javac: the Java source directories + /// of the same source set. Kotlin resolves the project's Java types from these + /// sources, so a Kotlin `@RestClient` returning a Java DTO compiles -- and the + /// processors, which resolve types through the classpath, must see them too. + /// They are compiled into a scratch directory put on the processing + /// classpath only; javac's real pass still produces the shipped classes. + public ProcessAnnotationsAction withPendingJavaSources(java.util.Collection javaSourceDirs) { + this.pendingJavaSources.addAll(javaSourceDirs); + return this; + } + + /// Compiles [#pendingJavaSources] into `out` against `classpath`, or answers + /// null when there is nothing to compile or javac refuses. A refusal is left + /// to compileJava to report with the real diagnostics; this pass then runs + /// as before, without the Java types. + private File compilePendingJava(Task task, List classpath, String encoding) { + List sources = new ArrayList(); + for (File dir : pendingJavaSources) { + collectJava(dir, sources); + } + if (sources.isEmpty()) { + return null; + } + javax.tools.JavaCompiler javac = javax.tools.ToolProvider.getSystemJavaCompiler(); + if (javac == null) { + return null; + } + File out = new File(task.getTemporaryDir(), "pending-java"); + try { + if (out.exists()) { + org.apache.commons.io.FileUtils.deleteDirectory(out); + } + } catch (IOException ex) { + throw new GradleException("Could not delete " + out, ex); + } + if (!out.mkdirs()) { + throw new GradleException("Could not create " + out); + } + StringBuilder cp = new StringBuilder(); + for (String element : classpath) { + if (cp.length() > 0) { + cp.append(File.pathSeparatorChar); + } + cp.append(element); + } + List args = new ArrayList(); + java.util.Collections.addAll(args, "-proc:none", "-nowarn", "-implicit:class", "-encoding", encoding, + "-cp", cp.toString(), "-d", out.getAbsolutePath()); + for (File f : sources) { + args.add(f.getAbsolutePath()); + } + java.io.ByteArrayOutputStream diagnostics = new java.io.ByteArrayOutputStream(); + int result = javac.run(null, diagnostics, diagnostics, args.toArray(new String[0])); + if (result != 0) { + task.getLogger().info("cn1: the Java sources did not compile ahead of javac, so Kotlin annotations " + + "are processed without the project's Java types:\n" + diagnostics); + return null; + } + return out; + } + + private static void collectJava(File dir, List out) { + File[] children = dir == null ? null : dir.listFiles(); + if (children == null) { + return; + } + for (File c : children) { + if (c.isDirectory()) { + collectJava(c, out); + } else if (c.getName().endsWith(".java")) { + out.add(c); + } + } + } + + @Override + public void execute(Task task) { + File classesDir = this.classesDir.get(); + Properties raw = null; + if (settingsFile.isFile()) { + raw = new Properties(); + try (InputStream in = new FileInputStream(settingsFile)) { + raw.load(in); + } catch (IOException ex) { + task.getLogger().warn("cn1: could not read " + settingsFile + ": " + ex.getMessage()); + raw = null; + } + } + // The main class from the EFFECTIVE settings (a -P override wins), the raw + // file for the duplicate-declaration check: see AnnotationProcessing. + Properties effective = new Properties(); + if (raw != null) { + effective.putAll(raw); + } + for (Map.Entry e : userProperties.get().entrySet()) { + effective.setProperty(e.getKey(), e.getValue()); + } + String main = effective.getProperty("codename1.mainName"); + String pkg = effective.getProperty("codename1.packageName"); + String mainClass = main == null || main.trim().isEmpty() ? null + : (pkg == null || pkg.trim().isEmpty() ? main.trim() : pkg.trim() + "." + main.trim()); + List classpath = new ArrayList(); + classpath.add(classesDir.getAbsolutePath()); + for (File f : compileClasspath) { + classpath.add(f.getAbsolutePath()); + } + String encoding = sourceEncoding != null && sourceEncoding.isPresent() ? sourceEncoding.get() : this.encoding; + File pending = compilePendingJava(task, classpath, encoding); + if (pending != null) { + classpath.add(pending.getAbsolutePath()); + } + try { + new AnnotationProcessing(new GradleLog(task.getLogger()), classesDir, stubDir, projectDir, raw, + mainClass, new ArrayList(sourceRoots.get()), encoding, classpath).run(); + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/RunBackendTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/RunBackendTask.java new file mode 100644 index 00000000000..5caa63fb72c --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/RunBackendTask.java @@ -0,0 +1,125 @@ +/* + * 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.gradle.tasks; + +import com.codename1.build.BuildFailureException; +import com.codename1.gradle.GradleLog; +import com.codename1.maven.BackendMainClass; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.TaskAction; +import org.gradle.process.ExecOperations; +import org.gradle.work.DisableCachingByDefault; + +import javax.inject.Inject; + +/// Runs a backend on this JVM, as `mvn cn1:backend` does: the generated entry +/// point (or the one class with a `main`), the runtime classpath, the backend +/// directory as the working directory so `application.properties` is found. +/// +/// It starts in seconds, against the minute and a half a native build takes. +/// Like the Maven goal it does not terminate TLS; `backendPackage` builds the +/// binary for that. +@DisableCachingByDefault(because = "Runs a server") +public abstract class RunBackendTask extends DefaultTask { + @Inject + protected abstract ExecOperations getExecOperations(); + + /// The runtime classpath, compiled classes first. + @Classpath + public abstract ConfigurableFileCollection getClasspath(); + + /// The compiled classes, where the generated entry point is recorded: every + /// classes directory of the main source set, Kotlin's included. + @Internal + public abstract ConfigurableFileCollection getClassesDirectories(); + + /// The class to run, when not the generated or the only one. + @Input + @Optional + public abstract Property getMainClass(); + + /// Program arguments. + @Input + public abstract ListProperty getArgs(); + + /// JVM options. + @Input + public abstract ListProperty getJvmArgs(); + + /// The backend's directory. + @Internal + public abstract DirectoryProperty getWorkingDirectory(); + + /// The explicit main class, else the generated entry point in whichever + /// classes directory holds it, else the one class with a main method. + private String resolveMainClass() throws BuildFailureException { + BackendMainClass finder = new BackendMainClass(new GradleLog(getLogger()), "-Pcn1.backend.mainClass"); + String explicit = getMainClass().getOrNull(); + if (explicit != null && !explicit.isEmpty()) { + return explicit; + } + java.util.List dirs = new java.util.ArrayList(); + for (java.io.File dir : getClassesDirectories().getFiles()) { + if (dir.isDirectory()) { + dirs.add(dir); + } + } + for (java.io.File dir : dirs) { + String generated = finder.generatedMainClass(dir); + if (generated != null && !generated.isEmpty()) { + return generated; + } + } + // Across every directory at once, so a main in each -- or several in one + // -- is reported as ambiguous rather than one being picked. + return finder.findMainClass(dirs); + } + + @TaskAction + public void run() { + final String main; + try { + main = resolveMainClass(); + } catch (BuildFailureException ex) { + throw new GradleException(ex.getMessage(), ex); + } + getLogger().lifecycle("Running " + main + " on " + System.getProperty("java.version")); + getExecOperations().javaexec(spec -> { + spec.classpath(getClasspath()); + spec.getMainClass().set(main); + spec.args(getArgs().get()); + spec.jvmArgs(getJvmArgs().get()); + spec.setWorkingDir(getWorkingDirectory().get().getAsFile()); + spec.setStandardInput(System.in); + }); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/SplitOutputCheck.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/SplitOutputCheck.java new file mode 100644 index 00000000000..780b0310995 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/SplitOutputCheck.java @@ -0,0 +1,116 @@ +/* + * 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.gradle.tasks; + +import org.gradle.api.Action; +import org.gradle.api.GradleException; +import org.gradle.api.Task; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +/// Fails the build when the Java and Kotlin classes directories hold the same +/// path. +/// +/// Gradle compiles Kotlin and Java into separate directories, and the +/// annotation processors run over each one after its compiler. They cannot +/// share one: a Java full recompilation empties its destination, which would +/// delete Kotlin's classes. So a processor that emits one fixed registry -- +/// `@AppIntent` handlers, ORM data access bootstraps -- writes a partial copy +/// into each directory when both languages declare that annotation, and only +/// one copy survives on the classpath. Maven compiles both into one directory +/// and never meets this. Rather than ship a registry missing one language's +/// declarations, the build stops and says so. +/// +/// Runs after javac, which in a mixed project runs after the Kotlin compiler. +public final class SplitOutputCheck implements Action { + /// The ORM enhancer's bookkeeping, which each directory keeps for itself. + static final String PER_DIRECTORY = "META-INF/cn1/orm-enhanced-dependencies.list"; + + private final org.gradle.api.provider.Provider javaClasses; + private final org.gradle.api.file.FileCollection classesDirs; + + /// @param javaClasses javac's destination + /// @param classesDirs every classes directory of the source set, Kotlin's + /// among them wherever the build put it + public SplitOutputCheck(org.gradle.api.provider.Provider javaClasses, + org.gradle.api.file.FileCollection classesDirs) { + this.javaClasses = javaClasses; + this.classesDirs = classesDirs; + } + + @Override + public void execute(Task task) { + File javaClasses = this.javaClasses.get(); + List both = new ArrayList(); + for (File other : classesDirs) { + if (!other.getAbsoluteFile().equals(javaClasses.getAbsoluteFile())) { + both.addAll(collisions(javaClasses, other)); + } + } + if (both.isEmpty()) { + return; + } + StringBuilder sb = new StringBuilder("The Java and Kotlin classes both produced "); + sb.append(both.size() == 1 ? "this file" : "these files").append(", and only one copy can be used:\n"); + for (String path : both) { + sb.append(" - ").append(path).append('\n'); + } + sb.append("This happens when both languages declare annotations that generate one shared registry " + + "(for example @AppIntent handlers or ORM entities). Declare each kind in one language, " + + "or move those classes to the same language."); + throw new GradleException(sb.toString()); + } + + /// The relative paths present under both directories, bookkeeping aside. + static List collisions(File a, File b) { + Set inA = new TreeSet(); + collect(a, "", inA); + List out = new ArrayList(); + Set inB = new TreeSet(); + collect(b, "", inB); + for (String path : inA) { + if (inB.contains(path) && !PER_DIRECTORY.equals(path)) { + out.add(path); + } + } + return out; + } + + private static void collect(File dir, String prefix, Set out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File c : children) { + if (c.isDirectory()) { + collect(c, prefix + c.getName() + "/", out); + } else { + out.add(prefix + c.getName()); + } + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/TranscodeSvgTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/TranscodeSvgTask.java new file mode 100644 index 00000000000..c1a010bdac7 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/TranscodeSvgTask.java @@ -0,0 +1,78 @@ +/* + * 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.gradle.tasks; + +import com.codename1.builders.BuildException; +import com.codename1.maven.SvgTranscodeRunner; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.tasks.CacheableTask; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; + +/// Transcodes the SVG and Lottie assets under `src/main/css` and +/// `src/main/svg` into Java sources, which the main source set compiles. The +/// same [SvgTranscodeRunner] as the Maven plugin's `transcode-svg` goal. +@CacheableTask +public abstract class TranscodeSvgTask extends Cn1Task { + /// The vector sources and the CSS that references them. + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getSources(); + + /// The generated Java sources. + @OutputDirectory + public abstract DirectoryProperty getOutputDirectory(); + + /// The 1x1 placeholders the CSS compiler sees for referenced images. + @OutputDirectory + public abstract DirectoryProperty getPlaceholderDirectory(); + + @TaskAction + public void transcode() { + // Every run regenerates everything, so start empty: the runner neither + // deletes the class of an asset that was removed nor rewrites SVGRegistry + // when it looks newer than what is left, and the deleted image stayed + // compiled and registered until a clean build. + for (DirectoryProperty dir : java.util.Arrays.asList(getOutputDirectory(), getPlaceholderDirectory())) { + java.io.File f = dir.get().getAsFile(); + try { + if (f.isDirectory()) { + org.apache.commons.io.FileUtils.cleanDirectory(f); + } + } catch (java.io.IOException ex) { + throw new GradleException("Could not clear " + f, ex); + } + } + try { + new SvgTranscodeRunner(layout().projectDir(), null, getOutputDirectory().get().getAsFile(), + getPlaceholderDirectory().get().getAsFile(), null, log()).run(); + } catch (BuildException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/resources/META-INF/gradle-plugins/com.codenameone.properties b/maven/codenameone-gradle-plugin/src/main/resources/META-INF/gradle-plugins/com.codenameone.properties new file mode 100644 index 00000000000..a47f3bd92c9 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/resources/META-INF/gradle-plugins/com.codenameone.properties @@ -0,0 +1 @@ +implementation-class=com.codename1.gradle.CodenameOnePlugin diff --git a/maven/codenameone-gradle-plugin/src/main/resources/com/codename1/gradle/plugin.properties b/maven/codenameone-gradle-plugin/src/main/resources/com/codename1/gradle/plugin.properties new file mode 100644 index 00000000000..15eb22f560e --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/resources/com/codename1/gradle/plugin.properties @@ -0,0 +1,2 @@ +# The plugin version, which is also the default Codename One version a project builds against. +version=${project.version} diff --git a/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/PluginHelpersTest.java b/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/PluginHelpersTest.java new file mode 100644 index 00000000000..df4b4967615 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/PluginHelpersTest.java @@ -0,0 +1,249 @@ +/* + * 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.gradle; + +import com.codename1.build.BuildArtifact; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class PluginHelpersTest { + @Test + void cn1UpdateRewritesOnlyThePluginVersion() { + String kts = "pluginManagement { repositories { gradlePluginPortal() } }\n" + + "plugins {\n id(\"com.codenameone\") version \"8.0.1\"\n kotlin(\"jvm\") version \"2.2.10\"\n}\n"; + String updated = UpdateSupport.withPluginVersion(kts, "8.0.2"); + assertEquals(kts.replace("\"8.0.1\"", "\"8.0.2\""), updated); + + String groovy = "plugins {\n id 'com.codenameone' version '8.0.1'\n}\n"; + assertEquals(groovy.replace("8.0.1", "9.0"), UpdateSupport.withPluginVersion(groovy, "9.0")); + + assertNull(UpdateSupport.withPluginVersion("plugins { id(\"com.codenameone\") }\n", "1.0"), + "a script that declares no version is left to the user"); + assertNull(UpdateSupport.withPluginVersion("plugins { id(\"com.codenameone.other\") version \"1\" }", "2")); + } + + @Test + void theLatestReleaseComesFromTheMarkerMetadata() { + String metadata = "8.1-SNAPSHOT8.0.2" + + ""; + assertEquals("8.0.2", UpdateSupport.releaseOf(metadata)); + assertEquals("8.1", UpdateSupport.releaseOf(" 8.1 ")); + assertNull(UpdateSupport.releaseOf("")); + } + + @Test + void aResolvedDependencyRoundTripsThroughATaskInput() { + BuildArtifact a = GradleProjectHost.decode("com.acme|maps-common|1.2|cn1css|zip|compile|/r/a|b.zip"); + assertEquals("com.acme", a.getGroupId()); + assertEquals("maps-common", a.getArtifactId()); + assertEquals("1.2", a.getVersion()); + assertEquals("cn1css", a.getClassifier()); + assertEquals("zip", a.getType()); + assertEquals("compile", a.getScope()); + assertEquals(new File("/r/a|b.zip"), a.getFile(), "the path is the last field and may hold anything"); + + assertNull(GradleProjectHost.decode("com.acme|x|1||jar|compile|/r/x.jar").getClassifier()); + assertNull(GradleProjectHost.decode("too|few|fields")); + assertNull(GradleProjectHost.decode(null)); + } + + @Test + void liveCssReloadUsesTheListCn1CssRecorded(@org.junit.jupiter.api.io.TempDir File dir) throws Exception { + File theme = new File(dir, "theme.css"); + File recorded = new File(dir, com.codename1.gradle.tasks.Cn1CssTask.SIMULATOR_INPUTS); + AppSupport.CssInputArgument arg = new AppSupport.CssInputArgument(recorded, theme, dir); + String property = "-D" + com.codename1.maven.SimulatorSupport.CSS_INPUT_PROPERTY + "="; + assertEquals(Collections.singletonList(property + theme.getAbsolutePath()), arg.asArguments(), + "no list (CSS switched off): the application's stylesheet alone"); + + java.nio.file.Files.write(recorded.toPath(), "/x/lib/theme.css,/x/theme.css\n".getBytes("UTF-8")); + assertEquals(Collections.singletonList(property + new File("/x/lib/theme.css").getAbsolutePath() + "," + + new File("/x/theme.css").getAbsolutePath()), arg.asArguments(), + "the library stylesheets come first, as cn1Css compiled them"); + + // Recorded relative to the project, so a checkout restored from the build + // cache elsewhere still points at its own files. + String recordedText = com.codename1.gradle.tasks.Cn1CssTask.relative( + new File(dir, "build/css/libs/a/theme.css").getAbsolutePath() + "," + theme.getAbsolutePath(), dir); + assertEquals("build/css/libs/a/theme.css,theme.css".replace("/", File.separator), recordedText); + java.nio.file.Files.write(recorded.toPath(), recordedText.getBytes("UTF-8")); + assertEquals(Collections.singletonList(property + new File(dir, "build/css/libs/a/theme.css").getAbsolutePath() + + "," + theme.getAbsolutePath()), arg.asArguments()); + } + + @Test + void aPublishedPomNamesTheFrameworkVersion() throws Exception { + String pom = "" + + "com.codenameonegooglemaps-lib" + + "com.codenameonepinned" + + "1.0" + + "org.exampleother" + + ""; + org.w3c.dom.Element project = javax.xml.parsers.DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(new org.xml.sax.InputSource(new java.io.StringReader(pom))).getDocumentElement(); + LibrarySupport.fillFrameworkVersions(project, "9.9.9"); + org.w3c.dom.NodeList deps = project.getElementsByTagName("dependency"); + assertEquals("9.9.9", version(deps.item(0)), "a versionless framework module gets the framework's"); + assertEquals("1.0", version(deps.item(1)), "a declared version is kept"); + assertNull(version(deps.item(2)), "only com.codenameone modules take the framework's version"); + } + + private static String version(org.w3c.dom.Node dep) { + org.w3c.dom.NodeList v = ((org.w3c.dom.Element) dep).getElementsByTagName("version"); + return v.getLength() == 0 ? null : v.item(0).getTextContent(); + } + + @Test + void everyResolvedModuleButTheFrameworksIsReadForPlatformProfiles() { + org.junit.jupiter.api.Assertions.assertTrue(Cn1libs.mayBeCn1lib("com.acme", "maps-lib")); + org.junit.jupiter.api.Assertions.assertTrue(Cn1libs.mayBeCn1lib("com.codenameone", "googlemaps-lib"), + "a cn1lib published under com.codenameone"); + org.junit.jupiter.api.Assertions.assertFalse(Cn1libs.mayBeCn1lib("com.codenameone", "codenameone-core")); + } + + @Test + void aTimestampedSnapshotKeepsItsClassifier() { + assertEquals("cn1css", GradleProjectHost.classifierOf("maps-1.0-SNAPSHOT-cn1css", "maps", "1.0-SNAPSHOT")); + assertEquals("cn1css", GradleProjectHost.classifierOf("maps-1.0-20260101.120000-3-cn1css", "maps", + "1.0-SNAPSHOT"), "a unique snapshot is named for its timestamp"); + assertEquals("", GradleProjectHost.classifierOf("maps-1.0-20260101.120000-3", "maps", "1.0-SNAPSHOT")); + assertEquals("", GradleProjectHost.classifierOf("maps-1.0", "maps", "1.0")); + assertEquals("sources", GradleProjectHost.classifierOf("maps-1.0-sources", "maps", "1.0")); + } + + @Test + void aPlatformPomDependencyIsWrittenAsMavenReadsIt() { + assertEquals(" \n g\n a\n" + + " 1\n \n", LibrarySupport.dependencyXml("g", "a", "1", "compile")); + org.junit.jupiter.api.Assertions.assertTrue(LibrarySupport.dependencyXml("g", "a", "1", "runtime") + .contains("runtime")); + } + + @Test + void mavenExclusionsBecomeGradleExcludeRules() { + assertEquals("{group=g, module=a}", String.valueOf(Cn1libs.exclusionRule("g", "a"))); + assertEquals("{group=g}", String.valueOf(Cn1libs.exclusionRule("g", "*")), "any artifact of the group"); + assertEquals("{module=a}", String.valueOf(Cn1libs.exclusionRule("*", "a"))); + assertNull(Cn1libs.exclusionRule("*", "*"), "*:* means nothing transitive"); + } + + @Test + void declarationsOfOneLibraryDifferingInExclusionsAreKeptApart() { + java.util.List none = Collections.emptyList(); + java.util.List one = Collections.singletonList(new String[] {"x", "y"}); + java.util.List two = Arrays.asList(new String[] {"x", "y"}, new String[] {"p", "q"}); + java.util.List twoReordered = Arrays.asList(new String[] {"p", "q"}, new String[] {"x", "y"}); + org.junit.jupiter.api.Assertions.assertNotEquals(Cn1libs.exclusionKey(none), Cn1libs.exclusionKey(one), + "a path that excludes nothing is a second edge, not a duplicate"); + assertEquals(Cn1libs.exclusionKey(two), Cn1libs.exclusionKey(twoReordered), "order does not matter"); + } + + @Test + void aPublishedDependencyKeepsItsExclusions() { + String xml = LibrarySupport.exclusionsXml(LibrarySupport.dependencyXml("g", "a", "1", "compile"), + java.util.Collections.singletonList(new String[] {"x", "y"})); + org.junit.jupiter.api.Assertions.assertTrue(xml.contains("\n \n" + + " x\n y\n"), xml); + org.junit.jupiter.api.Assertions.assertTrue(xml.endsWith(" \n \n"), xml); + assertEquals(LibrarySupport.dependencyXml("g", "a", "1", "compile"), LibrarySupport.exclusionsXml( + LibrarySupport.dependencyXml("g", "a", "1", "compile"), java.util.Collections.emptyList())); + } + + @Test + void aProjectCn1libIsPublishedAsItsLibPom() throws Exception { + String pom = "" + + "com.acmemaps-common1" + + "" + + "com.acmecharts-lib2" + + "" + + "org.exampleplain3" + + ""; + org.w3c.dom.Element project = javax.xml.parsers.DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(new org.xml.sax.InputSource(new java.io.StringReader(pom))).getDocumentElement(); + java.util.Map cn1libs = new java.util.HashMap(); + cn1libs.put("com.acme:maps", "maps-lib"); + cn1libs.put("com.acme:maps-common", "maps-lib"); + cn1libs.put("com.acme:charts-lib", "charts-lib"); + LibrarySupport.markCn1libDependencies(project, cn1libs); + org.w3c.dom.NodeList deps = project.getElementsByTagName("dependency"); + assertEquals("maps-lib", text(deps.item(0), "artifactId"), "a project cn1lib is its -lib pom"); + assertEquals("pom", text(deps.item(0), "type")); + assertEquals("charts-lib", text(deps.item(1), "artifactId")); + assertEquals("pom", text(deps.item(1), "type")); + assertNull(text(deps.item(2), "type"), "an ordinary library is left alone"); + } + + private static String text(org.w3c.dom.Node dep, String name) { + org.w3c.dom.NodeList v = ((org.w3c.dom.Element) dep).getElementsByTagName(name); + return v.getLength() == 0 ? null : v.item(0).getTextContent(); + } + + @Test + void backendArgumentsSplitOnWhitespace() { + assertEquals(Arrays.asList("-Xmx1g", "-Dx=y"), BackendSupport.split(" -Xmx1g \t -Dx=y ")); + assertEquals(Collections.emptyList(), BackendSupport.split(" ")); + } + + /// Cached native outputs are rebuilt when a build hint changes, however it + /// arrives; the recorded fingerprint never holds a hint's value in the clear. + @Test + void theBuildHintFingerprintTracksEveryHint() { + Properties a = new Properties(); + a.setProperty("codename1.arg.android.xpermissions", "CAMERA"); + a.setProperty("codename1.arg.ios.certificatePassword", "s3cret"); + Properties reordered = new Properties(); + reordered.setProperty("codename1.arg.ios.certificatePassword", "s3cret"); + reordered.setProperty("codename1.arg.android.xpermissions", "CAMERA"); + Properties changed = new Properties(); + changed.putAll(a); + changed.setProperty("codename1.arg.android.xpermissions", "CAMERA,NFC"); + + String fingerprint = GradleProjectHost.hintsFingerprint(a); + assertEquals(fingerprint, GradleProjectHost.hintsFingerprint(reordered)); + org.junit.jupiter.api.Assertions.assertNotEquals(fingerprint, GradleProjectHost.hintsFingerprint(changed)); + org.junit.jupiter.api.Assertions.assertFalse(fingerprint.contains("s3cret")); + } + + /// codenameone { mainClass } overrides the settings file's two keys. + @Test + void theMainClassExtensionSplitsIntoPackageAndName() { + java.util.Map p = ProjectSupport.mainClassProperties("com.acme.app.OtherApp"); + assertEquals("com.acme.app", p.get("codename1.packageName")); + assertEquals("OtherApp", p.get("codename1.mainName")); + assertEquals(Collections.emptyMap(), ProjectSupport.mainClassProperties("")); + assertEquals("", ProjectSupport.mainClassProperties("Bare").get("codename1.packageName")); + } + + @Test + void eachPlatformHasItsOwnCn1libConfiguration() { + assertEquals("cn1libIos", Cn1libs.configurationName("ios")); + assertEquals("cn1libJavascript", Cn1libs.configurationName("javascript")); + } +} diff --git a/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/tasks/TaskHelpersTest.java b/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/tasks/TaskHelpersTest.java new file mode 100644 index 00000000000..3ef78e71cb6 --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/tasks/TaskHelpersTest.java @@ -0,0 +1,108 @@ +/* + * 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.gradle.tasks; + +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectDescriptor; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TaskHelpersTest { + @TempDir + Path tmp; + + private File touch(String path) throws IOException { + File f = new File(tmp.toFile(), path); + f.getParentFile().mkdirs(); + Files.write(f.toPath(), new byte[0]); + return f; + } + + /// The Kotlin compliance pass knows the Java classes javac has not written + /// yet by their files. + @Test + void javaTypesAreNamedByTheirFiles() throws IOException { + touch("java/a/b/Main.java"); + touch("java/a/b/package-info.java"); + touch("java/Top.java"); + touch("java/a/notes.txt"); + assertEquals(new HashSet(Arrays.asList("a/b/Main", "Top")), + ComplianceAction.javaTypesUnder(new File(tmp.toFile(), "java"))); + assertEquals(Collections.emptySet(), ComplianceAction.javaTypesUnder(new File(tmp.toFile(), "missing"))); + } + + /// The GUI tools and the simulator read the project through this file, so it + /// must describe the Gradle layout and name the app. + @Test + void theDescriptorDescribesTheGradleProject() throws IOException { + File root = tmp.toFile(); + ProjectLayout layout = ProjectLayouts.of(BuildSystem.GRADLE, ProjectKind.APP, root, root); + Properties effective = new Properties(); + effective.setProperty("codename1.mainName", " MyApp "); + effective.setProperty("codename1.packageName", "com.acme"); + File file = new File(root, "build/codenameone/project.properties"); + PrepareSimulatorTask.writeDescriptor(layout, file, effective, + Collections.singletonList(new File(root, "build/generated/sources/rad-views").getAbsolutePath())); + + ProjectDescriptor d = ProjectDescriptor.read(file); + assertEquals("MyApp", d.get("mainName")); + assertEquals("com.acme", d.get("packageName")); + assertTrue(d.getAll("sourceRoot").contains(new File(root, "build/generated/sources/rad-views").getAbsolutePath()), + String.valueOf(d.getAll("sourceRoot"))); + ProjectLayout back = d.toLayout(); + assertEquals(BuildSystem.GRADLE, back.buildSystem()); + assertEquals(layout.javaSourceDir(), back.javaSourceDir()); + } + + /// Java and Kotlin compile into separate directories; the same generated + /// path in both means one language's registry is lost. The ORM enhancer's + /// per-directory bookkeeping is the one legitimate overlap. + @Test + void theSameGeneratedPathInBothClassDirectoriesIsACollision() throws IOException { + touch("java/cn1app/DaoBootstrap.class"); + touch("java/a/Main.class"); + touch("java/" + SplitOutputCheck.PER_DIRECTORY); + touch("kotlin/cn1app/DaoBootstrap.class"); + touch("kotlin/a/Helper.class"); + touch("kotlin/" + SplitOutputCheck.PER_DIRECTORY); + assertEquals(Collections.singletonList("cn1app/DaoBootstrap.class"), SplitOutputCheck.collisions( + new File(tmp.toFile(), "java"), new File(tmp.toFile(), "kotlin"))); + assertEquals(Collections.emptyList(), SplitOutputCheck.collisions( + new File(tmp.toFile(), "java"), new File(tmp.toFile(), "missing"))); + } +} diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 67b3f91dc6c..2074a09cd73 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -34,6 +34,15 @@ + + + ${project.groupId} + codenameone-build-engine + ${project.version} + ${project.groupId} codenameone-platform-feature-catalog @@ -67,14 +76,6 @@ ${project.version} test - - - org.xerial - sqlite-jdbc - 3.46.1.0 - test - junit junit @@ -288,31 +289,6 @@ runtime - - - com.codenameone - cn1-hardening - ${project.version} - - provided - true - - - * - * - - - 3.2.5 - - org.apache.maven.plugins - maven-dependency-plugin - - - - embed-hardening-engine - - prepare-package - - copy - - - - - com.codenameone - cn1-hardening - ${project.version} - standalone - jar - ${project.build.outputDirectory} - cn1-hardening.jar - - - true - true - - - - org.apache.maven.plugins maven-antrun-plugin @@ -518,27 +456,10 @@ - - - add-javascript-proxy-test-resources - generate-test-resources - - run - - - - - - - - diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/BuildException.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/BuildException.java deleted file mode 100644 index 1fb50fb271c..00000000000 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/BuildException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.codename1.builders; - -public class BuildException extends Exception { - public BuildException(String message) { - super(message); - } - - public BuildException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java index 5870d8ec09f..2171fb0f4b1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java @@ -81,7 +81,7 @@ public abstract class AbstractCN1Mojo extends AbstractMojo { * simulator.properties key carrying {@link #getCssCliClasspath()} through to the * simulator, where CSSWatcher forks the CSS compiler for live reload. */ - protected static final String CSS_CLI_CLASSPATH_PROPERTY = "cn1.css.cli.classpath"; + protected static final String CSS_CLI_CLASSPATH_PROPERTY = SimulatorSupport.CSS_CLI_CLASSPATH_PROPERTY; @Component @@ -336,16 +336,178 @@ protected static Properties nestedBuildProperties(Properties userProperties) { * simulator is supposed to report.

*/ protected static Properties commandLineBuildHints(Properties userProperties) { - Properties out = new Properties(); - if (userProperties == null) { + return SimulatorSupport.commandLineBuildHints(userProperties); + } + + /** + * This mojo's project as the build engine sees it. Engine code (the builders, + * {@link AppBuilder}, the CSS and annotation steps) asks its questions through + * this, so the Gradle plugin can answer the same ones from its own model. + */ + protected com.codename1.build.ProjectHost projectHost() { + return new MavenProjectHost(); + } + + /** + * The dependency keys the application needs without the desktop runtime + * aggregator, or null when unknown. Only the build goal computes it. + */ + protected Set neededWithoutDesktopRuntimeKeys() { + return null; + } + + /** {@link com.codename1.build.ProjectHost} answered from the Maven model. */ + protected class MavenProjectHost implements com.codename1.build.ProjectHost { + @Override + public com.codename1.build.Log log() { + return MavenLog.of(getLog()); + } + + @Override + public com.codename1.project.ProjectLayout layout() { + File dir = getCN1ProjectDir(); + com.codename1.project.ProjectLayout l = com.codename1.project.ProjectLayouts.detect( + dir != null ? dir : project.getBasedir()); + if (l != null) { + return l; + } + return com.codename1.project.ProjectLayouts.of(com.codename1.project.BuildSystem.MAVEN, + com.codename1.project.ProjectKind.APP, project.getBasedir(), project.getBasedir()); + } + + @Override + public File cn1ProjectDir() { + return getCN1ProjectDir(); + } + + @Override + public File baseDir() { + return project.getBasedir(); + } + + @Override + public File buildDirectory() { + return new File(project.getBuild().getDirectory()); + } + + @Override + public File outputDirectory() { + return new File(project.getBuild().getOutputDirectory()); + } + + @Override + public String finalName() { + return project.getBuild().getFinalName(); + } + + @Override + public String groupId() { + return project.getGroupId(); + } + + @Override + public Properties projectProperties() { + return project.getProperties(); + } + + @Override + public Properties userProperties() { + Properties p = AbstractCN1Mojo.this.userProperties(); + return p == null ? new Properties() : p; + } + + @Override + public List compileClasspathElements() throws com.codename1.build.BuildExecutionException { + try { + return project.getCompileClasspathElements(); + } catch (Exception ex) { + throw new com.codename1.build.BuildExecutionException("Failed to get classpath elements", ex); + } + } + + @Override + public List runtimeClasspathElements() throws com.codename1.build.BuildExecutionException { + try { + return project.getRuntimeClasspathElements(); + } catch (Exception ex) { + throw new com.codename1.build.BuildExecutionException("Failed to get classpath elements", ex); + } + } + + @Override + public List compileSourceRoots() { + return AbstractCN1Mojo.compileSourceRoots(project, AbstractCN1Mojo.this.userProperties()); + } + + @Override + public Collection artifacts() { + List out = new ArrayList(); + for (Artifact a : project.getArtifacts()) { + out.add(toBuildArtifact(a)); + } return out; } - for (String key : userProperties.stringPropertyNames()) { - if (key.startsWith("codename1.arg.")) { - out.setProperty(key, userProperties.getProperty(key)); + + @Override + public Set neededWithoutDesktopRuntime() { + return neededWithoutDesktopRuntimeKeys(); + } + + @Override + public File getJar(com.codename1.build.BuildArtifact artifact) { + for (Artifact a : project.getArtifacts()) { + if (a.getGroupId().equals(artifact.getGroupId()) + && a.getArtifactId().equals(artifact.getArtifactId()) + && Objects.equals(a.getClassifier(), artifact.getClassifier())) { + return AbstractCN1Mojo.this.getJar(a); + } } + return artifact.getFile(); + } + + @Override + public File getJar(String groupId, String artifactId, String classifier) { + return classifier == null + ? AbstractCN1Mojo.this.getJar(groupId, artifactId) + : AbstractCN1Mojo.this.getJar(groupId, artifactId, classifier); + } + + @Override + public long sessionStartTime() { + return getSession() != null && getSession().getRequest() != null + && getSession().getRequest().getStartTime() != null + ? getSession().getRequest().getStartTime().getTime() : Long.MAX_VALUE; + } + + @Override + public long sourcesModificationTime() throws IOException { + return getSourcesModificationTime(); + } + + @Override + public void attachArtifact(String type, String classifier, File file) { + if (classifier == null) { + projectHelper.attachArtifact(project, type, file); + } else { + projectHelper.attachArtifact(project, type, classifier, file); + } + } + + @Override + public String codenameOneVersion() { + return project.getProperties().getProperty("cn1.version", ""); + } + + @Override + public String pluginVersion() { + return project.getProperties().getProperty("cn1.plugin.version", ""); } - return out; + } + + /** The engine's view of a Maven artifact. */ + protected static com.codename1.build.BuildArtifact toBuildArtifact(Artifact a) { + return new com.codename1.build.BuildArtifact(a.getGroupId(), a.getArtifactId(), a.getVersion(), + a.getClassifier(), a.getType(), a.getScope(), a.getFile(), a.getDependencyTrail()); } /** The session this mojo is running in, for a subclass that has to reproduce it. */ @@ -974,95 +1136,27 @@ protected File getProjectNativeDir() { return new File(getCN1ProjectDir(), "native"); } - public static final String UPDATE_CODENAMEONE_JAR_URL = "https://www.codenameone.com/files/updates/UpdateCodenameOne.jar"; - public static final String UPDATE_CODENAMEONE_JAR_FALLBACK_URL = "https://github.com/codenameone/CodenameOne/raw/refs/heads/master/maven/UpdateCodenameOne.jar"; - public static final String UPDATE_CODENAMEONE_JAR_RESOURCE = "/com/codename1/maven/UpdateCodenameOne.jar"; - public static final String JPDATE_CODENAMEONE_JAR_PATH = System.getProperty("user.home") + File.separator + ".codenameone" + File.separator + "UpdateCodenameOne.jar"; - + public static final String UPDATE_CODENAMEONE_JAR_URL = com.codename1.build.CodenameOneUpdater.UPDATE_CODENAMEONE_JAR_URL; + public static final String UPDATE_CODENAMEONE_JAR_FALLBACK_URL = com.codename1.build.CodenameOneUpdater.UPDATE_CODENAMEONE_JAR_FALLBACK_URL; + public static final String UPDATE_CODENAMEONE_JAR_RESOURCE = com.codename1.build.CodenameOneUpdater.UPDATE_CODENAMEONE_JAR_RESOURCE; + public static final String JPDATE_CODENAMEONE_JAR_PATH = com.codename1.build.CodenameOneUpdater.updaterJar().getPath(); + /** Installs the updater in ~/.codenameone; see {@link com.codename1.build.CodenameOneUpdater}. */ protected void installUpdater() throws IOException { - File re = new File(JPDATE_CODENAMEONE_JAR_PATH); - if (re.exists()) { - getLog().debug("Designer is up to date"); - return; - } - re.getParentFile().mkdirs(); - - try (InputStream bundled = AbstractCN1Mojo.class.getResourceAsStream(UPDATE_CODENAMEONE_JAR_RESOURCE)) { - if (bundled != null) { - getLog().info("Installing Codename One Updater from bundled plugin resource"); - copyToFile(bundled, re); - return; - } - } - - IOException lastFailure = null; - for (String url : new String[] { UPDATE_CODENAMEONE_JAR_URL, UPDATE_CODENAMEONE_JAR_FALLBACK_URL }) { - getLog().info("Installing Codename One Updater from " + url); - try (InputStream is = new URL(url).openStream()) { - copyToFile(is, re); - return; - } catch (IOException ex) { - lastFailure = ex; - getLog().warn("Failed to download Codename One Updater from " + url + ": " + ex.getMessage()); - } - } - throw lastFailure != null ? lastFailure : new IOException("Failed to install Codename One updater"); + new com.codename1.build.CodenameOneUpdater(MavenLog.of(getLog()), antProject).installUpdater(); } - private static void copyToFile(InputStream is, File dest) throws IOException { - try (FileOutputStream os = new FileOutputStream(dest)) { - byte[] buf = new byte[65536]; - int len; - while ((len = is.read(buf)) > -1) { - os.write(buf, 0, len); - } - } - } - + /** Runs the updater; see {@link com.codename1.build.CodenameOneUpdater}, which Gradle shares. */ protected void updateCodenameOne(boolean force, File... files) throws MojoExecutionException { try { - installUpdater(); - } catch (Exception ex) { - getLog().error("Failed to install Codename One updater"); - throw new MojoExecutionException("Failed to install codenameone updater", ex); - } - if (!force) { - // If we're not forcing an update, and there are no missing files being requested, - // then we'll call it a day. - boolean missing = false; - for (File f : files) { - if (!f.exists()) { - missing = true; - break; - } - } - if (!missing) { - return; - } - } - - Java java = createJava(); - java.setFork(true); - java.setJar(new File(JPDATE_CODENAMEONE_JAR_PATH)); - File dummyProject = new File(project.getBuild().getDirectory(), path("codenameone", "update-dummy")); - File dummyProjectLib = new File(dummyProject, "lib"); - dummyProjectLib.mkdirs(); - File cn1Properties = new File(getCN1ProjectDir(), "codenameone_settings.properties"); - if (cn1Properties.exists()) { - try { - FileUtils.copyFile(cn1Properties, new File(dummyProject, cn1Properties.getName())); - } catch (IOException ex) { - getLog().warn("Failed to copy "+cn1Properties+" into dummy project", ex); - } + new com.codename1.build.CodenameOneUpdater(MavenLog.of(getLog()), antProject).update(force, + new File(project.getBuild().getDirectory(), "codenameone"), getCN1ProjectDir(), files); + } catch (com.codename1.build.BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); } - //java.createArg().setFile(getCN1ProjectDir()); - java.createArg().setFile(dummyProject); - java.createArg().setValue("force"); - java.executeJava(); } - - + + protected void copyKotlinIncrementalCompileOutputToOutputDir() { if ("true".equals(project.getProperties().getProperty("kotlin.compiler.incremental"))) { File kotlinIncrementalOutputDir = new File(project.getBuild().getDirectory() + File.separator + "kotlin-ic" + File.separator + "compile" + File.separator + "classes"); @@ -1083,7 +1177,90 @@ protected void copyKotlinIncrementalCompileOutputToOutputDir() { } protected static String OS = System.getProperty("os.name").toLowerCase(); protected static boolean isWindows = (OS.indexOf("win") >= 0); - protected static boolean isMac = (OS.indexOf("mac") >= 0); + + /// The jars `tool` runs from -- its own and its runtime dependencies -- + /// resolved through Maven at `version`. The tool is then started with + /// [#launchDesktopTool]; the Gradle plugin launches the same tools through + /// the same [DesktopTool], resolving them through its own configurations. + /// + /// @param toWorkOnIt how to run the tool from its own module, appended to + /// the failure when it cannot be resolved + protected List resolveDesktopTool(DesktopTool tool, String version, String toWorkOnIt) + throws MojoFailureException { + Artifact artifact = getArtifact("com.codenameone", tool.artifactId()); + if (artifact == null) { + artifact = repositorySystem.createArtifact("com.codenameone", tool.artifactId(), version, "jar"); + } + ArtifactResolutionResult result = repositorySystem.resolve(new ArtifactResolutionRequest() + .setLocalRepository(localRepository) + .setRemoteRepositories(new java.util.ArrayList( + remoteRepositories)) + .setResolveTransitively(true) + // Maven documents -o as "work offline"; the legacy resolver does not read the + // session flag by itself, so without this mvn -o still reaches the network and can + // refresh a snapshot over the jar this build just produced. + .setOffline(offline) + .setArtifact(artifact)); + List files = new java.util.ArrayList(); + addToolJar(files, artifact); + if (result != null && result.getArtifacts() != null) { + for (Artifact resolved : result.getArtifacts()) { + addToolJar(files, resolved); + } + } + // A cached main jar with a missing transitive dependency still leaves the + // list nonempty, and in detached mode the goal would report a successful + // launch while the process died in the tool's log with a + // NoClassDefFoundError nobody goes looking for. + String incomplete = resolutionFailure(result); + if (incomplete != null) { + throw new MojoFailureException(tool.displayName() + " could not be fully resolved: " + incomplete + + "\nRun the goal again with -U to refresh it, or drop -o if you are building offline."); + } + if (tool.primaryJar(files) == null) { + throw new MojoFailureException("Could not resolve " + tool.displayName() + " (com.codenameone:" + + tool.artifactId() + ":" + version + ").\n" + + "It is published alongside the Codename One plugin.\n" + toWorkOnIt); + } + return files; + } + + /// Starts `tool` bound to `input`: detached (logging to the tool's runtime + /// directory) or attached, waiting for its window to close. + protected void launchDesktopTool(DesktopTool tool, List classpath, File input, File workingDir, + boolean detached, List extraJvmArgs) throws MojoExecutionException { + try { + tool.launch(classpath, tool.primaryJar(classpath), input, workingDir, detached, extraJvmArgs, + MavenLog.of(getLog())); + } catch (com.codename1.build.BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex); + } + } + + /// Describes what the resolver could not produce, or null when the result is complete. + static String resolutionFailure(ArtifactResolutionResult result) { + if (result == null) { + return null; + } + if (result.hasMissingArtifacts()) { + return "missing " + result.getMissingArtifacts(); + } + if (result.hasExceptions()) { + Exception first = (Exception) result.getExceptions().get(0); + return first.getMessage(); + } + return null; + } + + private static void addToolJar(List files, Artifact artifact) { + if (artifact == null || artifact.getFile() == null || !"jar".equals(artifact.getType())) { + return; + } + File file = artifact.getFile().getAbsoluteFile(); + if (file.exists() && !files.contains(file)) { + files.add(file); + } + } protected File getFFmpegDir() { String path = System.getProperty("ffmpeg.dir", null); @@ -2032,7 +2209,7 @@ protected SvgTranscodeRunner newSvgTranscodeRunner() { File placeholders = svgPlaceholderDir != null ? svgPlaceholderDir : new File(project.getBuild().getDirectory(), "css-resources"); return new SvgTranscodeRunner(project.getBasedir(), svgSourceDirs, - svgOutputDir(), placeholders, svgPackage(), getLog()); + svgOutputDir(), placeholders, svgPackage(), MavenLog.of(getLog())); } // ------------------------------------------------------------------ @@ -2569,705 +2746,36 @@ private void warnCouldNotEditPom(String why) { getLog().warn(" "); } - /** - * Name of the resource {@code BuildHintAnnotationProcessor} emits into - * {@code target/classes} at PROCESS_CLASSES. - */ - private static final String ANNOTATION_HINTS_RESOURCE = - "META-INF/codenameone/build-hints.properties"; - /** * Overlays the build hints that came from annotations onto the settings the - * build request is assembled from. - * - *

Read from the compile classpath rather than from the staged fat jar. - * {@code mergeJars} builds that jar with Ant's {@code Zip} in update mode, - * which adds and overwrites entries but never removes one, and the jar is - * reused when it is not stale — so a project that had its annotations - * deleted would keep shipping yesterday's hints. The classpath directory is - * written by the annotation processor on every build and deleted by it when - * the last annotation goes away, so it always reflects the current source.

+ * build request is assembled from. See {@link AnnotationBuildHints}, in the + * build engine, which the Gradle plugin shares. */ protected void mergeAnnotationBuildHints(Properties target, List classpathElements) throws MojoFailureException { - if (target == null || classpathElements == null) { - return; - } - String expectedMain = null; - if (properties != null) { - String main = properties.getProperty("codename1.mainName"); - String pkg = properties.getProperty("codename1.packageName"); - if (main != null && main.trim().length() > 0) { - expectedMain = (pkg == null || pkg.trim().length() == 0) - ? main.trim() : pkg.trim() + "." + main.trim(); - } - } - // The manifest's presence, not its contents, is what proves the processor - // ran. An annotation with every member left at its default -- @Ios() after - // the last attribute was deleted -- is legal Java, and the processor emits - // a manifest carrying only the main-class stamp for it. Judging by the hint - // count alone would read that as "never processed" and refuse a build that - // is in fact perfectly configured. - String stale = null; - // The processor writes the manifest into the SAME directory it scanned, - // so a manifest stamped for a main class was written beside that class. - // An element carrying the stamp without the class is therefore left over - // -- the shape is moving the application class from a platform module - // into common without cleaning -- and project output comes before - // dependencies on the classpath, so the leftover was found first, applied - // its old values and returned before the real one was ever read. - // - // Ordered rather than refused. Refusing assumes the two can never be - // packaged apart, and an element that assembles resources from another - // module would then lose its hints entirely; putting the colocated ones - // first fixes the stale-wins bug and still falls back to a manifest that - // is all there is. - for (String element : colocatedFirst(classpathElements, expectedMain)) { - Properties found = readAnnotationHints(new File(element)); - if (found == null) { - continue; - } - String stamped = found.getProperty("cn1.buildHints.mainClass"); - if (expectedMain != null && !expectedMain.equals(stamped)) { - // A cn1lib that bound the goal itself, a stale artifact, or a - // file a project keeps in src/main/resources -- anything on the - // classpath can carry this name. It has to say it was generated - // for THIS main class: an unstamped one used to be accepted, and - // then it both applied somebody else's hints and counted as - // proof that the processor ran, which is what suppresses the - // refusal below when the goal is not bound at all. - getLog().debug("cn1: ignoring build hints from " + element - + " -- they were generated for " + stamped); - continue; - } - if (found.getProperty(com.codename1.maven.processors.BuildHintAnnotationProcessor - .SOURCE_DIGEST_KEY) == null) { - // The processor always records one. A manifest without it was - // written by something else. - getLog().debug("cn1: ignoring build hints from " + element - + " -- no processor fingerprint"); - continue; - } - // The stamp says which class produced this file, not when. Nothing - // clears target/classes between builds, so a project that ran the - // processor once and then stopped -- goal unbound, skipped, or bound - // to a phase that no longer runs -- keeps a manifest naming the right - // class while the annotations beside it have changed. Comparing the - // recorded fingerprint against the compiled class is what tells those - // apart; without it the build silently ships the older values and the - // guard below never runs. - String mismatch = digestMismatch(new File(element), expectedMain, found, classpathElements); - if (mismatch != null) { - getLog().debug("cn1: ignoring build hints from " + element + " -- " + mismatch); - stale = mismatch; - continue; - } - int applied = 0; - for (String key : found.stringPropertyNames()) { - if (!key.startsWith("codename1.arg.")) { - continue; - } - // The processor refuses a hint declared as an annotation and as a - // properties line, but it can only refuse the builds it runs in. - // The fingerprint above covers the annotations and nothing else, - // so with processing skipped a line added to the properties file - // afterwards leaves a manifest that still matches -- and this - // overlay would quietly replace the value the developer just - // wrote, until the next clean build regenerated the manifest and - // failed. Same declaration, same answer, whether or not - // target/classes happened to be cleaned. - String conflict = conflictingPropertiesDeclaration(target, key, found); - if (conflict != null) { - throw new MojoFailureException(conflict); - } - if (overriddenOnTheCommandLine(key)) { - // -D wins, and it wins whichever SPELLING it used. The - // overlay below only replaces the same key, so - // -Dcodename1.arg.cn1.androidTheme against an annotated - // and.themeMode left both set -- and the two readers then - // disagreed with each other: JavaSEPort takes the canonical - // and falls back to the alias, so the annotation won in the - // simulator, while AndroidGradleBuilder writes both and the - // alias landed last, so -D won on the device. Same command - // line, opposite results. - getLog().debug("cn1: " + key + " comes from the command line, " - + "so the annotation value is not applied"); - continue; - } - target.setProperty(key, found.getProperty(key)); - applied++; - } - // The FIRST accepted manifest is the answer, whether or not it set - // anything. Continuing when it happened to apply nothing let a later - // element decide instead: `@Ios()` with no members -- legal Java, and - // what is left after the last attribute is deleted -- produces a - // current manifest with no hint keys, and an older copy of the same - // main class further down the classpath carries a manifest that - // passes its own digest check, because it is fingerprinted against - // the stale class sitting beside it. Its obsolete hints were then - // applied over the authoritative empty one. The same is true of a - // manifest whose every key is overridden on the command line. - if (applied > 0) { - getLog().info("cn1: applied " + applied + " build hint(s) from annotations"); - } else { - getLog().debug("cn1: annotations were processed and set no build hint"); - } - failOnMisplacedAnnotations(classpathElements, expectedMain); - return; - } - // No manifest at all. If the compiled classes carry build hint - // annotations anyway, the processor never ran -- a mojo's defaultPhase - // does not add an execution to a project's POM, so an app that adopts the - // annotations without binding process-annotations compiles cleanly and - // ships with every annotated hint missing. Refuse rather than build that. - String annotated = classCarryingBuildHintAnnotations(classpathElements, expectedMain); - if (annotated != null) { - throw new MojoFailureException(annotated + " carries build hint annotations, but " - + (stale == null - ? "no " + ANNOTATION_HINTS_RESOURCE + " was produced" - : "the only " + ANNOTATION_HINTS_RESOURCE + " on the classpath is left " - + "over from an earlier build (" + stale + ")") - + ", so none of them reached this " - + "build.\n\nThe cn1 process-annotations goal has to run on the module that " - + "compiles it:\n" - + " \n" - + " cn1-process-classes\n" - + " process-classes\n" - + " \n" - + " process-annotations\n" - + " \n" - + " "); - } - } - - /** - * Refuses a build where a class other than the main one carries build hint - * annotations. - * - *

Run even when a manifest was accepted, because accepting one does not - * mean the processor ran THIS build: the fingerprint covers the main class, - * so an annotation added to a live class beside it leaves the manifest - * looking entirely current. With the goal unbound or skipped, the hints from - * that class reach nothing and the build succeeds having neither applied - * them nor said the annotation is in the wrong place -- the silent failure - * this whole feature exists to remove. Had the processor run, it would have - * refused the build for the same class.

- */ - private void failOnMisplacedAnnotations(List classpathElements, String expectedMain) - throws MojoFailureException { - if (expectedMain == null) { - return; - } - java.util.Collection descriptors = hintAnnotationDescriptors(classpathElements); - for (String element : classpathElements) { - String live = liveAnnotatedClass(new File(element), descriptors, expectedMain); - if (live != null) { - throw new MojoFailureException(live + " carries build hint annotations, but they " - + "are only read from the application's main class (" + expectedMain - + "), so none of them reached this build.\n\nMove them onto " - + expectedMain + ", or set those hints in " - + "codenameone_settings.properties."); - } - } - } - - /** - * The duplicate-declaration message for a hint set by an annotation and by a - * properties line, or null when there is no clash. - * - *

Only reached when the processor did not run this build; when it did, it - * has already failed for the same reason and with more to say -- it can point - * at the offending line. An alias counts as the same setting, matching what - * the processor checks, so declaring {@code and.captureRecord} in the file - * still collides with {@code @Android(captureRecord)}.

- */ - private String conflictingPropertiesDeclaration(Properties settings, String key, - Properties manifest) { - String name = key.substring("codename1.arg.".length()); - java.util.Set names = new java.util.LinkedHashSet(); - names.add(name); - for (com.codename1.build.shared.BuildHints.Hint h - : com.codename1.build.shared.BuildHints.entries()) { - if (name.equals(h.aliasOf()) - || name.equals(com.codename1.build.shared.BuildHints.canonicalName(h.name()))) { - names.add(h.name()); - } - } - for (String candidate : names) { - String candidateKey = "codename1.arg." + candidate; - String fromFile = settings.getProperty(candidateKey); - if (fromFile == null) { - continue; - } - String origin = manifest.getProperty("cn1.buildHints.origin." + name); - return candidateKey + " is declared twice.\n" - + " annotation : " + (origin == null ? "on the main class" : origin) - + " = " + manifest.getProperty(key) + "\n" - + " properties : codenameone_settings.properties\n" - + " " + candidateKey + "=" + fromFile + "\n" - + " A build hint has one source of truth. Delete the properties line and " - + "keep the annotation, or delete the annotation attribute and keep the line. " - + "(-D" + candidateKey + "=... overrides either and is not a conflict.)"; - } - return null; - } - - /** - * {@code classpathElements}, with those containing {@code expectedMain}'s - * class file first and the relative order otherwise preserved. - * - * @param classpathElements the compile classpath, in Maven's order - * @param expectedMain the binary name of the app's main class, or null - * @return the elements to search, colocated ones first - */ - private List colocatedFirst(List classpathElements, String expectedMain) { - if (expectedMain == null) { - return classpathElements; - } - List beside = new ArrayList(); - List rest = new ArrayList(); - for (String element : classpathElements) { - boolean here; - try { - here = readClass(new File(element), expectedMain) != null; - } catch (IOException | com.codename1.maven.annotations.ProcessingException ex) { - // Unreadable is not evidence either way; leave it where it was. - getLog().debug("cn1: could not look for " + expectedMain + " in " + element, ex); - here = false; - } - (here ? beside : rest).add(element); - } - beside.addAll(rest); - return beside; - } - - /** - * Why a manifest cannot have come from the class beside it, or null when it can. - * - *

Answered only when both halves are actually available: with no - * {@code codename1.mainName}, no class file for it ANYWHERE on the classpath, - * or no recorded fingerprint, there is nothing to compare and the manifest is - * taken at face value -- the same as before this check existed. It refuses - * only on positive evidence of a mismatch.

- */ - private String digestMismatch(File element, String expectedMain, Properties manifest, - List classpathElements) { - String recorded = manifest.getProperty( - com.codename1.maven.processors.BuildHintAnnotationProcessor.SOURCE_DIGEST_KEY); - if (expectedMain == null || recorded == null || recorded.length() == 0) { - return null; - } - // Beside the manifest first, then anywhere on the classpath. Looking only - // beside it answers "no evidence" for the layout that packages resources - // apart from classes, and a manifest with no evidence against it is taken - // at face value -- so a manifest left by a build that no longer runs the - // processor applied its obsolete hints, and counted as proof the processor - // ran, while the recompiled class sat in another element. - com.codename1.maven.annotations.AnnotatedClass cls = classOnClasspath(element, expectedMain); - if (cls == null) { - for (String other : classpathElements) { - cls = classOnClasspath(new File(other), expectedMain); - if (cls != null) { - break; - } - } - } - if (cls == null) { - return null; - } - try { - String actual = com.codename1.maven.processors.BuildHintAnnotationProcessor - .sourceDigest(cls); - if (recorded.equals(actual)) { - return null; - } - return "it was generated from a different set of annotations on " - + expectedMain + " than the one compiled onto the classpath"; - } catch (com.codename1.maven.annotations.ProcessingException ex) { - // Unreadable is not evidence of staleness. - getLog().debug("cn1: could not fingerprint " + expectedMain, ex); - return null; - } - } - - /** {@code expectedMain} read out of {@code element}, or null when it is not there. */ - private com.codename1.maven.annotations.AnnotatedClass classOnClasspath(File element, - String expectedMain) { - try { - return readClass(element, expectedMain); - } catch (IOException | com.codename1.maven.annotations.ProcessingException ex) { - getLog().debug("cn1: could not look for " + expectedMain + " in " + element, ex); - return null; - } - } - - /** Reads one compiled class out of a classpath directory or jar. */ - private com.codename1.maven.annotations.AnnotatedClass readClass(File element, String binaryName) - throws IOException, com.codename1.maven.annotations.ProcessingException { - String path = binaryName.replace('.', '/') + ".class"; - if (element.isDirectory()) { - File f = new File(element, path.replace('/', File.separatorChar)); - if (!f.isFile()) { - return null; - } - try (InputStream in = new FileInputStream(f)) { - return com.codename1.maven.annotations.ClassScanner.readClass(in, f); - } - } - if (element.isFile() && element.getName().endsWith(".jar")) { - try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(element)) { - java.util.zip.ZipEntry entry = zip.getEntry(path); - if (entry == null) { - return null; - } - try (InputStream in = zip.getInputStream(entry)) { - return com.codename1.maven.annotations.ClassScanner.readClass(in, element); - } - } - } - return null; - } - - /** - * The application class carrying a build hint annotation, or null. - * - *

Read straight out of the class file's annotation table rather than from - * source, so it sees exactly what the compiler emitted.

- * - *

The MAIN class alone when the project names one. The processor honours - * no other class, so no other class is evidence that annotations went - * unprocessed — and scanning them all meant a stale annotated - * {@code .class}, left behind by a rename without a clean, failed every build - * with "no manifest was produced" for a class the developer had already - * deleted. The processor ignores that orphan; so does this. Only when the - * project names no main class at all does this fall back to scanning - * everything, since then there is nothing more specific to ask about.

- */ - private String classCarryingBuildHintAnnotations(List classpathElements, - String expectedMain) { - java.util.Collection descriptors = hintAnnotationDescriptors(classpathElements); - if (expectedMain != null) { - for (String element : classpathElements) { - try { - if (mainClassCarriesAnnotation(new File(element), expectedMain, descriptors)) { - return expectedMain; - } - } catch (IOException | RuntimeException ex) { - getLog().debug("cn1: could not read " + expectedMain + " from " + element, ex); - } - } - // The main class carries none. A LIVE class elsewhere still counts: - // @Target(TYPE) accepts the placement, so without this the build - // succeeds having neither applied the hint nor said the annotation is - // in the wrong place -- which is the silent failure the whole feature - // removes. It is only stale output that must not count, and that is a - // question about the source, not about which class it is. - // - // Reported as a misplacement, because that is what it is: had the - // processor run it would have refused the build for this class. - for (String element : classpathElements) { - String live = liveAnnotatedClass(new File(element), descriptors); - if (live != null) { - return live; - } - } - return null; - } - // A reactor `package` build hands us the dependency module's jar rather - // than its output directory, which findAnnotatedClasses handles alongside - // a directory -- that is exactly the shape this check has to work in. - for (String element : classpathElements) { - String hit = findAnnotatedClass(new File(element), descriptors); - if (hit != null) { - return hit; - } - } - return null; - } - - /** - * The first annotated class in this element whose source still exists, or null. - * - *

Stale output is excluded the same way the processor excludes it — - * by asking whether the module's configured source roots still declare the - * class — so an orphan left by a rename cannot fail the build, while a - * class the developer actually wrote does.

- */ - private String liveAnnotatedClass(File element, java.util.Collection descriptors) { - return liveAnnotatedClass(element, descriptors, null); - } - - /// As above, ignoring `exclude` -- the class the manifest was generated for, - /// which carrying annotations is the whole point of. - private String liveAnnotatedClass(File element, java.util.Collection descriptors, - String exclude) { - List roots; - String encoding; - try { - // The module that PRODUCED this element, not the one running. In the - // generated layout the application's classes come from `common` - // while a platform module runs the build, so asking the running - // project where its sources are answered for the wrong module: every - // class compiled from `common` had no backing source, read as stale, - // and its misplaced annotation went unreported -- which is exactly - // the silence this check exists to break. - // - // The complete set, not only what getCompileSourceRoots lists: the - // Kotlin plugin compiles its own sourceDirs without adding them - // back, and this list is used to decide that a source is ABSENT. - org.apache.maven.project.MavenProject owner = moduleProducing(element); - org.apache.maven.project.MavenProject module = owner == null ? project : owner; - roots = compileSourceRoots(module, userProperties()); - // The charset that module compiles with, for the same reason - // ProcessAnnotationsMojo is given it: the scan below decides a source - // is ABSENT by reading it, and the single-byte encodings all decode - // every byte into DIFFERENT characters. Without it a name outside - // ASCII is unjudgeable and the class is kept -- which is the safe - // direction, but it keeps a genuinely deleted class forever and fails - // its placement check on every incremental build. This caller knows - // the module, so there is no reason for it to be the one guessing. - encoding = sourceEncodingOf(module, userProperties()); - } catch (RuntimeException ex) { - return null; - } - if (roots == null || roots.isEmpty()) { - // Not told where the sources are, so staleness cannot be judged and - // an orphan would fail the build. Silence is the lesser harm: the - // processor still refuses this placement whenever it runs. - return null; - } - // Every candidate, not the first: rejecting one stale class must not end - // the search, or whether a live misplacement is reported depends on the - // order the directory happened to be listed in. - for (String hit : findAnnotatedClasses(element, descriptors)) { - if (exclude != null && exclude.equals(hit)) { - continue; - } - try { - com.codename1.maven.annotations.AnnotatedClass cls = readClass(element, hit); - if (cls != null && com.codename1.maven.processors.BuildHintAnnotationProcessor - .hasBackingSource(cls, roots, encoding)) { - return hit; - } - } catch (IOException | com.codename1.maven.annotations.ProcessingException ex) { - getLog().debug("cn1: could not read " + hit + " from " + element, ex); - } - } - return null; - } - - /** - * Whether {@code -D} already sets this hint, under any spelling. - * - *

An alias and its target are ONE effective setting -- the builder reads - * {@code android.captureRecord} and then lets {@code and.captureRecord} - * override it -- so a command line that names either of them is setting the - * hint an annotation would otherwise supply. Comparing the keys literally - * missed that, and the documented rule is that {@code -D} overrides both the - * annotation and the properties file.

- */ - private boolean overriddenOnTheCommandLine(String key) { - return overriddenOnTheCommandLine(key, - getSession() == null ? null : getSession().getUserProperties()); - } - - static boolean overriddenOnTheCommandLine(String key, Properties user) { - if (user == null) { - return false; - } - String canonical = com.codename1.build.shared.BuildHints.canonicalName( - com.codename1.build.shared.BuildHints.strip(key)); - for (String name : user.stringPropertyNames()) { - if (!name.startsWith(com.codename1.build.shared.BuildHints.ARG_PREFIX)) { - continue; - } - String other = com.codename1.build.shared.BuildHints.canonicalName( - com.codename1.build.shared.BuildHints.strip(name)); - if (canonical.equals(other)) { - return true; - } - } - return false; - } - - /** Whether the named class, read from this classpath element, is annotated. */ - private boolean mainClassCarriesAnnotation(File element, String binaryName, - java.util.Collection descriptors) - throws IOException { - String path = binaryName.replace('.', '/') + ".class"; - if (element.isDirectory()) { - File f = new File(element, path.replace('/', File.separatorChar)); - if (!f.isFile()) { - return false; - } - try (InputStream in = new FileInputStream(f)) { - return carriesBuildHintAnnotation(in, descriptors); - } - } - if (element.isFile() && element.getName().endsWith(".jar")) { - try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(element)) { - java.util.zip.ZipEntry entry = zip.getEntry(path); - if (entry == null) { - return false; - } - try (InputStream in = zip.getInputStream(entry)) { - return carriesBuildHintAnnotation(in, descriptors); - } - } - } - return false; - } - - - private boolean carriesBuildHintAnnotation(InputStream in, - java.util.Collection descriptors) - throws IOException { - final boolean[] seen = {false}; - new org.objectweb.asm.ClassReader(in).accept( - new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + AnnotationBuildHints hints = new AnnotationBuildHints(MavenLog.of(getLog()), properties, + getSession() == null ? null : getSession().getUserProperties(), + new AnnotationBuildHints.Sources() { @Override - public org.objectweb.asm.AnnotationVisitor visitAnnotation( - String desc, boolean visible) { - if (descriptors.contains(desc)) { - seen[0] = true; - } - return null; - } - }, - org.objectweb.asm.ClassReader.SKIP_CODE - | org.objectweb.asm.ClassReader.SKIP_DEBUG - | org.objectweb.asm.ClassReader.SKIP_FRAMES); - return seen[0]; - } - - private String findAnnotatedClass(File dir, java.util.Collection descriptors) { - List all = findAnnotatedClasses(dir, descriptors); - return all.isEmpty() ? null : all.get(0); - } - - /** - * Every annotated class under this element, by BINARY name. - * - *

The binary name, not the file's own: returning {@code Wrong} for - * {@code com/example/Wrong.class} made the message name a class that does not - * exist, and made re-reading it by name fail, so the guard saw nothing.

- * - *

All of them, not the first: an incremental output directory can hold a - * stale annotated class and a live one at once, and stopping at whichever - * {@code File.listFiles} returned first made the answer depend on directory - * order.

- */ - private List findAnnotatedClasses(File element, java.util.Collection descriptors) { - List out = new ArrayList(); - if (element.isDirectory()) { - collectAnnotatedClasses(element, element, descriptors, out); - } else if (element.isFile() && element.getName().endsWith(".jar")) { - try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(element)) { - java.util.Enumeration entries = zip.entries(); - while (entries.hasMoreElements()) { - java.util.zip.ZipEntry entry = entries.nextElement(); - if (entry.isDirectory() || !entry.getName().endsWith(".class")) { - continue; + public List rootsFor(File element) { + org.apache.maven.project.MavenProject owner = moduleProducing(element); + return compileSourceRoots(owner == null ? project : owner, userProperties()); } - try (InputStream in = zip.getInputStream(entry)) { - if (carriesBuildHintAnnotation(in, descriptors)) { - out.add(entry.getName() - .substring(0, entry.getName().length() - ".class".length()) - .replace('/', '.')); - } - } - } - } catch (IOException | RuntimeException ex) { - getLog().debug("cn1: could not scan " + element + ": " + ex.getMessage()); - } - } - return out; - } - private void collectAnnotatedClasses(File root, File dir, - java.util.Collection descriptors, - List out) { - File[] children = dir.listFiles(); - if (children == null) { - return; - } - for (File f : children) { - if (f.isDirectory()) { - collectAnnotatedClasses(root, f, descriptors, out); - continue; - } - if (!f.getName().endsWith(".class")) { - continue; - } - try (InputStream in = new FileInputStream(f)) { - if (carriesBuildHintAnnotation(in, descriptors)) { - String rel = f.getAbsolutePath() - .substring(root.getAbsolutePath().length()) - .replace(File.separatorChar, '/'); - while (rel.startsWith("/")) { - rel = rel.substring(1); + @Override + public String encodingFor(File element) { + org.apache.maven.project.MavenProject owner = moduleProducing(element); + return sourceEncodingOf(owner == null ? project : owner, userProperties()); } - out.add(rel.substring(0, rel.length() - ".class".length()).replace('/', '.')); - } - } catch (IOException | RuntimeException ex) { - getLog().debug("cn1: could not scan " + f + ": " + ex.getMessage()); - } - } - } - - /// The build hint annotation types, read off the classpath they live on. - /// - /// The package is enumerated rather than listed: a generated table naming - /// each annotation was a second statement of which ones exist, and it went - /// stale the moment one was added without regenerating it. - private java.util.Collection hintAnnotationDescriptors( - List classpathElements) { + }); try { - return com.codename1.build.shared.BuildHintAnnotationReader - .bindingsFromClasspath(classpathElements).descriptors(); - } catch (IOException ex) { - getLog().debug("cn1: could not read the build hint annotations", ex); - return java.util.Collections.emptyList(); + hints.merge(target, classpathElements); + } catch (com.codename1.build.BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); } } - /** - * Reads the emitted hints out of a classpath element, which is either the - * module's output directory or a jar. - * - * @return the properties, or null when this element carries none - */ - private Properties readAnnotationHints(File element) { - if (element == null || !element.exists()) { - return null; - } - try { - if (element.isDirectory()) { - File f = new File(element, ANNOTATION_HINTS_RESOURCE); - if (!f.isFile()) { - return null; - } - try (FileInputStream in = new FileInputStream(f)) { - Properties p = new Properties(); - p.load(in); - return p; - } - } - try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(element)) { - java.util.zip.ZipEntry entry = zip.getEntry(ANNOTATION_HINTS_RESOURCE); - if (entry == null) { - return null; - } - try (InputStream in = zip.getInputStream(entry)) { - Properties p = new Properties(); - p.load(in); - return p; - } - } - } catch (IOException ex) { - getLog().warn("cn1: could not read build hints from " + element + ": " - + ex.getMessage()); - return null; - } + static boolean overriddenOnTheCommandLine(String key, Properties user) { + return AnnotationBuildHints.overriddenOnTheCommandLine(key, user); } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java index 58bc7033b51..f0b30c09371 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java @@ -22,7 +22,13 @@ */ package com.codename1.maven; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.repository.ArtifactRepository; +import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; +import org.apache.maven.artifact.resolver.ArtifactResolutionResult; +import org.apache.maven.model.Plugin; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -32,33 +38,12 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; -import org.apache.maven.model.Plugin; import org.apache.maven.project.MavenProject; -import org.codehaus.plexus.util.xml.Xpp3Dom; import org.apache.maven.repository.RepositorySystem; -import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; -import org.apache.maven.artifact.resolver.ArtifactResolutionResult; -import org.apache.maven.artifact.repository.ArtifactRepository; +import org.codehaus.plexus.util.xml.Xpp3Dom; import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import com.codename1.maven.annotations.AnnotatedClass; -import com.codename1.maven.annotations.ClassScanner; -import com.codename1.maven.annotations.ProcessingException; -import com.codename1.maven.annotations.ProcessorContext; -import com.codename1.maven.processors.OrmAnnotationProcessor; -import com.codename1.maven.processors.RestControllerAnnotationProcessor; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Enumeration; import java.util.List; -import java.util.Map; -import java.util.Properties; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; /** * Translates a backend module to C and compiles it to a native binary: @@ -87,6 +72,11 @@ * generated C relies on wrapping arithmetic, and clang -O3 miscompiles it without * them. */ +// +// The packaging itself is BackendPackager, in the build engine, which the Gradle +// plugin's backendPackage task runs too. This mojo supplies the Maven answers: the +// compiler plugin's encoding, the repository to resolve the runtime from, and the +// artifact id the binary is named after. // Forks the lifecycle up to compile first, so `mvn cn1:backend-package` on its own does // the obvious thing on a clean checkout instead of failing on an empty // target/classes. @@ -181,176 +171,52 @@ public class BackendPackageMojo extends AbstractMojo { private boolean devTools; public void execute() throws MojoExecutionException, MojoFailureException { - File jdk = resolveJdk(); - File work = new File(project.getBuild().getDirectory(), "cn1-backend"); - File classes = new File(work, "classes"); - File javaApi = new File(work, "javaapi-classes"); - File runtimeSources = new File(work, "runtime-src"); - File nativeSources = new File(work, "native"); - File translated = new File(work, "translated"); - File dependencyClasses = new File(work, "dependency-classes"); - // Emptied, not just created. Every one of these is derived, and nothing here - // removes a file that stopped being produced: a renamed or deleted source - // left its old .class behind, the translator still read it, and even - // requireMainClass accepted a main class the module no longer had -- so the - // package that came out was the previous implementation. Rebuilding from - // clean costs nothing, since neither the javac nor the clang pass below was - // ever incremental. - emptyDirs(classes, javaApi, runtimeSources, nativeSources, translated, - dependencyClasses); - mkdirs(work, classes, javaApi, runtimeSources, nativeSources, translated, - dependencyClasses); - - // The version of the runtime THIS MODULE compiles against, not the - // module's own: the sources handed to the translator have to be the same - // ones behind the classes the developer just built against, or the local - // run and the deployed binary are different programs. - String runtimeVersion = backendRuntimeVersion(); - File runtimeJar = resolve("com.codenameone", "codenameone-backend", - runtimeVersion, "parparvm-sources"); - unzip(runtimeJar, runtimeSources, nativeSources); - File parparvmBundle = resolve("com.codenameone", "codenameone-parparvm", - runtimeVersion, "bundle"); - File bundleDir = new File(work, "parparvm"); - mkdirs(bundleDir); - unzip(parparvmBundle, bundleDir, null); - File compilerJar = new File(bundleDir, "parparvm-compiler.jar"); - File javaApiJar = new File(bundleDir, "parparvm-java-api.jar"); - if (!compilerJar.isFile() || !javaApiJar.isFile()) { - throw new MojoExecutionException("The ParparVM bundle is missing its " - + "compiler or JavaAPI jar: " + parparvmBundle); + try { + packager().execute(); + } catch (BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } catch (BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); } - unzip(javaApiJar, javaApi, null); - - compile(jdk, javaApi, runtimeSources, classes); - generateControllers(classes, work); - requireMainClass(classes); - translate(jdk, compilerJar, javaApi, classes, nativeSources, translated, - dependencyClasses); - File binary = output != null ? output - : new File(project.getBuild().getDirectory(), project.getArtifactId()); - link(translated, binary); - getLog().info("built " + binary); } - /** - * Compiles the module's sources and the runtime's against the JavaAPI as the - * BOOTCLASSPATH. See the class comment for why that matters. - */ - /** - * Generates the routers and the bootstrap for this module's `@RestController` - * classes, into the directory this goal has just compiled into. - * - * Not left to the `process-annotations` goal, which writes into Maven's - * target/classes: that is a different build, made against a JDK rather than - * against the backend's class library, and the translator never reads it. A - * router generated there would be absent from the binary while looking present - * in the project. Generating into the tree that is about to be translated is - * what makes the wiring real. - * - * Sets mainClass to the generated bootstrap when the module did not name one. - */ - private void generateControllers(File classes, File work) throws MojoExecutionException { - Map index; - try { - index = ClassScanner.scan(classes); - } catch (ProcessingException err) { - throw new MojoExecutionException("Could not scan the compiled backend classes: " - + err.getMessage(), err); - } - RestControllerAnnotationProcessor processor = new RestControllerAnnotationProcessor(); - processor.setDevTools(devTools); - ProcessorContext ctx = new ProcessorContext(classes, new File(work, "stubs"), index, - getLog(), project.getBasedir(), new Properties(), mainClass, - java.util.Collections.emptyList(), "UTF-8", - compileClasspathWithoutRuntime()); - // THE ENTITIES FIRST, into this same tree. - // - // The entry point generated below references cn1app.BackendDaoBootstrap - // whenever the module has an @Entity, because that reference is the only - // thing that keeps the generated daos in the binary -- the translator - // drops a class nothing names. This goal empties and rebuilds its own - // class tree and never copies Maven's target/classes into it, so a dao - // generated by the process-annotations goal is not here: without this - // pass the entry point names a class that does not exist and packaging - // fails at its own javac, on any project that has both an entity and a - // controller. - // - // The flavour is forced because the classpath cannot answer for it here; - // see OrmAnnotationProcessor#setBackendFlavour. - OrmAnnotationProcessor entities = new OrmAnnotationProcessor(); - entities.setBackendFlavour(true); - try { - entities.start(ctx); - for (AnnotatedClass cls : index.values()) { - if (!cls.getClassAnnotations().isEmpty()) { - entities.processClass(cls, ctx); - } + /** The engine packager, answering from this mojo's project and parameters. */ + private BackendPackager packager() { + BackendPackager p = new BackendPackager(new MavenModuleHost(project, MavenLog.of(getLog()))) { + @Override + protected String binaryName() { + return project.getArtifactId(); } - entities.finish(ctx); - } catch (ProcessingException err) { - throw new MojoExecutionException("Could not process @Entity: " - + err.getMessage(), err); - } - // Re-scanned, so the controller pass sees the daos and the bootstrap the - // pass above just wrote. hasGeneratedDaos() reads this index. - try { - index = ClassScanner.scan(classes); - } catch (ProcessingException err) { - throw new MojoExecutionException("Could not re-scan the backend classes after " - + "generating the entity daos: " + err.getMessage(), err); - } - try { - processor.start(ctx); - for (AnnotatedClass cls : index.values()) { - if (!cls.getClassAnnotations().isEmpty()) { - processor.processClass(cls, ctx); - } + + @Override + protected File processedResourcesDirectory() { + return new File(project.getBuild().getOutputDirectory()); } - // finish() runs the bean pass -- the wiring, the rewritten classes and - // the classes generated beside them -- into this same tree. - processor.finish(ctx); - entities.enhance(ctx); - } catch (ProcessingException err) { - throw new MojoExecutionException("Could not process @RestController: " - + err.getMessage(), err); - } - if (ctx.hasErrors()) { - StringBuilder sb = new StringBuilder("The backend's annotations could not be processed:"); - for (ProcessorContext.ProcessingError e : ctx.getErrors()) { - sb.append("\n ").append(e); + + @Override + protected boolean declaresResources() { + return !project.getBuild().getResources().isEmpty(); } - throw new MojoExecutionException(sb.toString()); - } - byte[] generated = ctx.getEmittedResources() - .get(RestControllerAnnotationProcessor.MAIN_CLASS_RESOURCE); - if (generated == null) { - return; - } - String name; - try { - name = new String(generated, "UTF-8").trim(); - } catch (java.io.UnsupportedEncodingException err) { - throw new MojoExecutionException("UTF-8 is required of every JDK", err); - } - if (mainClass == null || mainClass.length() == 0) { - mainClass = name; - getLog().info("cn1: entry point " + name + ", generated from @RestController"); - } + + @Override + protected String sourceEncoding() { + return BackendPackageMojo.this.sourceEncoding(); + } + + @Override + protected File resolve(String groupId, String artifactId, String version, String classifier) + throws BuildExecutionException { + try { + return BackendPackageMojo.this.resolve(groupId, artifactId, version, classifier); + } catch (MojoExecutionException ex) { + throw new BuildExecutionException(ex.getMessage(), ex); + } + } + }; + return p.mainClass(mainClass).output(output).target(target).jdk(jdkHome, jdk8Home).cflags(cflags) + .sqlite(sqlite).checkedCasts(checkedCasts).devTools(devTools); } - /** - * Fails here, with the reason, rather than inside the translator. - * - * The compile below reads .java and only .java, on purpose: recompiling against - * the JavaAPI bootclasspath is what turns "this backend uses a class the runtime - * does not have" into a compile error instead of a link failure on the device, - * and reusing the jar Maven already built would give that up. The cost is that a - * main class written in Kotlin -- which `cn1:backend` runs happily, because that - * goal is a JVM launch -- never reaches this directory, and the translator's own - * complaint about it names neither Kotlin nor the reason. So say it plainly. The - * developer guide's "Limits worth knowing" carries the same statement. - */ /** * The encoding this module's sources are actually written in. * @@ -390,684 +256,6 @@ private String sourceEncoding() { return "UTF-8"; } - private void requireMainClass(File classes) throws MojoFailureException { - if (mainClass == null || mainClass.length() == 0) { - throw new MojoFailureException("No entry point: set , or annotate " - + "a class with @RestController and let the bootstrap be generated " - + "from it"); - } - if (new File(classes, mainClass.replace('.', '/') + ".class").isFile()) { - return; - } - throw new MojoFailureException("The main class " + mainClass + " was not " - + "produced by the backend compile. This goal compiles Java sources " - + "against the backend class library, so a main class written in " - + "Kotlin or generated into the build output is not visible to it " - + "yet -- write the entry point in Java, or keep it on the JVM with " - + "cn1:backend"); - } - - private void compile(File jdk, File javaApi, File runtimeSources, File classes) - throws MojoExecutionException, MojoFailureException { - List sources = new ArrayList(); - for (Object root : project.getCompileSourceRoots()) { - collectJava(new File(String.valueOf(root)), sources); - } - collectJava(runtimeSources, sources); - if (sources.isEmpty()) { - throw new MojoFailureException("No Java sources to compile"); - } - - List command = new ArrayList(Arrays.asList( - new File(jdk, "bin/javac").getAbsolutePath(), - "-nowarn", "-encoding", sourceEncoding(), - "-bootclasspath", javaApi.getAbsolutePath(), - "-source", "1.8", "-target", "1.8", - "-d", classes.getAbsolutePath())); - // The module's own dependencies, MINUS the backend runtime: its compiled - // form was built against a JDK, and the sources unpacked above are the - // half that belongs on this bootclasspath. - List classpath = new ArrayList(); - for (Object element : compileClasspathWithoutRuntime()) { - classpath.add(String.valueOf(element)); - } - if (!classpath.isEmpty()) { - command.add("-classpath"); - command.add(join(classpath, File.pathSeparator)); - } - command.addAll(sources); - // -source/-target 8 is the translator's input format and is not a property - // of the compiler running here: every javac from 8 up emits the same class - // file version 52 for it. A javac that has dropped the option says so in - // its own words and names no remedy, so the remedy is added to it. - // - // THE REMEDY IS THE JDK RUNNING MAVEN, not -Dcn1.backend.jdk, even though - // that property is what selects the compiler on this line. Only the two - // FORKED steps read it -- this javac and the translator's java. - // generateControllers() compiles the router and the entry point in - // process, through ToolProvider.getSystemJavaCompiler(), which is the - // Maven JVM's compiler and cannot be pointed anywhere; it asks for - // -source 1.8 as well, so it fails next on a compiler that has dropped - // it. Naming the property here would send a developer to a setting that - // moves the failure by one step and no further. - // - // Routing that in-process compile through the selected JDK is not the - // answer either. A release that removes -source 8 takes it away from the - // Maven JVM too, and this build needs a javac that emits class file - // version 52 in many more places than this goal -- codenameone-core - // compiles at 1.5. The whole toolchain moves then, not one mojo. - try { - run(command, project.getBasedir(), "compile the backend sources"); - } catch (MojoFailureException err) { - throw dropsSourceEight(err) - ? new MojoFailureException(err.getMessage() + "\n\n" - + SOURCE_EIGHT_REMOVED_HINT, err) - : err; - } - stageResources(classes); - } - - /** - * Copies the module's resources in beside the classes just compiled. - * - * This directory is emptied and then filled from .java alone, and the project's - * own output directory is excluded from the translator input on purpose -- its - * classes were built against a JDK. The consequence was that anything read from - * the classpath, a properties or configuration file, was present under - * cn1:backend and simply absent from the packaged binary. Nothing failed at - * build time; the resource was just not there at runtime. - * - * Everything EXCEPT .class is taken, which is exactly the resources and none of - * the JDK-compiled code. - * - * ONLY Maven's processed output, never the raw resource directories. Those - * directories are what a / selects FROM, so copying them - * wholesale packaged the files the build was configured to leave out -- an - * environment file or a secret excluded on purpose would have gone into the - * executable, and the later overlay could not remove it. The processed copy is - * the answer Maven already computed. - */ - private void stageResources(File classes) throws MojoExecutionException { - File processed = new File(project.getBuild().getOutputDirectory()); - if (!processed.isDirectory()) { - // Nothing has processed the resources, so there are none to stage and - // nothing to guess at. Said out loud, because a resource silently absent - // from the binary is the failure this whole step exists to prevent. - if (!project.getBuild().getResources().isEmpty()) { - getLog().warn("cn1: this module declares resources but " - + processed + " does not exist, so none are packaged. Run " - + "process-resources first, or invoke this through the " - + "lifecycle rather than as a bare goal."); - } - return; - } - try { - int staged = copyNonClasses(processed, classes); - // Staged is not the same as READABLE, and the difference is silent. - // These files reach the translator, so anything that reads them at - // BUILD time works -- but the backend translates as app type "clean", - // and only the linux and windows types embed classpath resources into - // the binary. The clean runtime's Class.getResourceAsStream returns - // null unconditionally, so getResourceAsStream finds the file under - // cn1:backend, on the JVM, and finds nothing in the packaged - // executable. Said out loud rather than left to be discovered in - // production; embedding them is a change to the translator and the - // shared runtime, not to this goal. - if (staged > 0) { - getLog().warn("cn1: staged " + staged + " resource file(s) for translation, " - + "but a packaged backend cannot READ them: getResourceAsStream " - + "answers null in the translated runtime, though it works under " - + "cn1:backend. Read configuration from a file path or the " - + "environment instead of the classpath."); - } - } catch (IOException err) { - // A resource that cannot be staged is a packaging failure, not a note: - // the executable would be reported as built while missing something - // cn1:backend has, and the difference would first appear in production. - throw new MojoExecutionException("Could not stage the processed resources " - + "from " + processed + " into " + classes, err); - } - } - - /** @return how many non-class files were copied. */ - private int copyNonClasses(File from, File to) throws IOException { - if (from == null || !from.isDirectory()) { - return 0; - } - File[] children = from.listFiles(); - if (children == null) { - return 0; - } - int copied = 0; - for (File child : children) { - File target = new File(to, child.getName()); - if (child.isDirectory()) { - target.mkdirs(); - copied += copyNonClasses(child, target); - } else if (!child.getName().endsWith(".class")) { - copyFile(child, target); - copied++; - } - } - return copied; - } - - private static void copyFile(File from, File to) throws IOException { - InputStream in = new java.io.FileInputStream(from); - try { - OutputStream out = new java.io.FileOutputStream(to); - try { - byte[] chunk = new byte[8192]; - int n; - while ((n = in.read(chunk)) > 0) { - out.write(chunk, 0, n); - } - } finally { - out.close(); - } - } finally { - in.close(); - } - } - - private List compileClasspathWithoutRuntime() throws MojoExecutionException { - List classpath = new ArrayList(); - try { - for (Object element : project.getCompileClasspathElements()) { - classpath.add(String.valueOf(element)); - } - } catch (Exception err) { - throw new MojoExecutionException("Could not resolve the compile classpath", err); - } - return withoutRuntime(classpath, runtimeArtifactFile(), - project.getBuild().getOutputDirectory()); - } - - /** - * The classpath without the backend runtime and without this module's own - * output. - * - *

IDENTIFIED BY FILE, not by looking for "codenameone-backend" anywhere in - * a path. A project checked out under a directory whose name contains that -- - * /work/codenameone-backend-demo/ is the obvious one -- has EVERY reactor - * dependency below it match, so a backend depending on a sibling contract - * module lost it from both the compile classpath and the translation, and - * failed on classes it plainly depends on. The name of a directory somewhere - * above the project is not something a build should read meaning into. - * - *

The runtime is left out because its sources are compiled into `classes` - * already; the module's own output for the same reason. - * - * @param runtime the resolved runtime artifact, or null when it cannot be - * located -- then nothing is dropped for it, which is - * duplicate work rather than a missing class - */ - static List withoutRuntime(List classpath, File runtime, String ownOutput) { - List out = new ArrayList(); - File runtimeFile = runtime == null ? null : runtime.getAbsoluteFile(); - File output = ownOutput == null ? null : new File(ownOutput).getAbsoluteFile(); - for (int i = 0; i < classpath.size(); i++) { - String path = classpath.get(i); - File element = new File(path).getAbsoluteFile(); - if (runtimeFile != null && runtimeFile.equals(element)) { - continue; - } - if (output != null && output.equals(element)) { - continue; - } - out.add(path); - } - return out; - } - - /** The resolved file of com.codenameone:codenameone-backend, or null. */ - private File runtimeArtifactFile() { - java.util.Set artifacts = project.getArtifacts(); - if (artifacts != null) { - for (Artifact artifact : artifacts) { - if ("com.codenameone".equals(artifact.getGroupId()) - && "codenameone-backend".equals(artifact.getArtifactId())) { - return artifact.getFile(); - } - } - } - return null; - } - - /** - * The compile classpath staged as ONE directory the translator can read. - * - * ByteCodeTranslator walks its inputs with File.listFiles, which answers NULL - * for a jar -- and the walk reads null as an empty directory, so a dependency - * resolved from the repository as a jar contributed nothing at all, without a - * word. The build then failed much later, while linking, on the symbols of - * classes the translator had never been shown. A module in the same reactor - * resolves to its target/classes and worked, which is why the generated - * project's own contract module never showed this. - * - *

ONE TREE, FIRST WINS, IN CLASSPATH ORDER, and not a list of inputs. - * Parser.classIndex keeps the first definition of a class it parsed, so the - * order of the translator's inputs IS precedence -- and javac resolved the - * same classpath the same way, so anything that reorders it compiles against - * one definition and translates another. Staging settles it on disk instead: - * whatever arrives first is what is there, every later copy is skipped, and - * there is no order left to get wrong. It also means a class two dependencies - * both carry is PARSED once rather than twice, which is where duplicate - * symbols came from. - * - *

Directories are copied rather than passed through for that reason alone. - * They cost a copy of their class files per build, which is a reactor - * module's output and small beside the translation that follows. - * - *

cn1-native goes where the runtime jar's natives go, so a dependency that - * ships them is built rather than dropped just as quietly. META-INF is - * skipped out of jars by the same unpacking the runtime gets; a directory is - * copied as it stands, which is what passing it as an input already did. - * - * @param classpath compile classpath elements, in classpath order - * @param staged directory to stage into; assumed empty - * @param nativeSources where a dependency's cn1-native entries belong - */ - private List stageDependencyClasses(List classpath, File staged, - File nativeSources) throws MojoExecutionException { - List out = new ArrayList(); - boolean any = false; - for (int i = 0; i < classpath.size(); i++) { - File element = new File(classpath.get(i)); - if (element.isDirectory()) { - copyDirectoryFirstWins(element, staged, nativeSources); - any = true; - } else if (element.isFile()) { - unzip(element, staged, nativeSources, true); - any = true; - } - // An entry that is neither is one javac will complain about; there is - // nothing here to stage and nothing to say that it will not say. - } - if (any) { - out.add(staged.getAbsolutePath()); - } - return out; - } - - private void translate(File jdk, File compilerJar, File javaApi, File classes, - File nativeSources, File translated, File dependencyClasses) - throws MojoExecutionException, MojoFailureException { - String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1); - String packageName = mainClass.lastIndexOf('.') < 0 ? "" - : mainClass.substring(0, mainClass.lastIndexOf('.')); - - // BEFORE the natives are copied below, because a dependency that ships - // cn1-native adds to them. - List dependencyInputs = stageDependencyClasses( - compileClasspathWithoutRuntime(), dependencyClasses, nativeSources); - - // The C has to be in the source root BEFORE the translator runs: it reads - // the directory to decide which native-only Java methods to keep, and the - // signature verifier checks every declared native against an actual - // implementation. - File sourceDir = new File(translated, "dist/" + simpleName + "-src"); - mkdirs(sourceDir); - copyDirectory(nativeSources, sourceDir); - - List command = new ArrayList(); - command.add(new File(jdk, "bin/java").getAbsolutePath()); - if (sqlite) { - command.add("-Dcn1.sqlite=true"); - } - if (checkedCasts) { - command.add("-Dcn1.checkedCasts=true"); - } - command.add("-cp"); - command.add(compilerJar.getAbsolutePath()); - command.add("com.codename1.tools.translator.ByteCodeTranslator"); - command.add("clean"); - // The module's dependencies belong on the translator's input, not only on - // javac's classpath. Without them a backend that uses a type from another - // module -- the shared contract or DTO module the generated project - // recommends -- compiles here and then fails to translate, because javac - // resolved the type from a jar whose bytecode the translator never sees. - // The runtime is excluded for the same reason it is excluded from javac's - // classpath: its sources are compiled into `classes` already. - StringBuilder translatorInput = new StringBuilder(); - translatorInput.append(javaApi.getAbsolutePath()) - .append(';').append(classes.getAbsolutePath()); - for (int i = 0; i < dependencyInputs.size(); i++) { - translatorInput.append(';').append(dependencyInputs.get(i)); - } - command.add(translatorInput.toString()); - command.add(translated.getAbsolutePath()); - command.add(simpleName); - command.add(packageName); - command.add(simpleName); - command.add("1.0"); - command.add("clean"); - command.add("none"); - run(command, project.getBasedir(), "translate the backend to C"); - } - - private void link(File translated, File binary) - throws MojoExecutionException, MojoFailureException { - String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1); - File sourceDir = new File(translated, "dist/" + simpleName + "-src"); - // Kept as a loud failure rather than dropped: the parameter names a real - // capability, and silently ignoring -Dcn1.backend.target would hand back a - // host binary labelled as a cross-compiled one. The script named here lives in - // the Codename One repository, not in a generated project, which is why the - // message says where it is instead of assuming it is on hand. - if (target != null && target.length() > 0) { - throw new MojoFailureException("cn1.backend.target is not supported from " - + "this goal yet: it builds for the machine it runs on. The " - + "cross-compiled targets (musl-x86_64, musl-arm64, glibc-x86_64, " - + "glibc-arm64) are produced by package.sh in the Codename One " - + "repository, which drives one container image per target; run " - + "this goal inside a container of the target flavour to get the " - + "same artifact here"); - } - List command = new ArrayList(Arrays.asList( - "clang", "-O3", "-w", - // Mandatory for generated C: Java arithmetic wraps, and clang -O3 - // provably miscompiles the output without these. - "-fwrapv", "-fno-strict-aliasing", - "-fno-builtin-fmod", "-fno-builtin-fmodf")); - if (!sqlite) { - // Turning the engine OFF is two changes, not one. Without - // -Dcn1.sqlite=true the translator leaves cn1_sqlite3.h out, but - // cn1_backend_db.c is copied and compiled either way -- and its - // SQLite branch includes that header unconditionally, so the compile - // fails with "cn1_sqlite3.h file not found" and the option advertised - // as saving the engine could not produce a binary at all. The macro - // is what compiles that file to stubs instead, which answer "could - // not open" and become an IOException, rather than dropping the Db - // natives and taking their Java methods with them. build.sh has - // always set both; this half had only the first. - command.add("-DCN1_BACKEND_NO_SQLITE"); - } - if (cflags != null && cflags.trim().length() > 0) { - command.addAll(Arrays.asList(cflags.trim().split("\\s+"))); - } - // Before -I on the generated sources, which is where build.sh puts them. - command.addAll(hostLibraryFlags(opensslPrefixes(), nghttp2Prefixes())); - command.add("-I" + sourceDir.getAbsolutePath()); - File[] cFiles = sourceDir.listFiles(); - if (cFiles == null) { - throw new MojoExecutionException("The translator produced nothing in " + sourceDir); - } - for (File file : cFiles) { - String name = file.getName(); - // .S as well as .c, which is what vm/backend/build.sh compiles. The - // translator always emits cn1_virtual_thread_asm.S, and - // cn1_virtual_thread.c calls cn1VirtualThreadSwitch out of it, so a - // command that passed only .c reached the linker with that symbol - // undefined and this goal could not produce a binary at all. - // Generated resource assembly is in the same position. - if (name.endsWith(".c") || name.endsWith(".S") || name.endsWith(".s")) { - command.add(file.getAbsolutePath()); - } - } - command.addAll(Arrays.asList("-lm", "-lpthread", - "-lcurl", "-lssl", "-lcrypto", "-lnghttp2")); - command.add("-o"); - command.add(binary.getAbsolutePath()); - run(command, project.getBasedir(), "compile the generated C"); - } - - /** - * -I and -L for the TLS and HTTP/2 libraries, where this machine keeps them. - * - * macOS ships libcrypto WITHOUT its headers, so a stock machine with the - * usual Homebrew OpenSSL could not compile the generated C at all: the goal - * the documentation tells a developer to run failed on openssl/ssl.h, and the - * only way out was to work out cn1.backend.cflags for themselves. The same - * prefixes and the same probe headers as vm/backend/build.sh, so the two ways - * of building a backend look in the same places -- including OPENSSL_PREFIX - * and NGHTTP2_PREFIX, which a developer who has already set them for build.sh - * should not have to set again under another name. - * - *

On a Linux box the distribution's -dev package puts the headers where - * clang already looks, none of these probes match, and this adds nothing. - * - * @param openssl candidate prefixes for OpenSSL, in order of preference - * @param nghttp2 candidate prefixes for nghttp2 - */ - static List hostLibraryFlags(List openssl, List nghttp2) { - List out = new ArrayList(); - addPrefix(out, openssl, "include/openssl/sha.h"); - addPrefix(out, nghttp2, "include/nghttp2/nghttp2.h"); - return out; - } - - /** The first prefix that actually carries `probe`, as -I and -L. */ - private static void addPrefix(List out, List prefixes, String probe) { - if (prefixes == null) { - return; - } - for (int i = 0; i < prefixes.size(); i++) { - String prefix = prefixes.get(i); - if (prefix == null || prefix.length() == 0) { - continue; - } - if (new File(prefix, probe).isFile()) { - out.add("-I" + new File(prefix, "include").getAbsolutePath()); - out.add("-L" + new File(prefix, "lib").getAbsolutePath()); - return; - } - } - } - - private List opensslPrefixes() { - return prefixesFrom(System.getenv("OPENSSL_PREFIX"), - "/opt/homebrew/opt/openssl@3", "/usr/local/opt/openssl@3"); - } - - private List nghttp2Prefixes() { - return prefixesFrom(System.getenv("NGHTTP2_PREFIX"), - "/opt/homebrew/opt/libnghttp2", "/opt/homebrew/opt/nghttp2", - "/usr/local/opt/libnghttp2"); - } - - private static List prefixesFrom(String fromEnvironment, String... defaults) { - List out = new ArrayList(); - if (fromEnvironment != null && fromEnvironment.length() > 0) { - out.add(fromEnvironment); - } - out.addAll(Arrays.asList(defaults)); - return out; - } - - /** - * The codenameone-backend version this module depends on. - * - * Deliberately an error rather than a default when the dependency is absent: - * guessing a version here would translate a different runtime from the one the - * module was compiled and tested against. - */ - private String backendRuntimeVersion() throws MojoFailureException { - java.util.Set artifacts = project.getArtifacts(); - if (artifacts != null) { - for (Artifact artifact : artifacts) { - if ("com.codenameone".equals(artifact.getGroupId()) - && "codenameone-backend".equals(artifact.getArtifactId())) { - return artifact.getVersion(); - } - } - } - throw new MojoFailureException("This module does not depend on " - + "com.codenameone:codenameone-backend, so there is no backend " - + "runtime to translate. Add it as a dependency."); - } - - /** - * The JDK that compiles the sources for translation and runs the translator. - * - * ANY JDK 8 OR NEWER, which for almost every project means the one already - * running Maven and nothing to configure. This goal used to demand a JDK 8 - * and refuse to run without one, on the premise that a newer javac emits - * class files the translator cannot read. The premise was wrong: {@link - * #compile} passes -source 1.8 -target 1.8, so the class file version is 52 - * whichever javac produces it, and the bootclasspath that confines the build - * to the server-safe surface is enforced identically -- a reference to - * java.nio.file still fails to compile on 8, 17, 21 and 25 alike. The cost of - * the premise was paid entirely by the developer, who had to install a JDK - * from 2014 to package a server. - * - * The two properties are tried before the running JDK so an explicit choice - * still wins, and cn1.backend.jdk8 is among them so the setups that were - * required to set it keep working. - * - * WHAT THEY SELECT IS THE TWO FORKED STEPS -- {@link #compile}'s javac and - * {@link #translate}'s java -- and nothing else. The router and entry point - * that {@link #generateControllers} produces are compiled in process by the - * JDK running Maven, which JSR 199 gives no way to redirect. That costs - * nothing today, because every JDK from 8 up emits the class file version 52 - * the translator reads; see {@link #compile} for why it is also not worth - * forking, and why the error message there names Maven's own JDK. - */ - private File resolveJdk() throws MojoFailureException { - String[] configured = {jdkHome, jdk8Home}; - for (int i = 0; i < configured.length; i++) { - if (configured[i] != null && configured[i].length() > 0) { - File home = new File(configured[i]); - if (hasJavac(home)) { - return requireEightOrNewer(home); - } - } - } - File running = new File(System.getProperty("java.home")); - if (hasJavac(running)) { - return requireEightOrNewer(running); - } - // The Java 8 layout points java.home at the jre inside the JDK, where - // there is no compiler; it is one level up. - File parent = running.getParentFile(); - if (parent != null && hasJavac(parent)) { - return requireEightOrNewer(parent); - } - throw new MojoFailureException("Packaging a backend needs a JDK, and " - + System.getProperty("java.home") + " has no javac -- Maven is " - + "running on a JRE. Run it on a JDK, or point " - + "-Dcn1.backend.jdk at one."); - } - - /** - * Refuses a JDK older than 8, whose javac cannot emit the format the - * translator reads. - * - * Only reachable through an explicitly configured JDK: Maven itself needs 8 - * or newer, so the running one always passes. A javac that cannot be asked - * its version is ACCEPTED rather than refused -- the compile that follows - * reports what is actually wrong with it, and inventing a failure here would - * hide that. - */ - private File requireEightOrNewer(File home) throws MojoFailureException { - int major = javacMajor(home); - if (major > 0 && major < 8) { - throw new MojoFailureException("A JDK 8 or newer is required to " - + "package a backend; " + home + " is a JDK " + major + "."); - } - return home; - } - - /** - * The feature version of a JDK's javac, or -1 when it cannot be determined. - * - * Read by running it, not by parsing the path: a directory name says nothing - * reliable. Java 8 prints "javac 1.8.0_402" on stderr and later releases - * print "javac 21.0.2" on stdout, so both streams are read and both spellings - * are understood. - */ - private static int javacMajor(File home) { - try { - ProcessBuilder builder = new ProcessBuilder( - new File(home, "bin/javac").isFile() - ? new File(home, "bin/javac").getAbsolutePath() - : new File(home, "bin/javac.exe").getAbsolutePath(), - "-version"); - builder.redirectErrorStream(true); - Process process = builder.start(); - StringBuilder output = new StringBuilder(); - InputStream in = process.getInputStream(); - byte[] chunk = new byte[512]; - int n; - while ((n = in.read(chunk)) > 0) { - output.append(new String(chunk, 0, n, "UTF-8")); - } - process.waitFor(); - return parseJavacVersion(output.toString()); - } catch (IOException err) { - return -1; - } catch (InterruptedException err) { - Thread.currentThread().interrupt(); - return -1; - } - } - - /** Package private so the parsing is testable without a JDK to run. */ - static int parseJavacVersion(String output) { - if (output == null) { - return -1; - } - int at = output.indexOf("javac "); - if (at < 0) { - return -1; - } - String version = output.substring(at + "javac ".length()).trim(); - // "1.8.0_402" is Java 8; "21.0.2" is Java 21. The leading "1." is the old - // spelling and the number after it is the feature version. - if (version.startsWith("1.")) { - version = version.substring(2); - } - int end = 0; - while (end < version.length() && Character.isDigit(version.charAt(end))) { - end++; - } - if (end == 0) { - return -1; - } - try { - return Integer.parseInt(version.substring(0, end)); - } catch (NumberFormatException err) { - return -1; - } - } - - /** - * What to do about a javac that has dropped -source 8. - * - * Package private so a test can hold the wording to a remedy that WORKS. An - * earlier version of this sent the developer to -Dcn1.backend.jdk, which - * selects the two forked steps and not the in-process compile of the - * generated router and entry point, so following it moved the failure by one - * step and no further. See #compile. - */ - static final String SOURCE_EIGHT_REMOVED_HINT = - "This javac no longer accepts -source 8, which is the format the " - + "translator reads. Run Maven itself on a JDK that still does: " - + "-Dcn1.backend.jdk selects the compiler for this step and the " - + "translator, but the generated router and entry point are compiled " - + "in process by the JDK running Maven."; - - /** - * Whether a failed compile is javac refusing -source 8 outright, rather than - * anything about the sources. - * - * A future release will remove the option -- 21 and 25 already warn that it is - * obsolete -- and javac's own wording ("Source option 8 is no longer - * supported") names no way forward, so the caller adds one. - */ - private static boolean dropsSourceEight(MojoFailureException err) { - String message = err.getMessage(); - return message != null - && message.indexOf("Source option") >= 0 - && message.indexOf("no longer supported") >= 0; - } - - /** Windows names it javac.exe, and a JRE has neither. */ - private static boolean hasJavac(File home) { - return new File(home, "bin/javac").isFile() - || new File(home, "bin/javac.exe").isFile(); - } - private File resolve(String groupId, String artifactId, String version, String classifier) throws MojoExecutionException { Artifact artifact = repositorySystem.createArtifactWithClassifier( @@ -1084,289 +272,48 @@ private File resolve(String groupId, String artifactId, String version, String c return artifact.getFile(); } - /** - * Unpacks a jar. Entries under cn1-native/ go to `nativeTarget` when one is - * given, because the C belongs in the translator's source root rather than on - * the Java source path. - */ - private void unzip(File jar, File javaTarget, File nativeTarget) - throws MojoExecutionException { - unzip(jar, javaTarget, nativeTarget, false); - } + // ---- Kept under their old names for the tests; see BackendPackager. ---- - /** - * @param firstWins leave an entry alone when something is already at its - * destination, which is how a classpath resolves a class two - * entries both carry - */ - private void unzip(File jar, File javaTarget, File nativeTarget, boolean firstWins) - throws MojoExecutionException { + private File resolveJdk() throws MojoFailureException { try { - ZipFile zip = new ZipFile(jar); - try { - Enumeration entries = zip.entries(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - if (entry.isDirectory()) { - continue; - } - String name = entry.getName(); - File destination; - if (name.startsWith("cn1-native/")) { - if (nativeTarget == null) { - continue; - } - destination = resolveInside(nativeTarget, - name.substring("cn1-native/".length()), jar, name); - } else if (name.startsWith("META-INF/")) { - continue; - } else { - destination = resolveInside(javaTarget, name, jar, name); - } - if (firstWins && destination.isFile()) { - continue; - } - mkdirs(destination.getParentFile()); - InputStream in = zip.getInputStream(entry); - try { - copy(in, destination); - } finally { - in.close(); - } - } - } finally { - zip.close(); - } - } catch (IOException err) { - throw new MojoExecutionException("Could not unpack " + jar, err); + return packager().resolveJdk(); + } catch (BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); } } - /** - * The entry's destination, proven to be inside the directory it unpacks into. - * - * An archive entry name is attacker-controlled data, not a path this build - * chose: an entry called `../../../../etc/whatever` makes `new File(root, name)` - * resolve outside `root`, so unpacking writes wherever the entry says. That is - * Zip Slip, and here it would run with the developer's privileges during an - * ordinary `mvn package` against whatever jar the coordinates resolved to. - * - * Compared after canonicalisation rather than on the raw string, because `..` - * is not the only way out -- a symlinked parent resolves elsewhere too, and the - * textual check passes for both. The separator is appended to the root so a - * sibling whose name merely starts with it ("/tmp/outdir-evil" against - * "/tmp/outdir") cannot satisfy the prefix test. - */ - private static File resolveInside(File root, String relative, File jar, String entryName) - throws IOException { - File destination = new File(root, relative); - String prefix = root.getCanonicalPath() + File.separator; - String resolved = destination.getCanonicalPath(); - if (!resolved.startsWith(prefix)) { - throw new IOException("Refusing to unpack " + jar + ": entry \"" + entryName - + "\" resolves to " + resolved + ", outside " + root.getCanonicalPath()); - } - return destination; - } - - private static void copy(InputStream in, File destination) throws IOException { - OutputStream out = new FileOutputStream(destination); + private File requireEightOrNewer(File home) throws MojoFailureException { try { - byte[] chunk = new byte[8192]; - int n; - while ((n = in.read(chunk)) > 0) { - out.write(chunk, 0, n); - } - } finally { - out.close(); + return packager().requireEightOrNewer(home); + } catch (BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); } } - /** As copyDirectory, but never replacing a file that is already there. */ - private void copyDirectoryFirstWins(File from, File to) throws MojoExecutionException { - copyDirectoryFirstWins(from, to, null); + private static boolean dropsSourceEight(MojoFailureException err) { + return BackendPackager.dropsSourceEight(new BuildFailureException(err.getMessage())); } - /** - * @param nativeTarget where a top-level cn1-native subtree belongs, or null - * when this is already below one - * - *

The same routing the jar branch does, and for the same reason: the - * translator has no code that looks for cn1-native inside a class input, and - * only nativeSources is copied into its C source root. Copied as it stood, - * a reactor module's C was staged among the classes where nothing reads it -- - * and a native whose C is absent is not a link error, because a Java native - * method is kept alive BY its symbol appearing in the native sources, so the - * dead-code pass drops the method and the build stays green with the feature - * inert. The same dependency packaged correctly once it was installed as a - * jar and consumed that way, which is the worst shape for this to take. - */ - private void copyDirectoryFirstWins(File from, File to, File nativeTarget) + private List stageDependencyClasses(List classpath, File staged, File nativeSources) throws MojoExecutionException { - File[] children = from.listFiles(); - if (children == null) { - return; - } - for (File child : children) { - if (nativeTarget != null && child.isDirectory() - && "cn1-native".equals(child.getName())) { - mkdirs(nativeTarget); - // Without the native target below it: cn1-native is matched at the - // root only, exactly as the jar branch matches the prefix. - copyDirectoryFirstWins(child, nativeTarget); - continue; - } - File destination = new File(to, child.getName()); - if (child.isDirectory()) { - mkdirs(destination); - copyDirectoryFirstWins(child, destination); - continue; - } - if (destination.isFile()) { - continue; - } - try { - InputStream in = new java.io.FileInputStream(child); - try { - copy(in, destination); - } finally { - in.close(); - } - } catch (IOException err) { - throw new MojoExecutionException("Could not copy " + child, err); - } - } - } - - private void copyDirectory(File from, File to) throws MojoExecutionException { - File[] children = from.listFiles(); - if (children == null) { - return; - } - for (File child : children) { - File destination = new File(to, child.getName()); - if (child.isDirectory()) { - mkdirs(destination); - copyDirectory(child, destination); - continue; - } - try { - InputStream in = new java.io.FileInputStream(child); - try { - copy(in, destination); - } finally { - in.close(); - } - } catch (IOException err) { - throw new MojoExecutionException("Could not copy " + child, err); - } - } - } - - private void collectJava(File dir, List out) { - File[] children = dir.listFiles(); - if (children == null) { - return; - } - for (File child : children) { - if (child.isDirectory()) { - collectJava(child, out); - } else if (child.getName().endsWith(".java")) { - out.add(child.getAbsolutePath()); - } - } - } - - private void run(List command, File directory, String what) - throws MojoExecutionException, MojoFailureException { try { - ProcessBuilder builder = new ProcessBuilder(command); - builder.directory(directory); - builder.redirectErrorStream(true); - Process process = builder.start(); - StringBuilder output = new StringBuilder(); - InputStream in = process.getInputStream(); - byte[] chunk = new byte[8192]; - int n; - while ((n = in.read(chunk)) > 0) { - output.append(new String(chunk, 0, n, "UTF-8")); - } - int status = process.waitFor(); - if (status != 0) { - throw new MojoFailureException("Could not " + what + ":\n" + output); - } - if (output.length() > 0) { - getLog().debug(output.toString()); - } - } catch (IOException err) { - throw new MojoExecutionException("Could not " + what, err); - } catch (InterruptedException err) { - Thread.currentThread().interrupt(); - throw new MojoExecutionException("Interrupted while trying to " + what, err); + return packager().stageDependencyClasses(classpath, staged, nativeSources); + } catch (BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex); } } - /** - * Removes each directory and its contents, and refuses to continue if one - * survives. - * - * The emptiness is the point, and it used to be assumed: File.delete returns - * false for a locked file on Windows or anything under a read-only directory, - * nothing looked at that, and the stale .class stayed where ClassScanner, - * requireMainClass and the translator would all find it. A controller or an - * entry point deleted from the source tree is then still packaged, so the - * build ships the previous implementation and says nothing. Checking the - * result rather than each delete catches every reason one can survive. - */ - private static void emptyDirs(File... dirs) throws MojoExecutionException { - for (File dir : dirs) { - deleteTree(dir); - if (dir == null || !dir.exists()) { - continue; - } - String[] left = dir.list(); - if (left != null && left.length > 0) { - throw new MojoExecutionException("Could not empty " + dir - + ": " + left.length + " entr" + (left.length == 1 ? "y" : "ies") - + " could not be deleted, and building over them would package " - + "classes that are no longer in the source tree."); - } - } - } + static final String SOURCE_EIGHT_REMOVED_HINT = BackendPackager.SOURCE_EIGHT_REMOVED_HINT; - private static void deleteTree(File file) { - if (file == null || !file.exists()) { - return; - } - File[] children = file.listFiles(); - if (children != null) { - for (File child : children) { - deleteTree(child); - } - } - // The result is checked by emptyDirs, which looks at what actually - // survived rather than at each delete: a directory that could not be - // removed but is empty is harmless, and one that still holds a class is - // not, whatever the reason. - file.delete(); + static int parseJavacVersion(String output) { + return BackendPackager.parseJavacVersion(output); } - private static void mkdirs(File... dirs) { - for (File dir : dirs) { - if (dir != null && !dir.isDirectory()) { - dir.mkdirs(); - } - } + static List withoutRuntime(List classpath, File runtime, String ownOutput) { + return BackendPackager.withoutRuntime(classpath, runtime, ownOutput); } - private static String join(List parts, String separator) { - StringBuilder out = new StringBuilder(); - for (int iter = 0; iter < parts.size(); iter++) { - if (iter > 0) { - out.append(separator); - } - out.append(parts.get(iter)); - } - return out.toString(); + static List hostLibraryFlags(List openssl, List nghttp2) { + return BackendPackager.hostLibraryFlags(openssl, nghttp2); } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java index 3da203233ce..0335969e38c 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java @@ -105,19 +105,16 @@ public void execute() throws MojoExecutionException, MojoFailureException { throw new MojoExecutionException("Could not resolve the runtime classpath", err); } - String main = mainClass; - if (main == null || main.length() == 0) { - // The generator's own answer first. Annotation processing writes the - // entry point it created into META-INF/cn1-backend-main, and that is - // a statement of WHICH main to run -- scanning for main methods is a - // guess, and it fails the moment the module also holds a demo or a - // tool with one: the run is refused as ambiguous though the choice - // had already been made. cn1:backend-package reads the same marker, - // and the two must not disagree about what the module runs. - main = generatedMainClass(classes); - } - if (main == null || main.length() == 0) { - main = findMainClass(classes); + // The generator's own answer first: annotation processing writes the entry + // point it created into META-INF/cn1-backend-main, and that is a statement + // of WHICH main to run. Scanning for main methods is the fallback, and is + // refused as ambiguous rather than guessed. See BackendMainClass, which + // cn1:backend-package and the Gradle plugin share. + String main; + try { + main = new BackendMainClass(MavenLog.of(getLog()), "-Dcn1.backend.mainClass").resolve(classes, mainClass); + } catch (com.codename1.build.BuildFailureException err) { + throw new MojoFailureException(err.getMessage(), err); } List command = new ArrayList(); @@ -154,125 +151,6 @@ public void execute() throws MojoExecutionException, MojoFailureException { } } - /** - * The one class in this module with a main method. - * - * Deliberately an error when there are several rather than a guess: picking - * one and running it is how a developer ends up debugging the wrong process. - */ - /** - * The entry point annotation processing generated, or null when this module - * has none -- one written by hand, with no @RestController in it, has no - * marker and falls through to the scan below. - */ - private String generatedMainClass(File classesDir) { - File marker = new File(classesDir, - com.codename1.maven.processors.RestControllerAnnotationProcessor - .MAIN_CLASS_RESOURCE.replace('/', File.separatorChar)); - if (!marker.isFile()) { - return null; - } - try { - byte[] raw = new byte[(int) marker.length()]; - InputStream in = new java.io.FileInputStream(marker); - try { - int at = 0; - while (at < raw.length) { - int n = in.read(raw, at, raw.length - at); - if (n <= 0) { - break; - } - at += n; - } - } finally { - in.close(); - } - String name = new String(raw, "UTF-8").trim(); - return name.length() == 0 ? null : name; - } catch (IOException err) { - // Unreadable is not the same as absent, and the scan below still has - // a fair chance of being right; refusing outright would be worse. - getLog().warn("cn1: could not read " + marker + ": " + err); - return null; - } - } - - private String findMainClass(File classesDir) throws MojoFailureException { - List found = new ArrayList(); - collectMainClasses(classesDir, classesDir, found); - if (found.size() == 1) { - return found.get(0); - } - if (found.isEmpty()) { - throw new MojoFailureException("No class with a main method under " - + classesDir + "; set -Dcn1.backend.mainClass"); - } - throw new MojoFailureException("Several classes have a main method (" - + join(found, ", ") + "); choose one with -Dcn1.backend.mainClass"); - } - - private void collectMainClasses(File root, File dir, List found) { - File[] children = dir.listFiles(); - if (children == null) { - return; - } - for (File child : children) { - if (child.isDirectory()) { - collectMainClasses(root, child, found); - } else if (child.getName().endsWith(".class") && child.getName().indexOf('$') < 0) { - String name = child.getAbsolutePath() - .substring(root.getAbsolutePath().length() + 1) - .replace(File.separatorChar, '.'); - name = name.substring(0, name.length() - ".class".length()); - if (hasMainMethod(child)) { - found.add(name); - } - } - } - } - - /** - * Whether the class DECLARES `public static void main(String[])`. - * - * Read from the class file rather than by loading it: loading runs the static - * initialiser, and a backend's initialiser is as likely as not to open a - * socket or a database. The method table is read with ASM rather than by - * searching the bytes, because the constant pool of a class that merely CALLS - * main carries the same two strings. - */ - private boolean hasMainMethod(File classFile) { - final boolean[] found = new boolean[1]; - try { - InputStream in = new java.io.FileInputStream(classFile); - try { - new org.objectweb.asm.ClassReader(in).accept( - new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { - @Override - public org.objectweb.asm.MethodVisitor visitMethod(int access, - String name, String descriptor, String signature, - String[] exceptions) { - int wanted = org.objectweb.asm.Opcodes.ACC_PUBLIC - | org.objectweb.asm.Opcodes.ACC_STATIC; - if ("main".equals(name) - && "([Ljava/lang/String;)V".equals(descriptor) - && (access & wanted) == wanted) { - found[0] = true; - } - return null; - } - }, - org.objectweb.asm.ClassReader.SKIP_CODE - | org.objectweb.asm.ClassReader.SKIP_DEBUG - | org.objectweb.asm.ClassReader.SKIP_FRAMES); - } finally { - in.close(); - } - } catch (Exception err) { - return false; - } - return found[0]; - } - private static String javaExecutable() { File home = new File(System.getProperty("java.home")); File candidate = new File(home, "bin/java"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java index cd69157ca11..68370a32e01 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java @@ -22,1287 +22,44 @@ */ package com.codename1.maven; -import org.apache.commons.io.FileUtils; -import org.apache.maven.artifact.Artifact; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.ResolutionScope; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.ClassVisitor; -import org.objectweb.asm.ClassWriter; -import org.objectweb.asm.FieldVisitor; -import org.objectweb.asm.MethodVisitor; -import org.objectweb.asm.Opcodes; -import org.objectweb.asm.Type; -import org.objectweb.asm.util.CheckClassAdapter; -import org.objectweb.asm.tree.AbstractInsnNode; -import org.objectweb.asm.tree.ClassNode; -import org.objectweb.asm.tree.InvokeDynamicInsnNode; -import org.objectweb.asm.tree.MethodInsnNode; -import org.objectweb.asm.tree.MethodNode; -import org.objectweb.asm.tree.analysis.Analyzer; -import org.objectweb.asm.tree.analysis.AnalyzerException; -import org.objectweb.asm.tree.analysis.BasicInterpreter; -import org.objectweb.asm.tree.analysis.BasicValue; -import org.objectweb.asm.tree.analysis.Frame; -import java.io.BufferedInputStream; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FilenameFilter; import java.io.IOException; -import java.io.InputStream; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.net.MalformedURLException; -import java.net.URL; -import java.net.URLClassLoader; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Deque; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -import static com.codename1.maven.PathUtil.path; /** - * Performs bytecode-level API compliance checks by scanning compiled classes. + * Checks the compiled application against the Codename One Java runtime API; see + * {@link BytecodeCompliance}, which holds the check and is shared with the Gradle plugin. */ @Mojo(name = "bytecode-compliance", defaultPhase = LifecyclePhase.PROCESS_CLASSES, requiresDependencyResolution = ResolutionScope.TEST) public class BytecodeComplianceMojo extends AbstractCN1Mojo { - private static final Map SUGGESTED_REPLACEMENTS; - private static final Set SIMD_OWNER_NAMES; - private static final Set PRIMITIVE_WRAPPER_INTERNAL_NAMES; - - static { - Map m = new HashMap(); - m.put("java/lang/System#exit(I)V", "Use com.codename1.ui.CN.exitApplication() to close apps on supported targets."); - m.put("java/lang/Thread#sleep(J)V", "Use com.codename1.ui.util.UITimer or Display.callSerially() instead of blocking sleeps."); - m.put("java/lang/Thread#sleep(JI)V", "Use com.codename1.ui.util.UITimer or Display.callSerially() instead of blocking sleeps."); - m.put("java/lang/Runtime#getRuntime()Ljava/lang/Runtime;", "Use Codename One platform services instead of raw java.lang.Runtime access."); - SUGGESTED_REPLACEMENTS = Collections.unmodifiableMap(m); - Set simdOwners = new HashSet(); - simdOwners.add("com/codename1/util/Simd"); - simdOwners.add("com/codename1/impl/ios/IOSSimd"); - simdOwners.add("com/codename1/impl/javase/JavaSESimd"); - SIMD_OWNER_NAMES = Collections.unmodifiableSet(simdOwners); - Set primitiveWrappers = new HashSet(); - primitiveWrappers.add("java/lang/Boolean"); - primitiveWrappers.add("java/lang/Byte"); - primitiveWrappers.add("java/lang/Character"); - primitiveWrappers.add("java/lang/Double"); - primitiveWrappers.add("java/lang/Float"); - primitiveWrappers.add("java/lang/Integer"); - primitiveWrappers.add("java/lang/Long"); - primitiveWrappers.add("java/lang/Short"); - PRIMITIVE_WRAPPER_INTERNAL_NAMES = Collections.unmodifiableSet(primitiveWrappers); - } - - private static final int MAX_CLASS_MAJOR_VERSION = Opcodes.V17; - private static final String JDK_API_REWRITE_HELPER_INTERNAL_NAME = "com/codename1/impl/JdkApiRewriteHelper"; - private static final String SIMD_INTERNAL_NAME = "com/codename1/util/Simd"; - private static final Map INVOCATION_REWRITE_RULES = createInvocationRewriteRules(); - - private File complianceOutputFile; - private InvocationRewriteSummary lastInvocationRewriteSummary = new InvocationRewriteSummary(); - private URLClassLoader validationClassLoader; - - private static Map createInvocationRewriteRules() { - Map rules = new LinkedHashMap(); - rules.put( - MethodRef.virtual("java/lang/String", "split", "(Ljava/lang/String;)[Ljava/lang/String;"), - MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "split", "(Ljava/lang/String;Ljava/lang/String;)[Ljava/lang/String;") - ); - rules.put( - MethodRef.virtual("java/lang/String", "split", "(Ljava/lang/String;I)[Ljava/lang/String;"), - MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "split", "(Ljava/lang/String;Ljava/lang/String;I)[Ljava/lang/String;") - ); - rules.put( - MethodRef.virtual("java/lang/String", "replaceAll", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"), - MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "replaceAll", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;") - ); - rules.put( - MethodRef.virtual("java/lang/String", "replaceFirst", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"), - MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "replaceFirst", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;") - ); - return Collections.unmodifiableMap(rules); - } - - private static boolean isSimdOwner(String owner) { - return owner != null && SIMD_OWNER_NAMES.contains(owner); - } - - private static boolean isSimdAllocaMethod(String owner, String name, String descriptor) { - if (!isSimdOwner(owner)) { - return false; - } - return name != null - && name.startsWith("alloca") - && name.length() > "alloca".length() - && Character.isUpperCase(name.charAt("alloca".length())) - && isSimdAllocaDescriptor(descriptor); - } - - private static boolean isSimdAllocaDescriptor(String descriptor) { - if (descriptor == null) { - return false; - } - Type returnType = Type.getReturnType(descriptor); - if (returnType == null || returnType.getSort() != Type.ARRAY || returnType.getDimensions() != 1) { - return false; - } - Type elementType = returnType.getElementType(); - int elementSort = elementType.getSort(); - return elementSort == Type.BYTE || elementSort == Type.INT || elementSort == Type.FLOAT; - } - @Override protected void executeImpl() throws MojoExecutionException, MojoFailureException { - if (shouldSkipComplianceCheck() || !isCN1ProjectDir()) { + if (!isCN1ProjectDir()) { return; } - - complianceOutputFile = new File(path(project.getBuild().getDirectory(), "codenameone", "compliance_check.txt")); - getLog().info("Running bytecode compliance check against Codename One Java Runtime API"); - getLog().info("See https://www.codenameone.com/javadoc/ for supported Classes and Methods"); - - if (!hasChangedSinceLastCheck()) { - getLog().info("Sources haven't changed since the last compliance check. Skipping check"); - return; - } - - copyKotlinIncrementalCompileOutputToOutputDir(); - - File outputDir = new File(project.getBuild().getOutputDirectory()); - if (!outputDir.isDirectory()) { - writeComplianceSuccess("No output classes found for compliance check in " + outputDir.getAbsolutePath(), 0); - return; - } - - int rewrittenClassCount = enforceMaxClassVersion(outputDir, MAX_CLASS_MAJOR_VERSION); - InvocationRewriteSummary invocationRewriteSummary = applyInvocationRewrites(outputDir); - lastInvocationRewriteSummary = invocationRewriteSummary; - - // Both calls above rewrite class files IN PLACE -- capping a class to the - // supported version, and redirecting a call the runtime does not have. If - // the main class is one of them, its bytes no longer match the digest the - // build hint manifest recorded, and the simulator -- which has no bytecode - // reader and so compares the class file itself -- reads a manifest written - // moments ago as stale and publishes none of the annotated hints. - // - // Every pom in this repository runs process-annotations after this goal, - // where the stamp would be taken from the rewritten bytes anyway. Stamping - // here as well is what makes that ordering stop mattering: whichever of - // the two runs last leaves a manifest describing the class on disk. A - // no-op when there is no manifest, which is every project that declares - // its hints in codenameone_settings.properties. - try { - com.codename1.maven.processors.BuildHintAnnotationProcessor - .restampClassDigest(outputDir); - } catch (IOException ioe) { - throw new MojoExecutionException( - "Could not stamp the build hint manifest under " + outputDir, ioe); - } - - List dependencyJars = getDependencyJarsForScanning(); - Map allowedIndex = buildClassIndex(Arrays.asList(getJavaRuntimeJar(), getCodenameOneJar())); - Map projectAndDependencyIndex = buildClassIndexWithOutput(outputDir, dependencyJars); - - List violations = scanProjectClasses(outputDir, allowedIndex, projectAndDependencyIndex); - if (!violations.isEmpty()) { - writeComplianceReport(violations, outputDir, dependencyJars, rewrittenClassCount); - logViolationSummary(violations); - throw new MojoFailureException(buildFailureSummary(violations)); - } - - writeComplianceSuccess("Completed compliance check on " + project.getName(), rewrittenClassCount); - getLog().info("Invocation rewrite summary: classes rewritten=" + invocationRewriteSummary.rewrittenClasses + ", callsites rewritten=" + invocationRewriteSummary.rewrittenCallsites); - } - - private boolean shouldSkipComplianceCheck() { - if ("true".equals(System.getProperty("skipComplianceCheck", "false"))) { - return true; - } - if ("true".equals(project.getProperties().getProperty("skipComplianceCheck", "false"))) { - return true; - } - if ("true".equals(System.getProperty("reloadClasses", "false"))) { - return true; - } - return "true".equals(project.getProperties().getProperty("reloadClasses", "false")); - } - - private boolean hasChangedSinceLastCheck() { - if (!complianceOutputFile.exists()) { - return true; - } - try { - long lastCheck = complianceOutputFile.lastModified(); - if (getSourcesModificationTime(true) > lastCheck) { - return true; - } - if (lastCheckFailed()) { - // A failure report is not a completed check. Without this a - // rerun with unchanged sources would skip straight past the - // violations that just failed the build. - return true; - } - // The invocation rewrites and the class-version cap mutate the - // compiled classes in place, so gating on source mtimes alone is - // not enough: a later compile pass can regenerate target/classes - // with unchanged sources (e.g. another `mvn package` re-running - // javac), silently shedding the rewrites. Shipping such classes - // breaks device builds -- the iOS translator emits calls to - // virtual_java_lang_String_replaceAll etc. that ParparVM's - // JavaAPI never declares. Re-run whenever any compiled class is - // newer than the last check; the marker is written after the - // rewrites, so an up-to-date output tree stays skippable. - return getCompiledClassesModificationTime() > lastCheck; - } catch (IOException ex) { - getLog().error("Failed to check sources/classes modification time for compliance check", ex); - return true; - } - } - - private boolean lastCheckFailed() throws IOException { - String content = FileUtils.readFileToString(complianceOutputFile, "UTF-8"); - return content.startsWith(FAILURE_REPORT_HEADER); - } - - private static final FilenameFilter CLASS_FILES_FILTER = (dir, name) -> name.endsWith(".class"); - - private long getCompiledClassesModificationTime() { - long mTime = lastModifiedRecursive(new File(project.getBuild().getOutputDirectory()), CLASS_FILES_FILTER); - // With kotlin.compiler.incremental the Kotlin compiler writes to its - // own output tree which executeImpl copies into the output directory, - // so fresh classes can sit there before any copy has happened. - File kotlinIncrementalOutputDir = new File(path(project.getBuild().getDirectory(), "kotlin-ic", "compile", "classes")); - if (kotlinIncrementalOutputDir.exists()) { - mTime = Math.max(mTime, lastModifiedRecursive(kotlinIncrementalOutputDir, CLASS_FILES_FILTER)); - } - return mTime; - } - - private void writeComplianceSuccess(String message, int rewrittenClassCount) throws MojoExecutionException { - complianceOutputFile.getParentFile().mkdirs(); - try { - StringBuilder content = new StringBuilder(); - content.append(message).append("\n"); - content.append("Rewritten class files to Java 17 major version: ").append(rewrittenClassCount).append("\n"); - content.append("Rewritten JDK API callsites: ").append(lastInvocationRewriteSummary.rewrittenCallsites) - .append(" across ").append(lastInvocationRewriteSummary.rewrittenClasses).append(" class(es)").append("\n"); - FileUtils.writeStringToFile(complianceOutputFile, content.toString(), "UTF-8"); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to write compliance file", ex); - } - } - - private static final String FAILURE_REPORT_HEADER = "Codename One compliance check failed."; - - private void writeComplianceReport(List violations, File outputDir, List dependencyJars, int rewrittenClassCount) throws MojoExecutionException { - StringBuilder report = new StringBuilder(); - report.append(FAILURE_REPORT_HEADER).append("\n"); - report.append("Project: ").append(project.getName()).append("\n"); - report.append("Output classes: ").append(outputDir.getAbsolutePath()).append("\n"); - report.append("Dependency jars scanned: ").append(dependencyJars.size()).append("\n"); - report.append("Rewritten class files to Java 17 major version: ").append(rewrittenClassCount).append("\n\n"); - report.append("Rewritten JDK API callsites: ").append(lastInvocationRewriteSummary.rewrittenCallsites) - .append(" across ").append(lastInvocationRewriteSummary.rewrittenClasses).append(" class(es)").append("\n\n"); - report.append("Violations (").append(violations.size()).append(")\n"); - report.append("========================================\n"); - int i = 1; - for (Violation violation : violations) { - report.append(i++).append(") ").append(violation.render()).append("\n\n"); - } - - complianceOutputFile.getParentFile().mkdirs(); - try { - FileUtils.writeStringToFile(complianceOutputFile, report.toString(), "UTF-8"); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to write compliance report", ex); - } - } - - private String buildFailureSummary(List violations) { - int maxInMessage = Math.min(5, violations.size()); - StringBuilder sb = new StringBuilder(); - sb.append("Compliance check failed with ").append(violations.size()).append(" forbidden API reference"); - if (violations.size() != 1) { - sb.append("s"); - } - sb.append(".\n"); - sb.append("See ").append(complianceOutputFile.getAbsolutePath()).append(" for the full report.\n"); - sb.append("First ").append(maxInMessage).append(" violation(s):"); - for (int i = 0; i < maxInMessage; i++) { - Violation v = violations.get(i); - sb.append("\n - ").append(v.renderInline()); - } - return sb.toString(); - } - - private void logViolationSummary(List violations) { - int maxToLog = Math.min(5, violations.size()); - getLog().error("Bytecode compliance check found " + violations.size() + " violation(s)."); - getLog().error("Detailed report written to " + complianceOutputFile.getAbsolutePath()); - for (int i = 0; i < maxToLog; i++) { - getLog().error("[" + (i + 1) + "] " + violations.get(i).renderInline()); - } - } - - - private int enforceMaxClassVersion(File outputDir, final int maxVersion) throws MojoExecutionException { - List classFiles = new ArrayList(); - collectClassFiles(outputDir, classFiles); - int rewritten = 0; - for (File classFile : classFiles) { - try { - byte[] originalBytes = FileUtils.readFileToByteArray(classFile); - ClassVersionInfo versionInfo = readClassVersion(originalBytes); - if (versionInfo.majorVersion > maxVersion) { - byte[] rewrittenBytes = rewriteClassVersion(originalBytes, maxVersion); - FileUtils.writeByteArrayToFile(classFile, rewrittenBytes); - rewritten++; - getLog().info("Rewrote class major version " + versionInfo.majorVersion + " -> " + maxVersion + " for " + classFile.getAbsolutePath()); - } - } catch (IOException ex) { - throw new MojoExecutionException("Failed to enforce class version for " + classFile, ex); - } - } - if (rewritten > 0) { - getLog().info("Rewrote " + rewritten + " class file(s) to Java 17 major version " + maxVersion); - } - return rewritten; - } - - private ClassVersionInfo readClassVersion(byte[] classBytes) { - final ClassVersionInfo out = new ClassVersionInfo(); - ClassReader reader = new ClassReader(classBytes); - reader.accept(new ClassVisitor(Opcodes.ASM9) { - @Override - public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { - out.majorVersion = version; - out.className = name; - } - }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); - return out; - } - - private byte[] rewriteClassVersion(byte[] classBytes, final int maxVersion) { - ClassReader reader = new ClassReader(classBytes); - ClassWriter writer = new ClassWriter(reader, 0); - ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) { + BytecodeCompliance check = new BytecodeCompliance(projectHost()) { @Override - public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { - int effectiveVersion = version > maxVersion ? maxVersion : version; - super.visit(effectiveVersion, access, name, signature, superName, interfaces); - } - }; - reader.accept(visitor, 0); - return writer.toByteArray(); - } - - private InvocationRewriteSummary applyInvocationRewrites(File outputDir) throws MojoExecutionException { - InvocationRewriteSummary summary = new InvocationRewriteSummary(); - List classFiles = new ArrayList(); - collectClassFiles(outputDir, classFiles); - try { - for (File classFile : classFiles) { - try { - byte[] originalBytes = FileUtils.readFileToByteArray(classFile); - InvocationRewriteResult rewriteResult = rewriteClassInvocations(originalBytes); - if (rewriteResult.rewrittenCallsites > 0) { - validateClass(rewriteResult.bytes, classFile, outputDir); - FileUtils.writeByteArrayToFile(classFile, rewriteResult.bytes); - summary.rewrittenClasses++; - summary.rewrittenCallsites += rewriteResult.rewrittenCallsites; - getLog().info("Applied " + rewriteResult.rewrittenCallsites + " invocation rewrite(s) in " + classFile.getAbsolutePath()); - } - } catch (IOException ex) { - throw new MojoExecutionException("Failed to rewrite invocations for " + classFile, ex); - } + protected long sourcesModificationTime() throws IOException { + // The common module's own sources, as the check always compared. + return getSourcesModificationTime(true); } - } finally { - closeValidationClassLoader(); - } - return summary; - } - private InvocationRewriteResult rewriteClassInvocations(byte[] classBytes) { - final InvocationRewriteResult result = new InvocationRewriteResult(); - final ClassReader reader = new ClassReader(classBytes); - final ClassWriter writer = new ClassWriter(reader, 0); - ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) { @Override - public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { - MethodVisitor delegate = super.visitMethod(access, name, descriptor, signature, exceptions); - return new MethodVisitor(Opcodes.ASM9, delegate) { - @Override - public void visitMethodInsn(int opcode, String owner, String methodName, String methodDescriptor, boolean isInterface) { - MethodRef source = new MethodRef(opcode, owner, methodName, methodDescriptor); - MethodRef target = INVOCATION_REWRITE_RULES.get(source); - if (target != null) { - result.rewrittenCallsites++; - super.visitMethodInsn(target.opcode, target.owner, target.name, target.descriptor, false); - return; - } - super.visitMethodInsn(opcode, owner, methodName, methodDescriptor, isInterface); - } - }; + protected void beforeCheck() { + copyKotlinIncrementalCompileOutputToOutputDir(); } }; - reader.accept(visitor, 0); - result.bytes = result.rewrittenCallsites > 0 ? writer.toByteArray() : classBytes; - return result; - } - - private void validateClass(byte[] classBytes, File classFile, File outputDir) throws MojoExecutionException { - ClassLoader loader = getValidationClassLoader(outputDir); - try { - StringWriter stringWriter = new StringWriter(); - PrintWriter printWriter = new PrintWriter(stringWriter); - CheckClassAdapter.verify(new ClassReader(classBytes), loader, false, printWriter); - printWriter.flush(); - String validationOutput = stringWriter.toString().trim(); - if (!validationOutput.isEmpty()) { - if (isUnresolvableTypeOutput(validationOutput)) { - getLog().debug("Skipping deep verification for " + classFile.getName() - + ": referenced type(s) not on classpath. Output: " + validationOutput); - return; - } - throw new MojoExecutionException("Bytecode validation failed for " + classFile + ": " + validationOutput); - } - } catch (RuntimeException ex) { - if (isUnresolvableTypeCause(ex)) { - getLog().debug("Skipping deep verification for " + classFile.getName() - + ": referenced type(s) not on classpath (" + ex.getMessage() + ")."); - return; - } - throw new MojoExecutionException("Bytecode validation failed for " + classFile, ex); - } - } - - private ClassLoader getValidationClassLoader(File outputDir) { - if (validationClassLoader != null) { - return validationClassLoader; - } - List urls = new ArrayList(); - try { - if (outputDir != null && outputDir.isDirectory()) { - urls.add(outputDir.toURI().toURL()); - } - if (project != null) { - for (File jar : getDependencyJarsForScanning()) { - if (jar != null && jar.exists()) { - urls.add(jar.toURI().toURL()); - } - } - } - } catch (MalformedURLException ex) { - getLog().debug("Failed to assemble validation classloader URLs; falling back to plugin classloader.", ex); - return getClass().getClassLoader(); - } - validationClassLoader = new URLClassLoader(urls.toArray(new URL[0]), getClass().getClassLoader()); - return validationClassLoader; - } - - private void closeValidationClassLoader() { - if (validationClassLoader != null) { - try { - validationClassLoader.close(); - } catch (IOException ex) { - getLog().debug("Failed to close validation classloader", ex); - } - validationClassLoader = null; - } - } - - private static boolean isUnresolvableTypeCause(Throwable ex) { - Throwable t = ex; - while (t != null) { - if (t instanceof ClassNotFoundException || t instanceof TypeNotPresentException || t instanceof NoClassDefFoundError) { - return true; - } - t = t.getCause(); - } - return false; - } - - private static boolean isUnresolvableTypeOutput(String output) { - return output.contains("ClassNotFoundException") - || output.contains("TypeNotPresentException") - || output.contains("NoClassDefFoundError") - || output.contains(" not present"); - } - - private List scanProjectClasses(File outputDir, final Map allowedIndex, final Map projectAndDependencyIndex) throws MojoExecutionException { - List classFiles = new ArrayList(); - collectClassFiles(outputDir, classFiles); - List violations = new ArrayList(); - for (File classFile : classFiles) { - try { - InputStream inputStream = new BufferedInputStream(new FileInputStream(classFile)); - try { - ClassReader reader = new ClassReader(inputStream); - reader.accept(new ComplianceScanner(classFile, outputDir, allowedIndex, projectAndDependencyIndex, violations), ClassReader.SKIP_FRAMES); - addSemanticStackViolations(classFile, outputDir, reader, violations); - } finally { - inputStream.close(); - } - } catch (IOException ex) { - throw new MojoExecutionException("Failed to scan class " + classFile, ex); - } - } - return violations; - } - - private void addSemanticStackViolations(File classFile, File outputDir, ClassReader reader, List violations) throws IOException, MojoExecutionException { - ClassNode classNode = new ClassNode(); - reader.accept(classNode, ClassReader.EXPAND_FRAMES); - for (MethodNode method : classNode.methods) { - if (method.instructions == null || method.instructions.size() == 0) { - continue; - } - Frame[] frames; - try { - Analyzer analyzer = new Analyzer(new SimdAllocaInterpreter()); - frames = analyzer.analyze(classNode.name, method); - } catch (AnalyzerException ex) { - throw new MojoExecutionException("Failed to analyze bytecode semantics for " + classFile + " in " + classNode.name + "#" + method.name + method.desc, ex); - } - for (AbstractInsnNode instruction = method.instructions.getFirst(); instruction != null; instruction = instruction.getNext()) { - int index = method.instructions.indexOf(instruction); - Frame frame = frames[index]; - if (frame == null) { - continue; - } - int opcode = instruction.getOpcode(); - if (opcode == Opcodes.MONITORENTER) { - BasicValue lockValue = frame.getStack(frame.getStackSize() - 1); - if (isPrimitiveWrapperValue(lockValue)) { - addSemanticViolation(violations, classFile, outputDir, classNode.name, method, - "Synchronization on primitive wrapper " + lockValue.getType().getInternalName(), - "Use a dedicated Object lock instead of synchronizing on primitive wrapper values."); - } - continue; - } - if (opcode == Opcodes.ARETURN) { - if (isAllocaValue(frame.getStack(frame.getStackSize() - 1))) { - addSemanticViolation(violations, classFile, outputDir, classNode.name, method, - "SIMD alloca value returned from method", - "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); - } - continue; - } - if (opcode == Opcodes.PUTSTATIC) { - if (isAllocaValue(frame.getStack(frame.getStackSize() - 1))) { - addSemanticViolation(violations, classFile, outputDir, classNode.name, method, - "SIMD alloca value stored into static field", - "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); - } - continue; - } - if (opcode == Opcodes.PUTFIELD) { - if (isAllocaValue(frame.getStack(frame.getStackSize() - 1))) { - addSemanticViolation(violations, classFile, outputDir, classNode.name, method, - "SIMD alloca value stored into instance field", - "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); - } - continue; - } - if (opcode == Opcodes.AASTORE) { - if (isAllocaValue(frame.getStack(frame.getStackSize() - 1))) { - addSemanticViolation(violations, classFile, outputDir, classNode.name, method, - "SIMD alloca value stored into object array", - "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); - } - continue; - } - if (instruction instanceof MethodInsnNode) { - MethodInsnNode methodInsn = (MethodInsnNode) instruction; - int argumentCount = Type.getArgumentTypes(methodInsn.desc).length; - boolean usesAlloca = false; - for (int i = 0; i < argumentCount; i++) { - if (isAllocaValue(frame.getStack(frame.getStackSize() - 1 - i))) { - usesAlloca = true; - break; - } - } - // Non-static calls also consume the receiver object from the stack. - if (!usesAlloca && opcode != Opcodes.INVOKESTATIC - && isAllocaValue(frame.getStack(frame.getStackSize() - 1 - argumentCount))) { - usesAlloca = true; - } - if (usesAlloca && !isSimdOwner(methodInsn.owner)) { - addSemanticViolation(violations, classFile, outputDir, classNode.name, method, - "SIMD alloca value passed to non-Simd method " + methodInsn.owner + "#" + methodInsn.name + methodInsn.desc, - "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); - } - continue; - } - if (instruction instanceof InvokeDynamicInsnNode) { - Type[] args = Type.getArgumentTypes(((InvokeDynamicInsnNode) instruction).desc); - for (int i = 0; i < args.length; i++) { - if (isAllocaValue(frame.getStack(frame.getStackSize() - 1 - i))) { - addSemanticViolation(violations, classFile, outputDir, classNode.name, method, - "SIMD alloca value passed to invokedynamic", - "Keep SIMD alloca scratch arrays method-local and only pass them to Simd methods."); - break; - } - } - } - } - } - } - - private void addSemanticViolation(List violations, File classFile, File outputDir, String sourceClass, MethodNode method, String referencedMember, String suggestion) { - String relativePath = classFile.getAbsolutePath().replace(outputDir.getAbsolutePath(), ""); - if (relativePath.startsWith(File.separator)) { - relativePath = relativePath.substring(1); - } - violations.add(new Violation(sourceClass, method.name + method.desc, referencedMember, - suggestion, relativePath)); - } - - private static boolean isAllocaValue(BasicValue value) { - return value instanceof SimdAllocaValue && ((SimdAllocaValue) value).alloca; - } - - private static boolean isPrimitiveWrapperValue(BasicValue value) { - if (value == null || value.getType() == null || value.getType().getSort() != Type.OBJECT) { - return false; - } - return PRIMITIVE_WRAPPER_INTERNAL_NAMES.contains(value.getType().getInternalName()); - } - - private static final class SimdAllocaValue extends BasicValue { - private final boolean alloca; - - private SimdAllocaValue(Type type, boolean alloca) { - super(type); - this.alloca = alloca; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || obj.getClass() != SimdAllocaValue.class) { - return false; - } - SimdAllocaValue other = (SimdAllocaValue) obj; - Type type = getType(); - Type otherType = other.getType(); - if (type == null ? otherType != null : !type.equals(otherType)) { - return false; - } - return alloca == other.alloca; - } - - @Override - public int hashCode() { - Type type = getType(); - return 31 * (type != null ? type.hashCode() : 0) + (alloca ? 1 : 0); - } - } - - private static final class SimdAllocaInterpreter extends BasicInterpreter { - private SimdAllocaInterpreter() { - super(Opcodes.ASM9); - } - - @Override - public BasicValue newValue(Type type) { - if (type == null) { - return BasicValue.UNINITIALIZED_VALUE; - } - if (type == Type.VOID_TYPE) { - return null; - } - return new SimdAllocaValue(type, false); - } - - @Override - public BasicValue copyOperation(AbstractInsnNode insn, BasicValue value) throws AnalyzerException { - return value; - } - - @Override - public BasicValue unaryOperation(AbstractInsnNode insn, BasicValue value) throws AnalyzerException { - BasicValue base = super.unaryOperation(insn, value); - if (base == null) { - return null; - } - return new SimdAllocaValue(base.getType(), isAllocaValue(value)); - } - - @Override - public BasicValue binaryOperation(AbstractInsnNode insn, BasicValue value1, BasicValue value2) throws AnalyzerException { - BasicValue base = super.binaryOperation(insn, value1, value2); - if (base == null) { - return null; - } - return new SimdAllocaValue(base.getType(), isAllocaValue(value1) || isAllocaValue(value2)); - } - - @Override - public BasicValue ternaryOperation(AbstractInsnNode insn, BasicValue value1, BasicValue value2, BasicValue value3) throws AnalyzerException { - BasicValue base = super.ternaryOperation(insn, value1, value2, value3); - if (base == null) { - return null; - } - return new SimdAllocaValue(base.getType(), isAllocaValue(value1) || isAllocaValue(value2) || isAllocaValue(value3)); - } - - @Override - public BasicValue naryOperation(AbstractInsnNode insn, List values) throws AnalyzerException { - if (insn instanceof MethodInsnNode) { - MethodInsnNode methodInsn = (MethodInsnNode) insn; - if (isSimdAllocaMethod(methodInsn.owner, methodInsn.name, methodInsn.desc)) { - return new SimdAllocaValue(Type.getReturnType(methodInsn.desc), true); - } - } - BasicValue base = super.naryOperation(insn, values); - if (base == null) { - return null; - } - boolean alloca = false; - for (BasicValue value : values) { - if (isAllocaValue(value)) { - alloca = true; - break; - } - } - return new SimdAllocaValue(base.getType(), alloca); - } - - @Override - public BasicValue merge(BasicValue value1, BasicValue value2) { - boolean alloca = isAllocaValue(value1) || isAllocaValue(value2); - if (value1.equals(value2)) { - return new SimdAllocaValue(value1.getType(), alloca); - } - if (isReferenceValue(value1) && isReferenceValue(value2)) { - return new SimdAllocaValue(Type.getObjectType("java/lang/Object"), alloca); - } - BasicValue base = super.merge(value1, value2); - if (base == null) { - return null; - } - return new SimdAllocaValue(base.getType(), alloca); - } - } - - private static boolean isReferenceValue(BasicValue value) { - if (value == null || value.getType() == null) { - return false; - } - int sort = value.getType().getSort(); - return sort == Type.OBJECT || sort == Type.ARRAY; - } - - private void collectClassFiles(File file, List out) { - if (file == null || !file.exists()) { - return; - } - if (file.isFile() && file.getName().endsWith(".class")) { - out.add(file); - return; - } - if (file.isDirectory()) { - File[] children = file.listFiles(); - if (children == null) { - return; - } - for (File child : children) { - collectClassFiles(child, out); - } - } - } - - private Map buildClassIndexWithOutput(File outputDir, List dependencyJars) throws MojoExecutionException { - Map index = buildClassIndex(Collections.singletonList(outputDir)); - Map dependencyIndex = buildClassIndex(dependencyJars); - index.putAll(dependencyIndex); - return index; - } - - private Map buildClassIndex(List roots) throws MojoExecutionException { - Map index = new HashMap(); - for (File root : roots) { - if (root == null || !root.exists()) { - continue; - } - if (root.isDirectory()) { - List classFiles = new ArrayList(); - collectClassFiles(root, classFiles); - for (File classFile : classFiles) { - try { - InputStream inputStream = new BufferedInputStream(new FileInputStream(classFile)); - try { - ClassMetadata metadata = readClassMetadata(inputStream, classFile.getAbsolutePath()); - if (metadata != null) { - index.put(metadata.name, metadata); - } - } finally { - inputStream.close(); - } - } catch (IOException ex) { - throw new MojoExecutionException("Failed reading class metadata from " + classFile, ex); - } - } - } else if (isClassArchive(root)) { - try { - indexArchive(root, index); - } catch (IOException ex) { - throw new MojoExecutionException("Failed reading jar metadata from " + root, ex); - } - } - } - return index; - } - - private void indexArchive(File archive, Map index) throws IOException { - InputStream fis = new BufferedInputStream(new FileInputStream(archive)); - try { - indexArchiveStream(fis, archive.getAbsolutePath(), index); - } finally { - fis.close(); - } - } - - private void indexArchiveStream(InputStream archiveStream, String sourcePrefix, Map index) throws IOException { - ZipInputStream zip = new ZipInputStream(archiveStream); - try { - ZipEntry entry; - while ((entry = zip.getNextEntry()) != null) { - if (entry.isDirectory()) { - continue; - } - String entryName = entry.getName(); - byte[] bytes = readAllBytes(zip); - if (entryName.endsWith(".class")) { - if (shouldSkipArchiveClassEntry(entryName)) { - continue; - } - ClassMetadata metadata = readClassMetadata(new ByteArrayInputStream(bytes), sourcePrefix + "!" + entryName); - if (metadata != null) { - index.put(metadata.name, metadata); - } - } else if (isClassArchiveName(entryName)) { - indexArchiveStream(new ByteArrayInputStream(bytes), sourcePrefix + "!" + entryName, index); - } - } - } finally { - zip.close(); - } - } - - private byte[] readAllBytes(InputStream input) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] buffer = new byte[8192]; - int len; - while ((len = input.read(buffer)) != -1) { - out.write(buffer, 0, len); - } - return out.toByteArray(); - } - - private boolean shouldSkipArchiveClassEntry(String name) { - if (name == null || name.isEmpty()) { - return true; - } - if (!name.endsWith(".class")) { - return true; - } - if ("module-info.class".equals(name)) { - return true; - } - return name.startsWith("META-INF/versions/"); - } - - private boolean isClassArchive(File file) { - if (file == null || !file.isFile()) { - return false; - } - return isClassArchiveName(file.getName()); - } - - private boolean isClassArchiveName(String name) { - if (name == null) { - return false; - } - String lower = name.toLowerCase(); - return lower.endsWith(".jar") || lower.endsWith(".cn1lib") || lower.endsWith(".zip"); - } - - private ClassMetadata readClassMetadata(InputStream inputStream, String sourceDescription) throws IOException { - final ClassMetadata metadata = new ClassMetadata(); try { - ClassReader reader = new ClassReader(inputStream); - reader.accept(new ClassVisitor(Opcodes.ASM9) { - @Override - public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { - metadata.name = name; - metadata.superName = superName; - metadata.interfaces = interfaces == null ? Collections.emptyList() : Arrays.asList(interfaces); - } - - @Override - public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { - metadata.fields.add(memberKey(name, descriptor)); - return null; - } - - @Override - public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { - metadata.methods.add(memberKey(name, descriptor)); - return null; - } - }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); - return metadata; - } catch (RuntimeException ex) { - getLog().warn("Skipping unreadable class metadata from " + sourceDescription + ": " + ex.getMessage()); - return null; - } - } - - private List getDependencyJarsForScanning() { - List jars = new ArrayList(); - for (Artifact artifact : project.getArtifacts()) { - if (artifact == null || artifact.getScope() == null) { - continue; - } - if (artifact.getGroupId().equals("com.codenameone") && artifact.getArtifactId().equals("codenameone-core")) { - continue; - } - if (artifact.getGroupId().equals("com.codenameone") && artifact.getArtifactId().equals("java-runtime")) { - continue; - } - if ("compile".equals(artifact.getScope()) - || "provided".equals(artifact.getScope()) - || "system".equals(artifact.getScope()) - || "runtime".equals(artifact.getScope()) - || "test".equals(artifact.getScope())) { - File jar = getJar(artifact); - if (isClassArchive(jar)) { - jars.add(jar); - } - } - } - return jars; - } - - private File getJavaRuntimeJar() { - for (Artifact artifact : project.getArtifacts()) { - if (JAVA_RUNTIME_ARTIFACT_ID.equals(artifact.getArtifactId()) && GROUP_ID.equals(artifact.getGroupId())) { - return getJar(artifact); - } - } - for (Artifact artifact : pluginArtifacts) { - if (JAVA_RUNTIME_ARTIFACT_ID.equals(artifact.getArtifactId()) && GROUP_ID.equals(artifact.getGroupId())) { - return getJar(artifact); - } - } - throw new RuntimeException(JAVA_RUNTIME_ARTIFACT_ID + " not found in dependencies"); - } - - private File getCodenameOneJar() { - String codenameOneCoreId = "codenameone-core"; - for (Artifact artifact : project.getArtifacts()) { - if (codenameOneCoreId.equals(artifact.getArtifactId()) && GROUP_ID.equals(artifact.getGroupId())) { - return getJar(artifact); - } - } - for (Artifact artifact : pluginArtifacts) { - if (codenameOneCoreId.equals(artifact.getArtifactId()) && GROUP_ID.equals(artifact.getGroupId())) { - return getJar(artifact); - } - } - throw new RuntimeException(codenameOneCoreId + " not found in dependencies"); - } - - private static String memberKey(String name, String descriptor) { - return name + descriptor; - } - - private static final class MethodRef { - final int opcode; - final String owner; - final String name; - final String descriptor; - - private MethodRef(int opcode, String owner, String name, String descriptor) { - this.opcode = opcode; - this.owner = owner; - this.name = name; - this.descriptor = descriptor; - } - - private static MethodRef virtual(String owner, String name, String descriptor) { - return new MethodRef(Opcodes.INVOKEVIRTUAL, owner, name, descriptor); - } - - private static MethodRef staticRef(String owner, String name, String descriptor) { - return new MethodRef(Opcodes.INVOKESTATIC, owner, name, descriptor); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (!(obj instanceof MethodRef)) { - return false; - } - MethodRef other = (MethodRef) obj; - return opcode == other.opcode - && owner.equals(other.owner) - && name.equals(other.name) - && descriptor.equals(other.descriptor); - } - - @Override - public int hashCode() { - int result = opcode; - result = 31 * result + owner.hashCode(); - result = 31 * result + name.hashCode(); - result = 31 * result + descriptor.hashCode(); - return result; - } - } - - private static final class InvocationRewriteResult { - byte[] bytes; - int rewrittenCallsites; - } - - private static final class InvocationRewriteSummary { - int rewrittenClasses; - int rewrittenCallsites; - } - - private static final class ClassVersionInfo { - int majorVersion; - String className; - } - - private static final class ClassMetadata { - String name; - String superName; - List interfaces = Collections.emptyList(); - Set methods = new HashSet(); - Set fields = new HashSet(); - } - - private final class ComplianceScanner extends ClassVisitor { - private final File classFile; - private final File outputDir; - private final Map allowedIndex; - private final Map projectAndDependencyIndex; - private final List violations; - private String className; - - private ComplianceScanner(File classFile, - File outputDir, - Map allowedIndex, - Map projectAndDependencyIndex, - List violations) { - super(Opcodes.ASM9); - this.classFile = classFile; - this.outputDir = outputDir; - this.allowedIndex = allowedIndex; - this.projectAndDependencyIndex = projectAndDependencyIndex; - this.violations = violations; - } - - @Override - public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { - className = name; - } - - @Override - public MethodVisitor visitMethod(int access, final String name, final String descriptor, String signature, String[] exceptions) { - final String sourceMethod = name + descriptor; - return new MethodVisitor(Opcodes.ASM9) { - @Override - public void visitMethodInsn(int opcode, String owner, String memberName, String memberDescriptor, boolean isInterface) { - checkMethodReference(className, sourceMethod, owner, memberName, memberDescriptor); - } - - @Override - public void visitFieldInsn(int opcode, String owner, String memberName, String memberDescriptor) { - checkFieldReference(className, sourceMethod, owner, memberName, memberDescriptor); - } - - @Override - public void visitTypeInsn(int opcode, String type) { - checkTypeReference(className, sourceMethod, type); - } - }; - } - - private void checkMethodReference(String sourceClass, String sourceMethod, String owner, String memberName, String memberDescriptor) { - if (shouldAllowMethod(owner, memberName, memberDescriptor)) { - return; - } - addViolation(sourceClass, sourceMethod, owner + "#" + memberName + memberDescriptor); - } - - private void checkFieldReference(String sourceClass, String sourceMethod, String owner, String memberName, String memberDescriptor) { - if (shouldAllowField(owner, memberName, memberDescriptor)) { - return; - } - addViolation(sourceClass, sourceMethod, owner + "#" + memberName + ":" + memberDescriptor); - } - - private void checkTypeReference(String sourceClass, String sourceMethod, String owner) { - if (isArrayDescriptor(owner)) { - return; - } - if (isInternalRewriteHelper(owner)) { - return; - } - if (projectAndDependencyIndex.containsKey(owner) || allowedIndex.containsKey(owner)) { - return; - } - addViolation(sourceClass, sourceMethod, owner + " (type)"); - } - - private void addViolation(String sourceClass, String sourceMethod, String referencedMember) { - String relativePath = classFile.getAbsolutePath().replace(outputDir.getAbsolutePath(), ""); - if (relativePath.startsWith(File.separator)) { - relativePath = relativePath.substring(1); - } - violations.add(new Violation(sourceClass, sourceMethod, referencedMember, replacementFor(referencedMember), relativePath)); - } - - private boolean shouldAllowMethod(String owner, String name, String descriptor) { - if (isArrayDescriptor(owner)) { - return true; - } - if (isInternalRewriteHelper(owner)) { - return true; - } - return resolveMember(owner, memberKey(name, descriptor), true); - } - - private boolean shouldAllowField(String owner, String name, String descriptor) { - if (isArrayDescriptor(owner)) { - return true; - } - if (isInternalRewriteHelper(owner)) { - return true; - } - return resolveMember(owner, memberKey(name, descriptor), false); - } - - private boolean resolveMember(String owner, String member, boolean method) { - if (owner == null || owner.isEmpty()) { - return false; - } - Deque queue = new ArrayDeque(); - Set seen = new HashSet(); - queue.add(owner); - while (!queue.isEmpty()) { - String current = queue.removeFirst(); - if (!seen.add(current)) { - continue; - } - ClassMetadata metadata = projectAndDependencyIndex.get(current); - if (metadata == null) { - metadata = allowedIndex.get(current); - } - if (metadata == null) { - continue; - } - Set members = method ? metadata.methods : metadata.fields; - if (members.contains(member)) { - return true; - } - if (metadata.superName != null) { - queue.add(metadata.superName); - } - for (String iface : metadata.interfaces) { - queue.add(iface); - } - } - return false; - } - } - - private static boolean isArrayDescriptor(String type) { - return type != null && type.startsWith("["); - } - - private static boolean isInternalRewriteHelper(String owner) { - return JDK_API_REWRITE_HELPER_INTERNAL_NAME.equals(owner); - } - - private static String replacementFor(String referencedMember) { - String direct = SUGGESTED_REPLACEMENTS.get(referencedMember); - if (direct != null) { - return direct; - } - int hashPos = referencedMember.indexOf('#'); - if (hashPos > 0) { - String ownerOnly = referencedMember.substring(0, hashPos); - if (ownerOnly.startsWith("java/awt/") || ownerOnly.startsWith("javax/swing/")) { - return "Codename One does not support AWT/Swing APIs. Use com.codename1.ui components for UI logic."; - } - } - return null; - } - - private static final class Violation { - private final String sourceClass; - private final String sourceMethod; - private final String referencedMember; - private final String suggestion; - private final String sourcePath; - - private Violation(String sourceClass, String sourceMethod, String referencedMember, String suggestion, String sourcePath) { - this.sourceClass = sourceClass; - this.sourceMethod = sourceMethod; - this.referencedMember = referencedMember; - this.suggestion = suggestion; - this.sourcePath = sourcePath; - } - - private String render() { - StringBuilder sb = new StringBuilder(); - sb.append("Source class: ").append(sourceClass).append("\n"); - sb.append("Source method: ").append(sourceMethod).append("\n"); - sb.append("Source bytecode file: ").append(sourcePath).append("\n"); - sb.append("Forbidden reference: ").append(referencedMember); - if (suggestion != null && !suggestion.isEmpty()) { - sb.append("\nSuggested replacement: ").append(suggestion); - } - return sb.toString(); - } - - private String renderInline() { - StringBuilder sb = new StringBuilder(); - sb.append(sourceClass).append("#").append(sourceMethod) - .append(" -> ").append(referencedMember) - .append(" (").append(sourcePath).append(")"); - if (suggestion != null && !suggestion.isEmpty()) { - sb.append(" Suggestion: ").append(suggestion); - } - return sb.toString(); + check.execute(); + } catch (com.codename1.build.BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); + } catch (com.codename1.build.BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); } } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 654202db82d..0671ffc7feb 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -22,14 +22,12 @@ */ package com.codename1.maven; -import com.codename1.ant.AntExecutor; import com.codename1.ant.SortedProperties; -import com.codename1.builders.*; -import org.apache.commons.io.FileUtils; -import org.apache.commons.vfs2.FileObject; -import org.apache.commons.vfs2.FileSystemManager; -import org.apache.commons.vfs2.PatternFileSelector; -import org.apache.commons.vfs2.VFS; +import com.codename1.build.BuildArtifact; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.builders.BuildRequest; +import com.codename1.builders.Executor; import org.apache.maven.RepositoryUtils; import org.apache.maven.artifact.Artifact; import org.apache.maven.model.Dependency; @@ -37,30 +35,26 @@ import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.*; -import org.apache.tools.ant.taskdefs.Expand; -import org.apache.tools.ant.taskdefs.Zip; -import org.apache.tools.ant.types.FileSet; -import org.apache.tools.ant.types.ZipFileSet; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.ArtifactTypeRegistry; import org.eclipse.aether.collection.CollectRequest; import org.eclipse.aether.collection.CollectResult; import org.eclipse.aether.graph.DependencyNode; import org.eclipse.aether.graph.Exclusion; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.*; -import java.nio.charset.StandardCharsets; -import java.util.*; - -import static com.codename1.maven.PathUtil.path; +import java.io.File; +import java.io.IOException; +import java.util.*; /** * Mojo that uses the CodenameOneBuildClient to send builds to the CodenameOne build server. * * It also supports a few local build targets, such as "ios-source", which generates an Xcode project, * and "android-source", which generates an Android gradle project. + * + *

The build itself lives in {@link AppBuilder}, in the build engine, so the Gradle plugin runs + * the same code. This class answers AppBuilder's questions from the Maven model: the build + * directory, the resolved artifacts, the session's command-line properties, attaching outputs.

*/ @Mojo(name="build", requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME) @@ -79,8 +73,12 @@ public class CN1BuildMojo extends AbstractCN1Mojo { public static final String BUILD_TARGET_ANDROID_PROJECT = Executor.BUILD_TARGET_ANDROID_PROJECT; public static final String BUILD_TARGET_WINDOWS_NATIVE = Executor.BUILD_TARGET_WINDOWS_NATIVE; public static final String BUILD_TARGET_WINDOWS_NATIVE_PROJECT = Executor.BUILD_TARGET_WINDOWS_NATIVE_PROJECT; + public static final String BUILD_TARGET_MAC_NATIVE_PROJECT = Executor.BUILD_TARGET_MAC_NATIVE_PROJECT; + public static final String BUILD_TARGET_MAC_NATIVE = Executor.BUILD_TARGET_MAC_NATIVE; + public static final String BUILD_TARGET_MAC_NATIVE_LOCAL = Executor.BUILD_TARGET_MAC_NATIVE_LOCAL; + public static final String BUILD_TARGET_LINUX_NATIVE = Executor.BUILD_TARGET_LINUX_NATIVE; - private String serverMustProvideKotlinVersion; + static final String DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID = AppBuilder.DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID; /** * The target platform. E.g. javase, javascript, ios, android, win @@ -113,7 +111,7 @@ public class CN1BuildMojo extends AbstractCN1Mojo { /** * Maven's own resolver, to ask what the application needs without the desktop runtime - * aggregator (see {@link #neededWithoutDesktopRuntime()}). + * aggregator (see {@link #computeNeededWithoutDesktopRuntime()}). */ @Component private org.eclipse.aether.RepositorySystem resolverSystem; @@ -124,11 +122,6 @@ public class CN1BuildMojo extends AbstractCN1Mojo { @Parameter(property = "open", defaultValue = "true") private boolean open; - - private String cn1MavenPluginVersion = ""; - private String cn1MavenVersion = ""; - - @Override protected String helpStep() { return "build_submit"; @@ -148,790 +141,59 @@ protected String helpAction() { @Override protected void executeImpl() throws MojoExecutionException, MojoFailureException { - if ("none".equalsIgnoreCase(buildTarget)) { - getLog().info("BuildTarget is None. Skipping cn1build goal"); - return; - } - cn1MavenPluginVersion = project.getProperties().getProperty("cn1.plugin.version", ""); - cn1MavenVersion = project.getProperties().getProperty("cn1.version", ""); - - - String projectPlatform = project.getProperties().getProperty("codename1.projectPlatform"); - if (projectPlatform == null) { - getLog().debug("Skipping build because codename1.projectPlatform property is not defined"); - return; - } - if (!projectPlatform.equals(platform)) { - getLog().debug("Skipping build because codename1.projectPlatform doesn't match the given platform"); - return; - } - - // Run the hardening pre-flight BEFORE the Android up-to-date cache short-circuit below. The cache - // check keys only on source-file timestamps (getSourcesModificationTime), not on build hints, so an - // explicit hardening request -- especially one made via a -D command-line property -- would - // otherwise return a stale, potentially unhardened APK without the pre-flight ever validating (or - // refusing) it. Checking here means an invalid/unsupported hardening request fails loudly instead - // of silently succeeding with the cached artifact. - applyHardeningPreflight(); - - // Signing problems that are visible in the .mobileprovision on disk should not cost a - // cloud build slot to discover. See applyIOSProvisioningPreflight. - applyIOSProvisioningPreflight(); - - // stageOnly builds no APK, so it must neither short-circuit on a cached one (it would - // then stage nothing) nor, below, stamp that cached APK as matching this configuration. - if (platform.contains("android") && !stageOnly) { - if (!BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { - File apkFile = androidApkFile(); - try { - // Up-to-date only when the APK is newer than the sources AND was built with the SAME - // hardening outcome, recorded in the marker beside it. The source-timestamp check cannot - // see a hardening change made through a build hint, so a change in EITHER direction -- - // enabling hardening (a stale unhardened APK) or disabling it (a stale hardened APK) -- - // must invalidate the cache and rebuild, rather than publish an APK that contradicts the - // current configuration. - if (apkFile.exists() - && hardeningCacheKey.equals(readTextFileOrNull(androidHardeningCacheMarker())) - && apkFile.lastModified() >= getSourcesModificationTime()) { - getLog().info("Sources have not been modified since APK at " + apkFile + " was created. Skipping Android build"); - return; - } - } catch (IOException ex) { - throw new MojoExecutionException("Failed to check sources modification time", ex); - } - } - } - - try { - createAntProject(); - } catch (IOException ex) { - getLog().error("Failed to create and build ANT project", ex); - throw new MojoFailureException("Failed to create and build ANT project", ex); - } catch (LibraryPropertiesException ex) { - getLog().error("Failed to merge properties from library "+ex.libName+". " + ex.getMessage()); - throw new MojoExecutionException("Failed to merge properties from library "+ex.libName+". " + ex.getMessage(), ex); - } - - // Record the hardening outcome this APK was built with, so a later invocation that changes - // hardening (in either direction) invalidates the timestamp-only up-to-date cache above. - if (platform.contains("android") && !stageOnly && !BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { - File marker = androidHardeningCacheMarker(); - if (androidApkFile().exists()) { - try { - writeStringToFile(marker, hardeningCacheKey); - } catch (IOException ex) { - getLog().debug("Could not record the hardening cache key at " + marker, ex); - } - } - } - } - - /** The Android APK this build produces (also the cache key's anchor). */ - private File androidApkFile() { - String apkName = project.getBuild().getFinalName() + ".apk"; - return new File(project.getBuild().getDirectory() + File.separator + apkName); - } - - /** The marker recording the hardening outcome the cached APK was built with, beside the APK. */ - private File androidHardeningCacheMarker() { - File apk = androidApkFile(); - return new File(apk.getParentFile(), apk.getName() + ".cn1hardenkey"); - } - - /** - * A stable fingerprint of every effective {@code codename1.arg.harden.*} setting, so a transform, - * keep-rule or seed change -- not only a level change -- invalidates the APK cache. Keys are sorted so - * the fingerprint is order-independent. - */ - private static String hardeningSettingsFingerprint(Properties settings) { - java.util.TreeMap hints = new java.util.TreeMap(); - for (String key : settings.stringPropertyNames()) { - if (key.startsWith("codename1.arg.harden.")) { - hints.put(key, settings.getProperty(key)); - } - } - StringBuilder sb = new StringBuilder(); - for (java.util.Map.Entry e : hints.entrySet()) { - sb.append(e.getKey()).append('=').append(e.getValue()).append('\n'); - } - return sha256Hex(sb.toString()); - } - - /** SHA-256 of {@code s} as lowercase hex (falls back to the string hash if SHA-256 is somehow absent). */ - private static String sha256Hex(String s) { - try { - java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); - byte[] d = md.digest(s.getBytes(java.nio.charset.Charset.forName("UTF-8"))); - StringBuilder sb = new StringBuilder(d.length * 2); - for (byte b : d) { - sb.append(Character.forDigit((b >> 4) & 0xf, 16)); - sb.append(Character.forDigit(b & 0xf, 16)); - } - return sb.toString(); - } catch (java.security.NoSuchAlgorithmException ex) { - return Integer.toHexString(s.hashCode()); - } - } - - /** Reads a small text file's trimmed content, or {@code null} if it is absent or unreadable. */ - private static String readTextFileOrNull(File f) { - if (f == null || !f.isFile()) { - return null; - } - try { - return new String(java.nio.file.Files.readAllBytes(f.toPath()), - java.nio.charset.Charset.forName("UTF-8")).trim(); - } catch (IOException ex) { - return null; - } - } - - /** Writes {@code content} to {@code f} (UTF-8), creating parent directories as needed. */ - private static void writeStringToFile(File f, String content) throws IOException { - if (f.getParentFile() != null) { - f.getParentFile().mkdirs(); - } - java.nio.file.Files.write(f.toPath(), - content.getBytes(java.nio.charset.Charset.forName("UTF-8"))); - } - - /** - * App-hardening pre-flight (Check 1). Validates {@code harden.level} and refuses targets that - * cannot be hardened before a build is spent. Runs for every target: for cloud targets it fails - * fast client-side before submission; for local/source targets it stops (or, with the escape - * hatch, forces hardening off) because a locally built binary never reaches the server and its - * mapping would be orphaned from the crash-symbolication service. - */ - /** - * The hardening pre-flight decisions, carried on the Mojo INSTANCE (not JVM-global state) so a - * concurrent module build under {@code mvn -T} cannot clobber them. Each reactor module executes its - * own CN1BuildMojo instance, so instance fields are naturally per-build; a shared System property was - * racy -- another platform's build could clear {@code cn1.harden.forceOff} between this build setting - * it and the Executor reading it, running hardening locally despite the escape-hatch decision. These - * are injected into this build's BuildRequest ({@link #applyHardeningRequestArgs}) so the Executor - * reads them from the request it was handed rather than from process-wide state. - */ - private boolean hardeningForceOff; - private String hardeningLibraryJars; - /** True once the pre-flight has resolved that hardening will ACTUALLY run for this build (non-off, - * not force-off). Used to publish the library classpath the engine needs. */ - private boolean hardeningWillRun; - /** A fingerprint of the hardening OUTCOME for this build ("unhardened", or "hardened:<level>"), - * recorded next to the Android APK so the timestamp-only up-to-date cache -- which cannot see a - * hardening change made through a build hint -- is invalidated when the outcome changes in EITHER - * direction (enabling or disabling hardening), not just off->on. */ - private String hardeningCacheKey = "unhardened"; - - /** Injects the pre-flight hardening decisions into this build's request (per-build, not global). */ - private void applyHardeningRequestArgs(BuildRequest r) { - if (hardeningForceOff) { - r.putArgument("cn1.harden.forceOff", "true"); - } - if (hardeningLibraryJars != null && hardeningLibraryJars.length() > 0) { - r.putArgument("cn1.hardening.libraryJars", hardeningLibraryJars); - } - } - - private void applyHardeningPreflight() throws MojoFailureException { - Properties settings = new Properties(); - File settingsFile = new File(getCN1ProjectDir(), "codenameone_settings.properties"); - if (settingsFile.isFile()) { - try (FileInputStream fis = new FileInputStream(settingsFile)) { - settings.load(fis); - } catch (IOException ex) { - getLog().debug("Could not read codenameone_settings.properties for hardening pre-flight", ex); - } - } - // A hint set by @Hardening reaches the settings only in createAntProject, which runs after - // the Android up-to-date short-circuit below and after hardeningCacheKey is read from it. - // Without this the early pass computes "unhardened" from the file while the completed build - // records "hardened:...", the two never match, and an up-to-date APK is rebuilt on every - // invocation -- and, worse, an unsupported hardening request made through an annotation - // escapes the refusal this early pass exists to perform. + MavenAppBuilder builder = new MavenAppBuilder(); + builder.platform = platform; + builder.buildTarget = buildTarget; + builder.automated = automated; + builder.stageOnly = stageOnly; + builder.open = open; try { - mergeAnnotationBuildHints(settings, project.getCompileClasspathElements()); - } catch (org.apache.maven.artifact.DependencyResolutionRequiredException ex) { - getLog().debug("Could not read annotation build hints for the hardening pre-flight", ex); - } - // Overlay -D command-line hints (e.g. -Dcodename1.arg.harden.level=standard) so an explicit - // hardening request made only on the command line is seen by this early check -- and, because this - // runs before the Android up-to-date cache short-circuit, is not silently dropped when a prior APK - // is newer than the sources (getSourcesModificationTime does not account for build hints). - // After the annotations, so -D still wins over one. - overlayCommandLineBuildHints(settings); - applyHardeningPreflight(settings); - } - - /** - * Refuses an iOS device build whose provisioning profile makes signing impossible, before - * the build is packaged and sent. - * - *

These failures used to be discoverable only on a build server: an unreadable profile - * surfaced as an XML parser stack trace that never named the profile, and a profile of the - * wrong kind surfaced as an {@code exportArchive} error minutes into the build, after the - * whole app had already been compiled and archived. Both are decided by a file sitting in - * the project. - * - *

Only cloud iOS device builds are checked. A local Xcode-project generation signs - * later (or not at all), and the native-Mac targets ride the same platform with a different - * signing identity, so neither is this check's business. - */ - private void applyIOSProvisioningPreflight() throws MojoFailureException { - if (!isIOSDeviceBuild()) { - return; - } - Properties settings = new Properties(); - File settingsFile = new File(getCN1ProjectDir(), "codenameone_settings.properties"); - if (settingsFile.isFile()) { - try (FileInputStream fis = new FileInputStream(settingsFile)) { - settings.load(fis); - } catch (IOException ex) { - getLog().debug("Could not read codenameone_settings.properties for the iOS provisioning pre-flight", ex); - return; - } - } else { - return; - } - overlayCommandLineBuildHints(settings); - - // Only what the file itself decides. Whether the profile's KIND matches the export - // method cannot be judged yet: a CN1Lib can supply ios.*.distributionMethod through - // its appended/required properties, which createAntProject merges further down, so - // that comparison waits for applyIOSProvisioningPreflight(Properties) below. - report(IOSProvisioningPreflight.checkProfileFile(settings, - IOSProvisioningPreflight.isReleaseTarget(buildTarget), new Date())); - } - - private boolean isIOSDeviceBuild() { - return IOSProvisioningPreflight.appliesTo(platform, buildTarget); - } - - /** - * The full pre-flight, run against the settings the build is actually submitted with -- - * the project's own, plus the command-line overlay, plus every CN1Lib-contributed - * property. This is where a profile-kind/distribution-method mismatch is decided, since - * a library can still change the method after the early pass has run. - */ - private void applyIOSProvisioningPreflight(Properties mergedSettings) throws MojoFailureException { - if (!isIOSDeviceBuild()) { - return; - } - boolean release = IOSProvisioningPreflight.isReleaseTarget(buildTarget); - report(IOSProvisioningPreflight.check(mergedSettings, release, new Date())); - // Every embedded app extension needs a profile of its own, and the app's profile says - // whether it can stand in for one. Run after check() so an unreadable or expired - // profile is reported as itself rather than as an extension problem. - report(IOSProvisioningPreflight.checkAppExtensions(mergedSettings, release, - project.getBasedir())); - report(IOSProvisioningPreflight.checkGeneratedExtensions(mergedSettings, release)); - report(IOSProvisioningPreflight.checkContinuitySync(mergedSettings, release)); - } - - private void report(List problems) throws MojoFailureException { - String fatal = null; - for (IOSProvisioningPreflight.Problem problem : problems) { - if (problem.fatal) { - getLog().error(problem.message); - if (fatal == null) { - fatal = problem.message; - } - } else { - getLog().warn(problem.message); - } - } - if (fatal != null) { - throw new MojoFailureException(fatal); + builder.execute(); + } catch (BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } catch (BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); } } /** - * Runs the hardening pre-flight against a given set of effective settings. Called first with the - * project's own {@code codenameone_settings.properties} (fail-fast before the merged jar is built), - * and again after {@code createAntProject} merges the CN1Lib-contributed - * {@code codenameone_library_appended/required.properties} -- a library can turn hardening on via - * {@code codename1.arg.harden.level}, which the early call cannot see, and without this second pass - * such a build would slip past the local-build refusal / force-off and produce a locally hardened - * artifact whose mapping is never uploaded. + * {@link AppBuilder} over this mojo's {@link MavenProjectHost}. The annotation merge, + * the command-line overlay and the updater go through the mojo's own methods, which + * know about the reactor (which module produced a classpath element) and the session. */ - private void applyHardeningPreflight(Properties settings) throws MojoFailureException { - String level = settings.getProperty("codename1.arg.harden.level", "off"); - // A per-platform opt-out (harden..enabled=false) means hardening won't run for - // this target, so the pre-flight must not reject it -- treat the level as off. The native-Mac - // targets ride the iOS pipeline with platform=ios, so derive their opt-out key from the - // build target instead (matching IPhoneBuilder, which reports "mac" for them). - String hardenPlatform = hardenPlatformForBuildTarget(buildTarget); - if (hardenPlatform == null) { - hardenPlatform = normalizeHardenPlatform(platform); - } - // A combined Apple build (iOS app + native-Mac/watch/tvOS slice) hardens ONE shared jar, so the - // builder hardens it unless EVERY shipped slice is opted out (Executor.anySliceHardeningEnabled). - // The preflight must use that same all-slice decision: keying off only the selected slice would, - // e.g. with harden.mac.enabled=false but iOS still on, treat the level as off and skip the - // local-build/on-device-debug refusal while the engine goes on to harden the shared jar locally - // and orphan its mapping. A non-Apple target has a single slice, so its own opt-out still applies. - boolean platformOptedOut; - if (isNativeMacOsTarget(buildTarget)) { - // A native macOS target is not a slice of an iOS build. It runs its - // own builder against the macosx SDK, and that builder's hardening - // platform list is "mac" alone -- there is no shared jar and no iOS - // app. The all-slice rule below starts by reading harden.ios.enabled, - // which defaults to true and is answering about a slice this build - // does not ship, so harden.mac.enabled=false was overruled by it and - // the build was refused as a hardened local/source one that the - // builder would in fact have hardened nothing for. - // - // Mac Catalyst is deliberately NOT here: it really is an iOS build - // with a hint set, it does ship the shared jar, and the all-slice - // rule is right for it. hardenPlatformForBuildTarget draws the same - // line for the same reason. - platformOptedOut = isHardenFalse( - settings.getProperty("codename1.arg.harden.mac.enabled", "true")); - } else if (isAppleHardenPlatform(hardenPlatform)) { - platformOptedOut = allAppleHardeningSlicesOptedOut(settings); - } else { - platformOptedOut = hardenPlatform != null && isHardenFalse( - settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true")); + private final class MavenAppBuilder extends AppBuilder { + MavenAppBuilder() { + super(projectHost()); } - if (platformOptedOut) { - level = "off"; - } - // Even at a non-off level, a build that has overridden every individual transform off - // (e.g. harden.rename=false, harden.strings=off, harden.controlFlow=false) requests nothing: - // the engine treats that as SKIPPED_NOT_REQUESTED, which is equivalent to off. Resolve the - // overrides to the effective transform set so such a build is not rejected on a local/source - // or on-device-debug target for a "hardening" it isn't actually asking for. An unknown level - // is NOT reduced here -- it must reach the preflight so the invalid-level check rejects it. - if (hardeningReducesToOff(settings, level, hardenPlatform)) { - level = "off"; - } - boolean allowLocal = "true".equalsIgnoreCase( - settings.getProperty("codename1.arg.harden.allowUnhardenedLocalBuild", "false").trim()); - boolean onDeviceDebug = "true".equalsIgnoreCase( - settings.getProperty("codename1.arg.android.onDeviceDebug", "false").trim()) - || (buildTarget != null && buildTarget.contains("on-device-debug")); - HardeningPreflight.Result r = HardeningPreflight.check(level, buildTarget, allowLocal, onDeviceDebug); - if (r.isFailed()) { - throw new MojoFailureException(r.getMessage()); - } - if (r.isForceOff()) { - getLog().warn(r.getMessage()); - hardeningForceOff = true; - } else { - hardeningForceOff = false; - } - hardeningWillRun = !"off".equalsIgnoreCase(level.trim()) && !r.isForceOff(); - // The APK's hardening OUTCOME. An unhardened build has one key regardless of the nominal level, so - // two off/force-off invocations still hit the cache. A hardened build fingerprints EVERY effective - // harden.* setting -- not just the level -- because harden.strings/rename/controlFlow/keep/seed all - // change the produced transforms and mapping, so two hardened:aggressive builds with different seeds - // or keep rules must still invalidate the cache. Compared against the marker recorded beside the APK. - hardeningCacheKey = hardeningWillRun - ? "hardened:" + hardeningSettingsFingerprint(settings) - : "unhardened"; - // Publish the compile classpath so the hardening engine can hand it to ProGuard as library - // jars (so an application method that overrides a framework method is not renamed apart from - // its superclass). Only needed when hardening will actually run. - if (hardeningWillRun) { + @Override + protected void updateCodenameOne(boolean force) throws BuildExecutionException { try { - List cp = project.getCompileClasspathElements(); - StringBuilder sb = new StringBuilder(); - for (String element : cp) { - File f = new File(element); - if (f.isFile() && element.endsWith(".jar")) { - if (sb.length() > 0) { - sb.append(File.pathSeparator); - } - sb.append(f.getAbsolutePath()); - } - } - hardeningLibraryJars = sb.toString(); - } catch (org.apache.maven.artifact.DependencyResolutionRequiredException ex) { - getLog().debug("Could not resolve compile classpath for hardening library jars", ex); + CN1BuildMojo.this.updateCodenameOne(force); + } catch (MojoExecutionException ex) { + throw new BuildExecutionException(ex.getMessage(), ex); } - } else { - hardeningLibraryJars = null; - } - } - - /** - * True for the targets that run the standalone macOS builder rather than a - * slice of an iOS build. - * - *

The same three {@link #hardenPlatformForBuildTarget(String)} maps to - * "mac", kept beside it so the two cannot come to disagree about which - * targets are natively macOS.

- */ - // Package-visible so a test can pin which targets are natively macOS; the - // hardening preflight's answer turns on it. - static boolean isNativeMacOsTarget(String buildTarget) { - return BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget) - || BUILD_TARGET_MAC_NATIVE.equals(buildTarget) - || BUILD_TARGET_MAC_NATIVE_LOCAL.equals(buildTarget); - } - - /** - * The {@code harden..enabled} opt-out key implied by the build target, for targets - * whose {@code codename1.platform} does not name their real hardening platform. The macOS - * targets run with {@code platform=ios} but harden as "mac", so their opt-out is - * {@code harden.mac.enabled}. Mac Catalyst needs no entry: it has no target of its own and - * really is an iOS build, so {@code harden.ios.enabled} is the correct key for it. - * Returns {@code null} when the target carries no such override and the platform value - * should be used. - */ - static String hardenPlatformForBuildTarget(String buildTarget) { - if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget) - || BUILD_TARGET_MAC_NATIVE.equals(buildTarget) - || BUILD_TARGET_MAC_NATIVE_LOCAL.equals(buildTarget)) { - return "mac"; - } - return null; - } - - /** - * True when a {@code harden.*} boolean setting reads as disabled, using the same tri-state rules - * as the engine's {@code HardeningConfig.boolTri}: {@code false}, {@code 0} and {@code off} all - * mean off. Recognizing only the literal {@code false} here would preflight-reject a - * local/source build that {@code harden..enabled=off} had actually turned off. - */ - private static boolean isHardenFalse(String value) { - if (value == null) { - return false; - } - String t = value.trim().toLowerCase(); - return "false".equals(t) || "0".equals(t) || "off".equals(t); - } - - /** True for the Apple hardening tags whose build ships several slices from one shared hardened jar. */ - private static boolean isAppleHardenPlatform(String hardenPlatform) { - return "ios".equals(hardenPlatform) || "mac".equals(hardenPlatform); - } - - /** - * True only when EVERY Apple slice this build ships (the iOS app plus any native-Mac/watch/tvOS - * target) has opted out via {@code harden..enabled}. Mirrors IPhoneBuilder.appleHardeningSlices - * / Executor.anySliceHardeningEnabled from the settings so the preflight's "reduced to off" decision - * matches the builder's "harden unless every slice opted out". A slice is present only when its target - * is enabled, so an unrelated tvOS opt-out never affects a plain iOS build. - */ - static boolean allAppleHardeningSlicesOptedOut(Properties settings) { - if (!isHardenFalse(settings.getProperty("codename1.arg.harden.ios.enabled", "true"))) { - return false; - } - if ("true".equals(settings.getProperty("codename1.arg.macNative.enabled", "false")) - && !isHardenFalse(settings.getProperty("codename1.arg.harden.mac.enabled", "true"))) { - return false; - } - if (appleSliceTargetEnabled(settings, "watch") - && !isHardenFalse(settings.getProperty("codename1.arg.harden.watch.enabled", "true"))) { - return false; } - if (appleSliceTargetEnabled(settings, "tv") - && !isHardenFalse(settings.getProperty("codename1.arg.harden.tv.enabled", "true"))) { - return false; - } - return true; - } - - /** True when the watch/tv slice is shipped: its {@code Native.enabled} or a {@code Main}. */ - private static boolean appleSliceTargetEnabled(Properties settings, String slice) { - return "true".equals(settings.getProperty("codename1.arg." + slice + "Native.enabled", "false")) - || settings.getProperty("codename1.arg." + slice + "Main", - settings.getProperty("codename1.arg." + slice + "Native.mainClass", "")).trim() - .length() > 0; - } - - /** - * True when, at this level, at least one hardening transform is still requested once the - * individual {@code harden.*} overrides are applied -- mirroring the engine's - * {@code HardeningConfig}/{@code willApplyAnyTransform} "is anything requested" decision (the - * platform-safety refinement is the server's, and only ever narrows this). A level whose every - * transform is overridden off requests nothing and is equivalent to {@code off}, so the preflight - * must not reject it. - */ - /** - * True when a valid non-off level requests no transform once the {@code harden.*} - * overrides are applied, so it is equivalent to {@code off} and must not be rejected. An unknown - * or misspelled level (rank 0) returns {@code false} so it is left untouched and reaches - * {@link HardeningPreflight#check} -- which rejects it fast, client-side, rather than letting a - * cloud build be submitted for the forked engine to reject later. - */ - static boolean hardeningReducesToOff(Properties settings, String level) { - return hardeningReducesToOff(settings, level, null); - } - - /** - * As {@link #hardeningReducesToOff(Properties, String)}, but taking the resolved hardening - * {@code platform} so a transform the engine SKIPS as unsafe on that target does not keep the level - * from reducing to off. Without this, a local iOS build with only control-flow left on (which the - * engine skips on the ParparVM native ports), or a JavaScript build with only string encryption left - * on (skipped on JS), would be rejected for a hardening it would never actually apply. - */ - static boolean hardeningReducesToOff(Properties settings, String level, String platform) { - return hardenLevelRank(level) >= 1 && !hardeningRequestsAnyTransform(settings, level, platform); - } - - static boolean hardeningRequestsAnyTransform(Properties settings, String level) { - return hardeningRequestsAnyTransform(settings, level, null); - } - - static boolean hardeningRequestsAnyTransform(Properties settings, String level, String platform) { - int rank = hardenLevelRank(level); - if (rank <= 0) { - return false; - } - // rank is 1..3 here (standard/aggressive/paranoid), so the standard-level defaults -- renaming - // and constant-string encryption -- are on unless explicitly overridden off. Control-flow is a - // default only from aggressive up. - boolean atLeastAggressive = rank >= 2; - // Rename is delivered on every platform (by the engine, or by R8 on Android), so it always counts. - boolean rename = hardenBoolTri( - settings.getProperty("codename1.arg.harden.rename"), true); - // String encryption and control-flow are subject to the engine's platform-safety rules: a - // transform the engine would skip on this target must not, on its own, keep the level from - // reducing to off. When the platform is unknown the safety checks pass (conservative -- the - // build is still preflighted rather than silently allowed). - boolean stringsOn = hardenStringsRequested( - settings.getProperty("codename1.arg.harden.strings"), true) - && stringEncryptionAppliesOn(platform); - boolean controlFlow = hardenBoolTri( - settings.getProperty("codename1.arg.harden.controlFlow"), atLeastAggressive) - && controlFlowAppliesOn(platform); - return rename || stringsOn || controlFlow; - } - - /** Engine rule: string encryption is skipped only on JavaScript (it would break the JS bridge). */ - private static boolean stringEncryptionAppliesOn(String platform) { - return !"javascript".equals(platform); - } - - /** - * Engine rule: control-flow obfuscation runs only on the JVM-bytecode ports (Android, JavaSE/ - * desktop); it is skipped on the ParparVM native ports and JavaScript. An unknown platform is - * treated as applicable so an ambiguous build is preflighted rather than silently allowed. - */ - private static boolean controlFlowAppliesOn(String platform) { - if (platform == null) { - return true; - } - return "and".equals(platform) || "android".equals(platform) - || "javase".equals(platform) || "desktop".equals(platform); - } - - /** off/empty/unknown = 0, standard = 1, aggressive = 2, paranoid = 3. */ - private static int hardenLevelRank(String level) { - if (level == null) { - return 0; - } - String v = level.trim().toLowerCase(); - if ("standard".equals(v)) { - return 1; - } - if ("aggressive".equals(v)) { - return 2; - } - if ("paranoid".equals(v)) { - return 3; - } - return 0; - } - - /** Tri-state boolean matching the engine's {@code HardeningConfig.boolTri}. */ - private static boolean hardenBoolTri(String value, boolean def) { - if (value == null) { - return def; - } - String t = value.trim().toLowerCase(); - if (t.isEmpty()) { - return def; - } - if ("true".equals(t) || "1".equals(t) || "2".equals(t) || "3".equals(t) || "on".equals(t)) { - return true; - } - if ("false".equals(t) || "0".equals(t) || "off".equals(t)) { - return false; - } - return def; - } - - /** - * Whether string encryption is requested, matching {@code HardeningConfig}'s {@code harden.strings} - * parsing: {@code off} disables; {@code constants}/{@code all} enable; anything else (including an - * unset value) falls back to the level default, which the CLI validates up front. - */ - private static boolean hardenStringsRequested(String strings, boolean def) { - if (strings == null) { - return def; - } - String v = strings.trim().toLowerCase(); - if (v.isEmpty()) { - return def; - } - if ("off".equals(v)) { - return false; - } - if ("constants".equals(v) || "all".equals(v)) { - return true; - } - return def; - } - - /** Maps {@code codename1.platform} to the {@code harden..enabled} opt-out key. */ - private static String normalizeHardenPlatform(String platform) { - if (platform == null) { - return null; - } - String p = platform.trim().toLowerCase(); - if (p.startsWith("android")) { - return "and"; - } - if (p.startsWith("ios")) { - return "ios"; - } - if (p.contains("javascript")) { - return "javascript"; - } - if (p.contains("win")) { - return "win"; - } - if (p.contains("mac")) { - return "mac"; - } - if (p.contains("linux")) { - return "linux"; - } - if (p.contains("javase") || p.contains("desktop")) { - return "javase"; - } - return null; - } - - /** - * Merge a set of jars into a single jar file. - * @param dest The destination jar file. Also the first source if it already exists. - * @param src The source jar files to be merged into the destination. - */ - private void mergeJars(File dest, File... src) { - Zip task = (Zip)antProject.createTask("zip"); - task.setDestFile(dest); - task.setUpdate(true); - for (File srcFile : src) { - - if (srcFile.isDirectory()) { - FileSet fs = new FileSet(); - // Multiversioned jars trip out the ASM class parsing. - // Specifically module-info.class files - fs.setExcludes("**/META-INF/versions/**"); - fs.setProject(this.antProject); - fs.setDir(srcFile); - task.addFileset(fs); - } else { - ZipFileSet fileset = new ZipFileSet(); - // Multiversioned jars trip out the ASM class parsing. - // Specifically module-info.class files - fileset.setExcludes("**/META-INF/versions/**"); - fileset.setProject(antProject); - fileset.setSrc(srcFile); - task.addZipfileset(fileset); + @Override + protected void mergeAnnotationBuildHints(Properties target, List classpathElements) + throws BuildFailureException { + try { + CN1BuildMojo.this.mergeAnnotationBuildHints(target, classpathElements); + } catch (MojoFailureException ex) { + throw new BuildFailureException(ex.getMessage(), ex); } } - task.execute(); - } - - /** - * Local JavaScript builds run ParparVM's translator on this machine, so - * they keep the classes a server build would have re-supplied. - */ - static boolean isLocalJavascriptBuild(String buildTarget) { - return buildTarget != null && buildTarget.contains("javascript") && isLocalBuildTarget(buildTarget); - } - - // A null build target never reaches these from the mojo -- the parameter is - // required -- but they are static and package private, so treat an absent - // target as "not local": the conservative answer, since it keeps the server - // supplied artifacts out of the staged jar rather than throwing. - - /** - * Whether the build itself re-supplies the artifact's classes after they - * are left out of the staged jar: codenameone-core and java-runtime come - * from the build server (or, for a local JavaScript build, stay in the jar - * so ParparVM's translator can see them), and kotlin-stdlib comes from the - * build server too. This is deliberately narrower than - * {@link #isStrippedFromStagedJar}: a reference into anything else that is - * left out really is a reference to a class nobody will supply. - */ - static boolean isSuppliedByBuildServer(String groupId, String artifactId, String buildTarget) { - if (GROUP_ID.equals(groupId) && contains(artifactId, BUNDLE_ARTIFACT_ID_BLACKLIST)) { - return !isLocalJavascriptBuild(buildTarget); - } - return !isLocalBuildTarget(buildTarget) - && "org.jetbrains.kotlin".equals(groupId) - && "kotlin-stdlib".equals(artifactId); - } - - /** - * Whether the given artifact is left out of the jar-with-dependencies that - * is staged for the build, either because the build re-supplies it or - * because its scope says it is not part of the application. Shared by the - * jar assembly and the class closure check so the two can never disagree - * about what is in the jar. - */ - static boolean isStrippedFromStagedJar(String groupId, String artifactId, String scope, String buildTarget) { - if (GROUP_ID.equals(groupId) && contains(artifactId, BUNDLE_ARTIFACT_ID_BLACKLIST)) { - // For local JavaScript builds we need codenameone-core and java-runtime classes - // in the staged jar - the build server normally re-supplies those, but ParparVM's - // ByteCodeTranslator runs locally here and resolves everything from the staged class - // directory. - return !isLocalJavascriptBuild(buildTarget); - } - if (isSuppliedByBuildServer(groupId, artifactId, buildTarget)) { - // When sending to the build server, we'll strip the kotlin-stdlib and the server will - // provide it. For local builds, it's easier to just include it. - return true; - } - // An artifact with no scope was never scoped out of the application, so - // keep its classes rather than dropping them from the jar. - return scope != null && !"compile".equals(scope); - } - - private boolean isStrippedFromStagedJar(Artifact artifact) { - return isStrippedAsDesktopRuntime(artifact) - || isStrippedFromStagedJar(artifact.getGroupId(), artifact.getArtifactId(), artifact.getScope(), buildTarget); - } - private boolean isStrippedAsDesktopRuntime(Artifact artifact) { - if (!isDesktopRuntimeBinary(artifact.getGroupId(), artifact.getArtifactId(), artifact.getDependencyTrail())) { - return false; - } - if (isDesktopRuntimeAggregator(artifact.getGroupId(), artifact.getArtifactId())) { - return true; + @Override + protected void overlayCommandLineBuildHints(Properties target) { + CN1BuildMojo.this.overlayCommandLineBuildHints(target); } - return isStrippedAsDesktopRuntime(dependencyKey(artifact.getGroupId(), artifact.getArtifactId(), - artifact.getClassifier()), neededWithoutDesktopRuntime()); } - /** - * Whether an artifact reached through the desktop runtime aggregator is left out of the - * staged jar: only when nothing else the application ships needs it. - * - *

The dependency trail cannot answer that on its own. Maven keeps one resolved - * artifact and one winning trail per coordinate, so when an application library also - * needs an ffmpeg artifact but the aggregator's path is the nearer one, the trail names - * only the aggregator. Stripping on the trail alone would then leave the library without - * classes it needs.

- * - * @param neededElsewhere the dependency keys the application's own compile dependencies - * need with the aggregator removed, or {@code null} when that could - * not be determined, in which case nothing is stripped - */ - static boolean isStrippedAsDesktopRuntime(String dependencyKey, Set neededElsewhere) { - return neededElsewhere != null && !neededElsewhere.contains(dependencyKey); - } - - /** groupId:artifactId:classifier, the identity {@link #isStrippedAsDesktopRuntime} compares. */ - static String dependencyKey(String groupId, String artifactId, String classifier) { - return groupId + ":" + artifactId + ":" + (classifier == null ? "" : classifier); + @Override + protected Set neededWithoutDesktopRuntimeKeys() { + return computeNeededWithoutDesktopRuntime(); } private Set neededWithoutDesktopRuntime; @@ -950,7 +212,7 @@ static String dependencyKey(String groupId, String artifactId, String classifier * artifact that could have been stripped -- an upload too large, which the build client * refuses loudly -- never drop one the application needs.

*/ - private Set neededWithoutDesktopRuntime() { + private Set computeNeededWithoutDesktopRuntime() { if (neededWithoutDesktopRuntimeComputed) { return neededWithoutDesktopRuntime; } @@ -963,7 +225,7 @@ private Set neededWithoutDesktopRuntime() { request.setRepositories(project.getRemoteProjectRepositories()); for (Dependency dependency : project.getDependencies()) { String scope = dependency.getScope(); - if (isDesktopRuntimeAggregator(dependency.getGroupId(), dependency.getArtifactId()) + if (AppBuilder.isDesktopRuntimeAggregator(dependency.getGroupId(), dependency.getArtifactId()) || (scope != null && scope.length() > 0 && !"compile".equals(scope))) { continue; } @@ -1006,2073 +268,112 @@ private static void addDependencyKeys(DependencyNode node, Set keys) { } org.eclipse.aether.artifact.Artifact a = node.getArtifact(); if (a != null && node.getDependency() != null) { - keys.add(dependencyKey(a.getGroupId(), a.getArtifactId(), a.getClassifier())); + keys.add(AppBuilder.dependencyKey(a.getGroupId(), a.getArtifactId(), a.getClassifier())); } for (DependencyNode child : node.getChildren()) { addDependencyKeys(child, keys); } } - private static boolean isDesktopRuntimeAggregator(String groupId, String artifactId) { - return GROUP_ID.equals(groupId) && DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID.equals(artifactId); + // ---- The build's decisions, kept reachable under their old names ---------------------- + // They live in AppBuilder now; these are what the tests (and anything else that asked + // CN1BuildMojo) call. + + static boolean isLocalJavascriptBuild(String buildTarget) { + return AppBuilder.isLocalJavascriptBuild(buildTarget); + } + + static boolean isSuppliedByBuildServer(String groupId, String artifactId, String buildTarget) { + return AppBuilder.isSuppliedByBuildServer(groupId, artifactId, buildTarget); + } + + static boolean isStrippedFromStagedJar(String groupId, String artifactId, String scope, String buildTarget) { + return AppBuilder.isStrippedFromStagedJar(groupId, artifactId, scope, buildTarget); + } + + static boolean isStrippedAsDesktopRuntime(String dependencyKey, Set neededElsewhere) { + return AppBuilder.isStrippedAsDesktopRuntime(dependencyKey, neededElsewhere); + } + + static String dependencyKey(String groupId, String artifactId, String classifier) { + return AppBuilder.dependencyKey(groupId, artifactId, classifier); } - /** - * Whether an existing staged jar may be used instead of merging a new one (before the - * timestamp check, which still applies). - * - *

With a record, only when it names exactly the current inputs. Without one, the jar - * was not written by this mojo -- or was written by a plugin older than the record, which - * is indistinguishable from a stale one. A project may deliberately produce this jar from - * its own pom to control what is uploaded, and that is still honoured: such a jar is - * written during the current Maven run, while a stale one predates it.

- * - *

Known limit, left deliberately: a record wins over the current-run exception. A - * project that once let this mojo stage the jar, then starts producing it from its own - * pom without cleaning target, and whose dependencies changed in between, has its jar - * replaced by a merged one. That customization is undocumented and used by nothing in - * the tree or the archetypes; the fix is to clean target once.

- * - * @param recorded the recorded inputs, trimmed, or {@code null} if there is no record - * @param current the inputs this build would merge - * @param jarModified the staged jar's modification time - * @param sessionStart when the current Maven run started - */ static boolean mayReuseStagedJar(String recorded, String current, long jarModified, long sessionStart) { - if (recorded == null) { - return jarModified >= sessionStart; - } - return recorded.equals(current.trim()); + return AppBuilder.mayReuseStagedJar(recorded, current, jarModified, sessionStart); } - /** - * Deletes a staged jar that must not be reused, and fails if it is still there. Ignoring - * a failed delete (a jar held open on Windows, a read-only target) would skip the merge - * and upload the very jar that was just found stale. - */ static void discardStagedJar(File jar) throws MojoExecutionException { - if (!jar.delete() && jar.exists()) { - throw new MojoExecutionException("Could not delete the out of date staged jar " + jar - + ", so it cannot be rebuilt and would be uploaded as it is. Close anything that" - + " has it open (an IDE, an antivirus scanner) or delete it by hand, then build again."); + try { + AppBuilder.discardStagedJar(jar); + } catch (BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex); } } - /** - * The record of what a staged jar was merged from, one absolute path per line in merge - * order. Compared, not parsed: any difference means the cached jar is not this build's. - */ static String describeStagedInputs(List jarsToMerge) { - StringBuilder sb = new StringBuilder(); - for (File f : jarsToMerge) { - sb.append(f.getAbsolutePath()).append('\n'); - } - return sb.toString(); + return AppBuilder.describeStagedInputs(jarsToMerge); } - /** - * The aggregator that puts the desktop media runtime -- org.bytedeco's ffmpeg, - * with natives for Android, iOS, Linux, macOS and Windows -- on the simulator - * and desktop run classpaths. - */ - static final String DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID = "cn1-binaries-javase"; + static boolean isDesktopRuntimeBinary(String groupId, String artifactId, List dependencyTrail) { + return AppBuilder.isDesktopRuntimeBinary(groupId, artifactId, dependencyTrail); + } - /** - * Whether the artifact is the desktop runtime aggregator or was resolved - * through it: the candidates for leaving out of the staged jar, whatever - * their scope. The aggregator itself is always left out; anything it pulled - * in is left out only when nothing else needs it - * ({@link #isStrippedAsDesktopRuntime(String, Set)}). - * - *

Projects generated from the archetype between #5380 and the fix for it - * declare the aggregator at compile scope, and no profile re-scopes it, so - * by scope alone it lands in the staged jar: about 300 MB of ffmpeg natives - * that the build client refuses to upload, failing every desktop build of - * every such project. Deciding by the dependency graph rather than the scope - * repairs those projects without asking anyone to edit a pom.

- */ - static boolean isDesktopRuntimeBinary(String groupId, String artifactId, List dependencyTrail) { - if (isDesktopRuntimeAggregator(groupId, artifactId)) { - return true; - } - if (dependencyTrail == null) { - return false; - } - // Trail entries are Artifact.getId(): groupId:artifactId:type:version. - String aggregator = GROUP_ID + ":" + DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID + ":"; - for (String node : dependencyTrail) { - if (node != null && node.startsWith(aggregator)) { - return true; - } - } - return false; - } - - /** - * Fails the build when the staged jar-with-dependencies contains an - * application class that references another application class which is not - * in the jar. Stale build output causes exactly this -- e.g. an IDE deletes - * the class files of removed/renamed sources while a dependent class - * compiled against them survives in target/classes, and an incremental - * Maven compile then ships it. The server-side VM translators only warn - * about the dangling reference and the build later fails with an obscure - * native compiler error (a missing generated header on iOS), so catch it - * here with an actionable message before uploading anything. - */ - private void verifyApplicationClassClosure(File jarWithDependencies, List cpElements) throws MojoFailureException { - if ("true".equals(System.getProperty("codename1.skipClassClosureCheck", "false")) - || "true".equals(project.getProperties().getProperty("codename1.skipClassClosureCheck", "false"))) { - getLog().info("Skipping application class closure check because codename1.skipClassClosureCheck=true"); - return; - } - // The project package space: compiled output directories plus module - // jars owned by this build (the app's common jar reaches the platform - // modules as a jar dependency, identified by the shared groupId). - List projectClassRoots = new ArrayList(); - for (String element : cpElements) { - File dir = new File(element); - if (dir.isDirectory()) { - projectClassRoots.add(dir); - } - } - for (Artifact artifact : project.getArtifacts()) { - if (project.getGroupId().equals(artifact.getGroupId())) { - File jar = getJar(artifact); - if (jar != null && jar.isFile()) { - projectClassRoots.add(jar); - } - } - } - // Classes left out of the staged jar that the build re-supplies: references - // into them are not missing. Anything else that was left out -- a `provided` - // scope third party dependency, say -- stays reportable, because nothing - // puts those classes back before the translators run. - boolean localJavascriptBuild = isLocalJavascriptBuild(buildTarget); - Set providedJars = new LinkedHashSet(); - for (Artifact artifact : project.getArtifacts()) { - if (isSuppliedByBuildServer(artifact.getGroupId(), artifact.getArtifactId(), buildTarget)) { - File jar = getJar(artifact); - if (jar != null && jar.isFile()) { - providedJars.add(jar); - } - } - } - if (!localJavascriptBuild) { - // codenameone-core and java-runtime are `provided` scope in the app's common - // module and `provided` is not transitive, so a platform module (ios, android, - // ...) that only depends on common does not see them among its own artifacts. - // They still have to count as provided: without them every reference into a - // package the app shares with the framework -- a patched framework class, or a - // helper deliberately placed in a com.codename1 package to reach package - // private API -- makes the whole framework package look like it is missing - // from the build. Resolve them from the plugin's own dependencies instead. - boolean coreResolved = false; - for (String bundled : BUNDLE_ARTIFACT_ID_BLACKLIST) { - File jar = getJar(GROUP_ID, bundled); - if (jar != null && jar.isFile()) { - providedJars.add(jar); - if ("codenameone-core".equals(bundled)) { - coreResolved = true; - } - } - } - if (!coreResolved) { - // Every application class references the framework, so without its class - // list the check cannot tell a stale class from a framework class and would - // report false positives. Skipping is the only safe answer, but say so - // loudly: a stale class will now reach the build unreported. - getLog().warn("Skipping the application class closure check for build target " + buildTarget - + " because " + GROUP_ID + ":codenameone-core could not be resolved." - + " Stale application classes will not be detected before the build runs."); - return; - } - } - Map> missing; - try { - missing = ClassClosureVerifier.findMissingProjectReferences(jarWithDependencies, projectClassRoots, providedJars); - } catch (IOException ex) { - getLog().warn("Could not verify the consistency of the application classes: " + ex.getMessage()); - return; - } - if (missing.isEmpty()) { - return; - } - StringBuilder sb = new StringBuilder(); - sb.append("The compiled application classes are inconsistent. The following class"); - sb.append(missing.size() == 1 ? " is" : "es are"); - sb.append(" referenced by your code but missing from the build:\n"); - for (Map.Entry> e : missing.entrySet()) { - sb.append(" - ").append(e.getKey().replace('/', '.')) - .append(" (referenced from "); - boolean first = true; - for (String referencing : e.getValue()) { - if (!first) { - sb.append(", "); - } - first = false; - sb.append(referencing.replace('/', '.')); - } - sb.append(")\n"); - } - sb.append("This usually means the build output contains stale classes from a previous build,\n"); - sb.append("e.g. after renaming, moving or deleting a class, or switching branches, without a\n"); - sb.append("clean rebuild. Run 'mvn clean' and rebuild. If a listed class was removed\n"); - sb.append("intentionally, also remove or update the code that still references it.\n"); - sb.append("To bypass this check build with -Dcodename1.skipClassClosureCheck=true"); - throw new MojoFailureException(sb.toString()); - } - - - /** - * Localized launcher icons (cn1_icon_<lang>[_<country>].png) are scaled up to the - * largest launcher density by the build server, so a low-resolution source produces a - * blurry icon. Maven copies these into the build output (target/classes) with its - * incremental resource plugin, which also leaves stale copies behind when a source icon - * is removed or replaced (only {@code mvn clean} clears them). Scan the compiled output - * directories that will be bundled and sent to the build server and warn about any - * localized icon that is too small to render sharply -- this catches both an undersized - * new icon and an outdated low-resolution one lingering in target/classes. - * - * @param classpathElements the compile classpath; directory entries are the project / - * module {@code target/classes} folders that get bundled. - * @param codenameOneSettings the project's codenameone_settings.properties, used to detect - * whether adaptive icons are enabled (which raises the target size). - */ - private void warnAboutSmallLocalizedIcons(List classpathElements, File codenameOneSettings) - throws MojoFailureException { - int largestTarget = 192; - try { - Properties settings = new Properties(); - try (FileInputStream fis = new FileInputStream(codenameOneSettings)) { - settings.load(fis); - } - if ("true".equals(settings.getProperty("codename1.arg.android.enableAdaptiveIcons", "false").trim())) { - largestTarget = 432; - } - } catch (IOException ex) { - getLog().debug("Could not read " + codenameOneSettings + " to determine adaptive icon setting", ex); - } - List undersizedIcons = new ArrayList(); - for (String element : classpathElements) { - File dir = new File(element); - if (dir.isDirectory()) { - collectSmallLocalizedIcons(dir, largestTarget, undersizedIcons); - } - } - if (!undersizedIcons.isEmpty()) { - throw new MojoFailureException("The following localized launcher icon(s) are smaller than " - + largestTarget + "x" + largestTarget + "px and would be upscaled to a blurry icon in the " - + "production build:\n " + String.join("\n ", undersizedIcons) - + "\nSupply each localized icon at no less than " + largestTarget + "x" + largestTarget - + "px (1024x1024 recommended, matching the main app icon). NOTE: if you recently replaced an icon, " - + "an offending copy may be a stale resource left in target/classes -- run 'mvn clean' to clear it."); - } - } - - private void collectSmallLocalizedIcons(File dir, int largestTarget, List undersizedIcons) { - File[] children = dir.listFiles(); - if (children == null) { - return; - } - for (File child : children) { - if (child.isDirectory()) { - collectSmallLocalizedIcons(child, largestTarget, undersizedIcons); - continue; - } - String lower = child.getName().toLowerCase(); - if (!lower.startsWith("cn1_icon_") || !lower.endsWith(".png")) { - continue; - } - try { - BufferedImage img = ImageIO.read(child); - if (img == null) { - undersizedIcons.add(child + " (not a valid PNG image)"); - continue; - } - if (img.getWidth() < largestTarget || img.getHeight() < largestTarget) { - undersizedIcons.add(child + " (" + img.getWidth() + "x" + img.getHeight() + "px)"); - } - } catch (IOException ex) { - getLog().debug("Could not read localized icon " + child + " to check its resolution", ex); - } - } - } - - /** - * The dependency scopes to include in the jar file that is sent to the build server. - */ - private static String[] BUNDLE_ARTIFACT_SCOPES = new String[] { "compile" }; - - /** - * Artifact IDs that should not be sent to the build server. - */ - private static String[] BUNDLE_ARTIFACT_ID_BLACKLIST = new String[] {"codenameone-core", "java-runtime"}; - - - /** - * Gets the app extensions jar file that should be included in any iOS builds. - * @return The app extensions jar file if it exists. null otherwise. - * @throws IOException - */ - private File getAppExtensionsJar() throws IOException { - - if (!project.getProperties().getProperty("codename1.platform", "").equalsIgnoreCase("ios")){ - // App extensions are only for iOS - return null; - } - if (!project.getBasedir().getName().equalsIgnoreCase("ios")) { - // Make sure we're building the ios project - return null; - } - - File appExtensionsDir = new File(project.getBasedir(), "app_extensions"); - - if (!appExtensionsDir.isDirectory()) return null; - - File appExtensionsJar = new File(project.getBuild().getDirectory() + File.separator + "codenameone" + File.separator + "app_extensions.jar"); - if (appExtensionsJar.exists() && appExtensionsJar.lastModified() < lastModifiedRecursive(appExtensionsDir)) { - // The app extensions jar is out of date. - appExtensionsJar.delete(); - } - - if (!appExtensionsJar.exists()) { - File tmpDir = new File(appExtensionsJar.getParentFile(), "app_extensions"); - if (tmpDir.exists()) { - FileUtils.deleteDirectory(tmpDir); - } - tmpDir.mkdirs(); - for (File appExtension : appExtensionsDir.listFiles()) { - Zip task = (Zip)antProject.createTask("zip"); - File dest = new File(tmpDir, appExtension.getName()+".ios.appext"); - task.setDestFile(dest); - task.setUpdate(false); - if (appExtension.isDirectory()) { - FileSet fs = new FileSet(); - fs.setProject(this.antProject); - fs.setDir(appExtension); - task.addFileset(fs); - task.execute(); - } else if (appExtension.getName().endsWith(".zip")) { - ZipFileSet fileset = new ZipFileSet(); - fileset.setProject(antProject); - fileset.setSrc(appExtension); - task.addZipfileset(fileset); - task.execute(); - } - - - } - Zip task = (Zip)antProject.createTask("zip"); - task.setDestFile(appExtensionsJar); - task.setUpdate(false); - FileSet fs = new FileSet(); - fs.setProject(this.antProject); - fs.setDir(tmpDir); - task.addFileset(fs); - task.execute(); - - } - - if (appExtensionsJar.exists()) { - return appExtensionsJar; - } - - return null; - } - - /** - * Gets the localizations jar file that should be included in any iOS builds. - * @return The localizations jar file if it exists. null otherwise. - * @throws IOException - */ - private File getStringsJar() throws IOException { - - if (!project.getProperties().getProperty("codename1.platform", "").equalsIgnoreCase("ios")){ - // App extensions are only for iOS - return null; - } - if (!project.getBasedir().getName().equalsIgnoreCase("ios")) { - // Make sure we're building the ios project - return null; - } - - File stringsDir = new File(project.getBasedir(), path("src", "main", "strings")); - - if (!stringsDir.isDirectory()) return null; - - File stringsJar = new File(project.getBuild().getDirectory() + File.separator + "codenameone" + File.separator + "strings.jar"); - if (stringsJar.exists() && stringsJar.lastModified() < lastModifiedRecursive(stringsDir)) { - // The app extensions jar is out of date. - stringsJar.delete(); - } - - if (!stringsJar.exists()) { - File tmpDir = new File(stringsJar.getParentFile(), "strings"); - if (tmpDir.exists()) { - FileUtils.deleteDirectory(tmpDir); - } - tmpDir.mkdirs(); - for (File lproj : stringsDir.listFiles()) { - Zip task = (Zip)antProject.createTask("zip"); - File dest = new File(tmpDir, lproj.getName()+".zip"); - task.setDestFile(dest); - task.setUpdate(false); - if (lproj.isDirectory()) { - FileSet fs = new FileSet(); - fs.setProject(this.antProject); - fs.setDir(lproj); - task.addFileset(fs); - task.execute(); - } else if (lproj.getName().endsWith(".zip")) { - ZipFileSet fileset = new ZipFileSet(); - fileset.setProject(antProject); - fileset.setSrc(lproj); - task.addZipfileset(fileset); - task.execute(); - } - - - } - Zip task = (Zip)antProject.createTask("zip"); - task.setDestFile(stringsJar); - task.setUpdate(false); - FileSet fs = new FileSet(); - fs.setProject(this.antProject); - fs.setDir(tmpDir); - task.addFileset(fs); - task.execute(); - - } - - if (stringsJar.exists()) { - return stringsJar; - } - - return null; - } - - public static final String BUILD_TARGET_MAC_NATIVE_PROJECT = Executor.BUILD_TARGET_MAC_NATIVE_PROJECT; - public static final String BUILD_TARGET_MAC_NATIVE = Executor.BUILD_TARGET_MAC_NATIVE; - public static final String BUILD_TARGET_MAC_NATIVE_LOCAL = Executor.BUILD_TARGET_MAC_NATIVE_LOCAL; - public static final String BUILD_TARGET_LINUX_NATIVE = Executor.BUILD_TARGET_LINUX_NATIVE; - - /** - * The entry points a project can declare besides {@code codename1.mainName}, - * mapped to the build argument each one becomes. A project with a - * {@code codename1.watchMain} gets an Apple Watch and a Wear OS app built - * from that root; {@code codename1.tvMain} does the same for tvOS. The - * accompanying {@code codename1.watchStandalone} says the watch app ships on - * its own rather than alongside the phone app. - * - *

These ride the extensible build-argument map rather than the - * {@link BuildRequest} wire format, so adding an entry point needs no - * protocol change. - */ - private static final Map SECONDARY_ENTRY_POINTS; - static { - Map m = new LinkedHashMap(); - m.put("codename1.watchMain", "watchMain"); - m.put("codename1.watchStandalone", "watchStandalone"); - m.put("codename1.tvMain", "tvMain"); - SECONDARY_ENTRY_POINTS = Collections.unmodifiableMap(m); - } - - /** - * Copies the secondary entry points declared in the project settings onto a - * local {@link BuildRequest}. The cloud path does the equivalent by mirroring - * them into the {@code codename1.arg.} namespace of the uploaded settings - * file, so both paths hand the builders the same arguments. - * - * @param r the request being assembled - * @param props the project's codenameone_settings.properties - */ - private static void putSecondaryEntryPointArguments(BuildRequest r, Properties props) { - for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) { - // The overlaid build argument first, exactly as the cloud mirror resolves it. - // - // Reading only the unprefixed project setting made the two paths disagree about the - // same invocation: -Dcodename1.arg.watchMain=... reached the daemon, because the - // mirror leaves an existing value alone, and was ignored locally -- so the standard - // override built one product in the cloud and a different one, or none, on the - // developer's machine. An override that works in one place and silently does nothing - // in the other is worse than one that works nowhere. - String value = props.getProperty("codename1.arg." + entry.getValue()); - if (value == null || value.trim().length() == 0) { - value = props.getProperty(entry.getKey()); - } - if (value != null && value.trim().length() > 0) { - r.putArgument(entry.getValue(), value.trim()); - } - } - } - - /** - * Copies the secondary entry points into the {@code codename1.arg.} namespace - * of the settings file that is uploaded to the build server. - * - *

They are declared without that prefix because they sit next to - * {@code codename1.mainName} and that is the shape developers expect. The - * server, however, only lifts {@code codename1.arg.*} keys out of the - * uploaded file, so without this mirror a cloud build never learns that the - * project has a watch or TV app and silently produces neither. - * - * @param props the settings being prepared for upload, mutated in place - */ - static void mirrorSecondaryEntryPointsToBuildArgs(Properties props) { - for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) { - String value = props.getProperty(entry.getKey()); - if (value == null || value.trim().length() == 0) { - continue; - } - String argKey = "codename1.arg." + entry.getValue(); - // An existing value WINS. This runs after overlayCommandLineBuildHints, so a - // -Dcodename1.arg.watchMain=... passed on the command line is already sitting here -- - // and overwriting it with the project file's codename1.watchMain made the standard - // build-argument override silently do nothing, handing the cloud the wrong lifecycle or - // a companion where a standalone Wear build was asked for. - // - // The mirror exists to carry a project-file setting into the args channel the daemon - // reads, which is only needed when nothing has put it there already. - String existing = props.getProperty(argKey); - if (existing != null && existing.trim().length() > 0) { - continue; - } - props.setProperty(argKey, value.trim()); - } - } - - private static boolean isLocalBuildTarget(String buildTarget) { - if (buildTarget == null) { - return false; - } - // windows-device (BUILD_TARGET_WINDOWS_NATIVE) is a *cloud* build: it sends - // a "win32" build to the server (see the windows-device target in - // buildxml-template.xml), mirroring linux-device. Only the explicit - // local-windows-device cross-compile and the windows-source project - // generation are local. - return (buildTarget.startsWith("local-") || BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) - || BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget) - || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget) - || BUILD_TARGET_WINDOWS_NATIVE_PROJECT.equals(buildTarget)); - } - - /** - * Collapses build-type-qualified app-extension keys into the unqualified keys the rest - * of the build pipeline understands. Like the app's own signing assets - * ({@code codename1.ios.debug.provision} vs {@code codename1.ios.release.provision}), - * an extension's provisioning profile differs between development and distribution - * builds, so both plain settings ({@code codename1.ios.debug.appext..provision}) - * and build hints ({@code codename1.arg.ios.release.appext..provisioningURL}) - * accept a {@code debug}/{@code release} qualifier after the {@code ios.} segment. - * The variant matching the build target overrides the unqualified key; both variants - * are removed afterwards so the build server only ever sees the resolved value. - */ - static void resolveAppExtensionBuildTypeQualifiers(Properties props, String buildTarget) { - String matching = buildTarget != null && buildTarget.contains("release") ? "release" : "debug"; - for (String key : new ArrayList(props.stringPropertyNames())) { - for (String prefix : new String[] {"codename1.arg.ios.", "codename1.ios."}) { - String qualifier; - if (key.startsWith(prefix + "debug.appext.")) { - qualifier = "debug"; - } else if (key.startsWith(prefix + "release.appext.")) { - qualifier = "release"; - } else { - continue; - } - String value = props.getProperty(key); - props.remove(key); - if (qualifier.equals(matching) && value != null && value.trim().length() > 0) { - props.setProperty(prefix + "appext." - + key.substring((prefix + qualifier + ".appext.").length()), value); - } - break; - } - } - } - - private void createAntProject() throws IOException, LibraryPropertiesException, MojoExecutionException, MojoFailureException { - File cn1dir = new File(project.getBuild().getDirectory() + File.separator + "codenameone"); - File antProject = new File(cn1dir, "antProject"); - - antProject.mkdirs(); - File codenameOneSettings = new File(getCN1ProjectDir(), "codenameone_settings.properties"); - File icon = new File(getCN1ProjectDir(), "icon.png"); - if (icon.exists()) { - FileUtils.copyFile(icon, new File(antProject, "icon.png")); - } else { - FileUtils.copyInputStreamToFile(getClass().getResourceAsStream("codenameone-icon.png"), new File(antProject, "icon.png")); - } - - File codenameOneSettingsCopy = new File(antProject, codenameOneSettings.getName()); - FileUtils.copyFile(codenameOneSettings, codenameOneSettingsCopy); - FileUtils.copyInputStreamToFile(getClass().getResourceAsStream("buildxml-template.xml"), new File(antProject, "build.xml")); - File distDir = new File(antProject, "dist"); - distDir.mkdirs(); - // The UpdateCodenameOne run inside the ant build writes lib/CLDC11.jar without - // creating parent dirs; make sure the dir exists (same latent hole as the - // template tmpProject -- only surfaces when the server publishes new versions). - new File(antProject, "lib").mkdirs(); - - - // Build a jar with all dependencies that we will send to the build server. - File jarWithDependencies = new File(path(project.getBuild().getDirectory(), project.getBuild().getFinalName() + "-"+buildTarget+"-jar-with-dependencies.jar")); - List cpElements; - try { - //getLog().info("Classpath Elements: "+ project.getCompileClasspathElements()); - cpElements = project.getCompileClasspathElements(); - } catch (Exception ex) { - throw new MojoExecutionException("Failed to get classpath elements", ex); - - } - - warnAboutSmallLocalizedIcons(cpElements, codenameOneSettings); - - File appExtensionsJar = getAppExtensionsJar(); - if (appExtensionsJar != null) { - cpElements.add(appExtensionsJar.getAbsolutePath()); - } - File stringsJar = getStringsJar(); - if (stringsJar != null) { - cpElements.add(stringsJar.getAbsolutePath()); - } - getLog().debug("Classpath Elements: "+cpElements); - // Decide what goes into the staged jar BEFORE deciding whether a cached one can be - // reused. The decision has side effects the build needs either way (the kotlin-stdlib - // version the server must supply), and the cached jar is only reusable when it was - // built from exactly this set. - List blackListJars = new ArrayList(); - boolean localJsBuild = isLocalJavascriptBuild(buildTarget); - for (Artifact artifact : project.getArtifacts()) { - boolean addToBlacklist = isStrippedFromStagedJar(artifact); - if (addToBlacklist && !isLocalBuildTarget(buildTarget) - && "org.jetbrains.kotlin".equals(artifact.getGroupId()) - && "kotlin-stdlib".equals(artifact.getArtifactId())) { - serverMustProvideKotlinVersion = artifact.getVersion(); - getLog().debug("Adding kotlin-stdlib to blacklist. Server will provide this:" + artifact); - } - if (addToBlacklist) { - File jar = getJar(artifact); - if (jar != null) { - blackListJars.add(jar.getAbsolutePath()); - blackListJars.add(jar.getPath()); - try { - blackListJars.add(jar.getCanonicalPath()); - getLog().debug("Added "+jar+" to blacklist"); - } catch (Exception ex){ - getLog().debug("Failed to add " + jar + " to blacklist. This is not a fatal error: " + ex); - } - } - } - } - List jarsToMerge = new ArrayList(); - for (String element : cpElements) { - - String canonicalEl = element; - try { - canonicalEl = new File(canonicalEl).getCanonicalPath(); - } catch (Exception ex){ - if (getLog().isDebugEnabled()) { - getLog().warn("Failed to resolve canonical path for " + element, ex); - } - } - - if (blackListJars.contains(element) || blackListJars.contains(canonicalEl)) { - getLog().debug("NOT adding jar "+element+" because it is on the blacklist"); - continue; - } - if (!new File(element).exists()) { - continue; - } - jarsToMerge.add(new File(element)); - } - if (localJsBuild) { - // For local JavaScript builds we need codenameone-core and java-runtime classes - // in the staged jar -- the build server normally re-supplies those, but ParparVM's - // ByteCodeTranslator runs locally here and resolves everything from the staged class - // directory. `provided`-scope deps are not transitive, so a child module that only - // depends on a `common` library never sees the project's codenameone-core / - // java-runtime jars on its compile classpath. Pull them in explicitly here. - for (String bundled : BUNDLE_ARTIFACT_ID_BLACKLIST) { - File jar = getJar("com.codenameone", bundled); - if (jar != null && jar.isFile() && !jarsToMerge.contains(jar)) { - getLog().info("Adding local-javascript dependency to jar-with-dependencies: " + jar); - jarsToMerge.add(jar); - } - } - } - - // Each staged jar this mojo writes records the inputs it was merged from. Timestamps - // alone cannot see a change in what the plugin excludes, so a jar staged before an - // exclusion was added (the ~157 MB of ffmpeg natives from #5380) would otherwise be - // reused, and uploaded, for as long as nothing on the classpath was rebuilt. - File stagedInputsFile = new File(jarWithDependencies.getPath() + ".inputs"); - String stagedInputs = describeStagedInputs(jarsToMerge); - if (jarWithDependencies.exists()) { - getLog().debug("Found jar file with dependencies at "+jarWithDependencies+". Will use that one unless it is out of date."); - long sessionStart = getSession() != null && getSession().getRequest() != null - && getSession().getRequest().getStartTime() != null - ? getSession().getRequest().getStartTime().getTime() : Long.MAX_VALUE; - if (!mayReuseStagedJar(readTextFileOrNull(stagedInputsFile), stagedInputs, - jarWithDependencies.lastModified(), sessionStart)) { - getLog().debug("Jar file was not staged from these inputs. "+jarWithDependencies+". Deleting"); - discardStagedJar(jarWithDependencies); - } else { - for (String artifact : cpElements) { - File jar = new File(artifact); - if (jar.isDirectory()) { - if (jarWithDependencies.lastModified() < lastModifiedRecursive(jar)) { - getLog().debug("Jar file out of date. Dependencies have changed. "+jarWithDependencies+". Deleting"); - discardStagedJar(jarWithDependencies); - break; - } - } else if (jar.exists() && jar.lastModified() > jarWithDependencies.lastModified()) { - // One of the dependency jar files is newer... so we delete the dependencies jar file - // and will generate a new one. - getLog().debug("Jar file out of date. Dependencies have changed. "+jarWithDependencies+". Deleting"); - discardStagedJar(jarWithDependencies); - break; - } - } - } - } - - if (!jarWithDependencies.exists()) { - getLog().info(jarWithDependencies + " not found. Generating jar with dependencies now"); - getLog().debug("Merging into jar with dependencies: "+jarsToMerge); - stagedInputsFile.delete(); - mergeJars(jarWithDependencies, jarsToMerge.toArray(new File[jarsToMerge.size()])); - writeStringToFile(stagedInputsFile, stagedInputs); - } - - verifyApplicationClassClosure(jarWithDependencies, cpElements); - - if (stageOnly) { - getLog().info("codename1.stageOnly is set: staged " + jarWithDependencies + " (" - + jarWithDependencies.length() + " bytes) for " + buildTarget + " and stopped before building"); - return; - } - - try { - updateCodenameOne(false); - } catch (MojoExecutionException ex) { - getLog().error("Failed to update Codename One"); - throw new IOException("Failed to update Codename One", ex); - } - File antDistDir = new File(antProject, "dist"); - File antDistJar = new File(antDistDir, project.getBuild().getFinalName() + "-"+buildTarget+"-jar-with-dependencies.jar"); - antDistDir.mkdirs(); - FileUtils.copyFile(jarWithDependencies, antDistJar); - Properties p = new Properties(); - p.setProperty("codenameone_settings.properties", codenameOneSettingsCopy.getAbsolutePath()); - p.setProperty("CodeNameOneBuildClient.jar", path(System.getProperty("user.home"), ".codenameone", "CodeNameOneBuildClient.jar")); - p.setProperty("dist.jar", antDistJar.getAbsolutePath()); - if (automated) { - p.setProperty("automated", "true"); - } - getLog().info("Running ANT build target " + buildTarget); - String logPasskey = UUID.randomUUID().toString(); - Properties cn1SettingsProps = new Properties(); - try (FileInputStream fis = new FileInputStream(codenameOneSettingsCopy)) { - cn1SettingsProps.load(fis); - } - // Build hints declared as annotations on the main class. Merged here, before - // everything that consumes the effective configuration: the command-line - // overlay below (so -D still wins), the CN1Lib appended/required merges (so a - // library appends onto an annotation-supplied value exactly as it would onto a - // file-supplied one), the gradle sanity check, both preflights, and the copy - // that is written back out and uploaded. - mergeAnnotationBuildHints(cn1SettingsProps, cpElements); - // The build request is assembled from this copy, not from the mojo's - // own properties, so the command-line overlay has to be applied here - // too -- otherwise a hint passed with -D is read by the mojo and still - // absent from what the builder actually sees. - overlayCommandLineBuildHints(cn1SettingsProps); - if (serverMustProvideKotlinVersion != null) { - cn1SettingsProps.setProperty("codename1.arg.requireKotlinStdlib", serverMustProvideKotlinVersion); - } - FileSystemManager fsManager = VFS.getManager(); - FileObject jarFile = fsManager.resolveFile( "jar:"+jarWithDependencies.getAbsolutePath() + "!/META-INF/codenameone" ); - if (jarFile != null) { - FileObject[] appendedPropsFiles = jarFile.findFiles(new PatternFileSelector(".*\\/codenameone_library_appended.properties")); - if (appendedPropsFiles != null) { - for (FileObject appendedPropsFile : appendedPropsFiles) { - SortedProperties appendedProps = new SortedProperties(); - try (InputStream appendedPropsIn = appendedPropsFile.getContent().getInputStream()) { - appendedProps.load(appendedPropsIn); - } - - for (String propName : appendedProps.stringPropertyNames()) { - String propVal = appendedProps.getProperty(propName); - if (!cn1SettingsProps.containsKey(propName)) { - cn1SettingsProps.put(propName, propVal); - } else { - String existing = cn1SettingsProps.getProperty(propName); - // Separator decided by the hint rather than by whatever the library - // baked into its own value -- see LibraryHintMerger for why a bare - // concatenation welds two Gradle statements into one. - if (!LibraryHintMerger.alreadyContains(propName, existing, propVal)) { - cn1SettingsProps.setProperty(propName, - LibraryHintMerger.append(propName, existing, propVal)); - } - } - } - } - } - FileObject[] requiredPropsFiles = jarFile.findFiles(new PatternFileSelector(".*\\/codenameone_library_required.properties")); - if (requiredPropsFiles != null) { - for (FileObject requiredPropsFile : requiredPropsFiles) { - SortedProperties requiredProps = new SortedProperties(); - try (InputStream appendedPropsIn = requiredPropsFile.getContent().getInputStream()) { - requiredProps.load(appendedPropsIn); - } - - String artifactId = requiredPropsFile.getParent().getName().getBaseName(); - String groupId = requiredPropsFile.getParent().getParent().getName().getBaseName(); - String libraryName = groupId + ":" + artifactId; - cn1SettingsProps = mergeRequiredProperties(libraryName, requiredProps, cn1SettingsProps); - } - } - - } - - // Fail here rather than in Gradle. A dependency hint that ran two statements together - // surfaces on the build server as a Groovy MissingMethodException against a generated - // build.gradle line, which says nothing about which hint or which library produced it -- - // and on a cloud build that answer costs a queue slot and a round trip to discover. - for (String gradleHint : new String[] {"codename1.arg.android.gradleDep", - "codename1.arg.gradleDependencies"}) { - String problem = LibraryHintMerger.findUnseparatedStatement( - gradleHint, cn1SettingsProps.getProperty(gradleHint)); - if (problem != null) { - throw new MojoExecutionException(problem); - } - } - - // Re-run the hardening pre-flight against the MERGED effective settings: a CN1Lib can supply - // codename1.arg.harden.level (or a per-platform opt-out) via the appended/required properties - // just merged above, which the early pre-flight -- run before this jar existed -- could not see. - // Without this, a library that turns hardening on would slip a local/source build past the - // local-build refusal / force-off and produce a locally hardened artifact whose mapping is never - // uploaded (so its crashes could never be retraced). - applyHardeningPreflight(cn1SettingsProps); - - // Same reason, for the same reason: a CN1Lib can supply ios.*.distributionMethod, so the - // profile's kind is compared against the export method only now that those properties - // have been merged -- an early refusal would reject a build the merge was about to fix. - applyIOSProvisioningPreflight(cn1SettingsProps); - - - cn1SettingsProps.setProperty("codename1.arg.hyp.beamId", logPasskey); - cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-core.version", cn1MavenVersion); - cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-maven-plugin", cn1MavenPluginVersion); - - mirrorSecondaryEntryPointsToBuildArgs(cn1SettingsProps); - - // App-extension provisioning profiles (e.g. the generated CN1Widgets WidgetKit - // extension) are named by the codename1.ios.appext..provision setting, which - // points at a local .mobileprovision file. Cloud builds have no folder to drop the - // file into, so base64-encode its bytes into the ios.appext..provisioningData - // build arg; the daemon decodes it back to .mobileprovision before signing. - // Done generically over , mirroring the daemon's per-extension plumbing. - // Extension profiles differ between build types just like the app's own - // (development for device/debug builds, distribution for release), so debug/release - // qualified keys are collapsed into the unqualified ones first. - resolveAppExtensionBuildTypeQualifiers(cn1SettingsProps, buildTarget); - String appExtPrefix = "codename1.ios.appext."; - String appExtSuffix = ".provision"; - for (String settingKey : new ArrayList(cn1SettingsProps.stringPropertyNames())) { - if (!settingKey.startsWith(appExtPrefix) || !settingKey.endsWith(appExtSuffix)) { - continue; - } - String extName = settingKey.substring(appExtPrefix.length(), settingKey.length() - appExtSuffix.length()); - if (extName.isEmpty()) { - continue; - } - String dataKey = "codename1.arg.ios.appext." + extName + ".provisioningData"; - if (cn1SettingsProps.containsKey(dataKey)) { - continue; - } - String profilePath = cn1SettingsProps.getProperty(settingKey); - if (profilePath == null || profilePath.trim().isEmpty()) { - continue; - } - File profileFile = new File(profilePath.trim()); - if (!profileFile.exists() || !profileFile.isFile()) { - getLog().warn("The app extension provisioning profile referenced by " + settingKey - + " was not found at " + profileFile.getAbsolutePath() + ". Skipping it; the " - + extName + " extension will not receive a provisioning profile for this build."); - continue; - } - try { - byte[] profileBytes = FileUtils.readFileToByteArray(profileFile); - cn1SettingsProps.setProperty(dataKey, java.util.Base64.getEncoder().encodeToString(profileBytes)); - } catch (IOException ex) { - getLog().warn("Failed to read the app extension provisioning profile referenced by " + settingKey - + " at " + profileFile.getAbsolutePath() + ". Skipping it: " + ex.getMessage()); - } - } - - try (FileOutputStream fos = new FileOutputStream(codenameOneSettingsCopy)) { - cn1SettingsProps.store(fos,""); - - } - final Process[] proc = new Process[1]; - final boolean[] closingHypLog = new boolean[1]; - Thread hyperBeamThread = new Thread(()->{ - - ProcessBuilder pb = new ProcessBuilder("hyp", "beam", logPasskey); - pb.redirectErrorStream(true); - try { - proc[0] = pb.start(); - - - InputStream out = proc[0].getInputStream(); - - - byte[] buffer = new byte[4000]; - while (isAlive(proc[0])) { - int no = out.available(); - if (no > 0) { - int n = out.read(buffer, 0, Math.min(no, buffer.length)); - getLog().info(new String(buffer, 0, n, StandardCharsets.UTF_8)); - } - - - try { - Thread.sleep(10); - } - catch (InterruptedException e) { - } - } - - } catch (Exception ex) { - if (!closingHypLog[0]) { - getLog().warn("Failed to start hyperlog. The build log will not stream to your console. If the build fails, you can download the error log at https://cloud.codenameone.com/secure/index.html"); - getLog().debug(ex); - } - - } - - }); - - - try { - - if (isLocalBuildTarget(buildTarget)) { - automated = false; - if (BUILD_TARGET_WINDOWS_NATIVE_PROJECT.equals(buildTarget) - || "local-windows-device".equals(buildTarget)) { - // Local native ParparVM Windows cross-compile (clang-cl) and the - // windows-source project generation. The cloud win32 build - // (windows-device) is NOT local -- it falls through to the - // server submission below. Distinct from the JVM-bundled - // "windows-desktop" (javase) target. - doWindowsNativeLocalBuild(antProject, cn1SettingsProps, antDistJar); - } else if ("local-linux-device".equals(buildTarget)) { - // Native ParparVM Linux build (GTK3/Cairo, CMake/Ninja). Distinct - // from the JVM-bundled "linux-desktop" (javase) target. - doLinuxNativeLocalBuild(antProject, cn1SettingsProps, antDistJar); - } else if (buildTarget.contains("android") || BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { - doAndroidLocalBuild(antProject, cn1SettingsProps, antDistJar); - } else if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget) - || BUILD_TARGET_MAC_NATIVE_LOCAL.equals(buildTarget)) { - // The native AppKit build. mac-source stops once the Xcode - // project exists; local-mac-device goes on to build it, and - // both run the same builder, so what a developer opens in - // Xcode is what the device target compiles. mac-os-x-native - // is the cloud target and is not handled here -- it has its - // own queue rather than riding the iOS one. - if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { - cn1SettingsProps.setProperty("codename1.arg.macos.sourceOnly", "true"); - } - doMacOSNativeLocalBuild(antProject, cn1SettingsProps, antDistJar); - } else if (buildTarget.contains("ios") || BUILD_TARGET_XCODE_PROJECT.equals(buildTarget)) { - doIOSLocalBuild(antProject, cn1SettingsProps, antDistJar); - } else if (buildTarget.contains("javascript")) { - doJavaScriptLocalBuild(antProject, cn1SettingsProps, antDistJar); - } else { - throw new MojoExecutionException("Build target not supported "+buildTarget); - } - } else { - // Cloud builds route through a remote build server. Nothing is - // injected for Mac Catalyst here: it is an iOS build that the - // user turns on with macNative.enabled, and the server-side - // IPhoneBuilder reads that hint directly. - if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { - cn1SettingsProps.setProperty("codename1.arg.macos.sourceOnly", "true"); - } - if (automated) { - getLog().debug("Attempting to start hyper beam stream the build log to the console"); - hyperBeamThread.start(); - } - AntExecutor.executeAntTask(new File(antProject, "build.xml").getAbsolutePath(), buildTarget, p); - } - } finally { - if (automated) { - try { - closingHypLog[0] = true; - proc[0].destroyForcibly(); - } catch (Exception ex) { - if (getLog().isDebugEnabled()) { - getLog().warn("Failed to shut down hyperlog process cleanly", ex); - } - } - } - } - - if (automated) { - getLog().info("Extracting server result"); - File result = new File(antDistDir, "result.zip"); - if (!result.exists()) { - throw new IOException("Failed to find result.zip after automated build"); - } - - Expand unzip = (Expand)this.antProject.createTask("unzip"); - unzip.setSrc(result); - File resultDir = new File(antDistDir, "result"); - resultDir.mkdir(); - unzip.setDest(resultDir); - unzip.execute(); - File[] resultFiles = resultDir.listFiles(); - // Every returned base, by extension, collected BEFORE anything is classified: see - // roleSuffixFor, which needs to know whether a suffixed entry names an artifact that - // is also here. - java.util.Map> basesByExtension = - new java.util.HashMap>(); - for (File child : resultFiles) { - String name = child.getName(); - int dot = name.lastIndexOf("."); - if (dot < 0) { - continue; - } - String ext = name.substring(dot); - java.util.Set bases = basesByExtension.get(ext); - if (bases == null) { - bases = new java.util.HashSet(); - basesByExtension.put(ext, bases); - } - bases.add(name.substring(0, dot)); - } - for (File child : resultFiles) { - String name = child.getName(); - int dotpos = name.lastIndexOf("."); - if (dotpos < 0) { - continue; - } - String extension = name.substring(dotpos); - String base = name.substring(0, dotpos); - // The role suffix has to survive into the copied name. Every entry used to land on - // target/, keyed on the extension alone, so a build that - // returns two artifacts of the same kind -- a phone APK and its companion Wear APK - // -- collapsed both onto one path and the last one written won. That is silent and - // it corrupts the primary artifact, not merely the secondary one. - String roleSuffix = roleSuffixFor(base, extension, basesByExtension); - File copyTo = new File(project.getBuild().getDirectory() + File.separator + project.getBuild().getFinalName() + roleSuffix + extension); - FileUtils.copyFile(child, copyTo); - if (roleSuffix.length() > 0) { - // Attached with a classifier so the companion artifact is installed and - // deployed beside the primary one rather than being an orphan in target/. - projectHelper.attachArtifact(project, extension.substring(1), - roleSuffix.substring(1), copyTo); - } else if (".war".equals(extension)) { - projectHelper.attachArtifact(project, "war", copyTo); - } else if (".zip".equals(extension) && "javascript".equals(buildTarget)) { - projectHelper.attachArtifact(project, "zip", "webapp", copyTo); - } else if (".dmg".equals(extension) && "mac-os-x-desktop".equals(buildTarget)) { - projectHelper.attachArtifact(project, "dmg", "mac-app", copyTo); - - } else if (".pkg".equals(extension) && "mac-os-x-desktop".equals(buildTarget)) { - projectHelper.attachArtifact(project, "pkg", "mac-app-installer", copyTo); - - } - - } - FileUtils.deleteDirectory(resultDir); - result.delete(); - afterBuild(); - } - - - - - } - - private static boolean isAlive(Process proc) { - try { - proc.exitValue(); - return false; - } - catch (IllegalThreadStateException e) { - return true; - } - } - private String generateCertificate(String password, String alias, String fullName, String orgName, String company, String city, String state, String twoLetterCountryCode, boolean sha512) throws Exception { - File keyTool = new File(System.getProperty("java.home") + File.separator + "bin" + File.separator + "keytool"); - if (!keyTool.exists()) { - keyTool = new File(System.getProperty("java.home") + File.separator + "bin" + File.separator + "keytool.exe"); - } - File keyfileLocation = new File(System.getProperty("user.home") + File.separator + "Keychain.ks"); - int counter = 1; - while (keyfileLocation.exists()) { - keyfileLocation = new File(System.getProperty("user.home") + File.separator + "Keychain_" + counter + ".ks"); - counter++; - } - - ProcessBuilder pb = new ProcessBuilder(keyTool.getAbsolutePath(), - "-genkey", "-keystore", keyfileLocation.getAbsolutePath(), "-storetype", "jks", "-alias", alias, - "-keyalg", "RSA", "-keysize", "2048", "-validity", "15000", "-dname", "CN=" + fullName.replace(",", "\\,") - + ", OU=" + orgName.replace(",", "\\,") - + ", O=" + company.replace(",", "\\,") - + ", L=" + city.replace(",", "\\,") - + ", S=" + state.replace(",", "\\,") - + ", C=" + twoLetterCountryCode, "-storepass", password, "-keypass", password, "-v"); - - if(sha512) { - pb.command().add("-sigalg"); - pb.command().add("SHA512withRSA"); - } - - Process p = pb.start(); - int res = p.waitFor(); - //error occurred - if(res > 0){ - final InputStream input = p.getInputStream(); - final InputStream stream = p.getErrorStream(); - - byte[] buffer = new byte[8192]; - int i = input.read(buffer); - while (i > -1) { - String str = new String(buffer, 0, i, StandardCharsets.UTF_8); - System.out.print(str); - i = stream.read(buffer); - } - i = stream.read(buffer); - while (i > -1) { - String str = new String(buffer, 0, i, StandardCharsets.UTF_8); - System.out.print(str); - i = stream.read(buffer); - } - - return null; - } - - - - return keyfileLocation.getAbsolutePath(); - } - - - private File getGeneratedAndroidProjectSourceDirectory() { - return new File(project.getBuild().getDirectory(), project.getBuild().getFinalName() + "-android-source"); - } - - private File getGeneratedIOSProjectSourceDirectory() { - return new File(project.getBuild().getDirectory(), project.getBuild().getFinalName() + "-ios-source"); - } - - private File getGeneratedMacProjectSourceDirectory() { - return new File(project.getBuild().getDirectory(), project.getBuild().getFinalName() + "-mac-source"); - } - - private boolean isMacNativeBuild(Properties props) { - return "true".equalsIgnoreCase(props.getProperty("codename1.arg.macNative.enabled", "false")); - } - - private void doAndroidLocalBuild(File tmpProjectDir, Properties props, File distJar) throws MojoExecutionException { - if (BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) { - - File generatedProject = getGeneratedAndroidProjectSourceDirectory(); - getLog().info("Generating android gradle Project to "+generatedProject+"..."); - try { - if (generatedProject.exists()) { - getLog().info("Android gradle project already exists. Checking to see if it needs updating..."); - if (getSourcesModificationTime() <= lastModifiedRecursive(generatedProject)) { - getLog().info("Sources have not changed. Skipping android gradle project generation"); - if (open) { - openAndroidStudioProject(generatedProject); - } - return; - - } - } - - } catch (IOException ex) { - throw new MojoExecutionException("Failed to find last modification time of "+generatedProject); - } - } - File codenameOneJar = getJar("com.codenameone", "codenameone-core"); - - AndroidGradleBuilder e = new AndroidGradleBuilder(); - e.setBuildTarget(buildTarget); - e.setLogger(getLog()); - File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "android-build"); - e.setBuildDirectory(buildDirectory); - - e.setCodenameOneJar(codenameOneJar); - - BuildRequest r = new BuildRequest(); - r.setDisplayName(props.getProperty("codename1.displayName")); - r.setPackageName(props.getProperty("codename1.packageName")); - r.setMainClass(props.getProperty("codename1.mainName")); - putSecondaryEntryPointArguments(r, props); - r.setVersion(props.getProperty("codename1.version")); - String iconPath = props.getProperty("codename1.icon"); - File iconFile = new File(iconPath); - if (!iconFile.isAbsolute()) { - iconFile = new File(getCN1ProjectDir(), iconPath); - } - try { - BufferedImage bi = ImageIO.read(iconFile); - if(bi.getWidth() != 512 || bi.getHeight() != 512) { - throw new MojoExecutionException("The icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices"); - } - r.setIcon(iconFile.getAbsolutePath()); - } catch (IOException ex) { - throw new MojoExecutionException("Error reading the icon: the icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices"); - } - - r.setVendor(props.getProperty("codename1.vendor")); - r.setSubTitle(props.getProperty("codename1.secondaryTitle")); - r.setType("android"); - - r.setKeystoreAlias(props.getProperty("codename1.android.keystoreAlias")); - String keystorePath = props.getProperty("codename1.android.keystore"); - if (keystorePath != null) { - File keystoreFile = new File(keystorePath); - if (!keystoreFile.isAbsolute()) { - keystoreFile = new File(getCN1ProjectDir(), keystorePath); - } - if (keystoreFile.exists() && keystoreFile.isFile()) { - try { - r.setCertificate(keystoreFile.getAbsolutePath()); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to load keystore file. ", ex); - } - } else { - - File androidCerts = new File(getCN1ProjectDir(), "androidCerts"); - androidCerts.mkdirs(); - keystoreFile = new File(androidCerts, "KeyChain.ks"); - if (!keystoreFile.exists()) { - try { - String alias = r.getKeystoreAlias(); - if (alias == null || alias.isEmpty()) { - alias = "androidKey"; - r.setKeystoreAlias(alias); - props.setProperty("codename1.android.keystoreAlias", alias); - } - String password = props.getProperty("codename1.android.keystorePassword"); - if (password == null || password.isEmpty()) { - password = "password"; - props.setProperty("codename1.android.keystorePassword", password); - - - } - getLog().info("No Keystore found. Generating one now"); - String keyPath = generateCertificate(password, alias, r.getVendor(), "", r.getVendor(), "Vancouver", "BC", "CA", false); - FileUtils.copyFile(new File(keyPath), keystoreFile); - r.setCertificate(keystoreFile.getAbsolutePath()); - getLog().info("Generated keystore with password 'password' at "+keystoreFile+". alias=androidKey"); - new File(keyPath).delete(); - SortedProperties sp = new SortedProperties(); - try (FileInputStream fis = new FileInputStream(new File(getCN1ProjectDir(), "codenameone_settings.properties"))) { - sp.load(fis); - } - sp.setProperty("codename1.android.keystore", keystoreFile.getAbsolutePath()); - sp.setProperty("codename1.android.keystorePassword", password); - sp.setProperty("codename1.android.keystoreAlias", alias); - try (FileOutputStream fos = new FileOutputStream(new File(getCN1ProjectDir(), "codenameone_settings.properties"))) { - sp.store(fos, "Updated keystore"); - } - } catch (Exception ex) { - getLog().error("Failed to generate keystore", ex); - throw new MojoExecutionException("Failed to generate keystore", ex); - } - } - - - } - } - r.setCertificatePassword(props.getProperty("codename1.android.keystorePassword")); - - for (Object k : props.keySet()) { - String key = (String)k; - if(key.startsWith("codename1.arg.")) { - String value = props.getProperty(key); - String currentKey = key.substring(14); - if(currentKey.indexOf(' ') > -1) { - throw new MojoExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); - } - r.putArgument(currentKey, value); - } - } - applyHardeningRequestArgs(r); - - BuildRequest request = r; - request.setIncludeSource(true); - String testBuild = request.getArg("build.unitTest", null); - if(testBuild != null && testBuild.equals("1")) { - e.setUnitTestMode(true); - } - - try { - getLog().info("Starting android project builder..."); - boolean result = e.runBuild(distJar, request); - getLog().info("Android project builder completed with result "+result); - if (!result) { - getLog().error("Received false return value from build()"); - throw new MojoExecutionException("Android build failed. Received false return value for build"); - } - - if (BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget) && e.getGradleProjectDirectory() != null) { - File gradleProject = e.getGradleProjectDirectory(); - File output = getGeneratedAndroidProjectSourceDirectory(); - output.getParentFile().mkdirs(); - try { - getLog().info("Copying Gradle Project to "+output); - FileUtils.copyDirectory(gradleProject, output); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to copy gradle project at "+gradleProject+" to "+output, ex); - } - - } - if (open) { - openAndroidStudioProject(getGeneratedAndroidProjectSourceDirectory()); - } - - - } catch (BuildException ex) { - - getLog().error("Failed to build Android project with error: "+ex.getMessage(), ex); - getLog().error(e.getErrorMessage()); - throw new MojoExecutionException("Failed to build android app", ex); - } finally { - - e.cleanup(); - } - - } - - private void openAndroidStudioProject(File generatedProject) { - if (isMac) { - getLog().info("Trying to open project in Android studio"); - ProcessBuilder pb = new ProcessBuilder("open", "-a", "/Applications/Android Studio.app", generatedProject.getAbsolutePath()); - try { - pb.start(); - } catch (Exception ex) { - getLog().warn("Failed to open project in Android studio", ex); - getLog().warn("Please open the project in Android studio manually."); - getLog().warn("The project is located at "+generatedProject.getAbsolutePath()); - } - } else if (isWindows) { - getLog().info("Trying to open project in Android studio"); - ProcessBuilder pb = new ProcessBuilder("C:\\Program Files\\Android\\Android Studio\\bin\\studio.bat", generatedProject.getAbsolutePath()); - try { - pb.start(); - } catch (Exception ex) { - getLog().warn("Failed to open project in Android studio", ex); - getLog().warn("Please open the project in Android studio manually."); - getLog().warn("The project is located at "+generatedProject.getAbsolutePath()); - } - } else { - getLog().warn("Opening automatically in Android studio not supported on this platform."); - getLog().warn("Please open the project in Android studio manually."); - getLog().warn("The project is located at "+generatedProject.getAbsolutePath()); - } - } - - private File getWorkspace(Properties props, File xcprojectRoot) { - return new File(xcprojectRoot, props.getProperty("codename1.mainName")+".xcworkspace"); - } - - private File getXcodeProject(Properties props, File xcprojectRoot) { - return new File(xcprojectRoot, props.getProperty("codename1.mainName")+".xcodeproj"); + static boolean isNativeMacOsTarget(String buildTarget) { + return AppBuilder.isNativeMacOsTarget(buildTarget); } - private File getWorkspaceOrProject(Properties props, File xcprojectRoot) { - File workspace = getWorkspace(props, xcprojectRoot); - if (workspace.exists()) { - return workspace; - } - return getXcodeProject(props, xcprojectRoot); + static String hardenPlatformForBuildTarget(String buildTarget) { + return AppBuilder.hardenPlatformForBuildTarget(buildTarget); } - private void openWorkspace(File workspace) throws MojoExecutionException { - try { - ProcessBuilder pb = new ProcessBuilder("open", workspace.getAbsolutePath()); - Process p = pb.start(); - int result = p.waitFor(); - if (result != 0) { - throw new MojoExecutionException("Failed to open project at "+workspace+". Result code: "+result); - } - } catch (Exception ex) { - throw new MojoExecutionException("Failed to open project at "+workspace, ex); - } + static boolean allAppleHardeningSlicesOptedOut(Properties settings) { + return AppBuilder.allAppleHardeningSlicesOptedOut(settings); } - private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) throws MojoExecutionException { - - boolean macNativeBuild = isMacNativeBuild(props); - - if (BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { - - File generatedProject = macNativeBuild - ? getGeneratedMacProjectSourceDirectory() - : getGeneratedIOSProjectSourceDirectory(); - getLog().info("Generating Xcode Project to "+generatedProject+"..."); - try { - if (generatedProject.exists()) { - getLog().info("Xcode project already exists. Checking to see if it needs updating..."); - if (getSourcesModificationTime() <= lastModifiedRecursive(generatedProject)) { - getLog().info("Sources have not changed. Skipping Xcode project generation"); - if (open) { - File projectToOpen = getWorkspaceOrProject(props, generatedProject); - getLog().info("Opening Xcode project "+projectToOpen); - openWorkspace(projectToOpen); - } - return; - - } - } - - } catch (IOException ex) { - throw new MojoExecutionException("Failed to find last modification time of "+generatedProject); - } - } - - File codenameOneJar = getJar("com.codenameone", "codenameone-core"); - - IPhoneBuilder e = new IPhoneBuilder(); - e.setLogger(getLog()); - File buildDirectory = new File(tmpProjectDir, - "dist" + File.separator + (macNativeBuild ? "mac-build" : "ios-build")); - e.setBuildDirectory(buildDirectory); - - e.setCodenameOneJar(codenameOneJar); - - BuildRequest r = new BuildRequest(); - r.setAppid(props.getProperty("codename1.ios.appid")); - r.setDisplayName(props.getProperty("codename1.displayName")); - r.setPackageName(props.getProperty("codename1.packageName")); - r.setMainClass(props.getProperty("codename1.mainName")); - putSecondaryEntryPointArguments(r, props); - r.setVersion(props.getProperty("codename1.version")); - String iconPath = props.getProperty("codename1.icon"); - File iconFile = new File(iconPath); - if (!iconFile.isAbsolute()) { - iconFile = new File(getCN1ProjectDir(), iconPath); - } - try { - BufferedImage bi = ImageIO.read(iconFile); - if(bi.getWidth() != 512 || bi.getHeight() != 512) { - throw new MojoExecutionException("The icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices"); - } - r.setIcon(iconFile.getAbsolutePath()); - } catch (IOException ex) { - throw new MojoExecutionException("Error reading the icon: the icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices"); - } - - r.setVendor(props.getProperty("codename1.vendor")); - r.setSubTitle(props.getProperty("codename1.secondaryTitle")); - r.setType("ios"); - - - for (Object k : props.keySet()) { - String key = (String)k; - if(key.startsWith("codename1.arg.")) { - String value = props.getProperty(key); - String currentKey = key.substring(14); - if(currentKey.indexOf(' ') > -1) { - throw new MojoExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); - } - r.putArgument(currentKey, value); - } - } - applyHardeningRequestArgs(r); - - BuildRequest request = r; - String incSources = request.getArg("build.incSources", null); - request.setIncludeSource(true); - - String testBuild = request.getArg("build.unitTest", null); - if(testBuild != null && testBuild.equals("1")) { - e.setUnitTestMode(true); - } - - try { - boolean result = e.runBuild(distJar, request); - if (!result) { - String builderLog = e.getErrorMessage(); - if (builderLog != null && builderLog.trim().length() > 0) { - getLog().error("iOS builder log:\n" + builderLog); - } - throw new MojoExecutionException("iOS build failed"); - } - - if ((BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) && e.getXcodeProjectDir() != null) { - File xcodeProject = e.getXcodeProjectDir(); - File output = macNativeBuild - ? getGeneratedMacProjectSourceDirectory() - : getGeneratedIOSProjectSourceDirectory(); - output.getParentFile().mkdirs(); - try { - // This directory is a generated target. Replacing it is - // required when class scanning removes a dependency: - // copyDirectory() alone leaves stale Podfiles, workspaces, - // Pods and optional native sources from the prior build. - if (output.exists()) { - FileUtils.deleteDirectory(output); - } - getLog().info("Copying Xcode Project to "+output); - FileUtils.copyDirectory(xcodeProject, output); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to copy xcode project at "+xcodeProject+" to "+output, ex); - } - if (open) { - - File projectToOpen = getWorkspaceOrProject(props, output); - getLog().info("Opening Xcode project "+projectToOpen); - openWorkspace(projectToOpen); - - } - } - - - } catch (BuildException ex) { - String builderLog = e.getErrorMessage(); - if (builderLog != null && builderLog.trim().length() > 0) { - getLog().error("iOS builder log:\n" + builderLog); - } - throw new MojoExecutionException("Failed to build ios app", ex); - } finally { - - e.cleanup(); - } - + static boolean hardeningReducesToOff(Properties settings, String level) { + return AppBuilder.hardeningReducesToOff(settings, level); } - /** - * Local native Windows build via {@link WindowsNativeBuilder}: translates the - * app with ParparVM's windows target and compiles it with clang-cl for the - * selected architecture ({@code windows.arch}). Mirrors the iOS local-build - * wiring. The native compile only succeeds on Windows with the MSVC/clang-cl - * toolchain present; elsewhere the builder fails fast with a clear message. - */ - /** - * Builds the native macOS (AppKit) application locally. - * - *

Unlike Mac Catalyst, which is the iOS build with one hint set, this - * runs its own builder against the macosx SDK. Both {@code mac-source} and - * {@code mac-os-x-native} come here; the former sets - * {@code macos.sourceOnly} so the builder stops once the Xcode project - * exists.

- */ - private void doMacOSNativeLocalBuild(File tmpProjectDir, Properties props, File distJar) throws MojoExecutionException { - File codenameOneJar = getJar("com.codenameone", "codenameone-core"); - MacOSNativeBuilder e = new MacOSNativeBuilder(); - e.setLogger(getLog()); - File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "macos-build"); - e.setBuildDirectory(buildDirectory); - e.setCodenameOneJar(codenameOneJar); - - BuildRequest r = new BuildRequest(); - r.setDisplayName(props.getProperty("codename1.displayName")); - r.setPackageName(props.getProperty("codename1.packageName")); - r.setMainClass(props.getProperty("codename1.mainName")); - putSecondaryEntryPointArguments(r, props); - r.setVersion(props.getProperty("codename1.version")); - r.setVendor(props.getProperty("codename1.vendor")); - // The icon rides the request rather than the source archive, exactly as - // it does for iOS and Windows, and the builder renders the asset catalog - // from it. Omitted here, the generated AppIcon.appiconset references ten - // PNGs that are never written: a generic icon in the Dock, and an App - // Store validation failure for having none. - String iconPath = props.getProperty("codename1.icon"); - if (iconPath != null && iconPath.length() > 0) { - File iconFile = new File(iconPath); - if (!iconFile.isAbsolute()) { - iconFile = new File(getCN1ProjectDir(), iconPath); - } - try { - r.setIcon(iconFile.getAbsolutePath()); - } catch (IOException ex) { - throw new MojoExecutionException("Error reading the icon at " - + iconFile.getAbsolutePath() - + ": it must be a 512x512 pixel PNG, which is scaled to the sizes the " - + "macOS asset catalog asks for.", ex); - } - } - r.setType("macos"); - for (Object k : props.keySet()) { - String key = (String) k; - if (key.startsWith("codename1.arg.")) { - String currentKey = key.substring("codename1.arg.".length()); - if (currentKey.indexOf(' ') > -1) { - throw new MojoExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); - } - r.putArgument(currentKey, props.getProperty(key)); - } - } - applyHardeningRequestArgs(r); - r.setIncludeSource(true); - - try { - boolean result = e.runBuild(distJar, r); - if (!result) { - String builderLog = e.getErrorMessage(); - if (builderLog != null && builderLog.trim().length() > 0) { - getLog().error("macOS builder log:\n" + builderLog); - } - throw new MojoExecutionException("Native macOS build failed"); - } - if (e.getXcodeProjectDir() != null && BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) { - // Collected under the same -mac-source name the - // Catalyst path used, so a project that switches between the - // two ports finds its Xcode project in the same place. - File output = getGeneratedMacProjectSourceDirectory(); - output.getParentFile().mkdirs(); - try { - // Replaced rather than merged: a stale source file from a - // previous build is still compiled by the regenerated - // project, and the resulting failure names a file the - // developer never wrote. - if (output.exists()) { - FileUtils.deleteDirectory(output); - } - getLog().info("Copying macOS Xcode project to " + output); - FileUtils.copyDirectory(e.getXcodeProjectDir(), output); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to collect the generated macOS Xcode project", ex); - } - // Opened, as the iOS project path beside this one does. The - // Mac Native Project IDE shortcut and a plain mac-source build - // both default to open=true, and without this they completed - // with no Xcode window and no indication that the documented - // option had been ignored. - // - // getWorkspaceOrProject rather than the .xcodeproj directly: - // it prefers a workspace when the generated project has one, - // which is the thing Xcode should be handed. - if (open) { - File projectToOpen = getWorkspaceOrProject(props, output); - getLog().info("Opening macOS Xcode project " + projectToOpen); - openWorkspace(projectToOpen); - } - } - if (e.getAppBundle() != null) { - getLog().info("Built native macOS application: " + e.getAppBundle().getAbsolutePath()); - // Every artifact, not just the first bundle: with - // macos.distribution=both there are two, each with its own - // container, and a dmg or pkg nobody is told about is a dmg - // nobody ships. - for (java.io.File artifact : e.getArtifacts()) { - if (!artifact.equals(e.getAppBundle())) { - getLog().info(" also produced: " + artifact.getAbsolutePath()); - } - } - } else if (e.getXcodeProjectDir() != null) { - getLog().info("Generated macOS Xcode project: " + e.getXcodeProjectDir().getAbsolutePath()); - } - } catch (com.codename1.builders.BuildException hardeningEx) { - throw new MojoExecutionException(hardeningEx.getMessage(), hardeningEx); - } catch (org.apache.tools.ant.BuildException ex) { - String builderLog = e.getErrorMessage(); - if (builderLog != null && builderLog.trim().length() > 0) { - getLog().error("macOS builder log:\n" + builderLog); - } - throw new MojoExecutionException("Failed to build the macOS app", ex); - } finally { - e.cleanup(); - } + static boolean hardeningReducesToOff(Properties settings, String level, String platform) { + return AppBuilder.hardeningReducesToOff(settings, level, platform); } - private void doWindowsNativeLocalBuild(File tmpProjectDir, Properties props, File distJar) throws MojoExecutionException { - File codenameOneJar = getJar("com.codenameone", "codenameone-core"); - WindowsNativeBuilder e = new WindowsNativeBuilder(); - e.setLogger(getLog()); - File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "windows-build"); - e.setBuildDirectory(buildDirectory); - e.setCodenameOneJar(codenameOneJar); - - BuildRequest r = new BuildRequest(); - r.setDisplayName(props.getProperty("codename1.displayName")); - r.setPackageName(props.getProperty("codename1.packageName")); - r.setMainClass(props.getProperty("codename1.mainName")); - putSecondaryEntryPointArguments(r, props); - r.setVersion(props.getProperty("codename1.version")); - r.setVendor(props.getProperty("codename1.vendor")); - r.setType("windows"); - for (Object k : props.keySet()) { - String key = (String) k; - if (key.startsWith("codename1.arg.")) { - String currentKey = key.substring("codename1.arg.".length()); - if (currentKey.indexOf(' ') > -1) { - throw new MojoExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); - } - r.putArgument(currentKey, props.getProperty(key)); - } - } - applyHardeningRequestArgs(r); - // Authenticode signing certificate. Configured through settings/properties - // (codename1.windows.signing.certificate = path to the .p12/.pfx, and - // codename1.windows.signing.password). This mirrors the cloud build, whose - // codeNameOne task uploads the same certificate into the request, so a - // local build and a cloud build sign from the same configuration. - String winCert = props.getProperty("codename1.windows.signing.certificate"); - if (winCert != null && !winCert.isEmpty()) { - File certFile = new File(winCert); - if (!certFile.isAbsolute()) { - certFile = new File(getCN1ProjectDir(), winCert); - } - if (certFile.isFile()) { - try { - r.setCertificate(certFile.getAbsolutePath()); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to read the Windows signing certificate: " + certFile, ex); - } - r.setCertificatePassword(props.getProperty("codename1.windows.signing.password")); - } else { - getLog().warn("codename1.windows.signing.certificate points at a missing file: " + certFile); - } - } - r.setIncludeSource(true); - - try { - boolean result = e.runBuild(distJar, r); - if (!result) { - String builderLog = e.getErrorMessage(); - if (builderLog != null && builderLog.trim().length() > 0) { - getLog().error("Windows builder log:\n" + builderLog); - } - throw new MojoExecutionException("Windows native build failed"); - } - if (e.getWindowsExecutable() != null) { - getLog().info("Built native Windows executable: " + e.getWindowsExecutable().getAbsolutePath()); - } - } catch (com.codename1.builders.BuildException hardeningEx) { - throw new MojoExecutionException(hardeningEx.getMessage(), hardeningEx); - } catch (org.apache.tools.ant.BuildException ex) { - String builderLog = e.getErrorMessage(); - if (builderLog != null && builderLog.trim().length() > 0) { - getLog().error("Windows builder log:\n" + builderLog); - } - throw new MojoExecutionException("Failed to build Windows app", ex); - } finally { - e.cleanup(); - } + static boolean hardeningRequestsAnyTransform(Properties settings, String level) { + return AppBuilder.hardeningRequestsAnyTransform(settings, level); } - /** - * Local native Linux build via {@link LinuxNativeBuilder}: translates the app - * with ParparVM's linux target and compiles it with CMake/Ninja for the - * selected architecture ({@code linux.arch}). Mirrors the Windows local-build - * wiring. The native compile only succeeds on Linux with the GTK3 dev stack + - * pkg-config present (and, for musl targets, a {@code zig}/musl toolchain); - * elsewhere the builder fails fast with a clear message. - */ - private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File distJar) throws MojoExecutionException { - File codenameOneJar = getJar("com.codenameone", "codenameone-core"); - LinuxNativeBuilder e = new LinuxNativeBuilder(); - e.setLogger(getLog()); - File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "linux-build"); - e.setBuildDirectory(buildDirectory); - e.setCodenameOneJar(codenameOneJar); - - BuildRequest r = new BuildRequest(); - r.setDisplayName(props.getProperty("codename1.displayName")); - r.setPackageName(props.getProperty("codename1.packageName")); - r.setMainClass(props.getProperty("codename1.mainName")); - putSecondaryEntryPointArguments(r, props); - r.setVersion(props.getProperty("codename1.version")); - r.setVendor(props.getProperty("codename1.vendor")); - r.setType("linux"); - for (Object k : props.keySet()) { - String key = (String) k; - if (key.startsWith("codename1.arg.")) { - String currentKey = key.substring("codename1.arg.".length()); - if (currentKey.indexOf(' ') > -1) { - throw new MojoExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); - } - r.putArgument(currentKey, props.getProperty(key)); - } - } - applyHardeningRequestArgs(r); - r.setIncludeSource(true); - - try { - boolean result = e.runBuild(distJar, r); - if (!result) { - String builderLog = e.getErrorMessage(); - if (builderLog != null && builderLog.trim().length() > 0) { - getLog().error("Linux builder log:\n" + builderLog); - } - throw new MojoExecutionException("Linux native build failed"); - } - if (e.getLinuxExecutable() != null) { - getLog().info("Built native Linux executable: " + e.getLinuxExecutable().getAbsolutePath()); - } - } catch (com.codename1.builders.BuildException hardeningEx) { - throw new MojoExecutionException(hardeningEx.getMessage(), hardeningEx); - } catch (org.apache.tools.ant.BuildException ex) { - String builderLog = e.getErrorMessage(); - if (builderLog != null && builderLog.trim().length() > 0) { - getLog().error("Linux builder log:\n" + builderLog); - } - throw new MojoExecutionException("Failed to build Linux app", ex); - } finally { - e.cleanup(); - } + static boolean hardeningRequestsAnyTransform(Properties settings, String level, String platform) { + return AppBuilder.hardeningRequestsAnyTransform(settings, level, platform); } - // Local ParparVM-backed JavaScript build target. - private void doJavaScriptLocalBuild(File tmpProjectDir, Properties props, File distJar) throws MojoExecutionException { - File codenameOneJar = getJar("com.codenameone", "codenameone-core"); - - JavaScriptBuilder e = new JavaScriptBuilder(); - e.setLogger(getLog()); - e.setBuildTarget(buildTarget); - File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "javascript-build"); - e.setBuildDirectory(buildDirectory); - e.setCodenameOneJar(codenameOneJar); - - BuildRequest r = new BuildRequest(); - r.setDisplayName(props.getProperty("codename1.displayName")); - r.setPackageName(props.getProperty("codename1.packageName")); - r.setMainClass(props.getProperty("codename1.mainName")); - putSecondaryEntryPointArguments(r, props); - r.setVersion(props.getProperty("codename1.version")); - String iconPath = props.getProperty("codename1.icon"); - if (iconPath != null) { - File iconFile = new File(iconPath); - if (!iconFile.isAbsolute()) { - iconFile = new File(getCN1ProjectDir(), iconPath); - } - if (iconFile.isFile()) { - try { - r.setIcon(iconFile.getAbsolutePath()); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to read icon " + iconFile, ex); - } - } - } - r.setVendor(props.getProperty("codename1.vendor")); - r.setSubTitle(props.getProperty("codename1.secondaryTitle")); - r.setType("javascript"); - - for (Object k : props.keySet()) { - String key = (String) k; - if (key.startsWith("codename1.arg.")) { - String value = props.getProperty(key); - String currentKey = key.substring(14); - if (currentKey.indexOf(' ') > -1) { - throw new MojoExecutionException("The build argument contains a space in the key: '" + currentKey + "'"); - } - r.putArgument(currentKey, value); - } - } - applyHardeningRequestArgs(r); - r.setIncludeSource(true); - - try { - boolean result = e.runBuild(distJar, r); - if (!result) { - String builderLog = e.getErrorMessage(); - if (builderLog != null && builderLog.trim().length() > 0) { - getLog().error("JavaScript builder log:\n" + builderLog); - } - throw new MojoExecutionException("JavaScript build failed"); - } - File outputZip = e.getJavaScriptOutputZip(); - if (outputZip != null && outputZip.isFile()) { - File copyTo = new File(project.getBuild().getDirectory() + File.separator + project.getBuild().getFinalName() + ".zip"); - try { - FileUtils.copyFile(outputZip, copyTo); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to copy JavaScript bundle to " + copyTo, ex); - } - projectHelper.attachArtifact(project, "zip", "webapp", copyTo); - getLog().info("JavaScript bundle written to " + copyTo); - } - File deployable = e.getJavaScriptDeployableArtifact(); - if (deployable != null && deployable.isFile()) { - String name = deployable.getName(); - int dot = name.lastIndexOf('.'); - String extension = dot < 0 ? "zip" : name.substring(dot + 1); - String classifier = "war".equals(extension) ? "webapp-proxy" : "proxy-" - + r.getArg("javascript.proxy.target", "jakarta-servlet"); - File copyTo = new File(project.getBuild().getDirectory(), name); - try { - FileUtils.copyFile(deployable, copyTo); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to copy JavaScript deployable bundle to " + copyTo, ex); - } - projectHelper.attachArtifact(project, extension, classifier, copyTo); - getLog().info("JavaScript deployable bundle written to " + copyTo); - } - // The translator writes its deployment configuration and its build report BESIDE - // the bundle rather than inside it, so unpacking the zip into a web root cannot - // publish them. The zip copied above is all that leaves the build directory, and - // the finally below deletes that directory outright -- without this the developer - // never sees either artifact at all. They are copied, not attached: host - // configuration is not something to publish to a Maven repository. - for (File artifact : e.getJavaScriptBuildArtifacts()) { - File copyTo = new File(project.getBuild().getDirectory(), artifact.getName()); - try { - if (artifact.isDirectory()) { - FileUtils.copyDirectory(artifact, copyTo); - } else { - FileUtils.copyFile(artifact, copyTo); - } - } catch (IOException ex) { - throw new MojoExecutionException("Failed to copy JavaScript build artifact to " - + copyTo, ex); - } - getLog().info("JavaScript build artifact written to " + copyTo); - } - } catch (BuildException ex) { - String builderLog = e.getErrorMessage(); - if (builderLog != null && builderLog.trim().length() > 0) { - getLog().error("JavaScript builder log:\n" + builderLog); - } - throw new MojoExecutionException("Failed to build JavaScript app", ex); - } finally { - e.cleanup(); - } + static void mirrorSecondaryEntryPointsToBuildArgs(Properties props) { + AppBuilder.mirrorSecondaryEntryPointsToBuildArgs(props); } - protected void afterBuild() { - + static void resolveAppExtensionBuildTypeQualifiers(Properties props, String buildTarget) { + AppBuilder.resolveAppExtensionBuildTypeQualifiers(props, buildTarget); } - /** - * Role suffixes a returned artifact may carry, longest first so a future - * "-wear-debug" cannot be shadowed by "-wear". - * - *

Kept deliberately closed. Anything not on this list is the primary artifact and - * keeps the plain {@code } name it has always had, so adding a - * role here is the only way to change where a file lands.

- */ - // Longest first: "-wear-debug" ends with neither "-wear" nor anything else here, but a - // future suffix that is a tail of another would match the shorter one if it came first. - /** - * The role a result entry plays, given what else came back. - * - *

A role suffix is a claim about a SET, not about a name, and the question it answers is - * "is there something here that this one is the companion TO". An app called - * {@code fitness-wear} returns one APK whose base ends in {@code -wear} and it is the primary - * artifact; the same app with a companion returns {@code fitness-wear} and - * {@code fitness-wear-wear}, where the second is not. Neither reading the name alone nor - * asking whether any unsuffixed entry exists separates those two cases -- in the second, no - * entry is unsuffixed at all.

- * - *

What does separate them is the artifact the suffix points at: strip it, and a companion - * names something else in the set while a primary names nothing.

- * - * @param base the entry's name with its extension removed - * @param extension the entry's extension, including the dot - * @param basesByExtension every returned base, keyed by extension - * @return the role suffix including its leading dash, or an empty string - */ static String roleSuffixFor(String base, String extension, java.util.Map> basesByExtension) { - String suffix = roleSuffixOf(base); - if (suffix.length() == 0 || basesByExtension == null) { - return ""; - } - java.util.Set siblings = basesByExtension.get(extension); - if (siblings == null) { - return ""; - } - return siblings.contains(base.substring(0, base.length() - suffix.length())) - ? suffix : ""; + return AppBuilder.roleSuffixFor(base, extension, basesByExtension); } - private static final String[] ARTIFACT_ROLE_SUFFIXES = {"-wear-debug", "-wear"}; - - /** - * The role suffix carried by a result entry's base name, or an empty string when it is - * the primary artifact. - * - *

A build may hand back more than one artifact of the same kind -- an Android - * companion build returns the phone APK and the Wear APK beside it -- and the two - * cannot share a destination path. The builder names the secondary one with a role - * suffix; this recovers it so the copy keeps it and the artifact can be attached - * under a matching classifier.

- * - * @param base the result file's name with its extension already removed - * @return the matching role suffix including its leading dash, or an empty string - */ static String roleSuffixOf(String base) { - if (base == null) { - return ""; - } - for (String suffix : ARTIFACT_ROLE_SUFFIXES) { - if (base.endsWith(suffix)) { - return suffix; - } - } - return ""; - } - - private static class LibraryPropertiesException extends Exception { - private String libName; - LibraryPropertiesException(String libName, String message) { - super(message); - this.libName = libName; - } + return AppBuilder.roleSuffixOf(base); } - private static class VersionMismatchException extends LibraryPropertiesException { - VersionMismatchException(String libName, String message) { - super(libName, message); - } + private static boolean isLocalBuildTarget(String buildTarget) { + return AppBuilder.isLocalBuildTarget(buildTarget); } - private static class PropertyConflictException extends LibraryPropertiesException { - PropertyConflictException(String libName, String message) { - super(libName, message); - } + private static void putSecondaryEntryPointArguments(BuildRequest r, Properties props) { + AppBuilder.putSecondaryEntryPointArguments(r, props); } - private SortedProperties mergeRequiredProperties(String libraryName, Properties libProps, Properties projectProps) throws LibraryPropertiesException { - - - String javaVersion = (String)projectProps.getProperty("codename1.arg.java.version", "8"); - String javaVersionLib = (String)libProps.get("codename1.arg.java.version"); - if(javaVersionLib != null){ - int v1 = JavaVersionUtil.parseJavaVersion(javaVersion, 5); - int v2 = JavaVersionUtil.parseJavaVersion(javaVersionLib, 5); - //if the lib java version is bigger, this library cannot be used - if(v1 < v2){ - throw new VersionMismatchException(libraryName, "Cannot use a cn1lib with java version " - + "greater then the project java version"); - } - } - //merge and save - SortedProperties merged = new SortedProperties(); - merged.putAll(projectProps); - Enumeration keys = libProps.propertyNames(); - while(keys.hasMoreElements()){ - String key = (String) keys.nextElement(); - if(!merged.containsKey(key)){ - merged.put(key, libProps.getProperty(key)); - }else{ - - //if this property already exists with a different value the - //install will fail - if(!merged.get(key).equals(libProps.getProperty(key))){ - if ("codename1.arg.java.version".equals(key)) { - // Preserve the project's java version when it is equal to or greater than - // the library requirement. This is validated above and allows using - // Java 8 cn1libs in Java 11/17+ projects. - continue; - } - throw new PropertyConflictException(libraryName, "Property " + key + " has a conflict"); - } - } - } - return merged; - + private SortedProperties mergeRequiredProperties(String libraryName, Properties libProps, Properties projectProps) + throws Exception { + return AppBuilder.mergeRequiredProperties(libraryName, libProps, projectProps); } - } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1DebugMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1DebugMojo.java index 833165336dd..212f8e544ba 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1DebugMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1DebugMojo.java @@ -57,8 +57,12 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException return; } - JavaVersionUtil.requireRuntimeJavaVersion(JavaVersionUtil.MIN_RUNTIME_JAVA_VERSION, + try { + JavaVersionUtil.requireRuntimeJavaVersion(JavaVersionUtil.MIN_RUNTIME_JAVA_VERSION, "debug the Codename One simulator"); + } catch (com.codename1.build.BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); + } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1RunMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1RunMojo.java index 11a7eead9ce..bd5260c0185 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1RunMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1RunMojo.java @@ -57,8 +57,12 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException return; } - JavaVersionUtil.requireRuntimeJavaVersion(JavaVersionUtil.MIN_RUNTIME_JAVA_VERSION, + try { + JavaVersionUtil.requireRuntimeJavaVersion(JavaVersionUtil.MIN_RUNTIME_JAVA_VERSION, "run the Codename One simulator"); + } catch (com.codename1.build.BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); + } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileCSSMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileCSSMojo.java index a8b5774dc0e..11801c9413f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileCSSMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileCSSMojo.java @@ -24,6 +24,8 @@ import java.io.File; +import java.util.ArrayList; +import java.util.List; import java.util.Set; import java.util.TreeSet; import java.io.IOException; @@ -35,10 +37,7 @@ import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.ResolutionScope; -import org.apache.tools.ant.taskdefs.Copy; -import org.apache.tools.ant.taskdefs.Expand; import org.apache.tools.ant.taskdefs.Java; -import org.apache.tools.ant.types.FileSet; import static com.codename1.maven.PathUtil.path; @@ -137,7 +136,12 @@ private void warnAboutUnresolvedVectorReferences() throws MojoExecutionException // builds is worse than the one it would add. Two registries on one // classpath is separately unsupported; see ensureSvgTranscoderWired. SvgTranscodeRunner runner = newSvgTranscodeRunner(); - Set referenced = runner.cssReferencedVectorNames(); + Set referenced; + try { + referenced = runner.cssReferencedVectorNames(); + } catch (com.codename1.builders.BuildException ex) { + throw new MojoExecutionException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } if (referenced.isEmpty()) { return; } @@ -325,130 +329,44 @@ private void executeImpl(String themePrefix) throws MojoExecutionException, Mojo getLog().warn("CSS compilation skipped because no CSS theme was found"); return; } - File themeResOutput = new File(project.getBuild().getOutputDirectory() + File.separator + themePrefix + "theme.res"); - // target/css + // target/css, where the merged stylesheet goes File cssBuildDir = new File(project.getBuild().getDirectory() + File.separator + "css"); - cssBuildDir.mkdirs(); - - // target/css/theme.css - the merged CSS file - File mergeFile = new File(cssBuildDir, themePrefix + "theme.css"); - mergeFile.getParentFile().mkdirs(); - try { - long sourcesModTime = Math.max(getCSSSourcesModificationTime(), - getLocalizationModificationTime()); - if (themeResOutput.exists() && sourcesModTime < themeResOutput.lastModified()) { - getLog().info("CSS sources unchanged since last compile. Skipping CSS compilation"); - return; - } - } catch (IOException ex) { - throw new MojoExecutionException("Failed to check CSS file modification times", ex); - } - // Compile a comma-delimited list a CSS files that will be sent to the CSS compiler as inputs. - // We look through all dependency artifacts with the cn1css classifier, and add their - // theme.css to the input list. (Codename One Library projects will include such an - // artifact if they have CSS files). - final StringBuilder inputs = new StringBuilder(); - - project.getArtifacts().forEach(artifact->{ + // The cn1libs' cn1css bundles, extracted beside the artifact in the local + // repository, merged before the project's own theme.css. See CssCompiler, + // which the Gradle plugin shares. + final List libraries = new ArrayList(); + for (Artifact artifact : project.getArtifacts()) { if (artifact.hasClassifier() && "cn1css".equals(artifact.getClassifier())) { File zip = findArtifactFile(artifact); if (zip == null || !zip.exists()) { - return; - } - - File extracted = new File(zip.getParentFile(), zip.getName()+"-extracted"); - getLog().debug("Checking for extracted CSS bundle "+extracted); - if (extracted.exists() && artifact.isSnapshot() && getLastModified(artifact) > extracted.lastModified()) { - try { - FileUtils.deleteDirectory(extracted); - } catch (IOException ex){ - getLog().error(ex); - } - } - if (!extracted.exists()) { - getLog().debug("CSS bundle "+zip+" not extracted yet. Extracting to "+extracted); - // This is a cn1css artifact, which is a zip file. - // We extract it so that we can access the files directly. - Expand expand = (Expand)antProject.createTask("unzip"); - expand.setSrc(zip); - expand.setDest(extracted); - expand.execute(); - - } - if (extracted.exists()) { - File extractedCssDir = new File(extracted, path("META-INF","codenameone", artifact.getGroupId(), artifact.getArtifactId(), "css")); - if (extractedCssDir.exists()) { - // We expect that the cn1css artifact has a theme.css file at its root - // If found, we add it to the list of inputs. - File theme = new File(extractedCssDir, themePrefix + "theme.css"); - if (theme.exists()) { - if (inputs.length() > 0) { - inputs.append(","); - } - inputs.append(theme.getAbsolutePath()); - } - } - - } else { - getLog().debug("CSS bundle extraction must have failed for "+zip+" because after extraction it still doesn't exist at "+extracted); + continue; } + libraries.add(new CssCompiler.LibraryCss(artifact.getGroupId(), artifact.getArtifactId(), zip, + new File(zip.getParentFile(), zip.getName() + "-extracted"), + artifact.isSnapshot(), getLastModified(artifact))); } - }); - - // The project's theme.css file is added to the input list last so that it will result in it - // being last in the merged theme.css file (i.e. the application project CSS can override the - // CSS in dependent libraries. - File cssTheme = new File(cssDirectory, themePrefix + "theme.css"); - if (cssTheme.exists()) { - if (inputs.length() > 0) { - inputs.append(","); - } - inputs.append(cssTheme.getAbsolutePath()); - } else { - if (themePrefix.isEmpty() && inputs.length() > 0) { - throw new MojoFailureException("Cannot compile CSS for this project. The project does not include a "+themePrefix+"-theme.css file in "+cssTheme+", but it includes dependencies that require CSS. Please add a CSS file at "+cssTheme); - - } - getLog().info("Skipping CSS compilation for because "+themePrefix + cssTheme+" does not exist"); - return; } - - - - // Run the CSS compiler CLI. It lives in com.codenameone:codenameone-css-cli, a thin - // module that is a dependency of the codenameone-maven-plugin, so the version is - // pinned to the plugin rather than picked up from the designer_1.jar in the user's - // home directory. We launch it on a resolved classpath instead of `java -jar` - // against the designer's shaded artifact -- the CLI needs codenameone-javase for - // CEF rasterization, but nothing needs a 43MB shaded copy of it. - // The Java task is created via createJava() (overridden in this class to use INFO log - // level) so subprocess output -- including stack traces from CN1CSSCLI failures -- - // shows up in normal mvn output instead of being hidden at DEBUG. - Java java = createJava(); - java.setDir(getCN1ProjectDir()); - java.setClasspath(new org.apache.tools.ant.types.Path(antProject, getCssCliClasspath())); - java.setClassname(CSS_CLI_MAIN_CLASS); - java.setFork(true); - java.setFailonerror(true); - java.createJvmarg().setValue("-Dcli=true"); - java.createArg().setValue("-css"); - java.createArg().setValue("-input"); - java.createArg().setValue(inputs.toString()); - - java.createArg().setValue("-output"); - java.createArg().setFile(themeResOutput); - - java.createArg().setValue("-merge"); - java.createArg().setFile(mergeFile); - File localizationDir = findLocalizationDirectory(); - if (localizationDir != null) { - java.createArg().setValue("-l"); - java.createArg().setFile(localizationDir); + long sourcesModTime; + try { + sourcesModTime = Math.max(getCSSSourcesModificationTime(), getLocalizationModificationTime()); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to check CSS file modification times", ex); } - int res = java.executeJava(); - if (res != 0) { - throw new MojoExecutionException("An error occurred while compiling the CSS files. Inputs: "+inputs+", output: " + new File(project.getBuild().getOutputDirectory() + File.separator + themePrefix + "theme.res") +", merge file: "+mergeFile); + try { + new CssCompiler(MavenLog.of(getLog()), antProject, new CssCompiler.JavaFactory() { + @Override + public Java create() { + // Overridden in this class to use INFO, so the compiler's stack + // traces reach normal mvn output; tests substitute a recorder. + return createJava(); + } + }).compile(themePrefix, cssDirectory, new File(project.getBuild().getOutputDirectory()), cssBuildDir, + libraries, findLocalizationDirectory(), getCssCliClasspath(), getCN1ProjectDir(), sourcesModTime); + } catch (com.codename1.build.BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); + } catch (com.codename1.build.BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex); } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ConvertToGradleMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ConvertToGradleMojo.java new file mode 100644 index 00000000000..8bc7607207a --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ConvertToGradleMojo.java @@ -0,0 +1,90 @@ +/* + * 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.maven; + +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; + +import java.io.File; + +/** + * Copies this Maven (or Ant) Codename One application into a new directory as a + * single-project Gradle build: {@code mvn cn1:convert-to-gradle}. + * + *

The original project is left untouched. The conversion is + * {@link GradleConversion}: sources, resources and CSS move to {@code src/main}, + * each platform's native-interface implementations to {@code src//}, + * a backend module to {@code backend/}, and the dependencies of + * {@code common/pom.xml} into {@code build.gradle.kts}. A project that still uses + * legacy {@code .cn1lib} files is refused with the list of them, since Gradle + * projects only consume cn1libs from a Maven repository.

+ */ +@Mojo(name = "convert-to-gradle", aggregator = true, requiresProject = false) +public class ConvertToGradleMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true) + private MavenProject project; + + /** The project to convert; defaults to the one Maven runs in, else the working directory. */ + @Parameter(property = "cn1.sourceProject") + private File sourceProject; + + /** Where the Gradle project goes; defaults to {@code -gradle} beside it. */ + @Parameter(property = "cn1.outputDir") + private File outputDir; + + /** + * Carry over a backend module that is still the archetype's untouched skeleton. + * Off by default; a backend with code of its own is always converted. + */ + @Parameter(property = "cn1.includeBackend", defaultValue = "false") + private boolean includeBackend; + + /** The plugin version the Gradle project declares; defaults to this plugin's. */ + @Parameter(property = "cn1.gradleVersion") + private String cn1Version; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + File source = sourceProject != null ? sourceProject + : project != null && project.getBasedir() != null ? project.getBasedir() : new File("."); + source = source.getAbsoluteFile(); + com.codename1.project.ProjectLayout layout = com.codename1.project.ProjectLayouts.detect(source); + File root = layout == null ? source : layout.rootDir(); + File target = outputDir != null ? outputDir.getAbsoluteFile() + : new File(root.getParentFile(), root.getName() + "-gradle"); + String version = cn1Version != null && cn1Version.length() > 0 ? cn1Version + : com.codename1.maven.help.ToolingHelp.pluginVersion(); + try { + new GradleConversion(MavenLog.of(getLog())).includeUntouchedBackend(includeBackend) + .convert(source, target, version); + } catch (com.codename1.build.BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); + } + getLog().info("Next: cd " + target + " && ./gradlew run"); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java index fd5dca592cb..9bc45a22277 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java @@ -92,7 +92,7 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException File outputDir = new File(project.getBuild().getOutputDirectory()); ProcessorContext ctx = new ProcessorContext(outputDir, stubSourceDirectory, /*classIndex*/ java.util.Collections.emptyMap(), - getLog()); + MavenLog.of(getLog())); for (Iterator it = processors.iterator(); it.hasNext(); ) { AnnotationProcessor p = it.next(); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java index d61351d9ea8..57377dd29b3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java @@ -1,3 +1,25 @@ +/* + * 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.maven; import com.codename1.ant.SortedProperties; @@ -50,6 +72,14 @@ public class GenerateAppProjectMojo extends AbstractMojo { @Parameter(property="version", defaultValue = "1.0-SNAPSHOT") private String version; + /** + * {@code maven} (the default) or {@code gradle}. With {@code gradle} the project is + * generated as usual and then converted to the single-project Gradle layout by + * {@link GradleConversion}, the same conversion {@code cn1:convert-to-gradle} runs. + */ + @Parameter(property = "cn1.buildTool", defaultValue = "maven") + private String buildTool; + private Properties loadSourceProjectProperties() throws IOException { Properties props = new Properties(); @@ -866,6 +896,11 @@ private String mainName() { public void execute() throws MojoExecutionException, MojoFailureException { try { doExecute(); + if ("gradle".equalsIgnoreCase(buildTool)) { + convertToGradle(); + } else if (buildTool != null && !"maven".equalsIgnoreCase(buildTool)) { + throw new MojoFailureException("cn1.buildTool must be maven or gradle, not " + buildTool); + } } catch (MojoExecutionException | MojoFailureException e) { com.codename1.maven.help.ToolingHelp.offerAfterFailure(getLog(), com.codename1.maven.help.ToolingHelp.COMPONENT_MAVEN_PLUGIN, @@ -881,6 +916,27 @@ public void execute() throws MojoExecutionException, MojoFailureException { } } + /** + * Replaces the generated Maven project with its Gradle conversion, in place. + */ + private void convertToGradle() throws MojoExecutionException, MojoFailureException { + File maven = targetProjectDir().getAbsoluteFile(); + File gradle = new File(maven.getParentFile(), maven.getName() + ".gradle-conversion"); + try { + new GradleConversion(MavenLog.of(getLog())).convert(maven, gradle, + com.codename1.maven.help.ToolingHelp.pluginVersion()); + FileUtils.deleteDirectory(maven); + if (!gradle.renameTo(maven)) { + throw new MojoExecutionException("Could not move " + gradle + " to " + maven); + } + } catch (com.codename1.build.BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); + } catch (IOException ex) { + throw new MojoExecutionException("Could not replace the Maven project with the Gradle one", ex); + } + getLog().info("Generated a Gradle project at " + maven + ". Run it with ./gradlew run"); + } + private void doExecute() throws MojoExecutionException, MojoFailureException { try { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGuiSourcesMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGuiSourcesMojo.java index 160831b387e..870b32856c3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGuiSourcesMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateGuiSourcesMojo.java @@ -75,307 +75,30 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException return; } System.setProperty("generate-gui-sources-done", "true"); - System.setProperty("javax.xml.bind.context.factory", "com.sun.xml.bind.v2.ContextFactory"); String buildClientJarPath = path(System.getProperty("user.home"), ".codenameone", "CodeNameOneBuildClient.jar"); File jarFile = new File(buildClientJarPath); if (!jarFile.exists()) { throw new MojoExecutionException(buildClientJarPath + " not found at " + jarFile.getAbsolutePath()); } - try (URLClassLoader classLoader = new URLClassLoader(new URL[]{jarFile.toURI().toURL()}, this.getClass().getClassLoader())) { - Class clazz = classLoader.loadClass("com.codename1.build.client.GenerateGuiSources"); - Object g = clazz.getDeclaredConstructor().newInstance(); - clazz.getMethod("setSrcDir", File.class).invoke(g, new File(getCN1ProjectDir(), "src" + File.separator + "main" + File.separator + "java")); - clazz.getMethod("setGuiDir", File.class).invoke(g, new File(getCN1ProjectDir(), "src" + File.separator + "main" + File.separator + "guibuilder")); - clazz.getMethod("execute").invoke(g); - } catch (Exception e) { - throw new MojoExecutionException("Failed to load and execute GenerateGuiSources", e); - } - - // Generate the RAD templates while we're at it - - File radViews = getRADViewsDirectory(); - getLog().debug("Looking for views in "+radViews); - if (radViews.isDirectory()) { - project.addCompileSourceRoot(getRADGeneratedSourcesDirectory().getAbsolutePath()); - Exception res = forEach(radViews, child -> { - if (!child.getName().endsWith(".xml")) { - return null; - } - File destClassFile = getDestClassForRADView(child); - getLog().debug("Found view "+child+". Checking against "+destClassFile); - if (!destClassFile.exists() || child.lastModified() > destClassFile.lastModified()) { - try { - generateRADViewClass(child); - } catch (IOException ex) { - return new MojoFailureException("Failed to generate class for RAD fragment XML file "+child, ex); - } - } - return null; - }); - if (res != null) { - throw new MojoFailureException("Failed to compile RAD views:" + res.getMessage(), res); - } - - } - } - - private File getRADViewsDirectory() { - return new File(getCN1ProjectDir(), path("src", "main", "rad", "views")); - } - - private File getRADGeneratedSourcesDirectory() { - return new File(path(project.getBuild().getDirectory(), "generated-sources" , "rad-views")); - } - - private static Exception forEach(File root, Function callback) { - Exception res = callback.apply(root); - if (res != null) return res; - if (root.isDirectory()) { - for (File child : root.listFiles()) { - res = forEach(child, callback); - if (res != null) return res; - } - } - return null; - - } - - private File getDestClassForRADView(File viewXMLFile) { - String ext = viewXMLFile.getName().substring(viewXMLFile.getName().lastIndexOf(".")); - String base = viewXMLFile.getName().substring(0, viewXMLFile.getName().lastIndexOf(".")); - File viewsDirectory = getRADViewsDirectory(); - - int levels = 0; - LinkedList pathParts = new LinkedList(); - File f = viewXMLFile.getParentFile(); - while (f != null && !f.equals(viewsDirectory)) { - pathParts.addFirst(f.getName()); - f = f.getParentFile(); - } - StringBuilder pathSb = new StringBuilder(); - for (String part : pathParts) { - pathSb.append(part).append(File.separator); - } - - File genSrcDir = new File(getRADGeneratedSourcesDirectory(), pathSb.substring(0, pathSb.length()-1)); - - File out = new File(genSrcDir, base + ".java"); - out = new File(out.getParentFile(), "Abstract" + out.getName()); - return out; - - } - - private String getPackageForRADView(File viewXMLFile) { - String ext = viewXMLFile.getName().substring(viewXMLFile.getName().lastIndexOf(".")); - String base = viewXMLFile.getName().substring(0, viewXMLFile.getName().lastIndexOf(".")); - File viewsDirectory = getRADViewsDirectory(); - - int levels = 0; - LinkedList pathParts = new LinkedList(); - File f = viewXMLFile.getParentFile(); - while (f != null && !f.equals(viewsDirectory)) { - pathParts.addFirst(f.getName()); - f = f.getParentFile(); - } - StringBuilder pathSb = new StringBuilder(); - for (String part : pathParts) { - pathSb.append(part).append("."); - } - return pathSb.substring(0, pathSb.length()-1); - } - - private void generateRADViewClass(File xmlViewFile) throws IOException { - parentEntityViewClass = "AbstractEntityView"; - viewModelType = "Entity"; - getLog().debug("Generating RAD View for XML template "+xmlViewFile); - StringBuilder sb = new StringBuilder(); - - String packageName = getPackageForRADView(xmlViewFile); - String className = xmlViewFile.getName().substring(0, xmlViewFile.getName().indexOf(".")); - - className = "Abstract" + className; - - String radViewString = FileUtils.readFileToString(xmlViewFile, "utf-8"); - generateSchemaFor(xmlViewFile, radViewString); - radViewString = addElementIdentifiersToXML(radViewString); - - if (!packageName.isEmpty()) { - sb.append("package ").append(packageName).append(";\n"); - } - sb.append("import com.codename1.rad.annotations.RAD;\n"); - sb.append("import com.codename1.rad.ui.AbstractEntityView;\n"); - sb.append("import com.codename1.rad.ui.EntityView;\n"); - sb.append("import com.codename1.rad.models.Entity;\n"); - sb.append("import com.codename1.rad.nodes.Node;\n"); - sb.append("import com.codename1.io.CharArrayReader;\n"); - sb.append("import com.codename1.rad.ui.ViewContext;\n"); - sb.append(importStatements); - sb.append("@RAD\n"); - String parentClassName = parentEntityViewClass; - if (parentClassName.equals("AbstractEntityView")) { - parentClassName += ""; - } - sb.append("public abstract class ").append(className).append(" extends ").append(parentClassName).append(" {\n"); - sb.append(" private static final String FRAGMENT_XML=\""); - sb.append(StringEscapeUtils.escapeJava(radViewString)); - sb.append("\";\n"); - sb.append(" public ").append(className).append("(ViewContext context) {\n"); - sb.append(" super(context);\n"); - sb.append(" }\n\n"); - - sb.append("}\n"); - - - - - - - File destFile = getDestClassForRADView(xmlViewFile); - if (!destFile.getParentFile().exists()) { - destFile.getParentFile().mkdirs(); - } - getLog().debug("Updating "+destFile); - FileUtils.writeStringToFile(destFile, sb.toString(), "utf-8"); - } - - private static final String RAD_XML_NAMESPACE = "http://www.codenameone.com/rad"; - - private String parentEntityViewClass = "AbstractEntityView"; - private String viewModelType = "Entity"; - - - private void generateSchemaFor(File xmlViewFile, String contents) throws IOException { - File generatedSources = new File(getCN1ProjectDir(), path("target", "generated-sources")); - File xmlSchemasDirectory = new File(generatedSources, "rad" + File.separator + "xmlSchemas"); - String packageName = getPackageForRADView(xmlViewFile); - String baseName = xmlViewFile.getName(); - baseName = baseName.substring(0, baseName.lastIndexOf(".")); - File actualXsdFile = new File(xmlSchemasDirectory, packageName.replace('.', File.separatorChar) + File.separator + baseName + ".xsd"); - File xsdAliasFile = new File(xmlViewFile.getParentFile(), baseName + ".xsd"); - if (!xsdAliasFile.exists()) { - StringBuilder sb = new StringBuilder(); - sb.append("\n"); - sb.append("\n"); - sb.append(" \n"); - sb.append("\n"); - getLog().info("Writing XSD alias file at "+xsdAliasFile); - FileUtils.writeStringToFile(xsdAliasFile, sb.toString(), "UTF-8"); - - } - if (!contents.contains("xsi:noNamespaceSchemaLocation=\"") || !contents.contains("xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"")) { - int rootTagStart = contents.indexOf("?>"); - if (rootTagStart < 0) { - getLog().info("Not adding schema declaration to "+xmlViewFile+" because it failed to find the root element. The file may be malformed."); - return; - } - rootTagStart = contents.indexOf("<", rootTagStart); - if (rootTagStart < 0) { - getLog().info("Not adding schema declaration to "+xmlViewFile+" because it failed to find the root element. The file may be malformed."); - return; - } - int rootTagEnd = contents.indexOf(">", rootTagStart); - if (rootTagEnd < 0) { - getLog().info("Not adding schema declaration to "+xmlViewFile+" because it failed to find the close of the root element. The file may be malformed."); - } - String toInject = !contents.contains("xsi:noNamespaceSchemaLocation=\"") ? " xsi:noNamespaceSchemaLocation=\"" + baseName + ".xsd\"" : ""; - if (!contents.contains("xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"")) { - toInject += " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\""; - } - if (!contents.substring(0, rootTagEnd).contains("xsi:xsi:noNamespaceSchemaLocation") || !contents.substring(0, rootTagEnd).contains("xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"")) { - contents = contents.substring(0, rootTagEnd) + toInject + contents.substring(rootTagEnd); - FileUtils.writeStringToFile(xmlViewFile, contents, "utf-8"); - getLog().info("Injected schema declaration into document element of " + xmlViewFile); - } - } - - - - - - } - - private StringBuilder importStatements = new StringBuilder(); - - private String addElementIdentifiersToXML(String xml) throws IOException { + GuiSourcesGenerator generator = new GuiSourcesGenerator(MavenLog.of(getLog()), getCN1ProjectDir(), + new File(getCN1ProjectDir(), path("src", "main", "rad", "views")), + new File(path(project.getBuild().getDirectory(), "generated-sources", "rad-views")), + new File(getCN1ProjectDir(), path("target", "generated-sources"))); try { - DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); - //an instance of builder to parse the specified xml file - DocumentBuilder db = dbf.newDocumentBuilder(); - Document doc = db.parse(new ByteArrayInputStream(xml.getBytes("utf-8"))); - class Context { - int index=0; - void crawl(org.w3c.dom.Element el) { - if (index == 0) { - if (el.hasAttribute("rad-extends")) { - parentEntityViewClass = el.getAttribute("rad-extends"); - } - if (el.hasAttribute("rad-model")) { - viewModelType = el.getAttribute("rad-model"); - } - - } - if (el.getTagName().equalsIgnoreCase("import")) { - importStatements.append(el.getTextContent()).append("\n"); - } - el.setAttribute("rad-id", String.valueOf(index++)); - NodeList children = el.getChildNodes(); - int len = children.getLength(); - for (int i=0; iThe scanning and generation live in {@link NativeInterfaces}, shared with the Gradle plugin; this goal + * points it at the Maven platform modules ({@code //src/main/}).

*/ @Mojo(name="generate-native-interfaces") @Execute(phase= LifecyclePhase.COMPILE) @@ -66,309 +64,27 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException return; } File classes = new File(project.getBuild().getOutputDirectory()); - final List failures = new ArrayList<>(); + List classpath; try { - scanClasses(classes, new ClassScanner() { - - @Override - public void usesClass(String cls) { - - } - - @Override - public void usesClassMethod(String cls, String method) { - - } - - @Override - public void extendsNativeInterface(String cls) { - try { - generateNativeInterface(cls); - } catch (Exception ex) { - getLog().error("Problem occurred while generating native interface for class "+cls); - getLog().error(ex); - failures.add(ex); - } - } - }); + classpath = project.getCompileClasspathElements(); } catch (Exception ex) { - throw new MojoExecutionException("Failed to generate native interfaces", ex); - } - if (!failures.isEmpty()) { - throw new MojoExecutionException("There were "+failures.size()+" failures while generating native interface stubs. Check the log for details."); - } - } - - - private void generateNativeInterface(String relativePath) throws Exception { - if (!relativePath.endsWith(".class")) { - relativePath += ".class"; - } - File classFile = new File(path(project.getBuild().getOutputDirectory(), relativePath.replace(".java", ".class"))); - Class c = null; - - if (classFile.exists()) { - - // Build classpath using compile classpath elements so class loading works - // for all project dependencies. - URLClassLoader cl = createProjectClassLoader(); - String classPath = relativePath - .replace("\\", ".") - .replace("/", ".") - .replace(File.separator, "."); - classPath = classPath.substring(0, classPath.lastIndexOf(".")); - c = cl.loadClass(classPath); - - } else { - throw new IllegalStateException("Project needs to be compiled first"); - } - - StubGenerator g = StubGenerator.create(getLog(), c, generateIosSwift, generateAndroidKotlin); - String s = g.verify(); - if (s != null) { - throw new RuntimeException("Generation Failed: " + s); + classpath = Collections.emptyList(); } - - - File destination = project.getBasedir().getCanonicalFile().getParentFile(); - if (!overwrite) { - List existingFiles = g.getExistingFiles(destination); - if (!existingFiles.isEmpty()) { - getLog().warn("Native interface files already exist for " + c.getName() + ". Skipping generation."); - for (File existingFile : existingFiles) { - getLog().warn("Existing file: " + existingFile.getAbsolutePath()); - } - getLog().warn("To overwrite these files, run: mvn cn1:generate-native-interfaces -Dcn1.generateNativeInterfaces.overwrite=true"); - return; - } - } - - g.generateCode(destination, overwrite); - - - } - - private URLClassLoader createProjectClassLoader() throws IOException { - List classpathUrls = new ArrayList<>(); + File root; try { - for (Object element : project.getCompileClasspathElements()) { - String pathElement = String.valueOf(element); - File file = new File(pathElement); - if (file.exists()) { - classpathUrls.add(file.toURI().toURL()); - } - } - } catch (DependencyResolutionRequiredException ex) { - throw new IOException("Failed to resolve compile classpath for native interface generation", ex); - } - - if (classpathUrls.isEmpty()) { - classpathUrls.add(new File(project.getBuild().getOutputDirectory()).toURI().toURL()); + root = project.getBasedir().getCanonicalFile().getParentFile(); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to resolve the project root", ex); } - - return new URLClassLoader(classpathUrls.toArray(new URL[0]), this.getClass().getClassLoader()); - } - - private static interface ClassScanner { - - public void usesClass(String cls); - - public void usesClassMethod(String cls, String method); - - public void extendsNativeInterface(String cls); - } - - private void scanClasses(File directory, final ClassScanner scanner) throws IOException { - File[] list = directory.listFiles(); - for (File current : list) { - if (current.isDirectory()) { - scanClasses(current, scanner); - } else { - if (current.getName().endsWith(".class")) { - InputStream is = new FileInputStream(current); - ClassReader r = null; - try { - r = new ClassReader(is); - } catch(RuntimeException re) { - - getLog().error("Problem reading class "+current, re); - - throw re; - } - is.close(); - ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM9) { - - @Override - public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { - scanner.usesClass(superName); - for (String s : interfaces) { - if ("com/codename1/system/NativeInterface".equals(s)) { - scanner.extendsNativeInterface(name); - } - scanner.usesClass(s); - } - if ("com/codename1/system/NativeInterface".equals(superName)) { - scanner.extendsNativeInterface(name); - } - } - - @Override - public void visitSource(String string, String string1) { - } - - @Override - public void visitOuterClass(String string, String string1, String string2) { - } - - @Override - public AnnotationVisitor visitAnnotation(String string, boolean bln) { - return null; - } - - @Override - public void visitAttribute(Attribute atrbt) { - } - - @Override - public void visitInnerClass(String string, String string1, String string2, int i) { - } - - @Override - public FieldVisitor visitField(int i, String string, String type, String string2, Object o) { - if (type.startsWith("L")) { - scanner.usesClass(com.codename1.builders.Executor.descriptorToInternalName(type)); - } - return null; - } - - @Override - public MethodVisitor visitMethod(int i, final String methodName, String string1, String string2, String[] strings) { - return new MethodVisitor(Opcodes.ASM9) { - @Override - public AnnotationVisitor visitAnnotationDefault() { - return null; - } - - @Override - public AnnotationVisitor visitAnnotation(String string, boolean bln) { - return null; - } - - @Override - public AnnotationVisitor visitParameterAnnotation(int i, String string, boolean bln) { - return null; - } - - @Override - public void visitAttribute(Attribute atrbt) { - } - - @Override - public void visitCode() { - } - - @Override - public void visitFrame(int i, int i1, Object[] os, int i2, Object[] os1) { - } - - @Override - public void visitInsn(int i) { - } - - @Override - public void visitIntInsn(int i, int i1) { - } - - @Override - public void visitVarInsn(int i, int i1) { - } - - @Override - public void visitTypeInsn(int i, String string) { - scanner.usesClass(string); - } - - @Override - public void visitFieldInsn(int i, String string, String string1, String string2) { - } - - @Override - public void visitMethodInsn(int i, String owner, String name, String string2) { - scanner.usesClass(owner); - if (name != null && !name.equals("")) { - scanner.usesClassMethod(owner, name); - } - } - - @Override - public void visitJumpInsn(int i, Label label) { - } - - @Override - public void visitLabel(Label label) { - } - - @Override - public void visitLdcInsn(Object o) { - if (o instanceof Type) { - scanner.usesClass(((Type) o).getClassName()); - } - } - - @Override - public void visitIincInsn(int i, int i1) { - } - - @Override - public void visitTableSwitchInsn(int i, int i1, Label label, Label[] labels) { - } - - @Override - public void visitLookupSwitchInsn(Label label, int[] ints, Label[] labels) { - } - - @Override - public void visitMultiANewArrayInsn(String string, int i) { - } - - @Override - public void visitTryCatchBlock(Label label, Label label1, Label label2, String string) { - } - - @Override - public void visitLocalVariable(String string, String classType, String string2, Label label, Label label1, int i) { - if (classType.startsWith("L")) { - scanner.usesClass(com.codename1.builders.Executor.descriptorToInternalName(classType)); - } - } - - @Override - public void visitLineNumber(int i, Label label) { - } - - @Override - public void visitMaxs(int i, int i1) { - } - - @Override - public void visitEnd() { - } - }; - } - - @Override - public void visitEnd() { - } - }; - try { - r.accept(classVisitor, ClassReader.EXPAND_FRAMES); - } catch(RuntimeException re) { - getLog().error("Error encountered while parsing class "+current.getName(), re); - - throw re; - } - } - } + try { + new NativeInterfaces(MavenLog.of(getLog()), classes, classpath).generate( + ProjectLayouts.of(BuildSystem.MAVEN, ProjectKind.APP, root, project.getBasedir()), + null, generateIosSwift, generateAndroidKotlin, overwrite, + "mvn cn1:generate-native-interfaces -Dcn1.generateNativeInterfaces.overwrite=true"); + } catch (com.codename1.build.BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); + } catch (com.codename1.build.BuildExecutionException ex) { + throw new MojoExecutionException("Failed to generate native interfaces", ex); } } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/MavenLog.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/MavenLog.java new file mode 100644 index 00000000000..133089ce10a --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/MavenLog.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2012, 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.maven; + +import org.apache.maven.plugin.logging.Log; + +/// Adapts Maven's `Log` to the build engine's [com.codename1.build.Log], which +/// has the same methods. +final class MavenLog implements com.codename1.build.Log { + private final Log log; + + private MavenLog(Log log) { + this.log = log; + } + + /// The engine view of `log`; null stays null. + static com.codename1.build.Log of(Log log) { + return log == null ? null : new MavenLog(log); + } + + @Override + public boolean isDebugEnabled() { + return log.isDebugEnabled(); + } + + @Override + public void debug(CharSequence content) { + log.debug(content); + } + + @Override + public void debug(CharSequence content, Throwable error) { + log.debug(content, error); + } + + @Override + public void debug(Throwable error) { + log.debug(error); + } + + @Override + public boolean isInfoEnabled() { + return log.isInfoEnabled(); + } + + @Override + public void info(CharSequence content) { + log.info(content); + } + + @Override + public void info(CharSequence content, Throwable error) { + log.info(content, error); + } + + @Override + public void info(Throwable error) { + log.info(error); + } + + @Override + public boolean isWarnEnabled() { + return log.isWarnEnabled(); + } + + @Override + public void warn(CharSequence content) { + log.warn(content); + } + + @Override + public void warn(CharSequence content, Throwable error) { + log.warn(content, error); + } + + @Override + public void warn(Throwable error) { + log.warn(error); + } + + @Override + public boolean isErrorEnabled() { + return log.isErrorEnabled(); + } + + @Override + public void error(CharSequence content) { + log.error(content); + } + + @Override + public void error(CharSequence content, Throwable error) { + log.error(content, error); + } + + @Override + public void error(Throwable error) { + log.error(error); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/MavenModuleHost.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/MavenModuleHost.java new file mode 100644 index 00000000000..8378a92284d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/MavenModuleHost.java @@ -0,0 +1,189 @@ +/* + * 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.maven; + +import com.codename1.build.BuildArtifact; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.Log; +import com.codename1.build.ProjectHost; +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectKind; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.project.MavenProject; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Properties; +import java.util.Set; + +/// A [ProjectHost] over one Maven module, for the goals that are not +/// `AbstractCN1Mojo`s (the backend goals). Answers only from the module itself. +final class MavenModuleHost implements ProjectHost { + private final MavenProject project; + private final Log log; + + MavenModuleHost(MavenProject project, Log log) { + this.project = project; + this.log = log; + } + + @Override + public Log log() { + return log; + } + + @Override + public ProjectLayout layout() { + File dir = project == null ? new File(".") : project.getBasedir(); + ProjectLayout l = ProjectLayouts.detect(dir); + return l != null ? l : ProjectLayouts.of(BuildSystem.MAVEN, ProjectKind.BACKEND, dir, dir); + } + + @Override + public File cn1ProjectDir() { + return project.getBasedir(); + } + + @Override + public File baseDir() { + return project.getBasedir(); + } + + @Override + public File buildDirectory() { + return new File(project.getBuild().getDirectory()); + } + + @Override + public File outputDirectory() { + return new File(project.getBuild().getOutputDirectory()); + } + + @Override + public String finalName() { + return project.getArtifactId(); + } + + @Override + public String groupId() { + return project.getGroupId(); + } + + @Override + public Properties projectProperties() { + return project.getProperties(); + } + + @Override + public Properties userProperties() { + return new Properties(); + } + + @Override + public List compileClasspathElements() throws BuildExecutionException { + try { + return project.getCompileClasspathElements(); + } catch (Exception ex) { + throw new BuildExecutionException("Could not resolve the compile classpath", ex); + } + } + + @Override + public List runtimeClasspathElements() throws BuildExecutionException { + try { + return project.getRuntimeClasspathElements(); + } catch (Exception ex) { + throw new BuildExecutionException("Could not resolve the runtime classpath", ex); + } + } + + @Override + public List compileSourceRoots() { + List out = new ArrayList(); + for (Object root : project.getCompileSourceRoots()) { + out.add(String.valueOf(root)); + } + return out; + } + + @Override + public Collection artifacts() { + List out = new ArrayList(); + if (project.getArtifacts() != null) { + for (Artifact a : project.getArtifacts()) { + out.add(AbstractCN1Mojo.toBuildArtifact(a)); + } + } + return out; + } + + @Override + public Set neededWithoutDesktopRuntime() { + return null; + } + + @Override + public File getJar(BuildArtifact artifact) { + return artifact.getFile(); + } + + @Override + public File getJar(String groupId, String artifactId, String classifier) { + for (BuildArtifact a : artifacts()) { + if (a.getGroupId().equals(groupId) && a.getArtifactId().equals(artifactId) + && (classifier == null ? a.getClassifier() == null : classifier.equals(a.getClassifier()))) { + return a.getFile(); + } + } + return null; + } + + @Override + public long sessionStartTime() { + return Long.MAX_VALUE; + } + + @Override + public long sourcesModificationTime() { + return Long.MAX_VALUE; + } + + @Override + public void attachArtifact(String type, String classifier, File file) { + log.info("Build output: " + file.getAbsolutePath()); + } + + @Override + public String codenameOneVersion() { + return project.getProperties().getProperty("cn1.version", ""); + } + + @Override + public String pluginVersion() { + return project.getProperties().getProperty("cn1.plugin.version", ""); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenCertificateWizardMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenCertificateWizardMojo.java index bb723e9ec59..5ad56abad76 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenCertificateWizardMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenCertificateWizardMojo.java @@ -23,34 +23,17 @@ package com.codename1.maven; import org.apache.commons.io.FileUtils; -import org.apache.commons.io.IOUtils; import org.apache.maven.artifact.Artifact; -import org.apache.maven.artifact.repository.ArtifactRepository; -import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; -import org.apache.maven.artifact.resolver.ArtifactResolutionResult; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; -import org.apache.tools.ant.taskdefs.Java; -import java.awt.Desktop; import java.io.File; import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URL; -import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.Base64; import java.util.List; import java.util.UUID; -import java.util.jar.JarEntry; -import java.util.jar.JarFile; -import java.util.prefs.Preferences; /** * Launches the standalone Certificate Wizard for a Codename One project. @@ -59,6 +42,9 @@ */ @Mojo(name = "certificatewizard") public class OpenCertificateWizardMojo extends AbstractCN1Mojo { + /// How the wizard starts, shared with the Gradle plugin's `certificateWizard` task. + private static final DesktopTool TOOL = DesktopTool.CERTIFICATE_WIZARD; + private static final String LAUNCHED_PROPERTY = "com.codename1.maven.OpenCertificateWizardMojo.launched"; @@ -111,52 +97,28 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } outputDir.mkdirs(); - Preferences prefs = Preferences.userRoot().node("/com/codename1/ui"); - String effectiveToken = firstNonEmpty(token, ""); - String effectiveUser = firstNonEmpty(user, prefs.get("user", null), ""); - String cachedToken = firstNonEmpty(prefs.get("token", null), ""); - if (effectiveToken.length() == 0 && isUsableJwt(cachedToken)) { - effectiveToken = cachedToken; - getLog().debug("Using cached Codename One signing API JWT"); - } - if (login && effectiveToken.length() == 0) { - LoginResult result = interactiveLogin(); - if (result != null) { - effectiveToken = result.token; - effectiveUser = firstNonEmpty(user, result.user, effectiveUser); - prefs.put("token", effectiveToken); - if (effectiveUser.length() > 0) { - prefs.put("user", effectiveUser); - } - } - } - - File runtimeDir = new File(System.getProperty("user.home"), ".certificateWizard"); - runtimeDir.mkdirs(); + // See CodenameOneLogin, shared with the Gradle plugin. + CodenameOneLogin.LoginResult signIn = new CodenameOneLogin(MavenLog.of(getLog())) + .resolve(token, user, login, loginTimeoutSeconds, baseUrl); + String effectiveToken = signIn.token; + String effectiveUser = signIn.user; + File runtimeDir = TOOL.runtimeDir(); File inputFile = new File(runtimeDir, "certificatewizard.input"); File outputFile = new File(runtimeDir, UUID.randomUUID().toString() + ".output"); writeBinding(inputFile, projectDir, outputDir, outputFile, effectiveUser, effectiveToken); - ToolClasspath toolClasspath = getCertificateWizardClasspath(); + List classpath = resolveDesktopTool(TOOL, pluginVersion(), + "To work on the wizard itself, run:\n" + + " cd scripts/certificatewizard && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase\n" + + " java -cp \"javase/target/codenameone-certificatewizard-*.jar:javase/target/libs/*\" " + + "com.codename1.certificatewizard.CertificateWizardLauncher"); getLog().info("Launching certificate wizard bound to " + projectDir); if (effectiveToken.length() == 0) { getLog().warn("No Codename One bearer token was found. The wizard will open in offline mode unless " + "you pass -Dtoken= or allow -Dcertificatewizard.login=true."); } - if (shouldSpawn()) { - launchDetached(toolClasspath, runtimeDir, inputFile, projectDir); - return; - } - - Java java = createJava(); - java.setFork(true); - java.setJvm(namedJavaLauncher(runtimeDir).getAbsolutePath()); - java.setClassname("com.codename1.certificatewizard.CertificateWizardLauncher"); - java.createClasspath().setPath(joinClasspath(toolClasspath.files)); - configureDesktopIdentity(java, toolClasspath.primaryJar, runtimeDir); - java.createJvmarg().setValue("-Dcertificatewizard.input=" + inputFile.getAbsolutePath()); - java.executeJava(); + launchDesktopTool(TOOL, classpath, inputFile, projectDir, shouldSpawn(), null); } @Override @@ -183,12 +145,6 @@ protected boolean isCN1ProjectDir() { return false; } - private void configureDesktopIdentity(Java java, File jar, File runtimeDir) { - for (String arg : desktopIdentityArgs(jar, runtimeDir)) { - java.createJvmarg().setValue(arg); - } - } - private boolean shouldSpawn() { String legacySpawn = System.getProperty("spawn"); if (legacySpawn != null) { @@ -197,72 +153,12 @@ private boolean shouldSpawn() { return spawn; } - private void launchDetached(ToolClasspath toolClasspath, File runtimeDir, File inputFile, File projectDir) - throws MojoExecutionException { - File log = new File(runtimeDir, "certificatewizard.log"); - List command = new ArrayList(); - command.add(namedJavaLauncher(runtimeDir).getAbsolutePath()); - command.addAll(desktopIdentityArgs(toolClasspath.primaryJar, runtimeDir)); - command.add("-Dcertificatewizard.input=" + inputFile.getAbsolutePath()); - command.add("-cp"); - command.add(joinClasspath(toolClasspath.files)); - command.add("com.codename1.certificatewizard.CertificateWizardLauncher"); - ProcessBuilder pb = new ProcessBuilder(command); - pb.directory(projectDir); - pb.redirectErrorStream(true); - pb.redirectOutput(ProcessBuilder.Redirect.appendTo(log)); - try { - pb.start(); - getLog().info("Certificate Wizard launched in the background. Log: " + log.getAbsolutePath()); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to launch Certificate Wizard", ex); - } - } - - private String javaExecutable() { - String executable = isWindows() ? "javaw.exe" : "java"; - return new File(new File(System.getProperty("java.home"), "bin"), executable).getAbsolutePath(); - } - File namedJavaLauncher(File runtimeDir) { - File java = new File(javaExecutable()); - if (isWindows()) { - // Never copy javaw.exe out of the JDK: a copied launcher loses the JDK's - // bin directory as its DLL search anchor, so dependent native libraries - // can bind to wrong DLLs from System32/PATH and break rendering - // (issue #5443). Launch the real javaw.exe instead. - return java; - } - File launcher = new File(runtimeDir, "Certificate Wizard"); - try { - Files.deleteIfExists(launcher.toPath()); - Files.createSymbolicLink(launcher.toPath(), java.toPath()); - return launcher; - } catch (IOException | UnsupportedOperationException | SecurityException ex) { - getLog().debug("Unable to create Certificate Wizard launcher symlink: " + ex.getMessage()); - return java; - } + return TOOL.javaLauncher(runtimeDir, MavenLog.of(getLog())); } List desktopIdentityArgs(File jar, File runtimeDir) { - List args = new ArrayList(); - args.add("-Dapple.awt.application.name=Certificate Wizard"); - args.add("-Dcom.apple.mrj.application.apple.menu.about.name=Certificate Wizard"); - args.add("-Dsun.awt.application.name=Certificate Wizard"); - args.add("-Dsun.awt.X11.XWMClass=CertificateWizard"); - if (isJava9OrNewer()) { - args.add("--add-exports=java.desktop/com.apple.eawt.event=ALL-UNNAMED"); - args.add("--add-exports=java.desktop/com.apple.eawt=ALL-UNNAMED"); - } - if (!isMacOs()) { - return args; - } - args.add("-Xdock:name=Certificate Wizard"); - File icon = extractWizardIcon(jar, runtimeDir); - if (icon != null && icon.isFile()) { - args.add("-Xdock:icon=" + icon.getAbsolutePath()); - } - return args; + return TOOL.identityArgs(jar, runtimeDir, MavenLog.of(getLog())); } static boolean isMacOs() { @@ -279,94 +175,7 @@ static boolean isJava9OrNewer() { } File extractWizardIcon(File jar, File runtimeDir) { - File iconFile = new File(runtimeDir, "certificate-wizard-icon.png"); - try (JarFile jf = new JarFile(jar)) { - JarEntry entry = jf.getJarEntry("icon.png"); - if (entry == null) { - getLog().debug("Certificate wizard jar does not contain icon.png"); - return null; - } - try (InputStream in = jf.getInputStream(entry)) { - FileUtils.copyInputStreamToFile(in, iconFile); - } - return iconFile; - } catch (IOException ex) { - getLog().debug("Unable to extract certificate wizard dock icon: " + ex.getMessage()); - return null; - } - } - - private LoginResult interactiveLogin() { - if (loginTimeoutSeconds <= 0) { - return null; - } - String key = UUID.randomUUID().toString(); - long deadline = System.currentTimeMillis() + loginTimeoutSeconds * 1000L; - try { - String root = normalizeBaseUrl(baseUrl); - String redirect = root + "/loggedIn.html"; - String loginUrl = root + "/appsec/7.0/set-user?redirect=" + enc(redirect) - + "&loginKey=" + enc(key); - getLog().info("Opening Codename One sign-in for certificate wizard authentication"); - getLog().info(loginUrl); - openBrowser(loginUrl); - while (System.currentTimeMillis() < deadline) { - LoginResult result = pollLogin(root, key); - if (result != null) { - getLog().info("Received Codename One signing API token for " + result.user); - return result; - } - try { - Thread.sleep(2000L); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - return null; - } - } - getLog().warn("Timed out waiting for Codename One browser login"); - } catch (Exception ex) { - getLog().warn("Unable to complete browser login: " + ex.getMessage()); - } - return null; - } - - private LoginResult pollLogin(String root, String key) throws IOException { - URL url = new URL(root + "/poll-user?ver=2&loginKey=" + enc(key)); - HttpURLConnection con = (HttpURLConnection) url.openConnection(); - try { - con.setConnectTimeout(10000); - con.setReadTimeout(10000); - con.setRequestMethod("GET"); - int code = con.getResponseCode(); - if (code == 404) { - return null; - } - if (code != 200) { - getLog().debug("Codename One login poll returned HTTP " + code); - return null; - } - String body = IOUtils.toString(con.getInputStream(), StandardCharsets.UTF_8).trim(); - if (body.length() == 0) { - return null; - } - String[] lines = body.split("\\r?\\n", 2); - String receivedToken = lines[0].trim(); - if (receivedToken.length() == 0) { - return null; - } - String receivedUser = lines.length > 1 ? lines[1].trim() : ""; - return new LoginResult(receivedToken, receivedUser); - } finally { - con.disconnect(); - } - } - - private void openBrowser(String url) throws Exception { - if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { - Desktop.getDesktop().browse(new URI(url)); - } else { - getLog().warn("Desktop browsing is not available. Open the URL above in a browser to continue."); - } + return TOOL.extractIcon(jar, runtimeDir, MavenLog.of(getLog())); } private void writeBinding(File inputFile, File projectDir, File outDir, File outputFile, @@ -386,82 +195,6 @@ private void writeBinding(File inputFile, File projectDir, File outDir, File out } } - private ToolClasspath getCertificateWizardClasspath() throws MojoExecutionException, MojoFailureException { - Artifact artifact = getArtifact("com.codenameone", "codenameone-certificatewizard"); - if (artifact == null) { - artifact = repositorySystem.createArtifact( - "com.codenameone", "codenameone-certificatewizard", pluginVersion(), "jar"); - } - ToolClasspath classpath = resolveToolClasspath(artifact); - if (classpath.primaryJar == null || classpath.files.isEmpty()) { - throw new MojoFailureException( - "Could not resolve the certificate wizard " - + "(com.codenameone:codenameone-certificatewizard:" + pluginVersion() - + ").\n" - + "It is distributed through Maven Central alongside the Codename One plugin.\n" - + "To work on the wizard itself, run:\n" - + " cd scripts/certificatewizard && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase\n" - + " java -cp \"javase/target/codenameone-certificatewizard-*.jar:javase/target/libs/*\" " - + "com.codename1.certificatewizard.CertificateWizardLauncher"); - } - return classpath; - } - - private ToolClasspath resolveToolClasspath(Artifact artifact) { - List files = new ArrayList(); - ArtifactResolutionResult result = repositorySystem.resolve(new ArtifactResolutionRequest() - .setLocalRepository(localRepository) - .setRemoteRepositories(new ArrayList(remoteRepositories)) - .setResolveTransitively(true) - .setArtifact(artifact)); - File primary = addArtifactFile(files, artifact); - if (result != null && result.getArtifacts() != null) { - for (Artifact resolved : result.getArtifacts()) { - File file = addArtifactFile(files, resolved); - if (primary == null && resolved != null - && "com.codenameone".equals(resolved.getGroupId()) - && "codenameone-certificatewizard".equals(resolved.getArtifactId())) { - primary = file; - } - } - } - return new ToolClasspath(primary, files); - } - - private static File addArtifactFile(List files, Artifact artifact) { - if (artifact == null || artifact.getFile() == null || !"jar".equals(artifact.getType())) { - return null; - } - File file = artifact.getFile().getAbsoluteFile(); - if (!file.exists()) { - return null; - } - if (!files.contains(file)) { - files.add(file); - } - return file; - } - - private static String joinClasspath(List files) { - StringBuilder out = new StringBuilder(); - for (File file : files) { - if (out.length() > 0) { - out.append(File.pathSeparator); - } - out.append(file.getAbsolutePath()); - } - return out.toString(); - } - - private static final class ToolClasspath { - final File primaryJar; - final List files; - - ToolClasspath(File primaryJar, List files) { - this.primaryJar = primaryJar; - this.files = files; - } - } private String pluginVersion() { if (pluginArtifacts != null) { @@ -476,103 +209,7 @@ private String pluginVersion() { project.getProperties().getProperty("cn1.version", "8.0-SNAPSHOT")); } - private static String firstNonEmpty(String a, String b, String c) { - if (a != null && a.trim().length() > 0) { - return a.trim(); - } - if (b != null && b.trim().length() > 0) { - return b.trim(); - } - return c == null ? "" : c; - } - - private static String firstNonEmpty(String a, String b) { - return firstNonEmpty(a, b, ""); - } - - private static String normalizeBaseUrl(String url) { - String out = url == null || url.trim().length() == 0 - ? "https://cloud.codenameone.com" : url.trim(); - while (out.endsWith("/")) { - out = out.substring(0, out.length() - 1); - } - return out; - } - - private static String enc(String s) throws IOException { - return URLEncoder.encode(s, "UTF-8"); - } - static boolean isUsableJwt(String token) { - long expiresAt = jwtExpiresAt(token); - return expiresAt > System.currentTimeMillis() + 120000L; - } - - static long jwtExpiresAt(String token) { - if (token == null) { - return -1L; - } - String[] parts = token.split("\\.", -1); - if (parts.length < 2) { - return -1L; - } - try { - byte[] decoded = Base64.getUrlDecoder().decode(padBase64(parts[1])); - String payload = new String(decoded, StandardCharsets.UTF_8); - long exp = numericJsonClaim(payload, "exp"); - return exp <= 0L ? -1L : exp * 1000L; - } catch (RuntimeException ex) { - return -1L; - } - } - - private static long numericJsonClaim(String json, String name) { - String quoted = "\"" + name + "\""; - int idx = json.indexOf(quoted); - if (idx < 0) { - return -1L; - } - int colon = json.indexOf(':', idx + quoted.length()); - if (colon < 0) { - return -1L; - } - int start = colon + 1; - while (start < json.length() && Character.isWhitespace(json.charAt(start))) { - start++; - } - int end = start; - while (end < json.length() && Character.isDigit(json.charAt(end))) { - end++; - } - if (end == start) { - return -1L; - } - try { - return Long.parseLong(json.substring(start, end)); - } catch (NumberFormatException ex) { - return -1L; - } - } - - private static String padBase64(String value) { - int remainder = value.length() % 4; - if (remainder == 0) { - return value; - } - StringBuilder out = new StringBuilder(value); - for (int i = remainder; i < 4; i++) { - out.append('='); - } - return out.toString(); - } - - private static final class LoginResult { - final String token; - final String user; - - LoginResult(String token, String user) { - this.token = token; - this.user = user == null ? "" : user; - } + return CodenameOneLogin.isUsableJwt(token); } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGameBuilderMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGameBuilderMojo.java index 91d196998a4..82008f90bba 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGameBuilderMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGameBuilderMojo.java @@ -24,9 +24,6 @@ import org.apache.commons.io.FileUtils; import org.apache.maven.artifact.Artifact; -import org.apache.maven.artifact.repository.ArtifactRepository; -import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; -import org.apache.maven.artifact.resolver.ArtifactResolutionResult; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.Mojo; @@ -59,6 +56,9 @@ @Mojo(name = "gamebuilder") public class OpenGameBuilderMojo extends AbstractCN1Mojo { + /// How the editor starts, shared with the Gradle plugin's `gameBuilder` task. + private static final DesktopTool TOOL = DesktopTool.GAME_BUILDER; + /** Optional scene class to open initially (e.g. com.example.Level1). */ @Parameter(property = "className", required = false) private String className; @@ -76,29 +76,24 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException + "resources" + File.separator + "games"); gamesDir.mkdirs(); - File runtimeDir = new File(System.getProperty("user.home"), ".gameBuilder"); - runtimeDir.mkdirs(); - String uuid = UUID.randomUUID().toString(); - File outputFile = new File(runtimeDir, uuid + ".output"); - writeBinding(new File(runtimeDir, "gamebuilder.input"), projectDir, gamesDir, outputFile); + File runtimeDir = TOOL.runtimeDir(); + File outputFile = new File(runtimeDir, UUID.randomUUID().toString() + ".output"); + File input = new File(runtimeDir, "gamebuilder.input"); + writeBinding(input, projectDir, gamesDir, outputFile); - ToolClasspath toolClasspath = getGameBuilderClasspath(); + List classpath = resolveDesktopTool(TOOL, pluginVersion(), + "To work on the editor itself, run it straight from its module:\n" + + " cd scripts/gamebuilder && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase\n" + + " java -cp \"javase/target/codenameone-gamebuilder-*.jar:javase/target/libs/*\" " + + "com.codename1.gamebuilder.GameBuilderStub"); getLog().info("Launching game builder bound to " + projectDir); - Java java = createJava(); - java.setFork(true); - if ("true".equals(System.getProperty("spawn", "true"))) { - java.setSpawn(true); - } - java.setClassname("com.codename1.gamebuilder.GameBuilderStub"); - java.createClasspath().setPath(joinClasspath(toolClasspath.files)); - // The forked editor reads the binding path from this system property. - java.createJvmarg().setValue("-Dgamebuilder.input=" - + new File(runtimeDir, "gamebuilder.input").getAbsolutePath()); + List extra = new ArrayList(); if (className != null && !className.trim().isEmpty()) { - java.createJvmarg().setValue("-Dgamebuilder.scene=" + className.trim()); + extra.add("-Dgamebuilder.scene=" + className.trim()); } - java.executeJava(); + launchDesktopTool(TOOL, classpath, input, projectDir, "true".equals(System.getProperty("spawn", "true")), + extra); } private void requireJava17() throws MojoFailureException, MojoExecutionException { @@ -137,76 +132,6 @@ private void writeBinding(File inputFile, File projectDir, File gamesDir, File o } } - /** - * Resolves the game-builder editor and runtime dependencies through Maven. - */ - private ToolClasspath getGameBuilderClasspath() throws MojoExecutionException, MojoFailureException { - Artifact artifact = getArtifact("com.codenameone", "codenameone-gamebuilder"); - if (artifact == null) { - artifact = repositorySystem.createArtifact( - "com.codenameone", "codenameone-gamebuilder", pluginVersion(), "jar"); - } - ToolClasspath classpath = resolveToolClasspath(artifact); - if (classpath.files.isEmpty()) { - throw new MojoFailureException( - "Could not resolve the game builder editor " - + "(com.codenameone:codenameone-gamebuilder:" + pluginVersion() + ").\n" - + "It is distributed through Maven Central alongside the Codename One plugin; make sure " - + "your build can reach Maven Central and is using a released plugin version.\n" - + "To work on the editor itself, run it straight from its module:\n" - + " cd scripts/gamebuilder && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase\n" - + " java -cp \"javase/target/codenameone-gamebuilder-*.jar:javase/target/libs/*\" " - + "com.codename1.gamebuilder.GameBuilderStub"); - } - return classpath; - } - - private ToolClasspath resolveToolClasspath(Artifact artifact) { - List files = new ArrayList(); - ArtifactResolutionResult result = repositorySystem.resolve(new ArtifactResolutionRequest() - .setLocalRepository(localRepository) - .setRemoteRepositories(new ArrayList(remoteRepositories)) - .setResolveTransitively(true) - .setArtifact(artifact)); - addArtifactFile(files, artifact); - if (result != null && result.getArtifacts() != null) { - for (Artifact resolved : result.getArtifacts()) { - addArtifactFile(files, resolved); - } - } - return new ToolClasspath(files); - } - - private static void addArtifactFile(List files, Artifact artifact) { - if (artifact == null || artifact.getFile() == null || !"jar".equals(artifact.getType())) { - return; - } - File file = artifact.getFile().getAbsoluteFile(); - if (!file.exists() || files.contains(file)) { - return; - } - files.add(file); - } - - private static String joinClasspath(List files) { - StringBuilder out = new StringBuilder(); - for (File file : files) { - if (out.length() > 0) { - out.append(File.pathSeparator); - } - out.append(file.getAbsolutePath()); - } - return out.toString(); - } - - private static final class ToolClasspath { - final List files; - - ToolClasspath(List files) { - this.files = files; - } - } - /** The Codename One plugin's own version, used as the game-builder editor's version. */ private String pluginVersion() { if (pluginArtifacts != null) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGuiBuilderMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGuiBuilderMojo.java index 3a146457b84..d5757213c0a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGuiBuilderMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGuiBuilderMojo.java @@ -29,9 +29,6 @@ import org.apache.commons.io.FileUtils; import org.apache.maven.artifact.Artifact; -import org.apache.maven.artifact.repository.ArtifactRepository; -import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; -import org.apache.maven.artifact.resolver.ArtifactResolutionResult; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.execution.MavenSession; @@ -44,7 +41,6 @@ import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; @@ -69,8 +65,10 @@ public class OpenGuiBuilderMojo extends AbstractCN1Mojo { /** The editor is compiled for this Java release, so an older forked JVM cannot load it. */ private static final int REQUIRED_JAVA_VERSION = 8; - /** {@code --add-exports} is a Java 9 option; passing it to an 8 JVM stops it from starting. */ - private static final int MODULE_OPTIONS_VERSION = 9; + /// How the editor starts, shared with the Gradle plugin's `guibuilder` task. + /// It passes `--add-exports` only on Java 9 and newer: the option stops an 8 + /// JVM before it prints anything the user would see. + private static final DesktopTool TOOL = DesktopTool.GUI_BUILDER; /** The invocation the guard belongs to. */ @Parameter(defaultValue = "${session}", readonly = true) @@ -106,29 +104,14 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException File cssFile = new File(projectDir, "src" + File.separator + "main" + File.separator + "css" + File.separator + "theme.css"); guiDir.mkdirs(); - File runtimeDir = new File(System.getProperty("user.home"), ".codenameoneGUIBuilder"); - runtimeDir.mkdirs(); - File input = new File(runtimeDir, "guibuilder-" + UUID.randomUUID() + ".input"); + File input = new File(TOOL.runtimeDir(), "guibuilder-" + UUID.randomUUID() + ".input"); writeBinding(input, projectDir, guiDir, sourceDir, cssFile); - ToolClasspath classpath = getGuiBuilderClasspath(); + List classpath = resolveDesktopTool(TOOL, pluginVersion(), + "To work on the editor, run:\n" + + " cd scripts/guibuilder && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase"); getLog().info("Launching Codename One GUI Builder bound to " + projectDir); - if (shouldSpawn()) { - launchDetached(classpath, runtimeDir, input, projectDir); - return; - } - Java java = createJava(); - java.setFork(true); - java.setClassname("com.codename1.guibuilder.CodenameOneGUIBuilderLauncher"); - java.createClasspath().setPath(joinClasspath(classpath.files)); - for (String arg : desktopIdentityArgs()) { - java.createJvmarg().setValue(arg); - } - java.createJvmarg().setValue("-Dguibuilder.input=" + input.getAbsolutePath()); - for (String arg : forwardedGuiBuilderProperties()) { - java.createJvmarg().setValue(arg); - } - java.executeJava(); + launchDesktopTool(TOOL, classpath, input, projectDir, shouldSpawn(), null); } /** @@ -218,15 +201,7 @@ static int javaFeatureVersion() { * forwarded. */ List forwardedGuiBuilderProperties() { - List args = new ArrayList(); - for (String key : System.getProperties().stringPropertyNames()) { - if (key.startsWith("guibuilder.") - && !key.equals("guibuilder.input") - && !key.equals("guibuilder.spawn")) { - args.add("-D" + key + "=" + System.getProperty(key)); - } - } - return args; + return TOOL.forwardedProperties(); } /** @@ -234,25 +209,8 @@ List forwardedGuiBuilderProperties() { * JavaSE port needs on Java 9 and newer, matching cn1:settings and cn1:certificate-wizard. */ List desktopIdentityArgs() { - List args = new ArrayList(); - args.add("-Dapple.awt.application.name=Codename One GUI Builder"); - args.add("-Dcom.apple.mrj.application.apple.menu.about.name=Codename One GUI Builder"); - args.add("-Dsun.awt.application.name=Codename One GUI Builder"); - args.add("-Dsun.awt.X11.XWMClass=CodenameOneGUIBuilder"); - if (javaFeatureVersion() >= MODULE_OPTIONS_VERSION) { - // On 8 these packages are already reachable and the option itself is unrecognized, - // which stops the forked JVM before it prints anything the user would see. - args.add("--add-exports=java.desktop/com.apple.eawt.event=ALL-UNNAMED"); - args.add("--add-exports=java.desktop/com.apple.eawt=ALL-UNNAMED"); - } - if (isMacOs()) { - args.add("-Xdock:name=Codename One GUI Builder"); - } - return args; - } - - private static boolean isMacOs() { - return System.getProperty("os.name", "").toLowerCase().contains("mac"); + // The GUI Builder ships no dock icon, so no jar or runtime directory is needed. + return TOOL.identityArgs(null, null, MavenLog.of(getLog())); } @Override @@ -291,88 +249,6 @@ void writeBinding(File input, File projectDir, File guiDir, File sourceDir, File } } - private void launchDetached(ToolClasspath classpath, File runtimeDir, File input, File projectDir) - throws MojoExecutionException { - List command = new ArrayList(); - command.add(javaExecutable()); - command.addAll(desktopIdentityArgs()); - command.add("-Dguibuilder.input=" + input.getAbsolutePath()); - command.addAll(forwardedGuiBuilderProperties()); - command.add("-cp"); - command.add(joinClasspath(classpath.files)); - command.add("com.codename1.guibuilder.CodenameOneGUIBuilderLauncher"); - ProcessBuilder builder = new ProcessBuilder(command); - builder.directory(projectDir); - builder.redirectErrorStream(true); - builder.redirectOutput(ProcessBuilder.Redirect.appendTo(new File(runtimeDir, "guibuilder.log"))); - try { - builder.start(); - getLog().info("GUI Builder launched in the background. Log: " + new File(runtimeDir, "guibuilder.log")); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to launch Codename One GUI Builder", ex); - } - } - - private ToolClasspath getGuiBuilderClasspath() throws MojoExecutionException, MojoFailureException { - Artifact artifact = getArtifact("com.codenameone", "codenameone-guibuilder"); - if (artifact == null) { - artifact = repositorySystem.createArtifact("com.codenameone", "codenameone-guibuilder", pluginVersion(), "jar"); - } - List files = new ArrayList(); - ArtifactResolutionResult result = repositorySystem.resolve(new ArtifactResolutionRequest() - .setLocalRepository(localRepository) - .setRemoteRepositories(new ArrayList(remoteRepositories)) - .setResolveTransitively(true) - // Maven documents -o as "work offline"; the legacy resolver does not read the - // session flag by itself, so without this mvn -o cn1:guibuilder still reaches the - // network and can refresh a snapshot over the jar this build just produced. - .setOffline(offline) - .setArtifact(artifact)); - addArtifact(files, artifact); - if (result != null && result.getArtifacts() != null) { - for (Artifact resolved : result.getArtifacts()) addArtifact(files, resolved); - } - // A cached main jar with a missing transitive dependency leaves this list nonempty, and in - // detached mode the goal would report a successful launch while the process died in - // guibuilder.log with a NoClassDefFoundError nobody goes looking for. - String incomplete = resolutionFailure(result); - if (incomplete != null) { - throw new MojoFailureException("The GUI Builder could not be fully resolved: " + incomplete - + "\nRun mvn -U cn1:guibuilder to refresh it, or drop -o if you are building offline."); - } - if (files.isEmpty()) { - throw new MojoFailureException("Could not resolve the GUI Builder (com.codenameone:codenameone-guibuilder:" - + pluginVersion() + "). It is distributed through Maven Central alongside the Codename One plugin.\n" - + "To work on the editor, run:\n" - + " cd scripts/guibuilder && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase"); - } - return new ToolClasspath(files); - } - - /** - * Describes what the resolver could not produce, or null when the result is complete. - * - * @param result the resolution result to inspect - * @return a human readable description of the first problem found - */ - static String resolutionFailure(ArtifactResolutionResult result) { - if (result == null) return null; - if (result.hasMissingArtifacts()) { - return "missing " + result.getMissingArtifacts(); - } - if (result.hasExceptions()) { - Exception first = (Exception) result.getExceptions().get(0); - return first.getMessage(); - } - return null; - } - - private static void addArtifact(List files, Artifact artifact) { - if (artifact == null || artifact.getFile() == null || !"jar".equals(artifact.getType())) return; - File file = artifact.getFile().getAbsoluteFile(); - if (file.exists() && !files.contains(file)) files.add(file); - } - private String pluginVersion() { if (pluginArtifacts != null) { for (Artifact artifact : pluginArtifacts) { @@ -384,22 +260,4 @@ private String pluginVersion() { project.getProperties().getProperty("cn1.version", "8.0-SNAPSHOT")); } - private String javaExecutable() { - boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win"); - return new File(new File(System.getProperty("java.home"), "bin"), windows ? "javaw.exe" : "java").getAbsolutePath(); - } - - private static String joinClasspath(List files) { - StringBuilder value = new StringBuilder(); - for (File file : files) { - if (value.length() > 0) value.append(File.pathSeparator); - value.append(file.getAbsolutePath()); - } - return value.toString(); - } - - private static final class ToolClasspath { - final List files; - ToolClasspath(List files) { this.files = files; } - } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java index 91e04b94646..5cb4fd24bb8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java @@ -24,28 +24,19 @@ import org.apache.commons.io.FileUtils; import org.apache.maven.artifact.Artifact; -import org.apache.maven.artifact.repository.ArtifactRepository; -import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; -import org.apache.maven.artifact.resolver.ArtifactResolutionResult; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; -import org.apache.tools.ant.taskdefs.Java; import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.ArrayList; import java.util.List; import org.apache.maven.execution.MavenSession; import org.apache.maven.project.MavenProject; import java.util.UUID; -import java.util.jar.JarEntry; -import java.util.jar.JarFile; /** * Opens the standalone Codename One Settings tool. @@ -54,6 +45,9 @@ */ @Mojo(name = "settings") public class OpenSettingsMojo extends AbstractCN1Mojo { + /// How the tool starts, shared with the Gradle plugin's `settings` task. + private static final DesktopTool TOOL = DesktopTool.SETTINGS; + private static final String LAUNCHED_PROPERTY = "com.codename1.maven.OpenSettingsMojo.launched"; @@ -73,29 +67,16 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException System.setProperty(LAUNCHED_PROPERTY, "true"); File projectDir = getCN1ProjectDir(); - File runtimeDir = new File(System.getProperty("user.home"), ".codenameoneSettings"); - runtimeDir.mkdirs(); - File inputFile = new File(runtimeDir, "settings-" + UUID.randomUUID() + ".input"); + File inputFile = new File(TOOL.runtimeDir(), "settings-" + UUID.randomUUID() + ".input"); writeBinding(inputFile, projectDir); - ToolClasspath toolClasspath = getSettingsClasspath(); + List classpath = resolveDesktopTool(TOOL, pluginVersion(), + "To work on the Settings tool itself, run:\n" + + " cd scripts/settings && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase\n" + + " java -cp \"javase/target/codenameone-settings-*.jar:javase/target/libs/*\" " + + "com.codename1.settings.CodenameOneSettingsLauncher"); getLog().info("Launching Codename One Settings bound to " + projectDir); - if (shouldSpawn()) { - launchDetached(toolClasspath, runtimeDir, inputFile, projectDir); - return; - } - - Java java = createJava(); - java.setFork(true); - java.setJvm(namedJavaLauncher(runtimeDir).getAbsolutePath()); - java.setClassname("com.codename1.settings.CodenameOneSettingsLauncher"); - java.createClasspath().setPath(joinClasspath(toolClasspath.files)); - configureDesktopIdentity(java, toolClasspath.primaryJar, runtimeDir); - java.createJvmarg().setValue("-Dsettings.input=" + inputFile.getAbsolutePath()); - for (String arg : forwardedSettingsProperties()) { - java.createJvmarg().setValue(arg); - } - java.executeJava(); + launchDesktopTool(TOOL, classpath, inputFile, projectDir, shouldSpawn(), null); } /** @@ -106,15 +87,7 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException * owned by this mojo and never forwarded. */ List forwardedSettingsProperties() { - List args = new ArrayList(); - for (String key : System.getProperties().stringPropertyNames()) { - if (key.startsWith("settings.") - && !key.equals("settings.input") - && !key.equals("settings.spawn")) { - args.add("-D" + key + "=" + System.getProperty(key)); - } - } - return args; + return TOOL.forwardedProperties(); } @Override @@ -145,91 +118,16 @@ private boolean shouldSpawn() { return spawn; } - private void launchDetached(ToolClasspath toolClasspath, File runtimeDir, File inputFile, File projectDir) - throws MojoExecutionException { - File log = new File(runtimeDir, "settings.log"); - List command = new ArrayList(); - command.add(namedJavaLauncher(runtimeDir).getAbsolutePath()); - command.addAll(desktopIdentityArgs(toolClasspath.primaryJar, runtimeDir)); - command.add("-Dsettings.input=" + inputFile.getAbsolutePath()); - command.addAll(forwardedSettingsProperties()); - command.add("-cp"); - command.add(joinClasspath(toolClasspath.files)); - command.add("com.codename1.settings.CodenameOneSettingsLauncher"); - ProcessBuilder pb = new ProcessBuilder(command); - pb.directory(projectDir); - pb.redirectErrorStream(true); - pb.redirectOutput(ProcessBuilder.Redirect.appendTo(log)); - try { - pb.start(); - getLog().info("Codename One Settings launched in the background. Log: " + log.getAbsolutePath()); - } catch (IOException ex) { - throw new MojoExecutionException("Failed to launch Codename One Settings", ex); - } - } - - private void configureDesktopIdentity(Java java, File jar, File runtimeDir) { - for (String arg : desktopIdentityArgs(jar, runtimeDir)) { - java.createJvmarg().setValue(arg); - } - } - List desktopIdentityArgs(File jar, File runtimeDir) { - List args = new ArrayList(); - args.add("-Dapple.awt.application.name=Codename One Settings"); - args.add("-Dcom.apple.mrj.application.apple.menu.about.name=Codename One Settings"); - args.add("-Dsun.awt.application.name=Codename One Settings"); - args.add("-Dsun.awt.X11.XWMClass=CodenameOneSettings"); - if (isJava9OrNewer()) { - args.add("--add-exports=java.desktop/com.apple.eawt.event=ALL-UNNAMED"); - args.add("--add-exports=java.desktop/com.apple.eawt=ALL-UNNAMED"); - } - if (isMacOs()) { - args.add("-Xdock:name=Codename One Settings"); - File icon = extractSettingsIcon(jar, runtimeDir); - if (icon != null && icon.isFile()) { - args.add("-Xdock:icon=" + icon.getAbsolutePath()); - } - } - return args; + return TOOL.identityArgs(jar, runtimeDir, MavenLog.of(getLog())); } File namedJavaLauncher(File runtimeDir) { - File java = new File(javaExecutable()); - if (isWindows()) { - // Never copy javaw.exe out of the JDK: a copied launcher loses the JDK's - // bin directory as its DLL search anchor, so dependent native libraries - // (fontmanager/freetype and friends) can bind to wrong DLLs from - // System32/PATH and break rendering (issue #5443). The cosmetic Task - // Manager name is not worth a broken JVM - launch the real javaw.exe. - return java; - } - File launcher = new File(runtimeDir, "Codename One Settings"); - try { - Files.deleteIfExists(launcher.toPath()); - Files.createSymbolicLink(launcher.toPath(), java.toPath()); - return launcher; - } catch (IOException | UnsupportedOperationException | SecurityException ex) { - getLog().debug("Unable to create Settings launcher symlink: " + ex.getMessage()); - return java; - } + return TOOL.javaLauncher(runtimeDir, MavenLog.of(getLog())); } File extractSettingsIcon(File jar, File runtimeDir) { - File iconFile = new File(runtimeDir, "settings-icon.png"); - try (JarFile jf = new JarFile(jar)) { - JarEntry entry = jf.getJarEntry("icon.png"); - if (entry == null) { - return null; - } - try (InputStream in = jf.getInputStream(entry)) { - FileUtils.copyInputStreamToFile(in, iconFile); - } - return iconFile; - } catch (IOException ex) { - getLog().debug("Unable to extract Settings dock icon: " + ex.getMessage()); - return null; - } + return TOOL.extractIcon(jar, runtimeDir, MavenLog.of(getLog())); } void writeBinding(File inputFile, File projectDir) throws MojoExecutionException { @@ -354,78 +252,6 @@ File multimoduleRoot(File projectDir) { return projectDir == null ? new File(".") : projectDir; } - private ToolClasspath getSettingsClasspath() throws MojoExecutionException, MojoFailureException { - Artifact artifact = getArtifact("com.codenameone", "codenameone-settings"); - if (artifact == null) { - artifact = repositorySystem.createArtifact( - "com.codenameone", "codenameone-settings", pluginVersion(), "jar"); - } - ToolClasspath classpath = resolveToolClasspath(artifact); - if (classpath.primaryJar == null || classpath.files.isEmpty()) { - throw new MojoFailureException( - "Could not resolve Codename One Settings " - + "(com.codenameone:codenameone-settings:" + pluginVersion() - + ").\n" - + "It is distributed through Maven Central alongside the Codename One plugin.\n" - + "To work on the Settings tool itself, run:\n" - + " cd scripts/settings && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase\n" - + " java -cp \"javase/target/codenameone-settings-*.jar:javase/target/libs/*\" " - + "com.codename1.settings.CodenameOneSettingsLauncher"); - } - return classpath; - } - - private ToolClasspath resolveToolClasspath(Artifact artifact) { - List files = new ArrayList(); - ArtifactResolutionResult result = repositorySystem.resolve(new ArtifactResolutionRequest() - .setLocalRepository(localRepository) - .setRemoteRepositories(new ArrayList(remoteRepositories)) - .setResolveTransitively(true) - .setArtifact(artifact)); - File primary = addArtifactFile(files, artifact); - if (result != null && result.getArtifacts() != null) { - for (Artifact resolved : result.getArtifacts()) { - File file = addArtifactFile(files, resolved); - if (primary == null && resolved != null - && "com.codenameone".equals(resolved.getGroupId()) - && "codenameone-settings".equals(resolved.getArtifactId())) { - primary = file; - } - } - } - return new ToolClasspath(primary, files); - } - - private static File addArtifactFile(List files, Artifact artifact) { - if (artifact == null || artifact.getFile() == null || !"jar".equals(artifact.getType())) { - return null; - } - File file = artifact.getFile().getAbsoluteFile(); - if (!file.exists()) { - return null; - } - if (!files.contains(file)) { - files.add(file); - } - return file; - } - - private static String joinClasspath(List files) { - StringBuilder out = new StringBuilder(); - for (File file : files) { - if (out.length() > 0) { - out.append(File.pathSeparator); - } - out.append(file.getAbsolutePath()); - } - return out.toString(); - } - - private String javaExecutable() { - String executable = isWindows() ? "javaw.exe" : "java"; - return new File(new File(System.getProperty("java.home"), "bin"), executable).getAbsolutePath(); - } - static boolean isMacOs() { return System.getProperty("os.name", "").toLowerCase().contains("mac"); } @@ -455,13 +281,4 @@ String pluginVersion() { project.getProperties().getProperty("cn1.version", "8.0-SNAPSHOT")); } - private static final class ToolClasspath { - final File primaryJar; - final List files; - - ToolClasspath(File primaryJar, List files) { - this.primaryJar = primaryJar; - this.files = files; - } - } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PathUtil.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PathUtil.java deleted file mode 100644 index 6760337ad93..00000000000 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PathUtil.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.codename1.maven; - -import java.io.File; - -/** - * Utility for creatings file paths. - */ -public class PathUtil { - public static String path(String... parts) { - StringBuilder sb = new StringBuilder(); - for (String part : parts) { - if (sb.length() > 0) { - sb.append(File.separator); - } - sb.append(part); - } - return sb.toString(); - } -} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java index 57ef0d04f39..08f560773cd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java @@ -23,27 +23,20 @@ package com.codename1.maven; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Properties; import org.apache.commons.io.FileUtils; -import org.apache.commons.text.StringEscapeUtils; import org.apache.maven.artifact.Artifact; import org.apache.maven.model.Dependency; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; -import org.apache.maven.plugin.logging.Log; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.ResolutionScope; -import org.apache.tools.ant.taskdefs.Expand; -import org.apache.tools.ant.taskdefs.Java; -import org.apache.tools.ant.types.Environment; -import org.apache.tools.ant.types.Path; import static com.codename1.maven.PathUtil.path; @@ -113,68 +106,25 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException File resFile = new File(getCN1ProjectDir(), "target" + File.separator + "classes" + File.separator + "theme.res"); File mergeFile = new File(getCN1ProjectDir(), "target" + File.separator + "css" + File.separator + "theme.css"); - final StringBuilder inputs = new StringBuilder(); - - project.getArtifacts().forEach(artifact->{ + // The same inputs the css goal compiles, library bundles first; see + // CssCompiler, shared with the Gradle plugin. + List libraries = new ArrayList(); + for (Artifact artifact : project.getArtifacts()) { if (artifact.hasClassifier() && "cn1css".equals(artifact.getClassifier())) { File zip = findArtifactFile(artifact); if (zip == null || !zip.exists()) { - return; - } - - File extracted = new File(zip.getParentFile(), zip.getName()+"-extracted"); - getLog().debug("Checking for extracted CSS bundle "+extracted); - if (extracted.exists() && artifact.isSnapshot() && getLastModified(artifact) > extracted.lastModified()) { - try { - FileUtils.deleteDirectory(extracted); - } catch (IOException ex){ - getLog().error(ex); - } - } - if (!extracted.exists()) { - getLog().debug("CSS bundle "+zip+" not extracted yet. Extracting to "+extracted); - // This is a cn1css artifact, which is a zip file. - // We extract it so that we can access the files directly. - Expand expand = (Expand)antProject.createTask("unzip"); - expand.setSrc(zip); - expand.setDest(extracted); - expand.execute(); - - } - if (extracted.exists()) { - File extractedCssDir = new File(extracted, path("META-INF","codenameone", artifact.getGroupId(), artifact.getArtifactId(), "css")); - if (extractedCssDir.exists()) { - // We expect that the cn1css artifact has a theme.css file at its root - // If found, we add it to the list of inputs. - File theme = new File(extractedCssDir, "theme.css"); - if (theme.exists()) { - if (inputs.length() > 0) { - inputs.append(","); - } - inputs.append(theme.getAbsolutePath()); - } - } - - } else { - getLog().debug("CSS bundle extraction must have failed for "+zip+" because after extraction it still doesn't exist at "+extracted); + continue; } + libraries.add(new CssCompiler.LibraryCss(artifact.getGroupId(), artifact.getArtifactId(), zip, + new File(zip.getParentFile(), zip.getName() + "-extracted"), + artifact.isSnapshot(), getLastModified(artifact))); } - }); - - // The project's theme.css file is added to the input list last so that it will result in it - // being last in the merged theme.css file (i.e. the application project CSS can override the - // CSS in dependent libraries. - - if (cssFile.exists()) { - if (inputs.length() > 0) { - inputs.append(","); - } - inputs.append(cssFile.getAbsolutePath()); } - + final String inputs = new CssCompiler(MavenLog.of(getLog()), antProject, null) + .inputs(libraries, "", cssFile); if (cssFile.exists()) { - project.getModel().addProperty("codename1.css.compiler.args.input", inputs.toString()); + project.getModel().addProperty("codename1.css.compiler.args.input", inputs); project.getModel().addProperty("codename1.css.compiler.args.output", resFile.getAbsolutePath()); project.getModel().addProperty("codename1.css.compiler.args.merge", mergeFile.getAbsolutePath()); } else { @@ -186,43 +136,27 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException project.getModel().addProperty("cn1.class.path", prepareClasspath()); } - Properties simulatorProperties = new Properties(); File simulatorPropertiesFile = new File(getCN1ProjectDir(), path("target", "codenameone", "simulator.properties")); - if (!simulatorPropertiesFile.getParentFile().exists()) { - simulatorPropertiesFile.getParentFile().mkdirs(); - } - - + String compileClasspath = null; try { - StringBuilder compileClasspath = new StringBuilder(); + StringBuilder cp = new StringBuilder(); for (String el : project.getCompileClasspathElements()) { - if (compileClasspath.length() > 0) { - compileClasspath.append(File.pathSeparator); + if (cp.length() > 0) { + cp.append(File.pathSeparator); } - compileClasspath.append(el); + cp.append(el); } - simulatorProperties.setProperty("cn1.maven.compileClasspathElements", compileClasspath.toString()); - - } catch (Exception ex){} - - // Classpath the simulator's CSSWatcher uses to fork the headless CSS compiler. - // Simulator.java loads simulator.properties into System properties, so the port - // reads this the same way SourceChangeWatcher reads - // cn1.maven.compileClasspathElements -- no change needed in generated poms. - simulatorProperties.setProperty(CSS_CLI_CLASSPATH_PROPERTY, getCssCliClasspath()); - - addCommandLineOverrides(simulatorProperties, - getSession() == null ? null : getSession().getUserProperties(), properties); - - try (FileOutputStream fos = new FileOutputStream(simulatorPropertiesFile)) { - if (System.getProperty("ffmpeg.dir") != null) { - simulatorProperties.setProperty("ffmpeg.dir", System.getProperty("ffmpeg.dir")); - } - simulatorProperties.store(fos, "Updated simulator properties in PrepareSimulatorClasspathMojo"); + compileClasspath = cp.toString(); + } catch (Exception ex) { + getLog().debug("Could not resolve the compile classpath for hot reload", ex); + } + try { + SimulatorSupport.writeSimulatorProperties(simulatorPropertiesFile, compileClasspath, + getCssCliClasspath(), getSession() == null ? null : getSession().getUserProperties(), + properties); } catch (IOException ex) { throw new MojoExecutionException("Failed to write simulator.properties file", ex); } - } protected File findCSSDirectory() { @@ -239,51 +173,10 @@ protected File findCSSDirectory() { } - /** - * Puts the command line's build hints and the effective entry point into the - * simulator's properties. - * - *

The simulator is started by {@code exec:exec} from the generated javase - * POM, whose {@code } are a fixed list -- no {@code -D} of ours - * reaches it, and that POM cannot be changed for a project that already - * exists. This file can: {@code Simulator.loadSimulatorProperties} copies - * every key here into the system properties, and does it BEFORE the - * annotated build hints are published.

- * - *

That is what makes the simulator's own guard work. It publishes an - * annotated hint only where no system property already claims the key, so - * that {@code -D} keeps winning the way it does in a device build -- but - * nothing was ever setting one, so the guard never fired and - * {@code -Dcodename1.arg.desktop.titleBar=...} did nothing locally while the - * same command line changed the device build.

- * - *

Only what {@code -D} passed, never the settings file's own hints: a - * system property outranks the file in {@code buildHint()}, so publishing - * those would hide the both-declared conflict the simulator reports.

- * - *

The entry point travels too. It is what {@code process-annotations} - * stamped the manifest with, and a simulator reading {@code codename1.mainName} - * out of the settings file instead disagreed with that stamp on an - * overridden build and refused the manifest outright.

- */ + /** See {@link SimulatorSupport#addCommandLineOverrides}. */ static void addCommandLineOverrides(Properties simulatorProperties, Properties userProperties, Properties effective) { - Properties commandLine = commandLineBuildHints(userProperties); - for (String key : commandLine.stringPropertyNames()) { - simulatorProperties.setProperty(key, commandLine.getProperty(key)); - } - copyEffective(simulatorProperties, effective, "codename1.mainName"); - copyEffective(simulatorProperties, effective, "codename1.packageName"); - } - - /// Copies one resolved settings key into the simulator's properties, when it - /// has a value. - private static void copyEffective(Properties simulatorProperties, Properties effective, - String key) { - String value = effective == null ? null : effective.getProperty(key); - if (value != null && value.trim().length() > 0) { - simulatorProperties.setProperty(key, value.trim()); - } + SimulatorSupport.addCommandLineOverrides(simulatorProperties, userProperties, effective); } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java index 3a9c5be3977..9677be2df64 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java @@ -22,11 +22,6 @@ */ package com.codename1.maven; -import com.codename1.maven.annotations.AnnotatedClass; -import com.codename1.maven.annotations.AnnotationProcessor; -import com.codename1.maven.annotations.ClassScanner; -import com.codename1.maven.annotations.ProcessingException; -import com.codename1.maven.annotations.ProcessorContext; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -37,17 +32,9 @@ import java.io.File; import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Map; import java.util.Properties; -import java.util.ServiceLoader; -import java.util.Set; /// PROCESS_CLASSES Mojo. ASM-scans the project's compiled `.class` files, /// dispatches each annotated class to the registered `AnnotationProcessor`s, @@ -98,192 +85,21 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException return; } - List processors = loadProcessors(); - if (processors.isEmpty()) { - getLog().debug("cn1: no AnnotationProcessor services registered — nothing to do"); - return; - } - - Map index; - try { - index = ClassScanner.scan(outputDirectory); - } catch (ProcessingException e) { - throw new MojoExecutionException("Failed to scan compiled classes under " - + outputDirectory + ": " + e.getMessage(), e); - } - - ProcessorContext ctx = new ProcessorContext(outputDirectory, stubSourceDirectory, - index, getLog(), getCN1ProjectDir(), rawProjectSettings(), mainClassBinaryName(), - // The roots Maven is actually compiling, so a processor asking - // whether a class still has a source is not guessing at the - // layout. - compileSourceRoots(project, userProperties()), - // The charset javac is given, so a processor reading a source - // back decodes the text that was actually compiled rather than - // one of the single-byte encodings that all decode without - // error and disagree about every non-ASCII character. - sourceEncodingOf(project, userProperties()), - // Where the build hint annotations are, so the processor reads - // what a member sets from the annotation rather than from a - // generated table naming each of them. - compileClasspathOf(project)); - - // start() - for (Iterator it = processors.iterator(); it.hasNext(); ) { - AnnotationProcessor p = it.next(); - try { - p.start(ctx); - } catch (ProcessingException e) { - throw new MojoFailureException( - "Annotation processor " + p.getClass().getName() + " start failed: " - + e.getMessage(), e); - } - } - - // processClass() — dispatched only when the class carries an annotation - // the processor declares interest in, anywhere in the class. - // - // The test is against getAllAnnotationDescriptors(), not - // getClassAnnotations(): a class whose only annotation sits on a method - // has an empty class-annotation map, so gating on that map alone would - // silently skip it. That is not hypothetical — it is exactly the shape - // of the documented static-factory @Route form and of an @AppIntent - // handler, and such a class would be dropped with no error anywhere. - for (AnnotatedClass cls : index.values()) { - Set present = cls.getAllAnnotationDescriptors(); - if (present.isEmpty()) continue; - for (Iterator it = processors.iterator(); it.hasNext(); ) { - AnnotationProcessor p = it.next(); - if (intersects(p.getAnnotationDescriptors(), present)) { - try { - p.processClass(cls, ctx); - } catch (ProcessingException e) { - throw new MojoFailureException( - "Annotation processor " + p.getClass().getName() + " failed on class " - + cls.getBinaryName() + ": " + e.getMessage(), e); - } - } - } - } - - // finish() - for (Iterator it = processors.iterator(); it.hasNext(); ) { - AnnotationProcessor p = it.next(); - try { - p.finish(ctx); - } catch (ProcessingException e) { - throw new MojoFailureException( - "Annotation processor " + p.getClass().getName() + " finish failed: " - + e.getMessage(), e); - } - } - - // Fail-fast: surface every recoverable error and abort if any. - if (ctx.hasErrors()) { - StringBuilder sb = new StringBuilder("Codename One annotation processing failed:\n"); - List errs = ctx.getErrors(); - for (int i = 0; i < errs.size(); i++) { - sb.append(" - ").append(errs.get(i)).append('\n'); - } - sb.append("Aborting before any generated class is written, so the build output reflects the source."); - throw new MojoFailureException(sb.toString()); - } - - // Flush emitted bytecode. - Map emitted = ctx.getEmittedClasses(); - for (Map.Entry e : emitted.entrySet()) { - File target = new File(outputDirectory, e.getKey() + ".class"); - File parent = target.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) { - throw new MojoExecutionException("Could not create " + parent); - } - try { - FileOutputStream fos = new FileOutputStream(target); - try { - fos.write(e.getValue()); - } finally { - fos.close(); - } - } catch (IOException ioe) { - throw new MojoExecutionException("Could not write generated class " + target, ioe); - } - } - - for (AnnotationProcessor processor : processors) { - if (processor instanceof com.codename1.maven.processors.OrmAnnotationProcessor) { - try { ((com.codename1.maven.processors.OrmAnnotationProcessor) processor).enhance(ctx); } - catch (ProcessingException error) { throw new MojoFailureException(error.getMessage(),error); } - } - } - - if (!emitted.isEmpty()) { - getLog().info("cn1: emitted " + emitted.size() + " generated class(es) under " - + outputDirectory); - } - - // Flush generated resources. These ride the project jar to the native - // builders -- including a cloud build server, which receives the whole - // artifact -- so they are how build-time metadata reaches the iOS and - // Android sides. Written after the error check for the same reason the - // classes are: a failed validation must not leave a manifest behind. - Map resources = ctx.getEmittedResources(); - for (Map.Entry e : resources.entrySet()) { - File target = new File(outputDirectory, e.getKey()); - File parent = target.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) { - throw new MojoExecutionException("Could not create " + parent); - } - try { - FileOutputStream fos = new FileOutputStream(target); - try { - fos.write(e.getValue()); - } finally { - fos.close(); - } - } catch (IOException ioe) { - throw new MojoExecutionException("Could not write generated resource " + target, ioe); - } - } - - if (!resources.isEmpty()) { - getLog().info("cn1: emitted " + resources.size() + " generated resource(s) under " - + outputDirectory); - } - - // The build hint manifest records the main class's own bytes so the - // simulator, which has no bytecode reader, can tell a current manifest - // from one an earlier build left behind. A processor may REPLACE that - // class through emitClass -- BindingAnnotationProcessor does, for a - // two-way @Bindable setter -- and those are flushed above, after every - // finish(). So the stamp is corrected here, which is the first moment - // the class on disk is final. A no-op when there is no manifest. try { - com.codename1.maven.processors.BuildHintAnnotationProcessor - .restampClassDigest(outputDirectory); - } catch (IOException ioe) { - throw new MojoExecutionException( - "Could not stamp the build hint manifest under " + outputDirectory, ioe); + new AnnotationProcessing(MavenLog.of(getLog()), outputDirectory, stubSourceDirectory, + getCN1ProjectDir(), rawProjectSettings(), mainClassBinaryName(), + // The roots Maven is actually compiling, and the charset javac is + // given; see AnnotationProcessing. + compileSourceRoots(project, userProperties()), + sourceEncodingOf(project, userProperties()), + compileClasspathOf(project)).run(); + } catch (com.codename1.build.BuildFailureException e) { + throw new MojoFailureException(e.getMessage(), e.getCause() == null ? e : e.getCause()); + } catch (com.codename1.build.BuildExecutionException e) { + throw new MojoExecutionException(e.getMessage(), e.getCause() == null ? e : e.getCause()); } } - private static boolean intersects(Set a, Set b) { - if (a == null || b == null || a.isEmpty() || b.isEmpty()) return false; - if (a.size() > b.size()) { - for (String s : b) if (a.contains(s)) return true; - } else { - for (String s : a) if (b.contains(s)) return true; - } - return false; - } - - private List loadProcessors() { - ServiceLoader sl = ServiceLoader.load( - AnnotationProcessor.class, AnnotationProcessor.class.getClassLoader()); - List out = new ArrayList(); - for (AnnotationProcessor p : sl) out.add(p); - return Collections.unmodifiableList(out); - } - /// Loads `codenameone_settings.properties` exactly as it sits on disk. /// /// Deliberately not the inherited `properties` field: that one has the diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java index 9a11a518b97..61ad9d584f5 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java @@ -22,19 +22,8 @@ */ package com.codename1.maven; -import java.io.DataOutputStream; import java.io.File; -import java.io.FileFilter; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.lang.reflect.Modifier; -import java.net.MalformedURLException; -import java.net.URL; -import java.net.URLClassLoader; import java.util.*; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; import org.apache.maven.artifact.Artifact; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -43,7 +32,6 @@ import org.apache.maven.plugins.annotations.ResolutionScope; import org.apache.maven.shared.invoker.*; import org.apache.tools.ant.taskdefs.Java; -import org.apache.tools.ant.types.Path; /** * This mojo runs tests in the Codename One Test Runner. This goal should be used in place of @@ -76,147 +64,24 @@ private File getCommonProjectBaseDir() { } private boolean prepareTests() throws MojoExecutionException { - - // At this point, we are running inside the common project. - try { - List paths = new ArrayList(); - File cn1ProjectDir = getCN1ProjectDir(); - - paths.add(new File(project.getBuild().getTestOutputDirectory())); - paths.add(new File(project.getBuild().getOutputDirectory())); - - for (Artifact artifact : project.getArtifacts()) { - File jar = getJar(artifact); - if (jar != null) { - paths.add(jar); - } else { - getLog().warn("Failed to resolve artifact "+artifact); - } - } - Class[] testCases = findTestCases(paths.toArray(new File[paths.size()])); - if (testCases.length == 0) { - return false; - } - File metadataFile = getMetaDataFile(); - metadataFile.getParentFile().mkdir(); - DataOutputStream fo = new DataOutputStream(new FileOutputStream(metadataFile)); - - Arrays.sort(testCases, new Comparator() { - @Override - public int compare(Class t, Class t1) { - return String.CASE_INSENSITIVE_ORDER.compare(t.getName(), t1.getName()); - } - }); - - fo.writeInt(VERSION); - - fo.writeInt(testCases.length); - - for(Class c : testCases) { - fo.writeUTF(c.getName()); - } - - fo.close(); - return true; - } catch (Exception ex) { - throw new MojoExecutionException("Failed to prepare tests", ex); - } - } - - private Class[] findTestCases(File... classesDirectories) throws MalformedURLException, IOException { - URL[] urls = new URL[classesDirectories.length]; - for(int iter = 0 ; iter < urls.length ; iter++) { - try { - urls[iter] = classesDirectories[iter].toURI().toURL(); - } catch (RuntimeException ex) { - getLog().error("Failed to add class directory "+iter+" in directory list: "+Arrays.toString(urls)); - throw ex; - } - } - URLClassLoader cl = new URLClassLoader(urls); - - // first directory is assumed to be the test classes directory - File userClassesDirectory = classesDirectories[0]; - - List classList = new ArrayList(); - findTestCasesInDir(userClassesDirectory.getAbsolutePath(), userClassesDirectory, cl, classList); - - Class[] arr = new Class[classList.size()]; - classList.toArray(arr); - return arr; - } - - private void findTestCasesInDir(String baseDir, File directory, URLClassLoader cl, List classList) throws IOException { - File[] files = directory.listFiles(new FileFilter() { - @Override - public boolean accept(File file) { - return file.isDirectory() || (file.getName().endsWith(".class") && file.getName().indexOf('$') < 0) - || file.getName().endsWith(".jar"); - } - }); - if (files == null) { - return; - } - for(File f : files) { - if(f.isDirectory()) { - findTestCasesInDir(baseDir, f, cl, classList); + List paths = new ArrayList(); + paths.add(new File(project.getBuild().getOutputDirectory())); + for (Artifact artifact : project.getArtifacts()) { + File jar = getJar(artifact); + if (jar != null) { + paths.add(jar); } else { - String fileName = f.getAbsolutePath(); - if(fileName.endsWith(".jar")) { - FileInputStream zipFile = new FileInputStream(fileName); - ZipInputStream zip = new ZipInputStream(zipFile); - ZipEntry entry; - while ((entry = zip.getNextEntry()) != null) { - //System.out.println("Extracting: " +entry); - if (entry.isDirectory()) { - continue; - } - - String entryName = entry.getName(); - if (entryName.endsWith(".class") && entryName.indexOf('$') < 0) { - String className = entryName.substring(baseDir.length() + 1, entryName.length() - 6); - className = className.replace('/', '.'); - isTestCase(cl, className, classList); - } - } - zip.close(); - } else { - String className = fileName.substring(baseDir.length() + 1, fileName.length() - 6); - className = className.replace(File.separatorChar, '.'); - isTestCase(cl, className, classList); - } + getLog().warn("Failed to resolve artifact "+artifact); } } - } - private boolean impl(Class cls) { - for(Class current : cls.getInterfaces()) { - if(current.getName().equals("com.codename1.testing.UnitTest")) { - return true; - } - } - Class parent = cls.getSuperclass(); - if(parent == Object.class || parent == null) { - return false; - } - return impl(parent); - } - - private boolean isTestCase(ClassLoader cl, String className, List classList) { try { - Class cls = cl.loadClass(className); - if(Modifier.isAbstract(cls.getModifiers())) { - return false; - } - if(impl(cls)) { - classList.add(cls); - return true; - } - } catch(Throwable t) { + return new Cn1TestRunner(MavenLog.of(getLog())).prepare( + new File(project.getBuild().getTestOutputDirectory()), paths); + } catch (com.codename1.build.BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex.getCause()); } - return false; } - @Override protected void executeImpl() throws MojoExecutionException, MojoFailureException { @@ -225,39 +90,28 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException getLog().info("No tests were found."); return; } - Java java = createJava(); - Path cp = java.createClasspath(); - cp.add(new Path(antProject, project.getBuild().getTestOutputDirectory())); - cp.add(new Path(antProject, project.getBuild().getOutputDirectory())); + List cp = new ArrayList(); + cp.add(new File(project.getBuild().getTestOutputDirectory())); + cp.add(new File(project.getBuild().getOutputDirectory())); for (Artifact artifact : project.getArtifacts()) { if ("provided".equals(artifact.getScope())) { continue; } - //if (artifact.getScope().equals("compile") || artifact.getScope().equals("system") || artifact.getScope().equals("test")) { File jar = getJar(artifact); if (jar != null) { - cp.add(new Path(antProject, jar.getAbsolutePath())); + cp.add(jar); } else { getLog().warn("Failed to resolve artifact: "+artifact); } } - cp.add(new Path(antProject, getProjectInternalTmpJar().getAbsolutePath())); - java.setClassname("com.codename1.impl.javase.TestRunner"); - java.createArg().setValue(properties.getProperty("codename1.packageName")+"."+properties.getProperty("codename1.mainName")); - //java.createArg().setValue("-quietMode"); - java.createArg().setValue("-junitXML"); - File reportsDir = new File(project.getBuild().getDirectory(), "cn1-reports"); - //noinspection ResultOfMethodCallIgnored - reportsDir.mkdirs(); - java.setDir(reportsDir); - java.setFork(true); - getLog().debug("Executing Test Runner: " + java.getCommandLine()); - int result = java.executeJava(); + cp.add(getProjectInternalTmpJar()); + Java java = createJava(); + getLog().debug("Executing Test Runner"); + int result = Cn1TestRunner.run(antProject, java, cp, + properties.getProperty("codename1.packageName")+"."+properties.getProperty("codename1.mainName"), + new File(project.getBuild().getDirectory(), "cn1-reports")); if (result != 0) { throw new MojoExecutionException("Tests failed"); } - - } - } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java index 3aba2c96254..70ab15c8cca 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java @@ -79,7 +79,11 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException // project whose pom never binds this mojo. See that class. // Parameters live on AbstractCN1Mojo so the self-repair and the // placeholder diagnostic resolve the same configuration this goal does. - newSvgTranscodeRunner().run(); + try { + newSvgTranscodeRunner().run(); + } catch (com.codename1.builders.BuildException ex) { + throw new MojoExecutionException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } registerSourceRoot(svgOutputDir()); } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderLldbConfigTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderLldbConfigTest.java deleted file mode 100644 index 7151ddc89a3..00000000000 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderLldbConfigTest.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.codename1.builders; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -class IPhoneBuilderLldbConfigTest { - - @Test - void generatedSchemesIgnoreParparVmGcStopSignal() { - String script = IPhoneBuilder.createLldbSchemeSetupScript(); - - assertTrue(script.contains("process handle -s false -n false -p true SIGUSR2")); - assertTrue(script.contains("scheme.launch_action.xml_element.attributes['customLLDBInitFile']")); - assertTrue(script.contains("scheme.test_action.xml_element.attributes['customLLDBInitFile']")); - assertTrue(script.contains("$(SRCROOT)/cn1.lldbinit")); - } -} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/stubgen/TestNativeInterface.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/stubgen/TestNativeInterface.java deleted file mode 100644 index 1fd87220d16..00000000000 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/stubgen/TestNativeInterface.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.codename1.maven.stubgen; - -import com.codename1.system.NativeInterface; - -public interface TestNativeInterface extends NativeInterface { - String hello(String name); -} diff --git a/maven/css-cli/pom.xml b/maven/css-cli/pom.xml index 25ecb93440d..ebc62325f08 100644 --- a/maven/css-cli/pom.xml +++ b/maven/css-cli/pom.xml @@ -49,6 +49,12 @@ com.codenameone codenameone-javase + + + com.codenameone + codenameone-project-model + ${project.version} + org.junit.jupiter junit-jupiter diff --git a/maven/css-cli/src/main/java/com/codename1/designer/css/CN1CSSCLI.java b/maven/css-cli/src/main/java/com/codename1/designer/css/CN1CSSCLI.java index 27b3441297f..30e0ba44415 100644 --- a/maven/css-cli/src/main/java/com/codename1/designer/css/CN1CSSCLI.java +++ b/maven/css-cli/src/main/java/com/codename1/designer/css/CN1CSSCLI.java @@ -30,6 +30,9 @@ import com.codename1.impl.javase.CN1Bootstrap; import com.codename1.io.Log; import com.codename1.io.Util; +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; import com.codename1.tools.resourcebuilder.PropertiesUtil; import com.codename1.ui.BrowserComponent; import com.codename1.ui.CN; @@ -515,29 +518,77 @@ private static void updateMergeFile(File[] inputFiles, File mergedFile) throws I + /// The directory holding the project's `codenameone_settings.properties`: + /// the project root under Ant and Gradle, `common/` under Maven. Null + /// when `start` is not inside a Codename One project. private static File getProjectDir(File start) { - File f = new File(start, "codenameone_settings.properties"); - - while (!f.exists() && f.getParentFile().getParentFile() != null) { - f = new File(f.getParentFile().getParentFile(), "codenameone_settings.properties"); - if (f.exists()) { - return f.getParentFile(); + ProjectLayout layout = getLayout(start); + return layout == null ? null : layout.projectDir(); + } + + /// The layout of the project containing `start`, or null. + /// + /// [ProjectLayouts#detect(File)] answers with canonical paths, while this + /// class compares paths as the caller spelled them (`getRelativePath` uses + /// `startsWith` on absolute paths). On macOS `/tmp` is a link to + /// `/private/tmp`, so a canonical project dir would no longer prefix an + /// input given under `/tmp`. The detected directories are therefore + /// re-expressed as ancestors of `start` in its own spelling. + /// The system property a build passes when it has moved the project's build + /// directory (Gradle's layout.buildDirectory), which detection by the + /// directory tree cannot see. + static final String BUILD_DIR_PROPERTY = "cn1.buildDir"; + + static ProjectLayout getLayout(File start) { + ProjectLayout layout = detectLayout(start); + String buildDir = System.getProperty(BUILD_DIR_PROPERTY); + return layout != null && buildDir != null && buildDir.length() > 0 + ? layout.withBuildDir(new File(buildDir)) : layout; + } + + private static ProjectLayout detectLayout(File start) { + if (start == null) { + return null; + } + ProjectLayout detected = ProjectLayouts.detect(start); + if (detected == null) { + return null; + } + File projectDir = sameSpellingAncestor(start.getAbsoluteFile(), detected.projectDir()); + if (projectDir == null) { + return detected; + } + File rootDir = sameSpellingAncestor(projectDir, detected.rootDir()); + if (rootDir == null) { + return detected; + } + return ProjectLayouts.of(detected.buildSystem(), detected.kind(), rootDir, projectDir); + } + + private static File sameSpellingAncestor(File from, File canonicalTarget) { + for (File f = from; f != null; f = f.getParentFile()) { + try { + if (f.getCanonicalFile().equals(canonicalTarget)) { + return f; + } + } catch (IOException ex) { + return null; } } - return f.exists() ? f.getParentFile() : null; - + return null; } - + private static File getLibCSSDirectory(File inputFile) throws IOException { if (System.getProperty("cn1.libCSSDir", null) != null) { return new File(System.getProperty("cn1.libCSSDir")); } - if (isMavenProject(inputFile)) { - return new File(getProjectDir(inputFile), "target" + File.separator + "css"); + ProjectLayout layout = getLayout(inputFile); + if (layout == null) { + // Outside any project: the Ant-relative path this always fell back to. + return new File("lib" + File.separator + "impl" + File.separator + "css"); } - return new File(getProjectDir(inputFile), - "lib" + File.separator + "impl" +File.separator + "css" - ); + // Ant: lib/impl/css; Maven: common/target/css; Gradle: build/css. + return layout.libraryCssDir(); } private static List findLibCSSFiles(File inputFile) throws IOException { @@ -558,8 +609,13 @@ private static List findLibCSSFiles(File inputFile) throws IOException { } - private static boolean isMavenProject(File inputFile) throws IOException { - return new File(getProjectDir(inputFile), "pom.xml").exists(); + /// True when the project's CSS output lives in a build directory (Maven's + /// `target/`, Gradle's `build/`) rather than beside the sources as under + /// Ant. Only Ant keeps the merged file next to `theme.css`, backs up a + /// hand-edited `.res`, and stores checksums beside the CSS. + private static boolean usesBuildDir(File inputFile) throws IOException { + ProjectLayout layout = getLayout(inputFile); + return layout != null && layout.buildSystem() != BuildSystem.ANT; } private static String getMergedFile(String inputPath) throws IOException { @@ -570,9 +626,12 @@ private static String getMergedFile(String inputPath) throws IOException { return System.getProperty("cn1.cssMergeFile"); } File inputFile = new File(inputPath); - if (isMavenProject(inputFile)) { + if (usesBuildDir(inputFile)) { + // ProjectLayout.cssMergeFile() is libraryCssDir()/.merged here; + // resolved through getLibCSSDirectory so cn1.libCSSDir still moves it. return new File(getLibCSSDirectory(inputFile), inputFile.getName()+".merged").getAbsolutePath(); } + // Ant: beside the input, spelled as the caller spelled it. return inputPath + ".merged"; } @@ -853,7 +912,7 @@ private static void compile(File inputFile, File outputFile) throws IOException FileLock lock = channel.lock(); try { Map checksums = loadChecksums(baseDir); - if (outputFile.exists() && !isMavenProject(inputFile)) { + if (outputFile.exists() && !usesBuildDir(inputFile)) { String outputFileChecksum = getMD5Checksum(outputFile.getAbsolutePath()); String previousChecksum = checksums.get(inputFile.getName()); if (previousChecksum == null || !previousChecksum.equals(outputFileChecksum)) { @@ -1035,14 +1094,16 @@ private static Map loadChecksums(File baseDir) { private static File getChecksumsFile(File baseDir) { try { - if (isMavenProject(baseDir)) { - return new File(getProjectDir(baseDir), "target" + File.separator + ".cn1_css_checksums"); + ProjectLayout layout = getLayout(baseDir); + if (layout != null) { + return new File(layout.cssChecksumDir(), ".cn1_css_checksums"); } } catch (Exception ex) { Log.e(ex); } - - return new File(baseDir, ".cn1_css_checksums"); + // No project around the input: next to the CSS directory, where an Ant + // project (whose root this is) has always kept them. + return new File(baseDir, ".cn1_css_checksums"); } private static void saveChecksums(File baseDir, Map map) throws IOException { diff --git a/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIProjectLayoutTest.java b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIProjectLayoutTest.java new file mode 100644 index 00000000000..2ee25a5b34b --- /dev/null +++ b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIProjectLayoutTest.java @@ -0,0 +1,135 @@ +/* + * 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.designer.css; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Where CN1CSSCLI puts the library CSS, the merged stylesheet and its +/// checksums for each build tool. The Ant and Maven answers are the ones the +/// CLI gave before it resolved projects through codenameone-project-model. +class CN1CSSCLIProjectLayoutTest { + + private static Object invoke(String name, Class[] types, Object... args) throws Exception { + Method m = CN1CSSCLI.class.getDeclaredMethod(name, types); + m.setAccessible(true); + return m.invoke(null, args); + } + + private static File libCssDir(File input) throws Exception { + return (File) invoke("getLibCSSDirectory", new Class[]{File.class}, input); + } + + private static String mergedFile(File input) throws Exception { + return (String) invoke("getMergedFile", new Class[]{String.class}, input.getPath()); + } + + private static File checksums(File baseDir) throws Exception { + return (File) invoke("getChecksumsFile", new Class[]{File.class}, baseDir); + } + + private static String relativePath(File file, File relativeTo) throws Exception { + return (String) invoke("getRelativePath", new Class[]{File.class, File.class}, file, relativeTo); + } + + private static File write(File f, String content) throws Exception { + f.getParentFile().mkdirs(); + Files.write(f.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return f; + } + + @Test + void antKeepsEverythingBesideTheSources(@TempDir Path tmp) throws Exception { + File root = tmp.toFile(); + write(new File(root, "codenameone_settings.properties"), "codename1.cssTheme=true\n"); + File theme = write(new File(root, "css/theme.css"), ""); + + assertEquals(new File(root, "lib/impl/css"), libCssDir(theme)); + assertEquals(theme.getPath() + ".merged", mergedFile(theme)); + assertEquals(new File(root, ".cn1_css_checksums"), checksums(theme.getParentFile().getParentFile())); + } + + @Test + void mavenWritesUnderCommonTarget(@TempDir Path tmp) throws Exception { + File root = tmp.toFile(); + write(new File(root, "pom.xml"), ""); + File common = new File(root, "common"); + write(new File(common, "pom.xml"), ""); + write(new File(common, "codenameone_settings.properties"), "codename1.cssTheme=true\n"); + File theme = write(new File(common, "src/main/css/theme.css"), ""); + + File lib = new File(common, "target/css"); + assertEquals(lib, libCssDir(theme)); + File merged = new File(lib, "theme.css.merged"); + assertEquals(merged.getAbsolutePath(), mergedFile(theme)); + assertEquals(new File(common, "target/.cn1_css_checksums"), + checksums(theme.getParentFile().getParentFile())); + // The temp dir is a symlinked path on macOS (/var -> /private/var); the + // project dir must stay in the caller's spelling or this throws. + assertEquals("../../src/main/css", relativePath(theme.getParentFile(), merged)); + } + + @Test + void gradleWritesUnderBuild(@TempDir Path tmp) throws Exception { + File root = tmp.toFile(); + write(new File(root, "settings.gradle.kts"), "rootProject.name = \"app\"\n"); + write(new File(root, "build.gradle.kts"), "plugins { id(\"com.codenameone\") }\n"); + write(new File(root, "codenameone_settings.properties"), "codename1.cssTheme=true\n"); + File theme = write(new File(root, "src/main/css/theme.css"), ""); + + File lib = new File(root, "build/css"); + assertEquals(lib, libCssDir(theme)); + assertEquals(new File(lib, "theme.css.merged").getAbsolutePath(), mergedFile(theme)); + assertEquals(new File(root, "build/.cn1_css_checksums"), + checksums(theme.getParentFile().getParentFile())); + } + + @Test + void libCssDirOverrideStillMovesTheMergedFile(@TempDir Path tmp) throws Exception { + File root = tmp.toFile(); + write(new File(root, "settings.gradle.kts"), "plugins { id(\"com.codenameone\") }\n"); + write(new File(root, "codenameone_settings.properties"), ""); + File theme = write(new File(root, "src/main/css/theme.css"), ""); + File override = new File(root, "elsewhere"); + String previous = System.getProperty("cn1.libCSSDir"); + try { + System.setProperty("cn1.libCSSDir", override.getPath()); + assertEquals(override, libCssDir(theme)); + assertEquals(new File(override, "theme.css.merged").getAbsolutePath(), mergedFile(theme)); + } finally { + if (previous == null) { + System.clearProperty("cn1.libCSSDir"); + } else { + System.setProperty("cn1.libCSSDir", previous); + } + } + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java index f00caf92f7e..f41f02034ea 100644 --- a/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java +++ b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java @@ -1588,6 +1588,12 @@ protected void writeUIXml(com.codename1.ui.Container cnt, FileOutputStream dest) "UI container XML persistence requires EditableResourcesEditor"); } + // The XML "team mode" mirror is an Ant-only feature: it fires for a .res + // directly in an Ant project's src/ (settings file one level up) and writes + // res/.xml there. Maven and Gradle keep the .res in src/main/resources + // and have no res/ mirror, so this deliberately stays a literal Ant check + // rather than going through codenameone-project-model, which would add a + // dependency to this module for no change in behaviour. public void saveXML(File resFile) throws IOException { if(xmlEnabled && resFile.getParentFile().getName().equals("src")) { if(new File(resFile.getParentFile().getParentFile(), "codenameone_settings.properties").exists()) { diff --git a/maven/designer/pom.xml b/maven/designer/pom.xml index b7d5482cba6..2321a72f3e4 100644 --- a/maven/designer/pom.xml +++ b/maven/designer/pom.xml @@ -40,6 +40,14 @@ com.codenameone codenameone-css-cli + + + com.codenameone + codenameone-project-model + ${project.version} + org.swinglabs diff --git a/maven/integration-tests/all.sh b/maven/integration-tests/all.sh index 500767bca08..b2134402148 100644 --- a/maven/integration-tests/all.sh +++ b/maven/integration-tests/all.sh @@ -15,3 +15,9 @@ bash cn1app-staged-jar-test.sh bash bare-bones-kotlin-test.sh bash migrate-kitchensink-test.sh bash googlemaps-demo.sh +bash gradle-app-test.sh +bash gradle-maven-parity-test.sh +bash gradle-cn1lib-test.sh +bash gradle-migrate-test.sh +bash gradle-initializr-test.sh +bash gradle-tools-test.sh diff --git a/maven/integration-tests/gradle-app-test.sh b/maven/integration-tests/gradle-app-test.sh new file mode 100755 index 00000000000..547b13955ad --- /dev/null +++ b/maven/integration-tests/gradle-app-test.sh @@ -0,0 +1,179 @@ +#!/bin/bash +# End to end over a Gradle application, from a project that starts with no +# native code and no backend: +# +# 1. cn1:convert-to-gradle turns an archetype app into the Gradle layout, which +# must carry none of the Maven modules or native trees. +# 2. classes + cn1Css compile the app and its theme, with the configuration +# cache stored on the first run and reused on the second. +# 3. A NativeInterface added afterwards gets its stubs from +# generateNativeInterfaces, which creates only the directories it writes; +# a re-run keeps edited stubs; a missing implementation fails the build +# before anything is sent. +# 4. Every cloud target stages its upload jar (codename1.stageOnly), holding +# the app and that platform's native sources -- and no other platform's. +# 5. addBackend adds backend/, whose runBackend serves /healthz. +# +# Needs the reactor installed (mvn install) and a JDK 17+ for Gradle; see +# inc/gradle.sh for MAVEN_REPO_LOCAL and GRADLE_JAVA_HOME. +SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" +set -e +source "$SCRIPTPATH/inc/env.sh" +source "$SCRIPTPATH/inc/gradle.sh" + +WORKDIR="$SCRIPTPATH/build/gradle-app" +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR" +cd "$WORKDIR" + +PKG=com.acme.gradleapp +PKG_PATH=com/acme/gradleapp +generate_gradle_app gradleapp "$PKG" +APP="$WORKDIR/gradleapp-gradle" + +echo "== layout" +for f in settings.gradle.kts build.gradle.kts gradlew codenameone_settings.properties src/main/java src/main/css/theme.css; do + [ -e "$APP/$f" ] || fail "converted project is missing $f" +done +for f in pom.xml common android ios javase javascript win linux backend src/android src/ios src/javase; do + [ ! -e "$APP/$f" ] || fail "converted project should not have $f" +done +grep -q 'java.version=17' "$APP/codenameone_settings.properties" || fail "a Gradle project targets Java 17" + +echo "== classes, theme, configuration cache" +run_gradle "$APP" classes cn1Css > "$WORKDIR/classes-1.log" 2>&1 || { cat "$WORKDIR/classes-1.log"; fail "classes"; } +THEME=$(find "$APP/build" -name theme.res | head -1) +[ -n "$THEME" ] && [ -s "$THEME" ] || fail "cn1Css produced no theme.res" +run_gradle "$APP" classes cn1Css > "$WORKDIR/classes-2.log" 2>&1 || { cat "$WORKDIR/classes-2.log"; fail "classes (2)"; } +grep -q "Configuration cache entry reused" "$WORKDIR/classes-2.log" \ + || { cat "$WORKDIR/classes-2.log"; fail "the second run did not reuse the configuration cache"; } + +echo "== native interfaces" +cat > "$APP/src/main/java/$PKG_PATH/Greeter.java" < "$WORKDIR/ni.log" 2>&1 || { cat "$WORKDIR/ni.log"; fail "generateNativeInterfaces"; } +ANDROID_IMPL="$APP/src/android/java/$PKG_PATH/GreeterImpl.java" +IOS_IMPL="$APP/src/ios/objectivec/com_acme_gradleapp_GreeterImpl.m" +JAVASE_IMPL="$APP/src/javase/java/$PKG_PATH/GreeterImpl.java" +for f in "$ANDROID_IMPL" "$IOS_IMPL" "$JAVASE_IMPL" "$APP/src/javascript/javascript/com_acme_gradleapp_Greeter.js" \ + "$APP/src/win/c/$PKG_PATH/GreeterImplCodenameOne.c" "$APP/src/linux/c/$PKG_PATH/GreeterImplCodenameOne.c"; do + [ -f "$f" ] || { find "$APP/src" -type f; fail "no stub at $f"; } +done + +# An edited stub survives a re-run, which only -Pcn1.overwrite=true replaces. +echo "// edited" >> "$JAVASE_IMPL" +run_gradle "$APP" generateNativeInterfaces > "$WORKDIR/ni-2.log" 2>&1 || { cat "$WORKDIR/ni-2.log"; fail "generateNativeInterfaces (2)"; } +grep -q "// edited" "$JAVASE_IMPL" || fail "a re-run overwrote an existing implementation" + +# The javase implementation joins the simulator's classpath. +run_gradle "$APP" javaseClasses > "$WORKDIR/javase.log" 2>&1 || { cat "$WORKDIR/javase.log"; fail "javaseClasses"; } +find "$APP/build" -path "*javase*" -name GreeterImpl.class | grep -q . || fail "the javase implementation was not compiled" + +echo "== a missing implementation fails before the build is sent" +mv "$IOS_IMPL" "$WORKDIR/GreeterImpl.m.bak" +if run_gradle "$APP" buildIos -Pcodename1.stageOnly=true > "$WORKDIR/verify.log" 2>&1; then + cat "$WORKDIR/verify.log" + fail "buildIos succeeded without an iOS implementation of Greeter" +fi +grep -q "GreeterImpl" "$WORKDIR/verify.log" || { cat "$WORKDIR/verify.log"; fail "the failure does not name the missing file"; } +# cn1Build with no platform fails for that, without first writing stubs into +# src/ (an absent platform used to mean "generate" to its check). +if run_gradle "$APP" cn1Build -Pcodename1.stageOnly=true > "$WORKDIR/no-platform.log" 2>&1; then + cat "$WORKDIR/no-platform.log" + fail "cn1Build succeeded without a platform" +fi +[ ! -e "$IOS_IMPL" ] || fail "cn1Build without a platform generated native stubs" +mv "$WORKDIR/GreeterImpl.m.bak" "$IOS_IMPL" + +# The simulator's code rides only a JVM target's upload, so a broken +# src/javase must not stop a device build. +BROKEN="$APP/src/javase/java/$PKG_PATH/Broken.java" +echo "this does not compile" > "$BROKEN" +run_gradle "$APP" buildIos -Pcodename1.stageOnly=true > "$WORKDIR/broken-javase.log" 2>&1 \ + || { cat "$WORKDIR/broken-javase.log"; fail "a broken src/javase stopped buildIos"; } +rm -f "$BROKEN" + +echo "== staged upload jars" +staged_jar() { + sed -n 's/.*codename1.stageOnly is set: staged \(.*\) ([0-9]* bytes) for .*/\1/p' "$1" | tail -1 +} +# task | a native source that must be in the jar | one that must not +for spec in \ + "buildAndroid|GreeterImpl\.java$|_GreeterImpl\.m$" \ + "buildIos|com_acme_gradleapp_GreeterImpl\.m$|$PKG_PATH/GreeterImpl\.java$" \ + "buildIosRelease|com_acme_gradleapp_GreeterImpl\.m$|$PKG_PATH/GreeterImpl\.java$" \ + "buildJavascript|^com_acme_gradleapp_Greeter\.js$|_GreeterImpl\.m$" \ + "buildWindowsDesktop|$PKG_PATH/GreeterImpl\.class$|_GreeterImpl\.m$" \ + "buildMacDesktop|$PKG_PATH/GreeterImpl\.class$|_GreeterImpl\.m$" \ + "buildWindowsDevice|$PKG_PATH/GreeterImplCodenameOne\.c$|_GreeterImpl\.m$" \ + "buildLinuxDevice|$PKG_PATH/GreeterImplCodenameOne\.c$|$PKG_PATH/GreeterImpl\.java$"; do + IFS='|' read -r task has lacks <<< "$spec" + log="$WORKDIR/stage-$task.log" + run_gradle "$APP" "$task" -Pcodename1.stageOnly=true > "$log" 2>&1 || { cat "$log"; fail "$task staging"; } + jar=$(staged_jar "$log") + [ -n "$jar" ] && [ -f "$jar" ] || { cat "$log"; fail "$task staged no jar"; } + assert_zip_has "$jar" "^$PKG_PATH/[A-Za-z]+\.class$" + assert_zip_has "$jar" "$has" + assert_zip_lacks "$jar" "$lacks" + # The framework is supplied by the build server, never uploaded. + assert_zip_lacks "$jar" "^com/codename1/ui/Form\.class$" + echo " $task: $(unzip -Z1 "$jar" | wc -l | tr -d ' ') entries in $(basename "$jar")" +done + +echo "== a custom icon reaches the cloud build" +# build.xml hands every target icon="${codename1.icon}", resolved against the +# staged project, so an icon at another path has to be staged there. +mkdir -p "$APP/branding" +cp "$APP/icon.png" "$APP/branding/app.png" 2>/dev/null || printf 'png' > "$APP/branding/app.png" +cp "$APP/codenameone_settings.properties" "$WORKDIR/settings.bak" +printf '\ncodename1.icon=branding/app.png\n' >> "$APP/codenameone_settings.properties" +run_gradle "$APP" buildAndroid -Pcodename1.stageOnly=true > "$WORKDIR/icon-stage.log" 2>&1 \ + || { cat "$WORKDIR/icon-stage.log"; fail "staging with a custom icon"; } +find "$APP/build" -path "*antProject/branding/app.png" | grep -q . \ + || { find "$APP/build" -name app.png; fail "the custom icon was not staged for the cloud build"; } +cp "$WORKDIR/settings.bak" "$APP/codenameone_settings.properties" +rm -rf "$APP/branding" + +echo "== unit tests in the simulator's runner" +run_gradle "$APP" cn1Test > "$WORKDIR/test.log" 2>&1 || { cat "$WORKDIR/test.log"; fail "cn1Test"; } + +echo "== a relocated build directory takes the Codename One outputs with it" +cp "$APP/build.gradle.kts" "$WORKDIR/build.gradle.kts.bak" +printf '\nlayout.buildDirectory.set(file("out"))\n' >> "$APP/build.gradle.kts" +rm -rf "$APP/build" "$APP/out" +run_gradle "$APP" classes cn1Css > "$WORKDIR/relocated.log" 2>&1 || { cat "$WORKDIR/relocated.log"; fail "building into out/"; } +[ -d "$APP/out/generated/sources/cn1-svg" ] || { find "$APP/out" -maxdepth 3; fail "transcodeSvg did not follow the build directory"; } +[ -d "$APP/out/classes/java/main" ] || fail "nothing was compiled into out/" +[ ! -e "$APP/build" ] || { find "$APP/build" -maxdepth 3; fail "Codename One outputs were left in build/"; } +cp "$WORKDIR/build.gradle.kts.bak" "$APP/build.gradle.kts" +rm -rf "$APP/out" + +echo "== backend added to an existing app" +run_gradle "$APP" addBackend > "$WORKDIR/add-backend.log" 2>&1 || { cat "$WORKDIR/add-backend.log"; fail "addBackend"; } +[ -f "$APP/backend/application.properties" ] || fail "addBackend wrote no backend/application.properties" +[ ! -e "$APP/backend/pom.xml" ] || fail "a Gradle backend has no pom" +check_backend_healthz "$APP" ":backend:runBackend" "$WORKDIR/backend-run.log" + +# A Gradle older than the plugin supports is refused up front with the fix, +# rather than failing later on an API it lacks. Opt-in: it downloads one more +# Gradle distribution. +if [ -n "${CN1_TEST_REFUSED_GRADLE_VERSION:-}" ]; then + echo "== Gradle $CN1_TEST_REFUSED_GRADLE_VERSION is refused" + sed -i.bak "s#gradle-[0-9.]*-bin.zip#gradle-$CN1_TEST_REFUSED_GRADLE_VERSION-bin.zip#" "$APP/gradle/wrapper/gradle-wrapper.properties" + rm -f "$APP/gradle/wrapper/gradle-wrapper.properties.bak" + if run_gradle "$APP" help > "$WORKDIR/old-gradle.log" 2>&1; then + cat "$WORKDIR/old-gradle.log" + fail "Gradle $CN1_TEST_REFUSED_GRADLE_VERSION was accepted" + fi + grep -q "needs Gradle [0-9.]* or newer; this build runs $CN1_TEST_REFUSED_GRADLE_VERSION" "$WORKDIR/old-gradle.log" \ + || { cat "$WORKDIR/old-gradle.log"; fail "the refusal does not say which Gradle is needed"; } +fi + +echo "gradle-app-test: OK" diff --git a/maven/integration-tests/gradle-cn1lib-test.sh b/maven/integration-tests/gradle-cn1lib-test.sh new file mode 100755 index 00000000000..48870f8ce44 --- /dev/null +++ b/maven/integration-tests/gradle-cn1lib-test.sh @@ -0,0 +1,359 @@ +#!/bin/bash +# A cn1lib built with Gradle, consumed by a Gradle app AND a Maven app. +# +# 1. The library project is the whole Gradle cn1lib bootstrap: the settings +# file, codenameone_library_appended.properties, and sources under src/. +# 2. `./gradlew publish` writes the Maven shape cn1lib-archetype publishes: +# N-common (with its properties under META-INF/codenameone), the cn1css +# zip, one jar per platform, and the N-lib pom with codename1.platform +# profiles. +# 3. A Gradle app declares cn1lib("G:N-lib:V"): the library's classes and +# hints reach every upload, its Android sources reach only Android's, and +# its CSS is merged into the theme. +# 4. A Maven app depends on the same N-lib pom and stages the same content, +# so a Gradle-built library is indistinguishable from an archetype-built one. +# +# Needs the reactor installed (mvn install) and a JDK 17+; see inc/gradle.sh. +SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" +set -e +source "$SCRIPTPATH/inc/env.sh" +source "$SCRIPTPATH/inc/gradle.sh" + +WORKDIR="$SCRIPTPATH/build/gradle-cn1lib" +LIBREPO="$WORKDIR/librepo" +GROUP=com.acme.cn1libtest +GROUP_PATH=com/acme/cn1libtest +LIBVER=1.0 +rm -rf "$WORKDIR" +# Maven copies the library into its local repository; a copy left by an earlier +# run would be used instead of this run's. +rm -rf "$CN1_REPO/$GROUP_PATH" +mkdir -p "$WORKDIR" "$LIBREPO" +cd "$WORKDIR" + +TEMPLATES="$SCRIPTPATH/../build-engine/src/main/resources/com/codename1/project/templates/gradle" + +# scaffold_lib -- the whole Gradle cn1lib bootstrap +scaffold_lib() { + local dir="$1" name="$2" + mkdir -p "$dir/gradle/wrapper" "$dir/src/main/java/$GROUP_PATH" "$dir/src/android/java/$GROUP_PATH" + cp "$TEMPLATES/gradlew" "$TEMPLATES/gradlew.bat" "$dir/" + cp "$TEMPLATES"/gradle/wrapper/* "$dir/gradle/wrapper/" + chmod +x "$dir/gradlew" + sed -e "s/__CN1_VERSION__/$CN1_VERSION/" -e "s/__PROJECT_NAME__/$name/" \ + "$TEMPLATES/settings.gradle.kts.txt" > "$dir/settings.gradle.kts" + cp "$TEMPLATES/gradle.properties.txt" "$dir/gradle.properties" + use_local_plugin "$dir" + : > "$dir/codenameone_library_appended.properties" +} + +# A library the published one uses in turn: its platform code must reach the +# applications too, as Maven's profiles deliver it through the whole graph. +echo "== inner library" +INNER="$WORKDIR/inner" +scaffold_lib "$INNER" inner +cat > "$INNER/build.gradle.kts" < "$INNER/src/main/java/$GROUP_PATH/Inner.java" < "$INNER/src/android/java/$GROUP_PATH/InnerAndroid.java" < "$WORKDIR/publish-inner.log" 2>&1 \ + || { cat "$WORKDIR/publish-inner.log"; fail "publishing the inner library"; } + +echo "== library" +LIB="$WORKDIR/greeter" +scaffold_lib "$LIB" greeter +mkdir -p "$LIB/src/main/css" "$LIB/src/ios/objectivec" +cat > "$LIB/build.gradle.kts" <' \ + > "$LIB/codenameone_library_appended.properties" +cat > "$LIB/src/main/java/$GROUP_PATH/Greeter.java" < "$LIB/src/main/css/theme.css" <<'EOF' +GreeterLabel { + color: #ff0000; +} +EOF +cat > "$LIB/src/android/java/$GROUP_PATH/GreeterAndroid.java" < "$LIB/src/ios/objectivec/greeter_ios.m" + +run_gradle "$LIB" publish > "$WORKDIR/publish.log" 2>&1 || { cat "$WORKDIR/publish.log"; fail "publishing the library"; } + +P="$LIBREPO/$GROUP_PATH" +grep -q "inner-common" "$P/greeter-javase/$LIBVER/greeter-javase-$LIBVER.pom" \ + || { cat "$P/greeter-javase/$LIBVER/greeter-javase-$LIBVER.pom"; fail "the -javase pom omits javaseImplementation"; } +grep -q "unwanted" "$P/greeter-javase/$LIBVER/greeter-javase-$LIBVER.pom" \ + || { cat "$P/greeter-javase/$LIBVER/greeter-javase-$LIBVER.pom"; fail "the -javase pom drops the exclusion"; } +for f in greeter-common/$LIBVER/greeter-common-$LIBVER.jar \ + greeter-common/$LIBVER/greeter-common-$LIBVER-cn1css.zip \ + greeter-android/$LIBVER/greeter-android-$LIBVER.jar \ + greeter-ios/$LIBVER/greeter-ios-$LIBVER.jar \ + greeter-javase/$LIBVER/greeter-javase-$LIBVER.jar \ + greeter-lib/$LIBVER/greeter-lib-$LIBVER.pom; do + [ -f "$P/$f" ] || { find "$LIBREPO" -type f; fail "the library did not publish $f"; } +done +assert_zip_has "$P/greeter-common/$LIBVER/greeter-common-$LIBVER.jar" "^$GROUP_PATH/Greeter\.class$" +assert_zip_has "$P/greeter-common/$LIBVER/greeter-common-$LIBVER.jar" \ + "^META-INF/codenameone/$GROUP/greeter-common/codenameone_library_appended\.properties$" +assert_zip_has "$P/greeter-common/$LIBVER/greeter-common-$LIBVER-cn1css.zip" "css/theme\.css$" +assert_zip_has "$P/greeter-android/$LIBVER/greeter-android-$LIBVER.jar" "^$GROUP_PATH/GreeterAndroid\.java$" +assert_zip_has "$P/greeter-ios/$LIBVER/greeter-ios-$LIBVER.jar" "^greeter_ios\.m$" +grep -q "codename1.platform" "$P/greeter-lib/$LIBVER/greeter-lib-$LIBVER.pom" \ + || fail "the -lib pom has no codename1.platform profiles" +# A Maven consumer reaches the inner library through this dependency, which only +# resolves as pom: a -lib artifact has no jar. +python3 - "$P/greeter-common/$LIBVER/greeter-common-$LIBVER.pom" <<'PY' \ + || fail "greeter-common does not depend on inner-lib as pom" +import sys, xml.etree.ElementTree as ET +ns = {'m': 'http://maven.apache.org/POM/4.0.0'} +root = ET.parse(sys.argv[1]).getroot() +for d in root.findall('m:dependencies/m:dependency', ns): + if d.findtext('m:artifactId', namespaces=ns) == 'inner-lib': + sys.exit(0 if d.findtext('m:type', namespaces=ns) == 'pom' else 1) +sys.exit(1) +PY + +# app-dir -- stages android and ios and checks what the library contributed +check_consumer() { + local label="$1" android_jar="$2" ios_jar="$3" + assert_zip_has "$android_jar" "^$GROUP_PATH/Greeter\.class$" + assert_zip_has "$android_jar" "^$GROUP_PATH/GreeterAndroid\.java$" + assert_zip_has "$android_jar" "^$GROUP_PATH/Inner\.class$" + assert_zip_has "$android_jar" "^$GROUP_PATH/InnerAndroid\.java$" + assert_zip_lacks "$android_jar" "greeter_ios\.m$" + assert_zip_has "$android_jar" "codenameone_library_appended\.properties$" + assert_zip_has "$ios_jar" "^$GROUP_PATH/Greeter\.class$" + assert_zip_has "$ios_jar" "greeter_ios\.m$" + assert_zip_lacks "$ios_jar" "GreeterAndroid\.java$" + assert_zip_lacks "$ios_jar" "InnerAndroid\.java$" + echo " $label: library classes, hints and per-platform sources staged, the inner library's too" +} +staged_jar() { + sed -n 's/.*codename1.stageOnly is set: staged \(.*\) ([0-9]* bytes) for .*/\1/p' "$1" | tail -1 +} + +echo "== Gradle consumer" +generate_gradle_app libconsumer com.acme.libconsumer +GAPP="$WORKDIR/libconsumer-gradle" +python3 - "$GAPP/build.gradle.kts" "$LIBREPO" "$GROUP:greeter-lib:$LIBVER" <<'EOF' +import sys +path, repo, coord = sys.argv[1:] +text = open(path).read() +marker = "dependencies {" +assert marker in text, text +text = text.replace(marker, marker + '\n cn1lib("%s")' % coord, 1) +text = 'repositories { maven(url = uri("%s")) }\n\n' % repo + text +open(path, "w").write(text) +EOF +run_gradle "$GAPP" classes cn1Css > "$WORKDIR/gapp-classes.log" 2>&1 || { cat "$WORKDIR/gapp-classes.log"; fail "Gradle consumer classes"; } +THEME=$(find "$GAPP/build" -name theme.res | head -1) +grep -aq "GreeterLabel" "$THEME" || fail "the library's CSS was not merged into $THEME" +run_gradle "$GAPP" buildAndroid -Pcodename1.stageOnly=true > "$WORKDIR/gapp-android.log" 2>&1 \ + || { cat "$WORKDIR/gapp-android.log"; fail "Gradle consumer buildAndroid"; } +run_gradle "$GAPP" buildIos -Pcodename1.stageOnly=true > "$WORKDIR/gapp-ios.log" 2>&1 \ + || { cat "$WORKDIR/gapp-ios.log"; fail "Gradle consumer buildIos"; } +check_consumer "Gradle app" "$(staged_jar "$WORKDIR/gapp-android.log")" "$(staged_jar "$WORKDIR/gapp-ios.log")" +# Without a theme.css of its own the library's CSS cannot be compiled; the build +# must say so, as Maven's does, instead of dropping the library's styles. +mv "$GAPP/src/main/css/theme.css" "$WORKDIR/theme.css.bak" +if run_gradle "$GAPP" cn1Css > "$WORKDIR/gapp-nocss.log" 2>&1; then + cat "$WORKDIR/gapp-nocss.log" + fail "cn1Css succeeded without the theme.css the library's CSS needs" +fi +grep -q "Cannot compile CSS for this project" "$WORKDIR/gapp-nocss.log" \ + || { cat "$WORKDIR/gapp-nocss.log"; fail "cn1Css failed for another reason"; } +mv "$WORKDIR/theme.css.bak" "$GAPP/src/main/css/theme.css" + +echo "== a cn1lib of the same build: cn1lib(project(...))" +generate_gradle_app localconsumer com.acme.localconsumer +LAPP="$WORKDIR/localconsumer-gradle" +mkdir -p "$LAPP/localmaps/src/main/java/$GROUP_PATH" "$LAPP/localmaps/src/android/java/$GROUP_PATH" +cat > "$LAPP/localmaps/build.gradle.kts" < "$LAPP/localmaps/codenameone_library_appended.properties" +cat > "$LAPP/localmaps/src/main/java/$GROUP_PATH/LocalMaps.java" < "$LAPP/localmaps/src/main/css/theme.css" <<'EOF' +LocalMapsLabel { + color: #00ff00; +} +EOF +cat > "$LAPP/localmaps/src/android/java/$GROUP_PATH/LocalMapsAndroid.java" <> "$LAPP/settings.gradle.kts" +python3 - "$LAPP/build.gradle.kts" <<'EOF' +import sys +path = sys.argv[1] +text = open(path).read() +assert "dependencies {" in text, text +open(path, "w").write(text.replace("dependencies {", 'dependencies {\n cn1lib(project(":localmaps"))', 1)) +EOF +run_gradle "$LAPP" buildAndroid -Pcodename1.stageOnly=true > "$WORKDIR/lapp-android.log" 2>&1 \ + || { cat "$WORKDIR/lapp-android.log"; fail "buildAndroid with a project cn1lib"; } +LJAR=$(staged_jar "$WORKDIR/lapp-android.log") +assert_zip_has "$LJAR" "^$GROUP_PATH/LocalMaps\.class$" +assert_zip_has "$LJAR" "^$GROUP_PATH/LocalMapsAndroid\.java$" +run_gradle "$LAPP" classes cn1Css > "$WORKDIR/lapp-css.log" 2>&1 \ + || { cat "$WORKDIR/lapp-css.log"; fail "cn1Css with a project cn1lib"; } +LTHEME=$(find "$LAPP/build" -name theme.res | head -1) +[ -n "$LTHEME" ] && grep -aq "LocalMapsLabel" "$LTHEME" || fail "the project cn1lib's CSS was not merged into the theme" +echo " project cn1lib: classes, android sources and CSS reach the application" +# A library of the same build that uses that one is published depending on its +# -lib pom, which is what Maven consumers resolve; Gradle would name the project. +mkdir -p "$LAPP/localwidgets/src/main/java/$GROUP_PATH" +cat > "$LAPP/localwidgets/build.gradle.kts" < "$LAPP/localwidgets/codenameone_library_appended.properties" +cat > "$LAPP/localwidgets/src/main/java/$GROUP_PATH/LocalWidgets.java" <> "$LAPP/settings.gradle.kts" +run_gradle "$LAPP" :localmaps:publish :localwidgets:publish > "$WORKDIR/lwidgets-publish.log" 2>&1 \ + || { cat "$WORKDIR/lwidgets-publish.log"; fail "publishing a cn1lib that uses a project cn1lib"; } +WPOM="$WORKDIR/localrepo/$GROUP_PATH/localwidgets-common/$LIBVER/localwidgets-common-$LIBVER.pom" +python3 - "$WPOM" <<'PY' || { cat "$WPOM"; fail "the published pom does not name localmaps-lib as a pom"; } +import sys, xml.etree.ElementTree as ET +ns = {'m': 'http://maven.apache.org/POM/4.0.0'} +root = ET.parse(sys.argv[1]).getroot() +for d in root.findall('m:dependencies/m:dependency', ns): + if d.findtext('m:artifactId', namespaces=ns) == 'localmaps-lib': + sys.exit(0 if d.findtext('m:type', namespaces=ns) == 'pom' else 1) +sys.exit(1) +PY +echo " a cn1lib using a project cn1lib publishes a dependency on its -lib pom" +# A Gradle application resolves the same publication. Gradle prefers a module's +# .module metadata to its pom, and java-library's names localmaps by its plain +# -common jar: the classes arrive but none of the inner library's platform jars. +# The cn1lib publishes no metadata, so Gradle reads the pom Maven reads. +WAPP="$WORKDIR/widgetsconsumer-gradle" +generate_gradle_app widgetsconsumer com.acme.widgetsconsumer +python3 - "$WAPP/build.gradle.kts" "$WORKDIR/localrepo" "$GROUP:localwidgets-lib:$LIBVER" <<'EOF' +import sys +path, repo, coord = sys.argv[1:] +text = open(path).read() +marker = "dependencies {" +assert marker in text, text +text = text.replace(marker, marker + '\n cn1lib("%s")' % coord, 1) +text = 'repositories { maven(url = uri("%s")) }\n\n' % repo + text +open(path, "w").write(text) +EOF +run_gradle "$WAPP" buildAndroid -Pcodename1.stageOnly=true > "$WORKDIR/wapp-android.log" 2>&1 \ + || { cat "$WORKDIR/wapp-android.log"; fail "a Gradle app using a published cn1lib that uses a project cn1lib"; } +WJAR=$(staged_jar "$WORKDIR/wapp-android.log") +assert_zip_has "$WJAR" "^$GROUP_PATH/LocalWidgets\.class$" +assert_zip_has "$WJAR" "^$GROUP_PATH/LocalMaps\.class$" +assert_zip_has "$WJAR" "^$GROUP_PATH/LocalMapsAndroid\.java$" +[ -z "$(find "$WORKDIR/localrepo" -name '*.module')" ] || fail "a cn1lib published Gradle module metadata" +echo " a Gradle app resolves it, the project cn1lib's classes and android sources included" + +echo "== Maven consumer" +MAPP="$WORKDIR/libconsumer" +python3 - "$MAPP" "$LIBREPO" "$GROUP" "$LIBVER" <<'EOF' +import sys +app, repo, group, version = sys.argv[1:] +root = app + "/pom.xml" +text = open(root).read() +entry = "gradle-cn1libfile://%s" % repo +if "" in text: + text = text.replace("", "" + entry, 1) +else: + text = text.replace("", "" + entry + "\n") +open(root, "w").write(text) +common = app + "/common/pom.xml" +text = open(common).read() +dep = ("%sgreeter-lib%s" + "pom") % (group, version) +assert "" in text +text = text.replace("", "" + dep, 1) +open(common, "w").write(text) +EOF +# The app was generated for Java 17, so Maven runs on the same JDK as Gradle. +for spec in "android android-device" "ios ios-device"; do + read -r platform target <<< "$spec" + (cd "$MAPP" && JAVA_HOME="$GRADLE_JDK" mvn_local package -DskipTests -Dopen=false -Dcodename1.platform=$platform \ + -Dcodename1.buildTarget=$target -Dcodename1.stageOnly=true < /dev/null) > "$WORKDIR/mapp-$platform.log" 2>&1 \ + || { tail -60 "$WORKDIR/mapp-$platform.log"; fail "Maven consumer $target"; } +done +check_consumer "Maven app" "$(staged_jar "$WORKDIR/mapp-android.log")" "$(staged_jar "$WORKDIR/mapp-ios.log")" + +echo "gradle-cn1lib-test: OK" diff --git a/maven/integration-tests/gradle-initializr-test.sh b/maven/integration-tests/gradle-initializr-test.sh new file mode 100755 index 00000000000..ed1785c0ee7 --- /dev/null +++ b/maven/integration-tests/gradle-initializr-test.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# The initializr's Gradle downloads, generated by the real generator with its +# shipped templates, built with their own wrapper: +# +# 1. "App" and "App + backend" compile (the backend as a subproject); +# 2. "Backend only" holds a server and nothing of a client app -- no +# codenameone_settings.properties, theme, simulator or native trees; +# 3. its runBackend serves the template's /healthz on the JVM; +# 4. backendPackage translates it to a native binary (skipped without clang, +# unless CN1_BACKEND_PACKAGE_REQUIRED=1), and the binary serves /healthz. +# +# Needs the reactor installed (mvn install) and a JDK 17+; see inc/gradle.sh. +SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" +set -e +source "$SCRIPTPATH/inc/env.sh" +source "$SCRIPTPATH/inc/gradle.sh" + +WORKDIR="$SCRIPTPATH/build/gradle-initializr" +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR/fixtures" + +# The real generator, with its shipped templates (the same run the initializr +# fixture test makes). It compiles with --release 8, so it needs a JDK 9+ javac. +PATH="$GRADLE_JDK/bin:$PATH" CN1_TEST_MAVEN_REPOSITORY="$CN1_REPO" \ + python3 "$SCRIPTPATH/../../scripts/tests/generate-initializr-fixtures.py" "$WORKDIR/fixtures" \ + > "$WORKDIR/fixtures.log" 2>&1 || { cat "$WORKDIR/fixtures.log"; fail "generating the initializr downloads"; } + +# download-name dir -- extracts one download and points it at the local plugin +extract() { + mkdir -p "$2" + unzip -q "$WORKDIR/fixtures/$1" -d "$2" + use_local_plugin "$2" +} + +echo "== App" +extract GRADLE-APP-INTELLIJ.zip "$WORKDIR/app" +[ ! -e "$WORKDIR/app/backend" ] || fail "the App download has a backend" +run_gradle "$WORKDIR/app" classes cn1Css > "$WORKDIR/app.log" 2>&1 || { cat "$WORKDIR/app.log"; fail "the App download does not build"; } + +echo "== App + backend" +extract GRADLE-APP_WITH_BACKEND-NETBEANS.zip "$WORKDIR/appbackend" +[ -f "$WORKDIR/appbackend/backend/application.properties" ] || fail "the App + backend download has no backend" +run_gradle "$WORKDIR/appbackend" classes :backend:classes > "$WORKDIR/appbackend.log" 2>&1 \ + || { cat "$WORKDIR/appbackend.log"; fail "the App + backend download does not build"; } + +echo "== Backend only" +SVC="$WORKDIR/launcherprobe" +mkdir -p "$SVC" +unzip -q "$WORKDIR/fixtures/GRADLE-BACKEND_ONLY-VS_CODE.zip" -d "$SVC" +use_local_plugin "$SVC" + +echo "== layout" +for f in settings.gradle.kts build.gradle.kts gradlew application.properties application-dev.properties; do + [ -e "$SVC/$f" ] || fail "backend-only download is missing $f" +done +for f in codenameone_settings.properties src/main/css pom.xml common backend src/android src/ios src/javase; do + [ ! -e "$SVC/$f" ] || fail "backend-only download should not have $f" +done +API=$(find "$SVC/src/main/java" -name Api.java | head -1) +[ -n "$API" ] || fail "no Api.java controller" + +echo "== tasks" +run_gradle "$SVC" tasks --group "codename one" > "$WORKDIR/tasks.log" 2>&1 || { cat "$WORKDIR/tasks.log"; fail "tasks"; } +for t in runBackend backendPackage cn1Update; do + grep -q "^$t " "$WORKDIR/tasks.log" || { cat "$WORKDIR/tasks.log"; fail "a backend offers $t"; } +done +for t in cn1Css buildAndroid generateNativeInterfaces run; do + if grep -q "^$t " "$WORKDIR/tasks.log"; then fail "a backend-only project should not offer $t"; fi +done + +echo "== runBackend" +check_backend_healthz "$SVC" runBackend "$WORKDIR/run.log" + +echo "== backendPackage" +if command -v clang > /dev/null 2>&1; then + run_gradle "$SVC" backendPackage > "$WORKDIR/package.log" 2>&1 || { cat "$WORKDIR/package.log"; fail "backendPackage"; } + BIN="$SVC/build/launcherprobe" + [ -x "$BIN" ] || { cat "$WORKDIR/package.log"; fail "backendPackage produced no executable at $BIN"; } + echo " $(du -h "$BIN" | cut -f1) binary" + check_binary_healthz "$BIN" "$SVC" "$WORKDIR/binary.log" +elif [ "${CN1_BACKEND_PACKAGE_REQUIRED:-}" = "1" ]; then + fail "backendPackage needs clang, and CN1_BACKEND_PACKAGE_REQUIRED=1 forbids skipping it" +else + echo " skipped: no clang on PATH" +fi + +echo "gradle-initializr-test: OK" diff --git a/maven/integration-tests/gradle-maven-parity-test.sh b/maven/integration-tests/gradle-maven-parity-test.sh new file mode 100755 index 00000000000..8be29183677 --- /dev/null +++ b/maven/integration-tests/gradle-maven-parity-test.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# The Gradle plugin reuses the Maven plugin's build engine rather than +# re-implementing it, so one application built both ways must upload the same +# thing. This stages every cloud target from an archetype app with a native +# interface, then from its Gradle conversion, and compares the upload jars +# entry by entry; then generates the Android Studio and (on macOS) Xcode +# projects both ways and compares those trees file by file. +# +# Entries that legitimately differ are the build tools' own bookkeeping and are +# listed in IGNORED below; anything else in one jar and not the other fails. +# +# Needs the reactor installed (mvn install) and a JDK 17+; see inc/gradle.sh. +SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" +set -e +source "$SCRIPTPATH/inc/env.sh" +source "$SCRIPTPATH/inc/gradle.sh" + +WORKDIR="$SCRIPTPATH/build/gradle-parity" +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR" +cd "$WORKDIR" + +PKG=com.acme.parity +PKG_PATH=com/acme/parity +mvn_local archetype:generate -DarchetypeArtifactId=cn1app-archetype -DarchetypeGroupId=com.codenameone \ + -DarchetypeVersion="$CN1_VERSION" -DartifactId=parity -DgroupId=$PKG -Dpackage=$PKG \ + -Dversion=1.0-SNAPSHOT -DmainName=Parity -DjavaVersion=17 -DinteractiveMode=false > "$WORKDIR/archetype.log" 2>&1 \ + || { tail -40 "$WORKDIR/archetype.log"; fail "archetype:generate"; } +MAPP="$WORKDIR/parity" +cat > "$MAPP/common/src/main/java/$PKG_PATH/Greeter.java" </resources. +mkdir -p "$MAPP/android/src/main/resources" "$MAPP/ios/src/main/resources" +echo "android" > "$MAPP/android/src/main/resources/parity-android.txt" +echo "ios" > "$MAPP/ios/src/main/resources/parity-ios.txt" +(cd "$MAPP" && JAVA_HOME="$GRADLE_JDK" mvn_local install -DskipTests -pl common -am > "$WORKDIR/mvn-common.log" 2>&1 \ + && JAVA_HOME="$GRADLE_JDK" mvn_local "com.codenameone:codenameone-maven-plugin:$CN1_VERSION:generate-native-interfaces" \ + > "$WORKDIR/mvn-ni.log" 2>&1) || { tail -40 "$WORKDIR/mvn-ni.log" "$WORKDIR/mvn-common.log"; fail "Maven native interfaces"; } +(cd "$MAPP" && mvn_local "com.codenameone:codenameone-maven-plugin:$CN1_VERSION:convert-to-gradle" > "$WORKDIR/convert.log" 2>&1) \ + || { cat "$WORKDIR/convert.log"; fail "convert-to-gradle"; } +GAPP="$WORKDIR/parity-gradle" +use_local_plugin "$GAPP" + +staged_jar() { + sed -n 's/.*codename1.stageOnly is set: staged \(.*\) ([0-9]* bytes) for .*/\1/p' "$1" | tail -1 +} + +# Bookkeeping each tool adds for itself, never read by the build server: +# directory entries, which the two archivers write for different parents, and +# Maven's own pom metadata and manifest. +IGNORED='/$|^META-INF/maven/|^META-INF/MANIFEST\.MF$' + +FAILED=0 +for spec in "android android-device buildAndroid" "ios ios-device buildIos" "javascript javascript buildJavascript" \ + "javase windows-desktop buildWindowsDesktop" "javase mac-os-x-desktop buildMacDesktop" \ + "win windows-device buildWindowsDevice" "linux linux-device buildLinuxDevice"; do + read -r platform target task <<< "$spec" + mlog="$WORKDIR/maven-$target.log" + glog="$WORKDIR/gradle-$target.log" + (cd "$MAPP" && JAVA_HOME="$GRADLE_JDK" mvn_local package -DskipTests -Dopen=false -Dcodename1.platform=$platform \ + -Dcodename1.buildTarget=$target -Dcodename1.stageOnly=true < /dev/null) > "$mlog" 2>&1 \ + || { tail -40 "$mlog"; fail "Maven staging $target"; } + run_gradle "$GAPP" "$task" -Pcodename1.stageOnly=true > "$glog" 2>&1 || { cat "$glog"; fail "Gradle staging $target"; } + mjar=$(staged_jar "$mlog") + gjar=$(staged_jar "$glog") + [ -f "$mjar" ] && [ -f "$gjar" ] || fail "$target: a staged jar is missing (maven=$mjar gradle=$gjar)" + unzip -Z1 "$mjar" | grep -Ev "$IGNORED" | sort > "$WORKDIR/maven-$target.txt" + unzip -Z1 "$gjar" | grep -Ev "$IGNORED" | sort > "$WORKDIR/gradle-$target.txt" + if diff "$WORKDIR/maven-$target.txt" "$WORKDIR/gradle-$target.txt" > "$WORKDIR/diff-$target.txt"; then + echo " $target: $(wc -l < "$WORKDIR/maven-$target.txt" | tr -d ' ') entries, identical" + else + echo "FAIL: $target uploads differ (< Maven only, > Gradle only):" + head -40 "$WORKDIR/diff-$target.txt" + FAILED=1 + fi +done +# The local builders: the Android Studio project and, where Xcode is installed, +# the Xcode project. Only generated, never compiled -- the question is whether +# both build tools hand the builder the same application, and the generated +# tree answers it file by file. See inc/compare_generated_projects.py for what +# may differ. +generated_dir() { + sed -n "s/.*Copying $2 Project to \(.*\)$/\1/p" "$1" | tail -1 +} +local_source() { + local platform=$1 target=$2 task=$3 kind=$4 + local mlog="$WORKDIR/maven-$target.log" glog="$WORKDIR/gradle-$target.log" + (cd "$MAPP" && JAVA_HOME="$GRADLE_JDK" mvn_local package -DskipTests -Dopen=false -Dcodename1.platform=$platform \ + -Dcodename1.buildTarget=$target < /dev/null) > "$mlog" 2>&1 || { tail -40 "$mlog"; fail "Maven $target"; } + run_gradle "$GAPP" "$task" -Popen=false > "$glog" 2>&1 || { cat "$glog"; fail "Gradle $target"; } + local mdir gdir + mdir=$(generated_dir "$mlog" "$kind") + gdir=$(generated_dir "$glog" "$kind") + [ -d "$mdir" ] && [ -d "$gdir" ] || fail "$target: a generated project is missing (maven=$mdir gradle=$gdir)" + if python3 "$SCRIPTPATH/inc/compare_generated_projects.py" "$mdir" "$gdir" > "$WORKDIR/compare-$target.txt"; then + echo " $target: $(cat "$WORKDIR/compare-$target.txt")" + else + echo "FAIL: $target projects differ:" + cat "$WORKDIR/compare-$target.txt" + FAILED=1 + fi +} + +local_source android android-source buildAndroidGradleProject Gradle +if xcrun --find xcodebuild > /dev/null 2>&1; then + local_source ios ios-source buildIosXcodeProject Xcode +elif [ "${CN1_IOS_SOURCE_REQUIRED:-}" = "1" ]; then + fail "ios-source needs Xcode, and CN1_IOS_SOURCE_REQUIRED=1 forbids skipping it" +else + echo " ios-source: skipped, no Xcode on this machine" +fi + +[ $FAILED -eq 0 ] || exit 1 +echo "gradle-maven-parity-test: OK" diff --git a/maven/integration-tests/gradle-migrate-test.sh b/maven/integration-tests/gradle-migrate-test.sh new file mode 100755 index 00000000000..5a435358d17 --- /dev/null +++ b/maven/integration-tests/gradle-migrate-test.sh @@ -0,0 +1,207 @@ +#!/bin/bash +# Migrating to Gradle with cn1:convert-to-gradle, which needs no project of its +# own (it runs anywhere, with -Dcn1.sourceProject): +# +# 1. An Ant project -- Java and Kotlin mixed in src/, css/, native/, +# a Java 8 target -- converts, builds (Kotlin included), and stages an +# Android upload carrying its native implementation. +# 2. A Maven project whose backend has code of its own keeps the backend as a +# subproject, and it builds. +# 3. A project with a legacy .cn1lib is refused, naming the file, and nothing +# is written. +# +# Needs the reactor installed (mvn install) and a JDK 17+; see inc/gradle.sh. +SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" +set -e +source "$SCRIPTPATH/inc/env.sh" +source "$SCRIPTPATH/inc/gradle.sh" + +WORKDIR="$SCRIPTPATH/build/gradle-migrate" +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR" +cd "$WORKDIR" + +convert() { + local src="$1" out="$2" log="$3" + shift 3 + mvn_local "com.codenameone:codenameone-maven-plugin:$CN1_VERSION:convert-to-gradle" \ + -Dcn1.sourceProject="$src" -Dcn1.outputDir="$out" "$@" > "$log" 2>&1 +} + +echo "== Ant to Gradle" +ANT="$WORKDIR/AntApp" +mkdir -p "$ANT/src/com/acme/antapp" "$ANT/css" "$ANT/native/android/com/acme/antapp" "$ANT/native/ios" +echo '' > "$ANT/build.xml" +cat > "$ANT/codenameone_settings.properties" <<'EOF' +codename1.mainName=AntApp +codename1.packageName=com.acme.antapp +codename1.displayName=AntApp +codename1.version=1.0 +codename1.cssTheme=true +codename1.arg.java.version=8 +EOF +cp "$SCRIPTPATH/../cn1app-archetype/src/main/resources/archetype-resources/common/icon.png" "$ANT/icon.png" 2>/dev/null \ + || printf 'png' > "$ANT/icon.png" +cat > "$ANT/src/com/acme/antapp/AntApp.java" <<'EOF' +package com.acme.antapp; + +import com.codename1.system.Lifecycle; +import com.codename1.system.NativeLookup; +import com.codename1.ui.Form; + +public class AntApp extends Lifecycle { + @Override + public void runApp() { + Form f = new Form(new Helper().title()); + MyNative n = NativeLookup.create(MyNative.class); + f.show(); + } +} +EOF +cat > "$ANT/src/com/acme/antapp/Helper.kt" <<'EOF' +package com.acme.antapp + +class Helper { + fun title(): String = "Converted" +} +EOF +# A Kotlin @RestClient returning a Java @Mapped type: Kotlin's annotations are +# processed before javac has run, so the processors must see the Java type too. +cat > "$ANT/src/com/acme/antapp/Pet.java" <<'EOF' +package com.acme.antapp; + +import com.codename1.annotations.Mapped; + +@Mapped +public class Pet { + public Long id; + public String name; + + public Pet() { + } +} +EOF +cat > "$ANT/src/com/acme/antapp/PetApi.kt" <<'EOF' +package com.acme.antapp + +import com.codename1.annotations.rest.GET +import com.codename1.annotations.rest.Path +import com.codename1.annotations.rest.RestClient +import com.codename1.io.rest.Response +import com.codename1.util.OnComplete + +@RestClient +interface PetApi { + @GET("/pet/{petId}") + fun getPetById(@Path("petId") petId: Long?, callback: OnComplete>) +} +EOF +cat > "$ANT/src/com/acme/antapp/MyNative.java" <<'EOF' +package com.acme.antapp; + +import com.codename1.system.NativeInterface; + +public interface MyNative extends NativeInterface { + int answer(); +} +EOF +cat > "$ANT/native/android/com/acme/antapp/MyNativeImpl.java" <<'EOF' +package com.acme.antapp; + +public class MyNativeImpl { + public int answer() { + return 42; + } + + public boolean isSupported() { + return true; + } +} +EOF +echo "// ios" > "$ANT/native/ios/com_acme_antapp_MyNativeImpl.m" +echo "stale" > "$ANT/src/theme.res" +# A Kotlin unit test: Gradle compiles it apart from the Java tests, and cn1Test +# must still find it. +mkdir -p "$ANT/test/com/acme/antapp" +cat > "$ANT/test/com/acme/antapp/HelperTest.kt" <<'EOF' +package com.acme.antapp + +import com.codename1.testing.AbstractTest + +class HelperTest : AbstractTest() { + override fun runTest(): Boolean = Helper().title() == "Converted" +} +EOF +echo "Form { color: red; }" > "$ANT/css/theme.css" + +convert "$ANT" "$WORKDIR/AntApp-gradle" "$WORKDIR/convert-ant.log" || { cat "$WORKDIR/convert-ant.log"; fail "converting the Ant project"; } +AG="$WORKDIR/AntApp-gradle" +use_local_plugin "$AG" +[ -f "$AG/src/main/kotlin/com/acme/antapp/Helper.kt" ] || fail "Kotlin sources belong in src/main/kotlin" +[ -f "$AG/src/android/java/com/acme/antapp/MyNativeImpl.java" ] || fail "native/android was not carried over" +[ -f "$AG/src/ios/objectivec/com_acme_antapp_MyNativeImpl.m" ] || fail "native/ios was not carried over" +[ ! -e "$AG/src/main/resources/theme.res" ] || fail "the saved theme.res was carried over" +grep -q "kotlin(\"jvm\")" "$AG/build.gradle.kts" || fail "a Kotlin project needs the Kotlin plugin" +grep -q "codename1.arg.java.version=17" "$AG/codenameone_settings.properties" || fail "the target was not raised to 17" +grep -q "Gradle projects compile for Java 17" "$WORKDIR/convert-ant.log" || fail "raising the target was not reported" + +run_gradle "$AG" classes cn1Css > "$WORKDIR/ant-classes.log" 2>&1 || { cat "$WORKDIR/ant-classes.log"; fail "the converted Ant project does not build"; } +run_gradle "$AG" buildAndroid -Pcodename1.stageOnly=true > "$WORKDIR/ant-android.log" 2>&1 \ + || { cat "$WORKDIR/ant-android.log"; fail "the converted Ant project does not stage"; } +JAR=$(sed -n 's/.*codename1.stageOnly is set: staged \(.*\) ([0-9]* bytes) for .*/\1/p' "$WORKDIR/ant-android.log" | tail -1) +assert_zip_has "$JAR" "^com/acme/antapp/AntApp\.class$" +assert_zip_has "$JAR" "^com/acme/antapp/Helper\.class$" +assert_zip_has "$JAR" "^com/acme/antapp/PetApiImpl\.class$" +assert_zip_has "$JAR" "^com/acme/antapp/MyNativeImpl\.java$" +run_gradle "$AG" cn1Test > "$WORKDIR/ant-test.log" 2>&1 || { cat "$WORKDIR/ant-test.log"; fail "cn1Test on the converted Ant project"; } +grep -q "com.acme.antapp.HelperTest passed" "$WORKDIR/ant-test.log" \ + || { cat "$WORKDIR/ant-test.log"; fail "cn1Test did not run the Kotlin test"; } +echo " Ant app converted, built with its Kotlin, staged, and its Kotlin test run" + +echo "== Maven to Gradle, with a backend of its own" +rm -rf mvnapp +mvn_local archetype:generate -DarchetypeArtifactId=cn1app-archetype -DarchetypeGroupId=com.codenameone \ + -DarchetypeVersion="$CN1_VERSION" -DartifactId=mvnapp -DgroupId=com.acme.mvnapp -Dpackage=com.acme.mvnapp \ + -Dversion=1.0-SNAPSHOT -DmainName=MvnApp -DjavaVersion=17 -DinteractiveMode=false > "$WORKDIR/archetype.log" 2>&1 \ + || { tail -40 "$WORKDIR/archetype.log"; fail "archetype:generate"; } +API=$(find mvnapp/backend/src/main/java -name Api.java | head -1) +[ -n "$API" ] || fail "the archetype has no backend Api.java" +PKG=$(sed -n 's/^package \(.*\);/\1/p' "$API") +cat > "$(dirname "$API")/Orders.java" < "$WORKDIR/mvn-classes.log" 2>&1 \ + || { cat "$WORKDIR/mvn-classes.log"; fail "the converted Maven project does not build"; } +grep -q "generated 2 @RestController router" "$WORKDIR/mvn-classes.log" \ + || { cat "$WORKDIR/mvn-classes.log"; fail "the backend's controllers were not both processed"; } +echo " Maven app converted with its backend subproject" + +echo "== legacy .cn1lib is refused" +mkdir -p "$ANT/lib" +printf 'PK' > "$ANT/lib/OldMaps.cn1lib" +if convert "$ANT" "$WORKDIR/refused" "$WORKDIR/convert-refused.log"; then + cat "$WORKDIR/convert-refused.log" + fail "a project with a legacy .cn1lib was converted" +fi +grep -q "OldMaps.cn1lib" "$WORKDIR/convert-refused.log" || { cat "$WORKDIR/convert-refused.log"; fail "the refusal does not name the library"; } +[ ! -e "$WORKDIR/refused/settings.gradle.kts" ] || fail "a refused conversion wrote a project" +echo " refused, naming OldMaps.cn1lib" + +echo "gradle-migrate-test: OK" diff --git a/maven/integration-tests/gradle-tools-test.sh b/maven/integration-tests/gradle-tools-test.sh new file mode 100755 index 00000000000..2460e913fe3 --- /dev/null +++ b/maven/integration-tests/gradle-tools-test.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# The desktop tools start the same way from both build tools. Launches +# Codename One Settings bound to an archetype app (mvn cn1:settings) and to its +# Gradle conversion (./gradlew settings), in the tool's screenshot mode, which +# renders the project, writes a PNG and exits. +# +# The Gradle half is what regressed: the tools' poms carry the JavaSE port in +# test scope only, which Maven's legacy resolver picks up anyway and Gradle +# rightly does not, so the tool died with NoClassDefFoundError: JavaSEPort and +# the task still reported success. A screenshot is proof the tool really ran. +# +# Needs com.codenameone:codenameone-settings in the local repository -- build it +# with `cd scripts/settings && mvn install -Pexecutable-jar -Dcodename1.platform=javase` +# -- and a display (xvfb on Linux). Skips without the tool unless +# CN1_TOOLS_REQUIRED=1. +SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" +set -e +source "$SCRIPTPATH/inc/env.sh" +source "$SCRIPTPATH/inc/gradle.sh" + +if [ ! -d "$CN1_REPO/com/codenameone/codenameone-settings/$CN1_VERSION" ]; then + if [ "${CN1_TOOLS_REQUIRED:-}" = "1" ]; then + fail "codenameone-settings $CN1_VERSION is not in $CN1_REPO, and CN1_TOOLS_REQUIRED=1 forbids skipping" + fi + echo "gradle-tools-test: skipped, codenameone-settings $CN1_VERSION is not installed" + exit 0 +fi + +WORKDIR="$SCRIPTPATH/build/gradle-tools" +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR" +cd "$WORKDIR" +generate_gradle_app toolsapp com.acme.toolsapp + +check_screenshot() { + local png="$1" log="$2" label="$3" + # Detached launches return before the window exists, so both run attached; + # the tool's own log is the one to read if it died. + [ -s "$png" ] || { cat "$log"; tail -40 "$HOME/.codenameoneSettings/settings.log" 2>/dev/null; \ + fail "$label: Settings wrote no screenshot"; } + [ "$(wc -c < "$png")" -gt 10000 ] || fail "$label: the screenshot is too small to be the rendered tool" + echo " $label: Settings rendered ($(wc -c < "$png" | tr -d ' ') bytes)" +} + +echo "== Maven: cn1:settings" +(cd toolsapp && JAVA_HOME="$GRADLE_JDK" mvn_local cn1:settings -Dsettings.spawn=false \ + -Dsettings.screenshot="$WORKDIR/maven.png" < /dev/null) > "$WORKDIR/maven.log" 2>&1 \ + || { tail -40 "$WORKDIR/maven.log"; fail "mvn cn1:settings"; } +check_screenshot "$WORKDIR/maven.png" "$WORKDIR/maven.log" Maven + +echo "== Gradle: settings" +run_gradle "$WORKDIR/toolsapp-gradle" settings -Pspawn=false -Dsettings.screenshot="$WORKDIR/gradle.png" \ + > "$WORKDIR/gradle.log" 2>&1 || { cat "$WORKDIR/gradle.log"; fail "./gradlew settings"; } +check_screenshot "$WORKDIR/gradle.png" "$WORKDIR/gradle.log" Gradle + +echo "gradle-tools-test: OK" diff --git a/maven/integration-tests/inc/compare_generated_projects.py b/maven/integration-tests/inc/compare_generated_projects.py new file mode 100644 index 00000000000..c38327bbe1d --- /dev/null +++ b/maven/integration-tests/inc/compare_generated_projects.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Compare the native project a Maven build generated with the one a Gradle +build generated from the same application (android-source, ios-source). + +The two builds run the same engine, so the trees must match file for file. +What legitimately differs is normalized, and nothing else is: + + * a Properties file's first comment is the time Properties.store() ran; + * the Android signing keystore is generated with a fresh random key; + * Maven's jars carry META-INF/maven/** and a MANIFEST.MF, which the builder + copies along with the rest of the jar; + * the iOS source manifest records where each file came from as an absolute + path, which names each build's own work directory; + * jars and zips are compared by entry name and content, not by bytes, since + their headers hold timestamps. + +Usage: compare_generated_projects.py MAVEN_DIR GRADLE_DIR +Exits 1 and lists every difference when the trees are not equivalent. +""" +import os +import re +import sys +import zipfile + +TIMESTAMP = re.compile(rb'^#[A-Z][a-z]{2} [A-Z][a-z]{2} [ 0-9]\d \d\d:\d\d:\d\d [A-Z+0-9:-]+ \d{4}\r?$', re.M) +SOURCE_MANIFEST_PATH = re.compile(rb'\|/[^|\n]*/antProject/') +KEYSTORES = {'app/keyStore'} + + +def maven_bookkeeping(rel): + return '/META-INF/maven/' in '/' + rel or rel.endswith('META-INF/MANIFEST.MF') + + +def files_under(root): + out = {} + for base, dirs, files in os.walk(root): + for name in files: + full = os.path.join(base, name) + rel = os.path.relpath(full, root).replace(os.sep, '/') + if rel in KEYSTORES or maven_bookkeeping(rel): + continue + out[rel] = full + return out + + +def read_bytes(path): + with open(path, 'rb') as f: + return f.read() + + +def normalized(rel, data): + if rel.endswith('.properties'): + data = TIMESTAMP.sub(b'#', data) + if rel.endswith('cn1-source-manifest.txt'): + data = SOURCE_MANIFEST_PATH.sub(b'|/antProject/', data) + return data + + +def archive_entries(path): + with zipfile.ZipFile(path) as z: + return {n: z.read(n) for n in z.namelist() if not n.endswith('/') and not maven_bookkeeping(n)} + + +def main(maven_dir, gradle_dir): + maven = files_under(maven_dir) + gradle = files_under(gradle_dir) + problems = [] + for rel in sorted(set(maven) - set(gradle)): + problems.append('only in Maven: ' + rel) + for rel in sorted(set(gradle) - set(maven)): + problems.append('only in Gradle: ' + rel) + for rel in sorted(set(maven) & set(gradle)): + a = read_bytes(maven[rel]) + b = read_bytes(gradle[rel]) + if a == b: + continue + if zipfile.is_zipfile(maven[rel]) and zipfile.is_zipfile(gradle[rel]): + ea, eb = archive_entries(maven[rel]), archive_entries(gradle[rel]) + if ea != eb: + names = sorted(n for n in set(ea) | set(eb) if ea.get(n) != eb.get(n)) + problems.append('archive differs: %s (%s)' % (rel, ', '.join(names[:5]))) + continue + if normalized(rel, a) != normalized(rel, b): + problems.append('differs: ' + rel) + compared = len(set(maven) & set(gradle)) + if problems: + print('%d difference(s) between %s and %s:' % (len(problems), maven_dir, gradle_dir)) + for p in problems[:60]: + print(' ' + p) + return 1 + if compared == 0: + # Two empty trees are "equal" and prove nothing. + print('no files to compare under %s and %s' % (maven_dir, gradle_dir)) + return 1 + print('%d files equivalent' % compared) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1], sys.argv[2])) diff --git a/maven/integration-tests/inc/gradle.sh b/maven/integration-tests/inc/gradle.sh new file mode 100644 index 00000000000..b5895cb96b1 --- /dev/null +++ b/maven/integration-tests/inc/gradle.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# Helpers for the Gradle integration tests (gradle-*.sh). +# +# The Gradle plugin under test is the one installed in the local Maven +# repository by `mvn install` -- the same place the Maven tests resolve the +# Maven plugin from. Set MAVEN_REPO_LOCAL when the reactor was installed with +# -Dmaven.repo.local; it defaults to ~/.m2/repository. +# +# Gradle runs on the project's own wrapper (generated by the plugin's template, +# pinned to the current Gradle), which needs JDK 17 or newer. GRADLE_JAVA_HOME, +# then JAVA17_HOME, then JAVA_HOME is used. CN1_TEST_GRADLE_VERSION swaps the +# wrapper's Gradle for another release. +# +# Usage: +# source "$SCRIPTPATH/inc/gradle.sh" +# use_local_plugin # point the project at the local repository +# run_gradle +# mvn_local # mvn with the same local repository + +CN1_REPO="${MAVEN_REPO_LOCAL:-$HOME/.m2/repository}" +GRADLE_JDK="${GRADLE_JAVA_HOME:-${JAVA17_HOME:-$JAVA_HOME}}" + +# Gradle itself needs JDK 17+. all.sh also runs on JDK 8-only machines (ant.yml), +# where these scripts have nothing to run on: they skip there, and +# gradle-smoke.yml, which provides the JDK, sets CN1_GRADLE_REQUIRED=1 so a +# missing one fails instead of reading as a pass. +GRADLE_JDK_MAJOR=$("$GRADLE_JDK/bin/java" -version 2>&1 | sed -n 's/.*version "\([0-9]*\)[."].*/\1/p' | head -1) +[ "$GRADLE_JDK_MAJOR" = "1" ] && GRADLE_JDK_MAJOR=8 # "1.8.0_..." is Java 8 +if [ -z "$GRADLE_JDK_MAJOR" ] || [ "$GRADLE_JDK_MAJOR" -lt 17 ]; then + if [ "${CN1_GRADLE_REQUIRED:-}" = "1" ]; then + echo "FAIL: Gradle needs JDK 17 or newer; $GRADLE_JDK is ${GRADLE_JDK_MAJOR:-unknown}. Set GRADLE_JAVA_HOME." >&2 + exit 1 + fi + echo "$(basename "$0"): skipped, no JDK 17 or newer (set GRADLE_JAVA_HOME or JAVA17_HOME)" + exit 0 +fi + +mvn_local() { + if [ -n "${MAVEN_REPO_LOCAL:-}" ]; then + mvn -B -ntp "-Dmaven.repo.local=$MAVEN_REPO_LOCAL" "$@" + else + mvn -B -ntp "$@" + fi +} + +# Resolve the plugin, and the framework it builds against, from the local +# repository instead of repo.codenameone.com, which does not have this build. +use_local_plugin() { + local dir="$1" + python3 - "$dir/settings.gradle.kts" "$CN1_REPO" "$CN1_VERSION" <<'EOF' +import re, sys +path, repo, version = sys.argv[1], sys.argv[2], sys.argv[3] +text = open(path).read() +text = text.replace('maven("https://repo.codenameone.com/maven2")', 'maven(url = uri("%s"))' % repo) +text = re.sub(r'(id\("com\.codenameone"\) version ")[^"]*(")', r'\g<1>%s\g<2>' % version, text) +open(path, 'w').write(text) +EOF + # CN1_TEST_GRADLE_VERSION runs the project on another Gradle than the one the + # template's wrapper pins -- CI uses it to hold the plugin to its documented + # minimum (CodenameOnePlugin.MINIMUM_GRADLE). + if [ -n "${CN1_TEST_GRADLE_VERSION:-}" ] && [ -f "$dir/gradle/wrapper/gradle-wrapper.properties" ]; then + sed -i.bak "s#^distributionUrl=.*#distributionUrl=https\\://services.gradle.org/distributions/gradle-$CN1_TEST_GRADLE_VERSION-bin.zip#" \ + "$dir/gradle/wrapper/gradle-wrapper.properties" + rm -f "$dir/gradle/wrapper/gradle-wrapper.properties.bak" + fi + if ! grep -q "codename1.repository=" "$dir/gradle.properties" 2>/dev/null; then + printf '\ncodename1.repository=%s\n' "$CN1_REPO" >> "$dir/gradle.properties" + fi +} + +run_gradle() { + local dir="$1" + shift + # No build cache: the scripts assert on what the build did (a processor's + # "generated 2 @RestController router" line, for one), and a re-run whose + # inputs match the last one would restore those outputs without doing it. + (cd "$dir" && JAVA_HOME="$GRADLE_JDK" ./gradlew --no-daemon --no-build-cache --stacktrace "$@") +} + +# Fails the test, naming what was expected. +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# Asserts a zip holds an entry matching the extended regex. +assert_zip_has() { + local zip="$1" pattern="$2" + unzip -Z1 "$zip" | grep -Eq "$pattern" || fail "$zip has no entry matching $pattern" +} + +# Asserts a zip holds no entry matching the extended regex. +assert_zip_lacks() { + local zip="$1" pattern="$2" + if unzip -Z1 "$zip" | grep -Eq "$pattern"; then + fail "$zip unexpectedly has an entry matching $pattern: $(unzip -Z1 "$zip" | grep -E "$pattern" | head -3)" + fi +} + +# Generates a Maven application from the archetype and converts it to Gradle. +# The conversion is itself under test here (cn1:convert-to-gradle), and it is +# the one way to produce a Gradle project from the command line. +# generate_gradle_app +generate_gradle_app() { + local name="$1" pkg="$2" + rm -rf "$name" "$name-gradle" + mvn_local archetype:generate \ + -DarchetypeArtifactId=cn1app-archetype \ + -DarchetypeGroupId=com.codenameone \ + -DarchetypeVersion="$CN1_VERSION" \ + -DartifactId="$name" \ + -DgroupId="$pkg" \ + -Dpackage="$pkg" \ + -Dversion=1.0-SNAPSHOT \ + -DmainName="$name" \ + -DjavaVersion=17 \ + -DinteractiveMode=false + (cd "$name" && mvn_local "com.codenameone:codenameone-maven-plugin:$CN1_VERSION:convert-to-gradle") + use_local_plugin "$name-gradle" +} + +# Starts a backend with the given task, waits for /healthz to answer, checks +# the template's /greet route answers through its injected Greeter service, then +# stops it, on the dev profile the template documents (in-memory SQLite). +# PORT overrides cn1.server.port, so parallel runs do not collide. +# check_backend_healthz +check_backend_healthz() { + local dir="$1" task="$2" log="$3" + local port=$((18000 + RANDOM % 2000)) + (cd "$dir" && CN1_PROFILE=dev PORT=$port JAVA_HOME="$GRADLE_JDK" exec ./gradlew --no-daemon "$task") > "$log" 2>&1 & + local pid=$! + local body="" i + for i in $(seq 1 180); do + body=$(curl -fsS "http://127.0.0.1:$port/healthz" 2>/dev/null) && break + kill -0 $pid 2>/dev/null || break + sleep 1 + done + local greeting="" + [ -n "$body" ] && greeting=$(curl -fsS "http://127.0.0.1:$port/greet/gradle" 2>/dev/null || true) + stop_tree $pid + [ -n "$body" ] || { cat "$log"; fail "$task never answered /healthz on port $port"; } + echo " $task /healthz: $body" + [ "$greeting" = "Hello, gradle" ] || { cat "$log"; fail "$task did not answer /greet through its injected service (got '$greeting')"; } +} + +# Checks a packaged backend binary answers /healthz, and /greet through the +# Greeter service the build wired into it. +# check_binary_healthz +check_binary_healthz() { + local bin="$1" dir="$2" log="$3" + local port=$((20000 + RANDOM % 2000)) + (cd "$dir" && CN1_PROFILE=dev PORT=$port exec "$bin") > "$log" 2>&1 & + local pid=$! + local body="" i + for i in $(seq 1 60); do + body=$(curl -fsS "http://127.0.0.1:$port/healthz" 2>/dev/null) && break + kill -0 $pid 2>/dev/null || break + sleep 1 + done + local greeting="" + [ -n "$body" ] && greeting=$(curl -fsS "http://127.0.0.1:$port/greet/gradle" 2>/dev/null || true) + stop_tree $pid + [ -n "$body" ] || { cat "$log"; fail "$bin never answered /healthz on port $port"; } + echo " $(basename "$bin") /healthz: $body" + [ "$greeting" = "Hello, gradle" ] || { cat "$log"; fail "$bin did not answer /greet through its injected service (got '$greeting')"; } +} + +# Kills a process and its children (gradlew forks the backend JVM). +stop_tree() { + local pid="$1" child + for child in $(pgrep -P "$pid" 2>/dev/null); do + stop_tree "$child" + done + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true +} diff --git a/maven/javase/pom.xml b/maven/javase/pom.xml index 7c195e6f899..e10ab67f51f 100644 --- a/maven/javase/pom.xml +++ b/maven/javase/pom.xml @@ -178,6 +178,17 @@ codenameone-build-hint-catalog ${project.version} + + + com.codenameone + codenameone-project-model + ${project.version} + com.codenameone codenameone-core diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/SimulatorProjectTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/SimulatorProjectTest.java new file mode 100644 index 00000000000..b443b63e7b3 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/SimulatorProjectTest.java @@ -0,0 +1,190 @@ +/* + * 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.impl.javase; + +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectLayout; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// How the simulator finds the project it runs, for each build tool. The +/// Maven and Ant answers must be the ones the simulator always gave; Gradle's +/// are new. +public class SimulatorProjectTest { + private final String savedUserDir = System.getProperty("user.dir"); + + @AfterEach + void restoreUserDir() { + System.setProperty("user.dir", savedUserDir); + SimulatorProject.reset(); + } + + private static File write(File dir, String name, String content) throws IOException { + File f = new File(dir, name); + f.getParentFile().mkdirs(); + try (FileOutputStream out = new FileOutputStream(f)) { + out.write(content.getBytes(StandardCharsets.UTF_8)); + } + return f; + } + + private static File canonical(File f) throws IOException { + return f.getCanonicalFile(); + } + + private static File gradleProject(File root) throws IOException { + write(root, "settings.gradle.kts", "rootProject.name = \"app\"\n"); + write(root, "build.gradle.kts", "plugins { id(\"com.codenameone\") }\n"); + write(root, "codenameone_settings.properties", "codename1.mainName=App\n"); + new File(root, "src/main/java").mkdirs(); + return root; + } + + private static File mavenProject(File root) throws IOException { + write(root, "pom.xml", ""); + write(root, "common/pom.xml", ""); + write(root, "common/codenameone_settings.properties", "codename1.mainName=App\n"); + new File(root, "javase").mkdirs(); + write(root, "javase/pom.xml", ""); + return root; + } + + @Test + void gradleProjectResolvesAtItsRoot(@TempDir Path tmp) throws IOException { + File root = gradleProject(tmp.toFile()); + ProjectLayout layout = SimulatorProject.locate(root); + assertNotNull(layout); + assertEquals(BuildSystem.GRADLE, layout.buildSystem()); + assertEquals(canonical(root), canonical(layout.projectDir())); + + System.setProperty("user.dir", root.getAbsolutePath()); + SimulatorProject.reset(); + assertEquals(canonical(new File(root, "codenameone_settings.properties")), + canonical(SimulatorProject.settingsFile())); + assertEquals(canonical(new File(root, "build/classes/java/main")), + canonical(SimulatorProject.classesDir(root))); + assertEquals(canonical(new File(root, "build/resources/main")), + canonical(SimulatorProject.resourcesOutputDir(root))); + assertEquals(canonical(new File(root, "build")), canonical(SimulatorProject.buildDir(root))); + } + + @Test + void mavenProjectResolvesToCommonFromEveryOldStartingPoint(@TempDir Path tmp) throws IOException { + File root = mavenProject(tmp.toFile()); + File common = new File(root, "common"); + // The old lookups: cwd, cwd/common and ../common. + for (File start : new File[]{common, root, new File(root, "javase")}) { + ProjectLayout layout = SimulatorProject.locate(start); + assertNotNull(layout, "from " + start); + assertEquals(BuildSystem.MAVEN, layout.buildSystem()); + assertEquals(canonical(common), canonical(layout.projectDir()), "from " + start); + } + assertEquals(new File(common, "target" + File.separator + "classes"), SimulatorProject.classesDir(common)); + assertEquals(new File(common, "target"), SimulatorProject.buildDir(common)); + } + + @Test + void antProjectKeepsTheMavenOutputPathsTheSimulatorAlwaysUsed(@TempDir Path tmp) throws IOException { + File root = tmp.toFile(); + write(root, "codenameone_settings.properties", "codename1.mainName=App\n"); + write(root, "build.xml", ""); + ProjectLayout layout = SimulatorProject.locate(root); + assertNotNull(layout); + assertEquals(BuildSystem.ANT, layout.buildSystem()); + assertEquals(new File(root, "target" + File.separator + "classes"), SimulatorProject.classesDir(root)); + } + + @Test + void searchIsNoWiderThanBefore(@TempDir Path tmp) throws IOException { + File root = gradleProject(tmp.toFile()); + File deep = new File(root, "src/main/java"); + // Inside the project but not at its root, nor in a direct child of it. + assertNull(SimulatorProject.locate(deep)); + File unrelated = new File(tmp.toFile(), "elsewhere/nested"); + unrelated.mkdirs(); + assertNull(SimulatorProject.locate(unrelated)); + } + + @Test + void noProjectFallsBackToTheWorkingDirectory(@TempDir Path tmp) throws IOException { + File dir = tmp.toFile(); + System.setProperty("user.dir", dir.getAbsolutePath()); + SimulatorProject.reset(); + assertNull(SimulatorProject.current()); + assertEquals(new File(dir.getAbsoluteFile(), "codenameone_settings.properties"), + SimulatorProject.settingsFile()); + assertEquals(new File(dir, "target" + File.separator + "classes"), SimulatorProject.classesDir(dir)); + } + + /// Hot reload's direct recompile uses the project's language level, or a + /// source using Java 17 syntax fails to recompile and the simulator keeps + /// running the old class. + @Test + void aGradleLaunchsBuildDirectoryIsWhereTheOutputsAre(@TempDir Path tmp) throws IOException { + File root = gradleProject(tmp.toFile()); + File out = new File(tmp.toFile(), "elsewhere"); + System.setProperty("user.dir", root.getAbsolutePath()); + System.setProperty("cn1.buildDir", out.getAbsolutePath()); + try { + SimulatorProject.reset(); + assertEquals(canonical(out), canonical(SimulatorProject.buildDir(root)), + "simulator.properties is read from under it"); + assertEquals(canonical(new File(out, "classes/java/main")), canonical(SimulatorProject.classesDir(root))); + assertEquals(canonical(new File(out, "resources/main")), + canonical(SimulatorProject.resourcesOutputDir(root))); + System.setProperty("cn1.gradleProjectPath", ":app"); + SimulatorProject.reset(); + assertEquals(":app:cn1Compile", SimulatorProject.current().gradleTaskPath("cn1Compile"), + "the launch's project path, not one derived from the directory"); + } finally { + System.clearProperty("cn1.buildDir"); + System.clearProperty("cn1.gradleProjectPath"); + } + } + + @Test + void hotReloadCompilesAtTheProjectsJavaLevel(@TempDir Path tmp) throws IOException { + File gradle = gradleProject(new File(tmp.toFile(), "g")); + assertEquals("17", SourceChangeWatcher.javaLevel( + com.codename1.project.ProjectLayouts.detect(gradle))); + + File maven8 = mavenProject(new File(tmp.toFile(), "m8")); + assertEquals("1.8", SourceChangeWatcher.javaLevel(com.codename1.project.ProjectLayouts.detect(maven8))); + + File maven17 = mavenProject(new File(tmp.toFile(), "m17")); + write(maven17, "common/codenameone_settings.properties", + "codename1.mainName=App\ncodename1.arg.java.version=17\n"); + assertEquals("17", SourceChangeWatcher.javaLevel(com.codename1.project.ProjectLayouts.detect(maven17))); + } +} diff --git a/maven/pom.xml b/maven/pom.xml index 35acbefc031..c74b37a72bd 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -81,6 +81,7 @@ cn1-binaries platform-feature-catalog + project-model build-hint-catalog build-hint-tools java-runtime @@ -109,7 +110,10 @@ on the plugin's classpath (it carries its own ProGuard/ASM). --> cn1-hardening designer + build-engine codenameone-maven-plugin + codenameone-gradle-plugin + codenameone-gradle-plugin-marker cn1app-archetype cn1lib-archetype cn1-debug-proxy diff --git a/maven/project-model/pom.xml b/maven/project-model/pom.xml new file mode 100644 index 00000000000..dc05926ded6 --- /dev/null +++ b/maven/project-model/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + com.codenameone + codenameone + 8.0-SNAPSHOT + + codenameone-project-model + Codename One Project Model + Build-tool independent description of a Codename One project's layout (Ant, Maven or Gradle), shared by the build plugins, the simulator and the desktop tools + + + 1.8 + 1.8 + UTF-8 + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.7.3.4 + + Max + Low + false + true + ${project.build.directory} + ${project.basedir}/spotbugs-exclude.xml + + + + spotbugs + verify + + spotbugs + + + + + + + diff --git a/maven/project-model/spotbugs-exclude.xml b/maven/project-model/spotbugs-exclude.xml new file mode 100644 index 00000000000..2e8dfe6673b --- /dev/null +++ b/maven/project-model/spotbugs-exclude.xml @@ -0,0 +1,4 @@ + + + + diff --git a/maven/project-model/src/main/java/com/codename1/project/BuildSystem.java b/maven/project-model/src/main/java/com/codename1/project/BuildSystem.java new file mode 100644 index 00000000000..24f50831285 --- /dev/null +++ b/maven/project-model/src/main/java/com/codename1/project/BuildSystem.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012, 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.project; + +/// The build tool a Codename One project is driven by. +/// +/// The layout rules differ per build tool, so everything that has to find a +/// project file asks [ProjectLayout] instead of assuming one of these. +public enum BuildSystem { + /// The legacy NetBeans-style project: `build.xml`, `src/`, `native/`, + /// `lib/` and `codenameone_settings.properties` in one directory. + ANT, + /// The multi-module project the `cn1app-archetype` generates: a root + /// `pom.xml` with `common/` holding the application and one module per + /// platform. + MAVEN, + /// The single-project build the `com.codenameone` Gradle plugin drives, + /// with the application at the root and platform sources under + /// `src//` only once they exist. + GRADLE +} diff --git a/maven/project-model/src/main/java/com/codename1/project/NativePlatform.java b/maven/project-model/src/main/java/com/codename1/project/NativePlatform.java new file mode 100644 index 00000000000..e42085e6e92 --- /dev/null +++ b/maven/project-model/src/main/java/com/codename1/project/NativePlatform.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2012, 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.project; + +/// A platform that can carry native-interface implementations, with the +/// directory name and source language each build tool uses for it. +public enum NativePlatform { + /// The simulator and desktop builds. + JAVASE("javase", "java"), + /// Android. + ANDROID("android", "java"), + /// iOS, Objective-C and Swift sources. + IOS("ios", "objectivec"), + /// The JavaScript port. + JAVASCRIPT("javascript", "javascript"), + /// The native Windows port, plain C. + WIN("win", "c"), + /// The native Linux port, plain C. + LINUX("linux", "c"); + + private final String id; + private final String language; + + NativePlatform(String id, String language) { + this.id = id; + this.language = language; + } + + /// The platform's name as it appears in directory names and in the + /// `codename1.platform` property, for example `android`. + public String id() { + return id; + } + + /// The source directory name under a platform directory, for example + /// `objectivec` for iOS. + public String language() { + return language; + } + + /// The platform whose [id()] is `id`, or null when there is none. + public static NativePlatform fromId(String id) { + for (NativePlatform p : values()) { + if (p.id.equals(id)) { + return p; + } + } + return null; + } +} diff --git a/maven/project-model/src/main/java/com/codename1/project/ProjectDescriptor.java b/maven/project-model/src/main/java/com/codename1/project/ProjectDescriptor.java new file mode 100644 index 00000000000..67ad662ef58 --- /dev/null +++ b/maven/project-model/src/main/java/com/codename1/project/ProjectDescriptor.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2012, 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.project; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The project facts a build tool resolved, written to a file for tools that +/// run outside the build. +/// +/// A build plugin knows things no directory walk can: the source roots a +/// profile added, the main class a `-D` override picked. It writes them here +/// (see [ProjectLayout#descriptorFile()]), and the simulator and the desktop +/// tools (Settings, GUI Builder, Game Builder, Certificate Wizard) read them +/// back instead of guessing. +/// +/// The format is the one the desktop tools' launch bindings already parse: one +/// `key=value` per line, split at the first `=`, `#` comments, and a key +/// repeated for a list (`sourceRoot=`). Values are written raw, not with +/// `java.util.Properties` escaping, so a Windows path reads back unchanged in +/// a tool that has no `Properties` class. A tool adds its own keys beside the +/// standard ones and ignores keys it does not know. +/// +/// Standard keys: +/// +/// | key | value | +/// |---|---| +/// | `buildSystem` | `ANT`, `MAVEN` or `GRADLE` | +/// | `kind` | `APP`, `LIB` or `BACKEND` | +/// | `rootDir` | [ProjectLayout#rootDir()] | +/// | `projectDir` | [ProjectLayout#projectDir()] | +/// | `settings` | [ProjectLayout#settingsFile()] | +/// | `dependencyFile` | [ProjectLayout#dependencyFile()] | +/// | `pom` | the same file, Maven only (the key older tools read) | +/// | `multimoduleRoot` | [ProjectLayout#rootDir()] when it differs from `projectDir` | +/// | `sourceRoot` | one per resolved source root | +/// | `sourceEncoding`, `mainName`, `packageName` | as the build resolved them | +public final class ProjectDescriptor { + /// The descriptor's file name. + public static final String FILE_NAME = "project.properties"; + + private final Map> values = new LinkedHashMap>(); + + /// An empty descriptor. + public ProjectDescriptor() { + } + + /// A descriptor carrying everything `layout` knows. + public static ProjectDescriptor fromLayout(ProjectLayout layout) { + ProjectDescriptor d = new ProjectDescriptor(); + d.set("buildSystem", layout.buildSystem().name()); + d.set("kind", layout.kind().name()); + d.set("rootDir", layout.rootDir().getAbsolutePath()); + d.set("projectDir", layout.projectDir().getAbsolutePath()); + d.set("settings", layout.settingsFile().getAbsolutePath()); + d.set("dependencyFile", layout.dependencyFile().getAbsolutePath()); + if (layout.buildSystem() == BuildSystem.MAVEN) { + d.set("pom", layout.dependencyFile().getAbsolutePath()); + } + if (!layout.rootDir().equals(layout.projectDir())) { + d.set("multimoduleRoot", layout.rootDir().getAbsolutePath()); + } + for (File root : layout.sourceRoots()) { + d.add("sourceRoot", root.getAbsolutePath()); + } + return d; + } + + /// Parses descriptor text. Never throws; malformed lines are skipped. + public static ProjectDescriptor parse(String content) { + ProjectDescriptor d = new ProjectDescriptor(); + if (content == null) { + return d; + } + String[] lines = content.replace("\r\n", "\n").split("\n"); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.length() == 0 || trimmed.startsWith("#")) { + continue; + } + int eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + String key = trimmed.substring(0, eq).trim(); + String val = trimmed.substring(eq + 1).trim(); + if (val.length() > 0) { + d.add(key, val); + } + } + return d; + } + + /// Reads a descriptor file. + public static ProjectDescriptor read(File f) throws IOException { + return parse(ProjectLayouts.readText(f)); + } + + /// The text [parse(String)] reads back. + public String format() { + StringBuilder sb = new StringBuilder(); + sb.append("# Written by the Codename One build; read by the simulator and desktop tools.\n"); + for (Map.Entry> e : values.entrySet()) { + for (String v : e.getValue()) { + sb.append(e.getKey()).append('=').append(v).append('\n'); + } + } + return sb.toString(); + } + + /// Writes [format()] to `f`, creating its directory. + public void write(File f) throws IOException { + File dir = f.getParentFile(); + if (dir != null && !dir.isDirectory() && !dir.mkdirs() && !dir.isDirectory()) { + throw new IOException("Could not create " + dir); + } + OutputStream out = new FileOutputStream(f); + try { + out.write(format().getBytes(Charset.forName("UTF-8"))); + } finally { + out.close(); + } + } + + /// Replaces every value of `key` with `value`; a null value removes it. + public ProjectDescriptor set(String key, String value) { + values.remove(key); + if (value != null) { + add(key, value); + } + return this; + } + + /// Adds one more value for `key`. + public ProjectDescriptor add(String key, String value) { + List list = values.get(key); + if (list == null) { + list = new ArrayList(); + values.put(key, list); + } + list.add(value); + return this; + } + + /// The first value of `key`, or null. + public String get(String key) { + List list = values.get(key); + return list == null || list.isEmpty() ? null : list.get(0); + } + + /// Every value of `key`, possibly empty. + public List getAll(String key) { + List list = values.get(key); + return list == null ? Collections.emptyList() : Collections.unmodifiableList(list); + } + + /// The layout this descriptor describes, or null when it lacks the + /// build system, kind or directories. + public ProjectLayout toLayout() { + BuildSystem bs = enumValue(BuildSystem.class, get("buildSystem")); + ProjectKind kind = enumValue(ProjectKind.class, get("kind")); + String root = get("rootDir"); + String project = get("projectDir"); + if (bs == null || kind == null || root == null || project == null) { + return null; + } + List roots = new ArrayList(); + for (String r : getAll("sourceRoot")) { + roots.add(new File(r)); + } + return new ProjectLayout(bs, kind, new File(root), new File(project), roots); + } + + private static > T enumValue(Class type, String name) { + if (name == null) { + return null; + } + for (T t : type.getEnumConstants()) { + if (t.name().equals(name)) { + return t; + } + } + return null; + } +} diff --git a/maven/project-model/src/main/java/com/codename1/project/ProjectKind.java b/maven/project-model/src/main/java/com/codename1/project/ProjectKind.java new file mode 100644 index 00000000000..225c07c9df0 --- /dev/null +++ b/maven/project-model/src/main/java/com/codename1/project/ProjectKind.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2012, 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.project; + +/// What a Codename One project builds. +public enum ProjectKind { + /// An application, identified by `codenameone_settings.properties`. + APP, + /// A cn1lib, identified by `codenameone_library_appended.properties`. + LIB, + /// A server-side backend, identified by `application.properties` with no + /// Codename One settings file beside it. + BACKEND +} diff --git a/maven/project-model/src/main/java/com/codename1/project/ProjectLayout.java b/maven/project-model/src/main/java/com/codename1/project/ProjectLayout.java new file mode 100644 index 00000000000..721e4c64abb --- /dev/null +++ b/maven/project-model/src/main/java/com/codename1/project/ProjectLayout.java @@ -0,0 +1,553 @@ +/* + * Copyright (c) 2012, 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.project; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Where everything in one Codename One project lives. +/// +/// Obtain one from [ProjectLayouts#detect(File)], or from a build tool that +/// already knows the answer through [ProjectLayouts#of(BuildSystem, ProjectKind, File, File)]. +/// Every path it returns is absolute. A path is returned whether or not it +/// exists yet: [nativeSourceDir(NativePlatform)], for example, names the +/// directory stubs WOULD go in, and only [ensureNativeSourceDir(NativePlatform)] +/// creates it. Callers that need an existing directory check for themselves. +/// +/// The three layouts, for an application: +/// +/// | | Ant | Maven | Gradle | +/// |---|---|---|---| +/// | settings file | `/` | `/common/` | `/` | +/// | Java sources | `src/` | `common/src/main/java` | `src/main/java` | +/// | CSS | `css/` | `common/src/main/css` | `src/main/css` | +/// | native Android | `native/android` | `android/src/main/java` | `src/android/java` | +/// | build output | `build/` | `common/target/` | `build/` | +public final class ProjectLayout { + /// The settings file of an application. + public static final String SETTINGS_FILE = "codenameone_settings.properties"; + /// The settings file of a cn1lib. + public static final String LIBRARY_SETTINGS_FILE = "codenameone_library_appended.properties"; + /// The configuration file of a backend. + public static final String BACKEND_SETTINGS_FILE = "application.properties"; + + private final BuildSystem buildSystem; + private final ProjectKind kind; + private final File rootDir; + private final File projectDir; + private final List sourceRootOverride; + private final DirectorySource buildDirSource; + private final String gradlePath; + + /// Supplies a directory when asked, for one a build tool can move after the + /// layout is made. A plain interface rather than a JDK functional one: this + /// class also runs inside Codename One tools, on the Codename One runtime. + public interface DirectorySource { + /// The directory, as of now. + File get(); + } + + ProjectLayout(BuildSystem buildSystem, ProjectKind kind, File rootDir, File projectDir, + List sourceRootOverride) { + this(buildSystem, kind, rootDir, projectDir, sourceRootOverride, null, null); + } + + private ProjectLayout(BuildSystem buildSystem, ProjectKind kind, File rootDir, File projectDir, + List sourceRootOverride, DirectorySource buildDirSource, String gradlePath) { + if (buildSystem == null || kind == null || rootDir == null || projectDir == null) { + throw new IllegalArgumentException("buildSystem, kind, rootDir and projectDir are required"); + } + this.buildSystem = buildSystem; + this.kind = kind; + this.rootDir = rootDir.getAbsoluteFile(); + this.projectDir = projectDir.getAbsoluteFile(); + this.sourceRootOverride = sourceRootOverride == null || sourceRootOverride.isEmpty() + ? null : Collections.unmodifiableList(new ArrayList(sourceRootOverride)); + this.buildDirSource = buildDirSource; + this.gradlePath = gradlePath; + } + + /// A copy of this layout whose Gradle project path (`:app`) is `path`, which + /// a build can decouple from the directory (`project(":app").projectDir = + /// file("clients/mobile")`); [#gradleTaskPath(String)] then uses it. + public ProjectLayout withGradlePath(String path) { + return new ProjectLayout(buildSystem, kind, rootDir, projectDir, sourceRootOverride, buildDirSource, path); + } + + /// A copy of this layout whose [buildDir()] -- and everything under it: + /// classes, resources, CSS output, the descriptor -- is wherever `source` + /// says when asked. Gradle lets a build move its build directory, and does + /// so after a plugin has made its layout; a source that reads Gradle's own + /// setting follows it. + public ProjectLayout withBuildDir(DirectorySource source) { + return new ProjectLayout(buildSystem, kind, rootDir, projectDir, sourceRootOverride, source, gradlePath); + } + + /// [#withBuildDir(DirectorySource)] with a directory already known. + public ProjectLayout withBuildDir(File dir) { + return withBuildDir(new FixedDirectory(dir)); + } + + /// A [DirectorySource] that always answers the same directory. + private static final class FixedDirectory implements DirectorySource { + private final File dir; + + FixedDirectory(File dir) { + this.dir = dir; + } + + @Override + public File get() { + return dir; + } + } + + /// A copy of this layout whose [sourceRoots()] are exactly `roots`. + /// + /// A build tool knows the source roots it RESOLVED (a Maven profile can + /// add one, a Gradle build script can move one) and a tool reading the + /// directory tree cannot, so the resolved list wins wherever it is known. + public ProjectLayout withSourceRoots(List roots) { + return new ProjectLayout(buildSystem, kind, rootDir, projectDir, roots, buildDirSource, gradlePath); + } + + /// The build tool. + public BuildSystem buildSystem() { + return buildSystem; + } + + /// What the project builds. + public ProjectKind kind() { + return kind; + } + + /// The top of the build: the Maven multi-module root, the Gradle root + /// project, or the Ant project directory. Wrappers (`mvnw`, `gradlew`) and + /// the settings build file live here. + public File rootDir() { + return rootDir; + } + + /// The directory holding the project's own sources and its settings file: + /// `common/` in a Maven application, `backend/` for a backend module or + /// subproject, and otherwise the same as [rootDir()]. + public File projectDir() { + return projectDir; + } + + /// The Codename One settings file for the project's kind: + /// `codenameone_settings.properties`, `codenameone_library_appended.properties` + /// or the backend's `application.properties`. + public File settingsFile() { + switch (kind) { + case LIB: + return new File(projectDir, LIBRARY_SETTINGS_FILE); + case BACKEND: + return new File(projectDir, BACKEND_SETTINGS_FILE); + default: + return new File(projectDir, SETTINGS_FILE); + } + } + + private boolean isAnt() { + return buildSystem == BuildSystem.ANT; + } + + private File srcMain(String name) { + return file(projectDir, "src", "main", name); + } + + /// The source roots that exist, Java first. Under Ant that is `src/`; + /// otherwise `src/main/java` and `src/main/kotlin`. When a build tool + /// supplied resolved roots through [withSourceRoots(List)], those. + public List sourceRoots() { + if (sourceRootOverride != null) { + return sourceRootOverride; + } + List out = new ArrayList(); + if (isAnt()) { + out.add(new File(projectDir, "src")); + return out; + } + out.add(srcMain("java")); + File kotlin = srcMain("kotlin"); + if (kotlin.isDirectory()) { + out.add(kotlin); + } + return out; + } + + /// The primary Java source root, whether or not it exists yet. + public File javaSourceDir() { + return sourceRoots().get(0); + } + + /// The test source root. + public File testSourceDir() { + if (isAnt()) { + return new File(projectDir, "test"); + } + return file(projectDir, "src", "test", "java"); + } + + /// The resources source directory. Ant keeps resources in `src/` next to + /// the code. + public File resourcesDir() { + if (isAnt()) { + return new File(projectDir, "src"); + } + return srcMain("resources"); + } + + /// The CSS directory. + public File cssDir() { + if (isAnt()) { + return new File(projectDir, "css"); + } + return srcMain("css"); + } + + /// The main theme stylesheet, `theme.css` in [cssDir()]. + public File themeCss() { + return new File(cssDir(), "theme.css"); + } + + /// The localization bundle directory, `l10n` beside [cssDir()]. + public File l10nDir() { + return new File(cssDir().getParentFile(), "l10n"); + } + + /// The legacy XML GUI builder directory. + public File guiBuilderDir() { + if (isAnt()) { + return file(projectDir, "res", "guibuilder"); + } + return srcMain("guibuilder"); + } + + /// The CodeRAD views directory. + public File radViewsDir() { + if (isAnt()) { + return file(projectDir, "rad", "views"); + } + return file(projectDir, "src", "main", "rad", "views"); + } + + /// The directory the Game Builder saves scenes to. + public File gamesDir() { + return new File(resourcesDir(), "games"); + } + + /// The application icon. + public File iconFile() { + return new File(projectDir, "icon.png"); + } + + /// The build output directory: `target/` under Maven, `build/` otherwise. + public File buildDir() { + if (buildDirSource != null) { + File dir = buildDirSource.get(); + if (dir != null) { + return dir.getAbsoluteFile(); + } + } + if (buildSystem == BuildSystem.MAVEN) { + return new File(projectDir, "target"); + } + return new File(projectDir, "build"); + } + + /// Where compiled classes go. + public File classesDir() { + switch (buildSystem) { + case MAVEN: + return file(buildDir(), "classes"); + case GRADLE: + return file(buildDir(), "classes", "java", "main"); + default: + return file(buildDir(), "classes"); + } + } + + /// Where processed resources go. Maven and Ant copy them next to the + /// classes; Gradle keeps them apart. + public File resourcesOutputDir() { + if (buildSystem == BuildSystem.GRADLE) { + return file(buildDir(), "resources", "main"); + } + return classesDir(); + } + + /// The directory holding the CSS of the project's cn1lib dependencies, one + /// subdirectory per library. Ant keeps it under `lib/impl/css`. + public File libraryCssDir() { + if (isAnt()) { + return file(projectDir, "lib", "impl", "css"); + } + return new File(buildDir(), "css"); + } + + /// The file the CSS compiler writes the merged stylesheet of `input` to. + public File cssMergeFile(File input) { + if (isAnt()) { + return new File(input.getAbsolutePath() + ".merged"); + } + return new File(libraryCssDir(), input.getName() + ".merged"); + } + + /// Where the CSS compiler keeps its checksums: the project directory itself + /// under Ant (where the CLI has always written them), the build directory + /// otherwise. + public File cssChecksumDir() { + if (isAnt()) { + return projectDir; + } + return buildDir(); + } + + /// The directory native-interface implementations for `platform` go in. + /// It is returned whether it exists or not; nothing here creates it. + public File nativeSourceDir(NativePlatform platform) { + switch (buildSystem) { + case ANT: + return file(projectDir, "native", platform.id()); + case MAVEN: + return file(rootDir, platform.id(), "src", "main", platform.language()); + default: + return file(projectDir, "src", platform.id(), platform.language()); + } + } + + /// [nativeSourceDir(NativePlatform)], created if missing. + public File ensureNativeSourceDir(NativePlatform platform) { + File dir = nativeSourceDir(platform); + if (!dir.isDirectory() && !dir.mkdirs() && !dir.isDirectory()) { + throw new IllegalStateException("Could not create " + dir); + } + return dir; + } + + /// The iOS app extensions directory (each child an extension project or a + /// zip of one): `ios/app_extensions` under Maven, `src/ios/app_extensions` + /// under Gradle, `native/ios/app_extensions` under Ant. + public File iosAppExtensionsDir() { + switch (buildSystem) { + case MAVEN: + return file(rootDir, "ios", "app_extensions"); + case GRADLE: + return file(projectDir, "src", "ios", "app_extensions"); + default: + return file(projectDir, "native", "ios", "app_extensions"); + } + } + + /// The iOS localized strings directory (one `.lproj` per language): + /// `ios/src/main/strings` under Maven, `src/ios/strings` under Gradle, + /// `native/ios/strings` under Ant. + public File iosStringsDir() { + switch (buildSystem) { + case MAVEN: + return file(rootDir, "ios", "src", "main", "strings"); + case GRADLE: + return file(projectDir, "src", "ios", "strings"); + default: + return file(projectDir, "native", "ios", "strings"); + } + } + + /// The platforms that already have a native source directory. + public List existingNativePlatforms() { + List out = new ArrayList(); + for (NativePlatform p : NativePlatform.values()) { + if (nativeSourceDir(p).isDirectory()) { + out.add(p); + } + } + return out; + } + + /// The directory legacy `.cn1lib` files are installed from, or null when + /// the build tool does not support them (Gradle). + public File legacyCn1libDir() { + switch (buildSystem) { + case ANT: + return new File(projectDir, "lib"); + case MAVEN: + return new File(rootDir, "cn1libs"); + default: + return null; + } + } + + /// The build file the project's own dependencies are declared in: the + /// `common/pom.xml` of a Maven application, the `build.gradle.kts` of a + /// Gradle project, and `build.xml` under Ant. + public File dependencyFile() { + switch (buildSystem) { + case MAVEN: + return new File(projectDir, "pom.xml"); + case GRADLE: + File kts = new File(projectDir, "build.gradle.kts"); + File groovy = new File(projectDir, "build.gradle"); + return !kts.exists() && groovy.exists() ? groovy : kts; + default: + return new File(projectDir, "build.xml"); + } + } + + /// The build file at [rootDir()]: the root `pom.xml`, the Gradle settings + /// script, or `build.xml`. + public File rootBuildFile() { + switch (buildSystem) { + case MAVEN: + return new File(rootDir, "pom.xml"); + case GRADLE: + File kts = new File(rootDir, "settings.gradle.kts"); + File groovy = new File(rootDir, "settings.gradle"); + return !kts.exists() && groovy.exists() ? groovy : kts; + default: + return new File(rootDir, "build.xml"); + } + } + + /// The backend directory: the project itself for a backend, the `backend` + /// module or subproject of an application when there is one, else null. + public File backendDir() { + if (kind == ProjectKind.BACKEND) { + return projectDir; + } + File dir = new File(rootDir, "backend"); + return dir.isDirectory() ? dir : null; + } + + /// Where a build tool writes the [ProjectDescriptor] for this project. + public File descriptorFile() { + return file(buildDir(), "codenameone", ProjectDescriptor.FILE_NAME); + } + + /// The command that recompiles the application's classes, run from + /// [compileWorkingDir()]. The simulator's hot reload runs it after a + /// source file changes. + /// + /// @param mavenHome the Maven installation to prefer, typically the + /// `maven.home` system property; may be null + /// @param windows whether to name the Windows wrapper scripts + public List compileCommand(String mavenHome, boolean windows) { + List cmd = new ArrayList(); + switch (buildSystem) { + case MAVEN: { + String mvn = null; + if (mavenHome != null) { + mvn = findExecutable(file(new File(mavenHome), "bin", "mvn"), windows); + } + if (mvn == null) { + mvn = findExecutable(new File(rootDir, "mvnw"), windows); + } + cmd.add(mvn == null ? "mvn" : mvn); + cmd.add("compile"); + cmd.add("-DskipComplianceCheck"); + cmd.add("-Dmaven.compiler.useIncrementalCompilation=false"); + cmd.add("-e"); + return cmd; + } + case GRADLE: { + String gradlew = findExecutable(new File(rootDir, "gradlew"), windows); + cmd.add(gradlew == null ? "gradle" : gradlew); + cmd.add("--quiet"); + cmd.add("--offline"); + // Hot reload recompiles often; the compliance check ran on the + // full build, as Maven's recompile skips it too. + cmd.add("-PskipComplianceCheck=true"); + cmd.add(gradleTaskPath("cn1Compile")); + return cmd; + } + default: + cmd.add(windows ? "ant.bat" : "ant"); + cmd.add("compile"); + return cmd; + } + } + + /// The directory [compileCommand(String, boolean)] runs in. + public File compileWorkingDir() { + return buildSystem == BuildSystem.GRADLE ? rootDir : projectDir; + } + + /// `task` qualified with this project's Gradle path, so it runs in the + /// right project when invoked from [rootDir()]. + public String gradleTaskPath(String task) { + if (gradlePath != null && gradlePath.length() > 0) { + return ":".equals(gradlePath) ? task : gradlePath + ":" + task; + } + if (projectDir.equals(rootDir)) { + return task; + } + String rel = rootDir.toURI().relativize(projectDir.toURI()).getPath(); + if (rel.endsWith("/")) { + rel = rel.substring(0, rel.length() - 1); + } + return ":" + rel.replace('/', ':') + ":" + task; + } + + private static String findExecutable(File base, boolean windows) { + String[] suffixes = windows ? new String[]{".cmd", ".bat", ".exe", ""} : new String[]{""}; + for (String suffix : suffixes) { + File f = new File(base.getPath() + suffix); + if (f.isFile()) { + return f.getAbsolutePath(); + } + } + return null; + } + + static File file(File base, String... parts) { + File f = base; + for (String p : parts) { + f = new File(f, p); + } + return f; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ProjectLayout)) { + return false; + } + ProjectLayout other = (ProjectLayout) o; + return buildSystem == other.buildSystem && kind == other.kind + && rootDir.equals(other.rootDir) && projectDir.equals(other.projectDir) + && sourceRoots().equals(other.sourceRoots()); + } + + @Override + public int hashCode() { + return (buildSystem.hashCode() * 31 + kind.hashCode()) * 31 + projectDir.hashCode(); + } + + @Override + public String toString() { + return buildSystem + " " + kind + " project at " + projectDir; + } +} diff --git a/maven/project-model/src/main/java/com/codename1/project/ProjectLayouts.java b/maven/project-model/src/main/java/com/codename1/project/ProjectLayouts.java new file mode 100644 index 00000000000..85d29b7d6af --- /dev/null +++ b/maven/project-model/src/main/java/com/codename1/project/ProjectLayouts.java @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2012, 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.project; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; + +/// Finds the Codename One project around a directory. +/// +/// These are the rules the Maven plugin, the simulator, the CSS compiler and +/// the Designer each used to carry their own copy of, merged so they cannot +/// disagree again. [detect(File)] walks up from the directory it is given +/// and, at each level, tries Gradle, then Maven, then Ant: +/// +/// - **Gradle**: a `settings.gradle.kts` / `settings.gradle` / +/// `build.gradle.kts` / `build.gradle` that names the `com.codenameone` +/// plugin, or any of them beside a Codename One settings file and no +/// `pom.xml`. Gradle is tried first because a Gradle application also keeps +/// `codenameone_settings.properties` at its root, which alone would read as Ant. +/// - **Maven**: a `pom.xml` with the application (or cn1lib) in `common/`, or +/// a `pom.xml` beside the settings file itself (a single-module project, or +/// `common/` reached before its parent). +/// - **Ant**: the settings file with no `pom.xml` and no Gradle script. +/// +/// A directory inside a `backend` module or subproject resolves to that +/// backend; everything else in a project resolves to the project itself. +public final class ProjectLayouts { + private static final String[] GRADLE_SCRIPTS = { + "settings.gradle.kts", "settings.gradle", "build.gradle.kts", "build.gradle" + }; + private static final String GRADLE_PLUGIN_ID = "com.codenameone"; + + private ProjectLayouts() { + } + + /// A layout for a project whose shape is already known, as a build plugin + /// knows it. + /// + /// @param rootDir the top of the build, see [ProjectLayout#rootDir()] + /// @param projectDir the directory holding the sources and settings file, + /// see [ProjectLayout#projectDir()] + public static ProjectLayout of(BuildSystem buildSystem, ProjectKind kind, File rootDir, File projectDir) { + return new ProjectLayout(buildSystem, kind, rootDir, projectDir, null); + } + + /// The project containing `start`, or null when `start` is not inside a + /// Codename One project. + public static ProjectLayout detect(File start) { + if (start == null) { + return null; + } + File dir = canonical(start); + if (dir.isFile()) { + dir = dir.getParentFile(); + } + File origin = dir; + while (dir != null) { + ProjectLayout found = detectAt(dir, origin); + if (found != null) { + return found; + } + dir = dir.getParentFile(); + } + return null; + } + + /// [detect(File)], reading back the [ProjectDescriptor] a build tool wrote + /// when there is one, so that resolved source roots win over the + /// directory-tree guess. + public static ProjectLayout detectWithDescriptor(File start) { + ProjectLayout layout = detect(start); + if (layout == null) { + return null; + } + File descriptorFile = layout.descriptorFile(); + if (!descriptorFile.isFile()) { + return layout; + } + try { + ProjectDescriptor d = ProjectDescriptor.read(descriptorFile); + ProjectLayout fromDescriptor = d.toLayout(); + return fromDescriptor != null && fromDescriptor.projectDir().equals(layout.projectDir()) + ? fromDescriptor : layout; + } catch (IOException ex) { + return layout; + } + } + + private static ProjectLayout detectAt(File dir, File origin) { + File settings = new File(dir, ProjectLayout.SETTINGS_FILE); + File libSettings = new File(dir, ProjectLayout.LIBRARY_SETTINGS_FILE); + boolean hasPom = new File(dir, "pom.xml").isFile(); + boolean hasCn1Settings = settings.isFile() || libSettings.isFile(); + + // A pom beside the Gradle scripts (a dual build, a migration in progress) + // leaves the directory to Maven unless a Gradle script names the plugin: + // then the Gradle build is the one that runs the simulator. + if ((!hasPom || gradleScriptNamesPlugin(dir)) && isGradleRoot(dir, hasCn1Settings)) { + File root = hasSettingsScript(dir) ? null : includingRoot(dir); + if (root != null) { + // A subproject that applies the plugin in its own build script; + // the root is the directory with the settings script, and the + // project -- its settings, sources and build output -- is this one. + ProjectLayout atRoot = gradleLayout(root, origin); + if (atRoot.kind() == ProjectKind.BACKEND) { + return atRoot; + } + return new ProjectLayout(BuildSystem.GRADLE, gradleKind(dir), root, dir, null); + } + return gradleLayout(dir, origin); + } + + if (hasPom) { + File common = new File(dir, "common"); + if (new File(common, ProjectLayout.SETTINGS_FILE).isFile() + || new File(common, ProjectLayout.LIBRARY_SETTINGS_FILE).isFile()) { + return mavenLayout(dir, common, origin); + } + if (hasCn1Settings) { + File parent = dir.getParentFile(); + if ("common".equals(dir.getName()) && parent != null && new File(parent, "pom.xml").isFile()) { + return mavenLayout(parent, dir, origin); + } + return new ProjectLayout(BuildSystem.MAVEN, settings.isFile() ? ProjectKind.APP : ProjectKind.LIB, + dir, dir, null); + } + return null; + } + + if (hasCn1Settings) { + // A subproject with no build script of its own -- the root build or a + // convention plugin applies com.codenameone to it -- is still Gradle's, + // when a build above includes it. An Ant project has its build.xml. + File root = new File(dir, "build.xml").isFile() ? null : includingRoot(dir); + if (root != null) { + return new ProjectLayout(BuildSystem.GRADLE, gradleKind(dir), root, dir, null); + } + return new ProjectLayout(BuildSystem.ANT, settings.isFile() ? ProjectKind.APP : ProjectKind.LIB, + dir, dir, null); + } + return null; + } + + private static ProjectLayout mavenLayout(File root, File common, File origin) { + File backend = new File(root, "backend"); + if (isInside(origin, backend) && new File(backend, "pom.xml").isFile()) { + return new ProjectLayout(BuildSystem.MAVEN, ProjectKind.BACKEND, root, backend, null); + } + ProjectKind kind = new File(common, ProjectLayout.SETTINGS_FILE).isFile() ? ProjectKind.APP : ProjectKind.LIB; + return new ProjectLayout(BuildSystem.MAVEN, kind, root, common, null); + } + + private static ProjectLayout gradleLayout(File root, File origin) { + File backend = new File(root, "backend"); + if (isInside(origin, backend) && new File(backend, ProjectLayout.BACKEND_SETTINGS_FILE).isFile()) { + return new ProjectLayout(BuildSystem.GRADLE, ProjectKind.BACKEND, root, backend, null); + } + return new ProjectLayout(BuildSystem.GRADLE, gradleKind(root), root, root, null); + } + + /// The kind of the Gradle project in `dir`, by the same files the plugin + /// reads: an application's settings, a cn1lib's settings, or a backend's + /// `application.properties`. Defaults to an application. + public static ProjectKind gradleKind(File dir) { + if (new File(dir, ProjectLayout.SETTINGS_FILE).isFile()) { + return ProjectKind.APP; + } + if (new File(dir, ProjectLayout.LIBRARY_SETTINGS_FILE).isFile()) { + return ProjectKind.LIB; + } + if (new File(dir, ProjectLayout.BACKEND_SETTINGS_FILE).isFile()) { + return ProjectKind.BACKEND; + } + return ProjectKind.APP; + } + + private static boolean isGradleRoot(File dir, boolean hasCn1Settings) { + boolean anyScript = false; + for (String name : GRADLE_SCRIPTS) { + File script = new File(dir, name); + if (!script.isFile()) { + continue; + } + anyScript = true; + if (mentionsPlugin(script)) { + return true; + } + } + if (!anyScript) { + return false; + } + // A Gradle script with no plugin id in it can still belong to us when the + // plugin is applied from elsewhere (buildSrc, a convention plugin). The + // settings file beside it, or a backend-only project's + // application.properties under a settings script, decides. + return hasCn1Settings || new File(dir, ProjectLayout.BACKEND_SETTINGS_FILE).isFile(); + } + + /// The root of the build that includes `dir` as a subproject, or null. As + /// Gradle finds it: the nearest directory above with a settings script -- + /// `clients/app` of a `:clients:app` project is two levels down. Accepted + /// when that build applies the plugin, or its settings script names this + /// project, so an unrelated build that happens to enclose a project is not + /// adopted. + private static File includingRoot(File dir) { + File candidate = dir.getParentFile(); + while (candidate != null && !hasSettingsScript(candidate)) { + candidate = candidate.getParentFile(); + } + if (candidate == null) { + return null; + } + if (isGradleRoot(candidate, false)) { + return candidate; + } + String rel = candidate.toURI().relativize(dir.toURI()).getPath(); + if (rel.endsWith("/")) { + rel = rel.substring(0, rel.length() - 1); + } + String path = rel.replace('/', ':'); + for (String name : new String[] {"settings.gradle.kts", "settings.gradle"}) { + File settings = new File(candidate, name); + if (settings.isFile()) { + String text = readQuietly(settings); + if (text.contains("\"" + path + "\"") || text.contains("\":" + path + "\"") + || text.contains("'" + path + "'") || text.contains("':" + path + "'")) { + return candidate; + } + } + } + return null; + } + + private static String readQuietly(File f) { + try { + return readText(f); + } catch (IOException ex) { + return ""; + } + } + + private static boolean gradleScriptNamesPlugin(File dir) { + for (String name : GRADLE_SCRIPTS) { + File script = new File(dir, name); + if (script.isFile() && mentionsPlugin(script)) { + return true; + } + } + return false; + } + + private static boolean hasSettingsScript(File dir) { + return new File(dir, "settings.gradle.kts").isFile() || new File(dir, "settings.gradle").isFile(); + } + + private static boolean mentionsPlugin(File script) { + try { + return readText(script).contains(GRADLE_PLUGIN_ID); + } catch (IOException ex) { + return false; + } + } + + static String readText(File f) throws IOException { + InputStream in = new FileInputStream(f); + try { + byte[] buf = new byte[(int) Math.min(f.length(), 1 << 20)]; + int off = 0; + while (off < buf.length) { + int n = in.read(buf, off, buf.length - off); + if (n < 0) { + break; + } + off += n; + } + return new String(buf, 0, off, Charset.forName("UTF-8")); + } finally { + in.close(); + } + } + + private static boolean isInside(File f, File dir) { + File d = canonical(dir); + for (File p = f; p != null; p = p.getParentFile()) { + if (p.equals(d)) { + return true; + } + } + return false; + } + + private static File canonical(File f) { + try { + return f.getCanonicalFile(); + } catch (IOException ex) { + return f.getAbsoluteFile(); + } + } +} diff --git a/maven/project-model/src/main/java/com/codename1/project/package-info.java b/maven/project-model/src/main/java/com/codename1/project/package-info.java new file mode 100644 index 00000000000..6fa0be7b53a --- /dev/null +++ b/maven/project-model/src/main/java/com/codename1/project/package-info.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2012, 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. + */ +/// A build-tool independent model of a Codename One project's layout. +/// +/// Ant, Maven and Gradle projects keep the same things in different places. +/// [com.codename1.project.ProjectLayouts] finds the project around a +/// directory and returns a [com.codename1.project.ProjectLayout] that +/// answers every "where is ..." question for it, so the build plugins, the +/// simulator, the CSS compiler and the desktop tools share one set of rules +/// instead of each carrying its own copy. +package com.codename1.project; diff --git a/maven/project-model/src/test/java/com/codename1/project/ProjectLayoutsTest.java b/maven/project-model/src/test/java/com/codename1/project/ProjectLayoutsTest.java new file mode 100644 index 00000000000..8a1f26d1c4e --- /dev/null +++ b/maven/project-model/src/test/java/com/codename1/project/ProjectLayoutsTest.java @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2012, 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.project; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProjectLayoutsTest { + @TempDir + File tmp; + + private static File touch(File base, String path, String content) throws IOException { + File f = new File(base, path); + f.getParentFile().mkdirs(); + Files.write(f.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return f; + } + + private static File touch(File base, String path) throws IOException { + return touch(base, path, ""); + } + + private static File dir(File base, String path) { + File f = new File(base, path); + f.mkdirs(); + return f; + } + + private File mavenApp() throws IOException { + File root = new File(tmp, "mavenapp"); + touch(root, "pom.xml"); + touch(root, "common/pom.xml"); + touch(root, "common/codenameone_settings.properties"); + dir(root, "common/src/main/java/com/example"); + touch(root, "android/pom.xml"); + dir(root, "android/src/main/java"); + touch(root, "backend/pom.xml"); + touch(root, "backend/application.properties"); + dir(root, "backend/src/main/java"); + return root; + } + + private File gradleApp() throws IOException { + File root = new File(tmp, "gradleapp"); + touch(root, "settings.gradle.kts", "plugins { id(\"com.codenameone\") version \"1.0\" }\n"); + touch(root, "codenameone_settings.properties"); + dir(root, "src/main/java/com/example"); + return root; + } + + private File antApp() throws IOException { + File root = new File(tmp, "antapp"); + touch(root, "build.xml"); + touch(root, "codenameone_settings.properties"); + dir(root, "src/com/example"); + dir(root, "nbproject"); + return root; + } + + @Test + void mavenAppResolvesFromEveryModule() throws IOException { + File root = mavenApp(); + for (String start : new String[]{"", "common", "common/src/main/java/com/example", "android/src/main/java"}) { + ProjectLayout l = ProjectLayouts.detect(new File(root, start)); + assertNotNull(l, start); + assertEquals(BuildSystem.MAVEN, l.buildSystem(), start); + assertEquals(ProjectKind.APP, l.kind(), start); + assertEquals(root.getCanonicalFile(), l.rootDir(), start); + assertEquals(new File(root, "common").getCanonicalFile(), l.projectDir(), start); + } + } + + @Test + void mavenBackendModuleResolvesToTheBackend() throws IOException { + File root = mavenApp(); + ProjectLayout l = ProjectLayouts.detect(new File(root, "backend/src/main/java")); + assertEquals(BuildSystem.MAVEN, l.buildSystem()); + assertEquals(ProjectKind.BACKEND, l.kind()); + assertEquals(new File(root, "backend").getCanonicalFile(), l.projectDir()); + assertEquals(new File(root, "backend/application.properties").getCanonicalFile(), l.settingsFile()); + } + + @Test + void mavenPathsMatchTheArchetype() throws IOException { + File root = mavenApp().getCanonicalFile(); + ProjectLayout l = ProjectLayouts.detect(root); + File common = new File(root, "common"); + assertEquals(new File(common, "codenameone_settings.properties"), l.settingsFile()); + assertEquals(Arrays.asList(new File(common, "src/main/java")), l.sourceRoots()); + assertEquals(new File(common, "src/main/css/theme.css"), l.themeCss()); + assertEquals(new File(common, "target/classes"), l.classesDir()); + assertEquals(new File(root, "android/src/main/java"), l.nativeSourceDir(NativePlatform.ANDROID)); + assertEquals(new File(root, "ios/src/main/objectivec"), l.nativeSourceDir(NativePlatform.IOS)); + assertEquals(new File(root, "cn1libs"), l.legacyCn1libDir()); + assertEquals(new File(common, "pom.xml"), l.dependencyFile()); + assertEquals(new File(root, "backend"), l.backendDir()); + assertEquals(new File(common, "target/css/theme.css.merged"), l.cssMergeFile(l.themeCss())); + } + + @Test + void gradleAppIsNotMistakenForAnt() throws IOException { + File root = gradleApp().getCanonicalFile(); + ProjectLayout l = ProjectLayouts.detect(new File(root, "src/main/java/com/example")); + assertEquals(BuildSystem.GRADLE, l.buildSystem()); + assertEquals(ProjectKind.APP, l.kind()); + assertEquals(root, l.rootDir()); + assertEquals(root, l.projectDir()); + assertEquals(new File(root, "src/android/java"), l.nativeSourceDir(NativePlatform.ANDROID)); + assertEquals(new File(root, "src/ios/objectivec"), l.nativeSourceDir(NativePlatform.IOS)); + assertEquals(new File(root, "src/win/c"), l.nativeSourceDir(NativePlatform.WIN)); + assertEquals(new File(root, "build/classes/java/main"), l.classesDir()); + assertEquals(new File(root, "build/resources/main"), l.resourcesOutputDir()); + assertEquals(new File(root, "build.gradle.kts"), l.dependencyFile()); + assertNull(l.legacyCn1libDir()); + assertNull(l.backendDir()); + assertTrue(l.existingNativePlatforms().isEmpty()); + } + + @Test + void gradleNativeDirIsOnlyCreatedOnRequest() throws IOException { + File root = gradleApp(); + ProjectLayout l = ProjectLayouts.detect(root); + assertFalse(l.nativeSourceDir(NativePlatform.JAVASE).exists()); + File created = l.ensureNativeSourceDir(NativePlatform.JAVASE); + assertTrue(created.isDirectory()); + assertEquals(Arrays.asList(NativePlatform.JAVASE), l.existingNativePlatforms()); + } + + @Test + void gradleBackendSubprojectAndBackendOnlyRoot() throws IOException { + File root = gradleApp(); + touch(root, "backend/application.properties"); + dir(root, "backend/src/main/java"); + ProjectLayout sub = ProjectLayouts.detect(new File(root, "backend/src/main/java")); + assertEquals(ProjectKind.BACKEND, sub.kind()); + assertEquals(root.getCanonicalFile(), sub.rootDir()); + assertEquals(":backend:cn1Compile", sub.gradleTaskPath("cn1Compile")); + assertEquals(new File(root, "backend").getCanonicalFile(), ProjectLayouts.detect(root).backendDir()); + + File service = new File(tmp, "service"); + touch(service, "settings.gradle.kts", "plugins { id(\"com.codenameone\") version \"1.0\" }\n"); + touch(service, "application.properties"); + ProjectLayout only = ProjectLayouts.detect(service); + assertEquals(BuildSystem.GRADLE, only.buildSystem()); + assertEquals(ProjectKind.BACKEND, only.kind()); + assertEquals(only.projectDir(), only.backendDir()); + assertEquals("cn1Compile", only.gradleTaskPath("cn1Compile")); + } + + @Test + void gradleSubprojectApplyingThePluginItselfKeepsTheRoot() throws IOException { + File root = new File(tmp, "multi"); + touch(root, "settings.gradle.kts", "pluginManagement { plugins { id(\"com.codenameone\") } }\ninclude(\"backend\")\n"); + touch(root, "codenameone_settings.properties"); + touch(root, "backend/build.gradle.kts", "plugins { id(\"com.codenameone\") }\n"); + touch(root, "backend/application.properties"); + ProjectLayout l = ProjectLayouts.detect(new File(root, "backend")); + assertEquals(root.getCanonicalFile(), l.rootDir()); + assertEquals(ProjectKind.BACKEND, l.kind()); + } + + @Test + void anIncludedAppSubprojectIsItsOwnProjectDirectory() throws IOException { + File root = new File(tmp, "multiapp"); + touch(root, "settings.gradle.kts", "include(\"app\")\n"); + touch(root, "build.gradle.kts", "plugins { id(\"com.codenameone\") version \"1.0\" apply false }\n"); + touch(root, "app/build.gradle.kts", "plugins { id(\"com.codenameone\") }\n"); + touch(root, "app/codenameone_settings.properties"); + dir(root, "app/src/main/java"); + ProjectLayout l = ProjectLayouts.detect(new File(root, "app/src/main/java")); + assertEquals(root.getCanonicalFile(), l.rootDir()); + assertEquals(new File(root, "app").getCanonicalFile(), l.projectDir()); + assertEquals(ProjectKind.APP, l.kind()); + assertEquals(new File(root, "app/codenameone_settings.properties").getCanonicalFile(), l.settingsFile()); + assertEquals(":app:cn1Compile", l.gradleTaskPath("cn1Compile")); + } + + @Test + void aNestedIncludedAppSubprojectFindsTheBuildRoot() throws IOException { + File root = new File(tmp, "nested"); + touch(root, "settings.gradle.kts", "include(\":clients:app\")\n"); + touch(root, "clients/app/build.gradle.kts", "plugins { id(\"com.codenameone\") version \"1.0\" }\n"); + touch(root, "clients/app/codenameone_settings.properties"); + dir(root, "clients/app/src/main/java"); + ProjectLayout l = ProjectLayouts.detect(new File(root, "clients/app/src/main/java")); + assertEquals(root.getCanonicalFile(), l.rootDir()); + assertEquals(new File(root, "clients/app").getCanonicalFile(), l.projectDir()); + assertEquals(":clients:app:cn1Compile", l.gradleTaskPath("cn1Compile")); + + File stray = new File(tmp, "enclosing"); + touch(stray, "settings.gradle.kts", "include(\"other\")\n"); + touch(stray, "deep/app/build.gradle.kts", "plugins { id(\"com.codenameone\") version \"1.0\" }\n"); + touch(stray, "deep/app/codenameone_settings.properties"); + ProjectLayout alone = ProjectLayouts.detect(new File(stray, "deep/app")); + assertEquals(new File(stray, "deep/app").getCanonicalFile(), alone.rootDir(), + "a build that neither applies the plugin nor names the project is not adopted"); + } + + @Test + void aGradleBuildNamingThePluginWinsOverAPomBesideIt() throws IOException { + File dual = new File(tmp, "dual"); + touch(dual, "pom.xml", ""); + touch(dual, "settings.gradle.kts", "plugins { id(\"com.codenameone\") version \"1.0\" }\n"); + touch(dual, "codenameone_settings.properties"); + assertEquals(BuildSystem.GRADLE, ProjectLayouts.detect(dual).buildSystem()); + + File maven = new File(tmp, "mavenOnly"); + touch(maven, "pom.xml", ""); + touch(maven, "build.gradle", "// an unrelated script\n"); + touch(maven, "codenameone_settings.properties"); + assertEquals(BuildSystem.MAVEN, ProjectLayouts.detect(maven).buildSystem(), + "a Gradle script that does not name the plugin leaves the pom in charge"); + } + + @Test + void aMovedBuildDirectoryMovesEverythingUnderIt() throws IOException { + File root = gradleApp(); + File out = new File(tmp, "elsewhere"); + final File[] current = {new File(root, "build")}; + ProjectLayout l = ProjectLayouts.detect(root).withBuildDir(() -> current[0]); + assertEquals(new File(root, "build").getAbsoluteFile(), l.buildDir()); + current[0] = out; + assertEquals(out.getAbsoluteFile(), l.buildDir(), "asked again, it follows the build"); + assertEquals(new File(out, "classes/java/main").getAbsoluteFile(), l.classesDir()); + assertEquals(new File(out, "resources/main").getAbsoluteFile(), l.resourcesOutputDir()); + assertEquals(out.getAbsoluteFile(), l.withSourceRoots(java.util.Collections.emptyList()).buildDir(), + "kept by the other copies"); + } + + @Test + void aSubprojectWithNoBuildScriptOfItsOwnIsStillGradles() throws IOException { + File root = new File(tmp, "central"); + touch(root, "settings.gradle.kts", "include(\"app\")\n"); + touch(root, "build.gradle.kts", "subprojects { apply(plugin = \"com.codenameone\") }\n"); + touch(root, "app/codenameone_settings.properties"); + ProjectLayout l = ProjectLayouts.detect(new File(root, "app")); + assertEquals(BuildSystem.GRADLE, l.buildSystem()); + assertEquals(root.getCanonicalFile(), l.rootDir()); + assertEquals(new File(root, "app").getCanonicalFile(), l.projectDir()); + + File ant = new File(tmp, "antInside"); + touch(ant, "settings.gradle.kts", "include(\"app\")\n"); + touch(ant, "app/build.xml", ""); + touch(ant, "app/codenameone_settings.properties"); + assertEquals(BuildSystem.ANT, ProjectLayouts.detect(new File(ant, "app")).buildSystem(), + "an Ant project keeps its build.xml"); + } + + @Test + void aGradlePathTheBuildSetsWinsOverTheDirectory() throws IOException { + File root = new File(tmp, "decoupled"); + touch(root, "settings.gradle.kts", "include(\":app\")\n"); + touch(root, "clients/mobile/build.gradle.kts", "plugins { id(\"com.codenameone\") version \"1.0\" }\n"); + touch(root, "clients/mobile/codenameone_settings.properties"); + ProjectLayout l = ProjectLayouts.detect(new File(root, "clients/mobile")); + assertEquals(":app:cn1Compile", l.withGradlePath(":app").gradleTaskPath("cn1Compile")); + assertEquals("cn1Compile", l.withGradlePath(":").gradleTaskPath("cn1Compile"), "the root project"); + assertEquals(":app:cn1Compile", l.withGradlePath(":app").withBuildDir(new File(tmp, "out")) + .gradleTaskPath("cn1Compile"), "kept by the other copies"); + } + + @Test + void gradleCn1lib() throws IOException { + File root = new File(tmp, "lib"); + touch(root, "settings.gradle.kts", "plugins { id(\"com.codenameone\") version \"1.0\" }\n"); + touch(root, "codenameone_library_appended.properties"); + ProjectLayout l = ProjectLayouts.detect(root); + assertEquals(ProjectKind.LIB, l.kind()); + assertEquals(new File(root, "codenameone_library_appended.properties").getCanonicalFile(), l.settingsFile()); + } + + @Test + void antPaths() throws IOException { + File root = antApp().getCanonicalFile(); + ProjectLayout l = ProjectLayouts.detect(new File(root, "src/com/example")); + assertEquals(BuildSystem.ANT, l.buildSystem()); + assertEquals(Arrays.asList(new File(root, "src")), l.sourceRoots()); + assertEquals(new File(root, "src"), l.resourcesDir()); + assertEquals(new File(root, "css/theme.css"), l.themeCss()); + assertEquals(new File(root, "native/javase"), l.nativeSourceDir(NativePlatform.JAVASE)); + assertEquals(new File(root, "lib/impl/css"), l.libraryCssDir()); + assertEquals(new File(root, "css/theme.css.merged"), l.cssMergeFile(l.themeCss())); + assertEquals(new File(root, "lib"), l.legacyCn1libDir()); + } + + @Test + void mavenCn1lib() throws IOException { + File root = new File(tmp, "mavenlib"); + touch(root, "pom.xml"); + touch(root, "common/pom.xml"); + touch(root, "common/codenameone_library_appended.properties"); + ProjectLayout l = ProjectLayouts.detect(new File(root, "common")); + assertEquals(BuildSystem.MAVEN, l.buildSystem()); + assertEquals(ProjectKind.LIB, l.kind()); + } + + @Test + void outsideAnyProject() { + assertNull(ProjectLayouts.detect(dir(tmp, "nothing/here"))); + assertNull(ProjectLayouts.detect(null)); + } + + @Test + void compileCommandsPerBuildTool() throws IOException { + File g = gradleApp(); + touch(g, "gradlew"); + List gradle = ProjectLayouts.detect(g).compileCommand(null, false); + assertTrue(gradle.get(0).endsWith("gradlew"), gradle.toString()); + assertEquals("cn1Compile", gradle.get(gradle.size() - 1)); + assertEquals(g.getCanonicalFile(), ProjectLayouts.detect(g).compileWorkingDir()); + + File m = mavenApp(); + List maven = ProjectLayouts.detect(m).compileCommand(null, false); + assertEquals("mvn", maven.get(0)); + assertEquals("compile", maven.get(1)); + assertEquals(new File(m, "common").getCanonicalFile(), ProjectLayouts.detect(m).compileWorkingDir()); + + assertEquals(Arrays.asList("ant", "compile"), ProjectLayouts.detect(antApp()).compileCommand(null, false)); + } + + @Test + void descriptorRoundTripAndOverride() throws IOException { + File root = gradleApp(); + ProjectLayout l = ProjectLayouts.detect(root); + File generated = dir(root, "build/generated/sources/cn1"); + ProjectLayout resolved = l.withSourceRoots(Arrays.asList(l.javaSourceDir(), generated.getCanonicalFile())); + ProjectDescriptor d = ProjectDescriptor.fromLayout(resolved).set("mainName", "MyApp"); + d.write(l.descriptorFile()); + + ProjectDescriptor back = ProjectDescriptor.read(l.descriptorFile()); + assertEquals("MyApp", back.get("mainName")); + assertEquals("GRADLE", back.get("buildSystem")); + assertNull(back.get("pom")); + assertEquals(resolved, back.toLayout()); + assertEquals(resolved.sourceRoots(), ProjectLayouts.detectWithDescriptor(root).sourceRoots()); + } + + @Test + void descriptorKeepsWindowsPathsAndMavenKeys() throws IOException { + ProjectDescriptor d = ProjectDescriptor.parse("# c\r\nsettings=C:\\work\\app\\common\\x.properties\r\nsourceRoot=a\r\nsourceRoot=b\r\nbogus\r\n"); + assertEquals("C:\\work\\app\\common\\x.properties", d.get("settings")); + assertEquals(Arrays.asList("a", "b"), d.getAll("sourceRoot")); + assertNull(d.toLayout()); + + ProjectDescriptor maven = ProjectDescriptor.fromLayout(ProjectLayouts.detect(mavenApp())); + assertNotNull(maven.get("pom")); + assertEquals(maven.get("pom"), maven.get("dependencyFile")); + assertNotNull(maven.get("multimoduleRoot")); + } +} diff --git a/maven/scripts/r2/frozen-coordinates.py b/maven/scripts/r2/frozen-coordinates.py index 31a2076fa60..e1a7cb8743d 100755 --- a/maven/scripts/r2/frozen-coordinates.py +++ b/maven/scripts/r2/frozen-coordinates.py @@ -115,7 +115,9 @@ REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) MOJO = os.path.join(REPO_ROOT, "maven", "codenameone-maven-plugin", "src", "main", "java", "com", "codename1", "maven", "AbstractCN1Mojo.java") -PLUGIN_SRC = os.path.join(REPO_ROOT, "maven", "codenameone-maven-plugin", "src", "main", "java") +# The build engine was split out of the plugin; getArtifact calls live in both. +PLUGIN_SRCS = [os.path.join(REPO_ROOT, "maven", "codenameone-maven-plugin", "src", "main", "java"), + os.path.join(REPO_ROOT, "maven", "build-engine", "src", "main", "java")] def fail(message): @@ -141,7 +143,7 @@ def read_classifiers(): """artifactId -> [classifier], from the plugin's own getArtifact calls.""" found = {} pattern = re.compile(r'getArtifact\(\s*"com\.codenameone"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)') - for path in glob.glob(os.path.join(PLUGIN_SRC, "**", "*.java"), recursive=True): + for path in (p for src in PLUGIN_SRCS for p in glob.glob(os.path.join(src, "**", "*.java"), recursive=True)): with open(path, "r") as handle: for artifact, classifier in pattern.findall(handle.read()): found.setdefault(artifact, []) diff --git a/scripts/build-hint-computed-sites.txt b/scripts/build-hint-computed-sites.txt index 8b8ba7c5d77..78d17bedec0 100644 --- a/scripts/build-hint-computed-sites.txt +++ b/scripts/build-hint-computed-sites.txt @@ -33,10 +33,10 @@ # literal search -- including this gate -- reported success while all ten had no # catalog row. -maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MapsProviderInjector.java|platform + ".maps.provider"|android.maps.provider,ios.maps.provider -maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/NativeVerifyOption.java|platformPrefix + "." + HINT|ios.nativeVerify,linux.nativeVerify,windows.nativeVerify -maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java|"macNative.provisioningProfile." + (isAppStore ? "appStore" : "developerID")|macNative.provisioningProfile.appStore,macNative.provisioningProfile.developerID -maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java|"ios.entitlements.com.apple.developer.healthkit." + suffix|ios.entitlements.com.apple.developer.healthkit.access -maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java|"ios.entitlements.com.apple.developer.healthkit." + canonicalKey|ios.entitlements.com.apple.developer.healthkit.access -maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java|hintAndMarker[0]|ios.wallet.nonuiImportsInject,ios.wallet.statusInject,ios.wallet.passEntriesInject,ios.wallet.remotePassEntriesInject,ios.wallet.generateRequestInject,ios.wallet.generateResponseInject,ios.wallet.uiImportsInject,ios.wallet.uiViewDidLoadInject,ios.wallet.uiAuthRequestInject,ios.wallet.uiAuthResponseInject -maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java|"ios." + privacyKey|ios.NSBluetoothAlwaysUsageDescription,ios.NSBluetoothPeripheralUsageDescription,ios.NSCameraUsageDescription,ios.NSMicrophoneUsageDescription,ios.NSSpeechRecognitionUsageDescription +maven/build-engine/src/main/java/com/codename1/builders/MapsProviderInjector.java|platform + ".maps.provider"|android.maps.provider,ios.maps.provider +maven/build-engine/src/main/java/com/codename1/builders/NativeVerifyOption.java|platformPrefix + "." + HINT|ios.nativeVerify,linux.nativeVerify,windows.nativeVerify +maven/build-engine/src/main/java/com/codename1/builders/MacNativeBuilder.java|"macNative.provisioningProfile." + (isAppStore ? "appStore" : "developerID")|macNative.provisioningProfile.appStore,macNative.provisioningProfile.developerID +maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java|"ios.entitlements.com.apple.developer.healthkit." + suffix|ios.entitlements.com.apple.developer.healthkit.access +maven/build-engine/src/main/java/com/codename1/builders/WatchNativeBuilder.java|"ios.entitlements.com.apple.developer.healthkit." + canonicalKey|ios.entitlements.com.apple.developer.healthkit.access +maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java|hintAndMarker[0]|ios.wallet.nonuiImportsInject,ios.wallet.statusInject,ios.wallet.passEntriesInject,ios.wallet.remotePassEntriesInject,ios.wallet.generateRequestInject,ios.wallet.generateResponseInject,ios.wallet.uiImportsInject,ios.wallet.uiViewDidLoadInject,ios.wallet.uiAuthRequestInject,ios.wallet.uiAuthResponseInject +maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java|"ios." + privacyKey|ios.NSBluetoothAlwaysUsageDescription,ios.NSBluetoothPeripheralUsageDescription,ios.NSCameraUsageDescription,ios.NSMicrophoneUsageDescription,ios.NSSpeechRecognitionUsageDescription diff --git a/scripts/build_hint_miner.py b/scripts/build_hint_miner.py index a485f1e918a..76c4ac179d6 100644 --- a/scripts/build_hint_miner.py +++ b/scripts/build_hint_miner.py @@ -26,7 +26,10 @@ import re, os, sys, json, collections ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -SRC = os.path.join(ROOT, "maven/codenameone-maven-plugin/src/main/java/com/codename1") +# The builders moved to maven/build-engine; the mojos stayed in the plugin. Both +# read hints, so both are mined. +SRCS = [os.path.join(ROOT, "maven/build-engine/src/main/java/com/codename1"), + os.path.join(ROOT, "maven/codenameone-maven-plugin/src/main/java/com/codename1")] OPENERS = [ (re.compile(r'\bgetArg\(\s*"'), False), @@ -212,7 +215,7 @@ def concat_of_literals(expr): return None return "".join(out) -for dirpath, _, files in os.walk(SRC): +for dirpath, _, files in (w for src in SRCS for w in os.walk(src)): for fn in sorted(files): if not fn.endswith(".java"): continue diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectBinding.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectBinding.java index e09edf80824..b682b3e3bbf 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectBinding.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectBinding.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2012, 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.certificatewizard.project; public final class ProjectBinding { @@ -7,6 +29,10 @@ public final class ProjectBinding { private String user; private String token; private String baseUrl; + private String buildSystem; + private String kind; + private String rootDir; + private String dependencyFile; public String projectDir() { return projectDir; @@ -32,6 +58,31 @@ public String baseUrl() { return baseUrl; } + /// `ANT`, `MAVEN` or `GRADLE`: the standard `buildSystem=` key of the + /// project descriptor the build writes, or null when the launcher did not + /// say (a Maven launcher that predates the key). + public String buildSystem() { + return buildSystem; + } + + /// `APP`, `LIB` or `BACKEND`, or null when the launcher did not say. + public String kind() { + return kind; + } + + /// The top of the build (Maven multi-module root, Gradle root project or + /// Ant project), falling back to [projectDir()] when the launcher did not + /// say. + public String rootDir() { + return rootDir != null && rootDir.length() > 0 ? rootDir : projectDir; + } + + /// The build file the project's dependencies are declared in (the + /// `pom.xml` or `build.gradle.kts`), or null when the launcher did not say. + public String dependencyFile() { + return dependencyFile; + } + public boolean isValid() { return settings != null && !settings.isEmpty(); } @@ -60,6 +111,10 @@ public static ProjectBinding parse(String content) { case "user" -> b.user = val; case "token" -> b.token = val; case "baseUrl" -> b.baseUrl = val; + case "buildSystem" -> b.buildSystem = val; + case "kind" -> b.kind = val; + case "rootDir" -> b.rootDir = val; + case "dependencyFile" -> b.dependencyFile = val; default -> { } } diff --git a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectIO.java b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectIO.java index 98486c481a0..2aeafe404b7 100644 --- a/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectIO.java +++ b/scripts/certificatewizard/common/src/main/java/com/codename1/certificatewizard/project/ProjectIO.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2012, 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.certificatewizard.project; import com.codename1.io.FileSystemStorage; @@ -76,13 +98,26 @@ public static ProjectBinding loadBinding() { } } + /// A `file://` URL for an OS path, as `FileSystemStorage` expects. + /// + /// The same in all four desktop tools (Settings, GUI Builder, Game Builder, + /// Certificate Wizard), because they read the same launch binding. A Windows + /// drive-letter path (`C:\Users\...`) needs a slash before the drive: + /// without it, stripping the `file://` prefix on the consumer side yields a + /// drive-relative `/C:\...` that resolves to `C:\C:\...` and silently + /// breaks every project-file read. Backslashes become forward slashes, and a + /// value that is already a URL passes through. public static String fsUrl(String path) { if (path == null) { return null; } - if (path.startsWith("file://") || path.indexOf("://") > 0) { + if (path.startsWith("file:") || path.indexOf("://") > 0) { return path; } - return "file://" + path; + String normalized = path.replace('\\', '/'); + if (normalized.length() > 1 && normalized.charAt(1) == ':') { + return "file:///" + normalized; + } + return "file://" + normalized; } } diff --git a/scripts/check-vpn-tunnel-extension-compiles.sh b/scripts/check-vpn-tunnel-extension-compiles.sh index 627f3c5e454..4eff243c907 100755 --- a/scripts/check-vpn-tunnel-extension-compiles.sh +++ b/scripts/check-vpn-tunnel-extension-compiles.sh @@ -61,7 +61,7 @@ trap 'rm -rf "$WORK"' EXIT # it can be compiled out of the tree like this rather than through the whole # plugin build. mkdir -p "$WORK/src/com/codename1/util" "$WORK/classes" "$WORK/out" -cp maven/codenameone-maven-plugin/src/main/java/com/codename1/util/IOSVpnTunnelExtensionBuilder.java \ +cp maven/build-engine/src/main/java/com/codename1/util/IOSVpnTunnelExtensionBuilder.java \ "$WORK/src/com/codename1/util/" cat > "$WORK/src/Emit.java" <<'JAVA' import com.codename1.util.IOSVpnTunnelExtensionBuilder; diff --git a/scripts/ci/select-cn1lib-checks.py b/scripts/ci/select-cn1lib-checks.py index e14ed35afdf..af19438aeea 100644 --- a/scripts/ci/select-cn1lib-checks.py +++ b/scripts/ci/select-cn1lib-checks.py @@ -49,7 +49,9 @@ def select(paths, libraries=None): result['android'].update(AI) result['desktop'].update(AI) result['ios_ai'].update(AI) - if path == 'maven/pom.xml' or path.startswith('maven/codenameone-maven-plugin/'): + # The build engine and project model are the plugin's own code, moved out of it. + if path == 'maven/pom.xml' or path.startswith(('maven/codenameone-maven-plugin/', 'maven/build-engine/', + 'maven/project-model/')): result['package'].update(PACKAGES) if path.startswith(('CodenameOne/src/', 'Ports/Android/', 'scripts/cn1lib-api-check/')) or path in ( 'scripts/check-cn1lib-android-api.py', '.ci/container/Dockerfile'): diff --git a/scripts/copyright-header-exclusions.txt b/scripts/copyright-header-exclusions.txt index da078059931..df66bc4af45 100644 --- a/scripts/copyright-header-exclusions.txt +++ b/scripts/copyright-header-exclusions.txt @@ -198,7 +198,7 @@ Ports/CLDC11/src/java/text/ParseException.java | Codename One contribution whose Ports/CLDC11/src/java/text/SimpleDateFormat.java | Codename One contribution whose copyright line names its contributor; the notice is kept as written maven/cn1app-archetype/src/main/resources/archetype-resources/common/src/test/java/MyFirstTest.java | Archetype template copied into user projects, which carries no Codename One header by design maven/cn1lib-archetype/src/main/resources/archetype-resources/common/src/test/java/FilechooserTest.java | Archetype template copied into user projects, which carries no Codename One header by design -maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Base64.java | Apache Software Foundation (Harmony-derived) source retaining its original Apache-2.0 notice +maven/build-engine/src/main/java/com/codename1/builders/Base64.java | Apache Software Foundation (Harmony-derived) source retaining its original Apache-2.0 notice maven/codenameone-maven-plugin/src/main/java/org/apache/maven/model/jdom/JDomActivation.java | Fork of the Apache Maven JDom model (Apache-2.0); upstream notice kept below the package line maven/codenameone-maven-plugin/src/main/java/org/apache/maven/model/jdom/JDomActivationProperty.java | Fork of the Apache Maven JDom model (Apache-2.0); upstream notice kept below the package line maven/codenameone-maven-plugin/src/main/java/org/apache/maven/model/jdom/JDomBacked.java | Fork of the Apache Maven JDom model (Apache-2.0); upstream notice kept below the package line @@ -227,12 +227,13 @@ maven/codenameone-maven-plugin/src/main/java/org/apache/maven/model/jdom/etl/Mod maven/codenameone-maven-plugin/src/main/java/org/apache/maven/model/jdom/etl/ModelETLFactory.java | Fork of the Apache Maven JDom model (Apache-2.0); upstream notice kept below the package line maven/codenameone-maven-plugin/src/main/java/org/apache/maven/model/jdom/etl/ModelETLRequest.java | Fork of the Apache Maven JDom model (Apache-2.0); upstream notice kept below the package line maven/codenameone-maven-plugin/src/main/java/org/apache/maven/model/jdom/util/JDomUtils.java | Fork of the Apache Maven JDom model (Apache-2.0); upstream notice kept below the package line -maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/Octal.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice -maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarConstants.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice -maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarEntry.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice -maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarInputStream.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice -maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarOutputStream.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice -maven/codenameone-maven-plugin/src/main/java/org/xeustechnologies/jtar/TarUtils.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice +maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarHeader.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice +maven/build-engine/src/main/java/org/xeustechnologies/jtar/Octal.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice +maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarConstants.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice +maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarEntry.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice +maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarInputStream.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice +maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarOutputStream.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice +maven/build-engine/src/main/java/org/xeustechnologies/jtar/TarUtils.java | jtar (Kamran Zafar, Apache-2.0) retaining its original notice scripts/cn1playground/common/src/main/java/bsh/BSHEnhancedForStatement.java | BeanShell source, Apache-2.0, retaining its original notice scripts/cn1playground/common/src/main/java/bsh/Primitive.java | BeanShell source, Apache-2.0, retaining its original notice vm/JavaAPI/src/java/net/URI.java | Codename One contribution whose copyright line names its contributor; the notice is kept as written @@ -243,3 +244,4 @@ vm/JavaAPI/src/java/text/DateFormatSymbols.java | Codename One contribution whos vm/JavaAPI/src/java/text/Format.java | Codename One contribution whose copyright line names its contributor; the notice is kept as written vm/JavaAPI/src/java/text/ParseException.java | Codename One contribution whose copyright line names its contributor; the notice is kept as written vm/JavaAPI/src/java/text/SimpleDateFormat.java | Codename One contribution whose copyright line names its contributor; the notice is kept as written +maven/build-engine/src/main/java/com/codename1/builders/util/JSONSanitizer.java | Adapted from the OWASP JSON Sanitizer (Apache-2.0), whose documentation it keeps verbatim diff --git a/scripts/gamebuilder/common/src/main/java/com/codename1/gamebuilder/project/ProjectBinding.java b/scripts/gamebuilder/common/src/main/java/com/codename1/gamebuilder/project/ProjectBinding.java index aa5bc53807a..c965f376ccb 100644 --- a/scripts/gamebuilder/common/src/main/java/com/codename1/gamebuilder/project/ProjectBinding.java +++ b/scripts/gamebuilder/common/src/main/java/com/codename1/gamebuilder/project/ProjectBinding.java @@ -32,6 +32,10 @@ public final class ProjectBinding { private String sourceDir; private String packageName; private String output; + private String buildSystem; + private String kind; + private String rootDir; + private String dependencyFile; public String projectDir() { return projectDir; @@ -41,12 +45,52 @@ public String packageName() { return packageName; } + /// The scene directory: `gamesDir=` when the launcher named it, otherwise + /// `games/` in the resources directory the binding's `buildSystem=` + /// implies (`src/` under Ant, `src/main/resources` under Maven's `common/` + /// and Gradle), so a bare project descriptor is enough to open a project. public String gamesDir() { - return gamesDir; + if (gamesDir != null || projectDir == null || buildSystem == null) { + return gamesDir; + } + return projectDir + (isAnt() ? "/src/games" : "/src/main/resources/games"); } + /// The Java source root, `sourceDir=` or else the build system's convention. public String sourceDir() { - return sourceDir; + if (sourceDir != null || projectDir == null || buildSystem == null) { + return sourceDir; + } + return projectDir + (isAnt() ? "/src" : "/src/main/java"); + } + + /// `ANT`, `MAVEN` or `GRADLE`: the standard `buildSystem=` key of the + /// project descriptor the build writes, or null when the launcher did not + /// say (a Maven launcher that predates the key). + public String buildSystem() { + return buildSystem; + } + + /// `APP`, `LIB` or `BACKEND`, or null when the launcher did not say. + public String kind() { + return kind; + } + + /// The top of the build (Maven multi-module root, Gradle root project or + /// Ant project), falling back to [projectDir()] when the launcher did not + /// say. + public String rootDir() { + return rootDir != null && rootDir.length() > 0 ? rootDir : projectDir; + } + + /// The build file the project's dependencies are declared in (the + /// `pom.xml` or `build.gradle.kts`), or null when the launcher did not say. + public String dependencyFile() { + return dependencyFile; + } + + private boolean isAnt() { + return "ANT".equals(buildSystem); } public String output() { @@ -54,7 +98,8 @@ public String output() { } public boolean isValid() { - return gamesDir != null && !gamesDir.isEmpty(); + String dir = gamesDir(); + return dir != null && !dir.isEmpty(); } /// Parses the {@code key=value} descriptor (lines starting with {@code #} are ignored). @@ -81,6 +126,10 @@ public static ProjectBinding parse(String content) { case "sourceDir" -> b.sourceDir = val; case "packageName" -> b.packageName = val; case "output" -> b.output = val; + case "buildSystem" -> b.buildSystem = val; + case "kind" -> b.kind = val; + case "rootDir" -> b.rootDir = val; + case "dependencyFile" -> b.dependencyFile = val; default -> { // ignore unknown keys for forward-compatibility } diff --git a/scripts/gamebuilder/common/src/main/java/com/codename1/gamebuilder/project/ProjectIO.java b/scripts/gamebuilder/common/src/main/java/com/codename1/gamebuilder/project/ProjectIO.java index 458f82ff7a1..a10a1cd085d 100644 --- a/scripts/gamebuilder/common/src/main/java/com/codename1/gamebuilder/project/ProjectIO.java +++ b/scripts/gamebuilder/common/src/main/java/com/codename1/gamebuilder/project/ProjectIO.java @@ -264,11 +264,26 @@ private static String join(String dir, String child) { /// Converts an OS path (as written into the binding by the Maven goal) to the /// `file:` URL `FileSystemStorage` requires. Pass-through if already a URL. - private static String fsUrl(String osPath) { - if (osPath == null || osPath.startsWith("file:")) { - return osPath; + /// A `file://` URL for an OS path, as `FileSystemStorage` expects. + /// + /// The same in all four desktop tools (Settings, GUI Builder, Game Builder, + /// Certificate Wizard), because they read the same launch binding. A Windows + /// drive-letter path (`C:\Users\...`) needs a slash before the drive: + /// without it, stripping the `file://` prefix on the consumer side yields a + /// drive-relative `/C:\...` that resolves to `C:\C:\...` and silently + /// breaks every project-file read. Backslashes become forward slashes, and a + /// value that is already a URL passes through. + public static String fsUrl(String path) { + if (path == null) { + return null; + } + if (path.startsWith("file:") || path.indexOf("://") > 0) { + return path; + } + String normalized = path.replace('\\', '/'); + if (normalized.length() > 1 && normalized.charAt(1) == ':') { + return "file:///" + normalized; } - String s = osPath.replace('\\', '/'); - return s.startsWith("/") ? "file://" + s : "file:///" + s; + return "file://" + normalized; } } diff --git a/scripts/gamebuilder/common/src/test/java/com/codename1/gamebuilder/project/ProjectBindingTest.java b/scripts/gamebuilder/common/src/test/java/com/codename1/gamebuilder/project/ProjectBindingTest.java index b31fce1881c..448430265ae 100644 --- a/scripts/gamebuilder/common/src/test/java/com/codename1/gamebuilder/project/ProjectBindingTest.java +++ b/scripts/gamebuilder/common/src/test/java/com/codename1/gamebuilder/project/ProjectBindingTest.java @@ -59,4 +59,33 @@ void handlesWindowsLineEndingsAndWhitespace() { ProjectBinding b = ProjectBinding.parse(" gamesDir = C:\\proj\\games \r\n"); assertEquals("C:\\proj\\games", b.gamesDir()); } + + /// A bare project descriptor names the build system and the project, and + /// the scene and source directories follow from its conventions. + @Test + void derivesDirectoriesFromTheDescriptorKeys() { + ProjectBinding gradle = ProjectBinding.parse("buildSystem=GRADLE\nkind=APP\nrootDir=/g\nprojectDir=/g\n" + + "dependencyFile=/g/build.gradle.kts\n"); + assertTrue(gradle.isValid()); + assertEquals("/g/src/main/resources/games", gradle.gamesDir()); + assertEquals("/g/src/main/java", gradle.sourceDir()); + assertEquals("GRADLE", gradle.buildSystem()); + assertEquals("APP", gradle.kind()); + assertEquals("/g", gradle.rootDir()); + assertEquals("/g/build.gradle.kts", gradle.dependencyFile()); + + ProjectBinding ant = ProjectBinding.parse("buildSystem=ANT\nprojectDir=/a\n"); + assertEquals("/a/src/games", ant.gamesDir()); + assertEquals("/a/src", ant.sourceDir()); + + // An explicit key still wins over the convention. + assertEquals("/x/games", ProjectBinding.parse("buildSystem=GRADLE\nprojectDir=/g\ngamesDir=/x/games\n").gamesDir()); + } + + @Test + void fsUrlHandlesWindowsDriveLetters() { + assertEquals("file:///C:/proj/games", ProjectIO.fsUrl("C:\\proj\\games")); + assertEquals("file:///proj/games", ProjectIO.fsUrl("/proj/games")); + assertEquals("file:///proj/games", ProjectIO.fsUrl("file:///proj/games")); + } } diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index d9dd5661c48..a4638953f1d 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -348,7 +348,7 @@ private void showUnboundState() { Container empty = new Container(BoxLayout.y()); empty.setUIID("BuilderWelcome"); empty.add(new Label("GUI Builder", "BuilderWelcomeTitle")); - empty.add(new SpanLabel("Open the builder from a Codename One Maven project with mvn cn1:guibuilder.", "BuilderWelcomeCopy")); + empty.add(new SpanLabel("Open the builder from a Codename One project: mvn cn1:guibuilder in a Maven project, or ./gradlew guibuilder in a Gradle project.", "BuilderWelcomeCopy")); form.add(BorderLayout.CENTER, BorderLayout.centerAbsolute(empty)); form.show(); } diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectBinding.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectBinding.java index 7987974f76b..433fb01f8ee 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectBinding.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectBinding.java @@ -29,13 +29,63 @@ public final class ProjectBinding { private String sourceDir; private String cssFile; private String initialForm; + private String buildSystem; + private String kind; + private String rootDir; + private String dependencyFile; public String projectDir() { return projectDir; } - public String guiDir() { return guiDir; } - public String sourceDir() { return sourceDir; } - public String cssFile() { return cssFile; } + + /// The `.gui` directory: `guiDir=` when the launcher named it, otherwise + /// the conventional one for the binding's `buildSystem=` (`res/guibuilder` + /// under Ant, `src/main/guibuilder` under Maven's `common/` and Gradle), so + /// a bare project descriptor is enough to open a project. + public String guiDir() { + if (guiDir != null || projectDir == null || buildSystem == null) return guiDir; + return projectDir + (isAnt() ? "/res/guibuilder" : "/src/main/guibuilder"); + } + + /// The Java source root, `sourceDir=` or else the build system's convention. + public String sourceDir() { + if (sourceDir != null || projectDir == null || buildSystem == null) return sourceDir; + return projectDir + (isAnt() ? "/src" : "/src/main/java"); + } + + /// The theme stylesheet, `cssFile=` or else the build system's convention. + public String cssFile() { + if (cssFile != null || projectDir == null || buildSystem == null) return cssFile; + return projectDir + (isAnt() ? "/css/theme.css" : "/src/main/css/theme.css"); + } + public String initialForm() { return initialForm; } - public boolean isValid() { return projectDir != null && guiDir != null; } + public boolean isValid() { return projectDir != null && guiDir() != null; } + + /// `ANT`, `MAVEN` or `GRADLE`: the standard `buildSystem=` key of the + /// project descriptor the build writes, or null when the launcher did not + /// say (a Maven launcher that predates the key). + public String buildSystem() { + return buildSystem; + } + + /// `APP`, `LIB` or `BACKEND`, or null when the launcher did not say. + public String kind() { + return kind; + } + + /// The top of the build (Maven multi-module root, Gradle root project or + /// Ant project), falling back to [projectDir()] when the launcher did not + /// say. + public String rootDir() { + return rootDir != null && rootDir.length() > 0 ? rootDir : projectDir; + } + + /// The build file the project's dependencies are declared in (the + /// `pom.xml` or `build.gradle.kts`), or null when the launcher did not say. + public String dependencyFile() { + return dependencyFile; + } + + private boolean isAnt() { return "ANT".equals(buildSystem); } public static ProjectBinding parse(String content) { ProjectBinding binding = new ProjectBinding(); @@ -63,6 +113,18 @@ public static ProjectBinding parse(String content) { case "initialForm": binding.initialForm = field; break; + case "buildSystem": + binding.buildSystem = field; + break; + case "kind": + binding.kind = field; + break; + case "rootDir": + binding.rootDir = field; + break; + case "dependencyFile": + binding.dependencyFile = field; + break; default: break; } diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectIO.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectIO.java index f67274ab4c4..37cd288c001 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectIO.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectIO.java @@ -167,17 +167,26 @@ private static void ensureParent(String path) { fs.mkdir(parent); } - /** - * Normalizes to forward slashes before building the URL. The Maven plugin hands this editor - * native paths, so on Windows every path arrives with backslashes; leaving them in place makes - * the separator arithmetic in {@code ensureParent} and {@code fileName} silently find nothing. - * A drive-lettered path becomes {@code file://C:/...}, which the JavaSE port maps back to - * {@code C:\...}. - */ + /// A `file://` URL for an OS path, as `FileSystemStorage` expects. + /// + /// The same in all four desktop tools (Settings, GUI Builder, Game Builder, + /// Certificate Wizard), because they read the same launch binding. A Windows + /// drive-letter path (`C:\Users\...`) needs a slash before the drive: + /// without it, stripping the `file://` prefix on the consumer side yields a + /// drive-relative `/C:\...` that resolves to `C:\C:\...` and silently + /// breaks every project-file read. Backslashes become forward slashes, and a + /// value that is already a URL passes through. public static String fsUrl(String path) { - if (path == null) return null; + if (path == null) { + return null; + } + if (path.startsWith("file:") || path.indexOf("://") > 0) { + return path; + } String normalized = path.replace('\\', '/'); - if (normalized.startsWith("file://") || normalized.indexOf("://") > 0) return normalized; + if (normalized.length() > 1 && normalized.charAt(1) == ':') { + return "file:///" + normalized; + } return "file://" + normalized; } } diff --git a/scripts/guibuilder/common/src/test/java/com/codename1/guibuilder/project/ProjectBindingTest.java b/scripts/guibuilder/common/src/test/java/com/codename1/guibuilder/project/ProjectBindingTest.java index f5e6583d6df..83d1b038020 100644 --- a/scripts/guibuilder/common/src/test/java/com/codename1/guibuilder/project/ProjectBindingTest.java +++ b/scripts/guibuilder/common/src/test/java/com/codename1/guibuilder/project/ProjectBindingTest.java @@ -39,4 +39,30 @@ void parsesModernProjectBinding() { assertEquals("com.example.Login", binding.initialForm()); assertEquals("/tmp/app/common/src/main/css/theme.css", binding.cssFile()); } + + /// A bare project descriptor names the build system and the project, and + /// the form, source and stylesheet locations follow from its conventions. + @Test + void derivesDirectoriesFromTheDescriptorKeys() { + ProjectBinding gradle = ProjectBinding.parse("buildSystem=GRADLE\nkind=APP\nrootDir=/g\nprojectDir=/g\n"); + assertTrue(gradle.isValid()); + assertEquals("/g/src/main/guibuilder", gradle.guiDir()); + assertEquals("/g/src/main/java", gradle.sourceDir()); + assertEquals("/g/src/main/css/theme.css", gradle.cssFile()); + assertEquals("/g", gradle.rootDir()); + + ProjectBinding ant = ProjectBinding.parse("buildSystem=ANT\nprojectDir=/a\n"); + assertEquals("/a/res/guibuilder", ant.guiDir()); + assertEquals("/a/src", ant.sourceDir()); + assertEquals("/a/css/theme.css", ant.cssFile()); + + // Without a build system nothing is guessed, as before. + assertFalse(ProjectBinding.parse("projectDir=/p\n").isValid()); + } + + @Test + void fsUrlHandlesWindowsDriveLetters() { + assertEquals("file:///C:/app/src", ProjectIO.fsUrl("C:\\app\\src")); + assertEquals("file:///app/src", ProjectIO.fsUrl("/app/src")); + } } diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectIoTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectIoTest.java index 3e9f41857f0..9b64b56ac26 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectIoTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectIoTest.java @@ -95,7 +95,9 @@ void writingCreatesMissingParentDirectories(@TempDir Path dir) throws Exception */ @Test void windowsPathsBecomeUsableFileUrls() { - assertEquals("file://C:/Users/dev/app/src/main/guibuilder", + // file:///C:/..., the one form the four desktop tools share: a standard URI, + // which JavaSEPort.unfile maps back to C:/... (see ProjectIO.fsUrl). + assertEquals("file:///C:/Users/dev/app/src/main/guibuilder", ProjectIO.fsUrl("C:\\Users\\dev\\app\\src\\main\\guibuilder")); assertEquals("file:///home/dev/app", ProjectIO.fsUrl("/home/dev/app")); assertEquals("file:///home/dev/app", ProjectIO.fsUrl("file:///home/dev/app")); diff --git a/scripts/initializr/common/pom.xml b/scripts/initializr/common/pom.xml index b89f6bfee92..3c3bbaf6825 100644 --- a/scripts/initializr/common/pom.xml +++ b/scripts/initializr/common/pom.xml @@ -477,6 +477,31 @@ + + + package-gradle-template + process-resources + + run + + + + + + + + + diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java index 74751573feb..08c3e5dcb50 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java @@ -1,3 +1,25 @@ +/* + * 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.initializr; import static com.codename1.ui.CN.*; @@ -94,6 +116,10 @@ public void runApp() { final boolean[] includeLocalizationBundles = new boolean[]{false}; final ProjectOptions.PreviewLanguage[] previewLanguage = new ProjectOptions.PreviewLanguage[]{ProjectOptions.PreviewLanguage.ENGLISH}; final ProjectOptions.JavaVersion[] javaVersion = new ProjectOptions.JavaVersion[]{ProjectOptions.JavaVersion.JAVA_17}; + final ProjectOptions.BuildTool[] buildTool = new ProjectOptions.BuildTool[]{ProjectOptions.BuildTool.MAVEN}; + final ProjectOptions.ProjectType[] projectType = new ProjectOptions.ProjectType[]{ProjectOptions.ProjectType.APP}; + // {Java 17, Java 8}: the build panel disables Java 8 while Gradle is selected. + final RadioButton[] javaButtons = new RadioButton[2]; final SpanLabel summaryLabel = new SpanLabel(); final TemplatePreviewPanel previewPanel = new TemplatePreviewPanel(selectedTemplate[0]); @@ -101,6 +127,7 @@ public void runApp() { final Label ideSubtitle = panelSubtitle(IDE.INTELLIJ.name()); final Label localeSubtitle = panelSubtitle("No bundles"); final Label javaSubtitle = panelSubtitle(ProjectOptions.JavaVersion.JAVA_17.label); + final Label buildSubtitle = panelSubtitle(ProjectOptions.BuildTool.MAVEN.label); appNameField.setUIID("InitializrField"); packageField.setUIID("InitializrField"); @@ -118,7 +145,8 @@ public void runApp() { final Runnable refresh = new Runnable() { public void run() { - ProjectOptions options = currentOptions(includeLocalizationBundles, previewLanguage, javaVersion); + ProjectOptions options = currentOptions(includeLocalizationBundles, previewLanguage, javaVersion) + .withBuild(buildTool[0], projectType[0]); previewPanel.setTemplate(selectedTemplate[0]); previewPanel.setOptions(options); @@ -127,6 +155,9 @@ public void run() { ? "Bundles . " + previewLanguage[0].label : "No bundles"); javaSubtitle.setText(javaVersion[0].label); + buildSubtitle.setText(buildTool[0] == ProjectOptions.BuildTool.GRADLE + ? buildTool[0].label + " . " + projectType[0].label + : buildTool[0].label); String appName = appNameField.getText() == null ? "" : appNameField.getText().trim(); bundleInfo.setText("Bundle: " + (appName.length() == 0 ? "App" : appName) + ".zip"); @@ -148,14 +179,20 @@ public void run() { Container localePanel = makePanel("Localization", localeSubtitle, true, false, createLocalizationPanel(includeLocalizationBundles, previewLanguage, refresh), form); Container javaPanel = makePanel("Java Version", javaSubtitle, true, false, - createJavaOptionsPanel(javaVersion, refresh), form); + createJavaOptionsPanel(javaVersion, javaButtons, refresh), form); + Container buildPanel = makePanel("Build Tool", buildSubtitle, true, false, + createBuildToolPanel(buildTool, projectType, javaVersion, javaButtons, refresh), form); Container settingsPanel = makePanel("Current Settings", panelSubtitle("Generated artifacts"), true, true, BoxLayout.encloseY(summaryLabel), form); // ----- live preview (stacked at the end of the single column) ----- Container previewWrap = createPreviewWrap(previewPanel); - Container column = BoxLayout.encloseY(hero, essentials, idePanel, localePanel, + // Gradle is offered once the plugin is published at the version the + // generated projects use; until then the Maven default stands alone. + buildPanel.setHidden(!GeneratorModel.isGradleOffered()); + buildPanel.setVisible(GeneratorModel.isGradleOffered()); + Container column = BoxLayout.encloseY(hero, essentials, idePanel, buildPanel, localePanel, javaPanel, settingsPanel, previewWrap); column.setUIID("InitializrColumn"); column.setScrollableY(true); @@ -179,7 +216,8 @@ public void run() { } String appName = appNameField.getText() == null ? "" : appNameField.getText().trim(); String packageName = packageField.getText() == null ? "" : packageField.getText().trim(); - ProjectOptions options = downloadOptions(includeLocalizationBundles, previewLanguage, javaVersion); + ProjectOptions options = downloadOptions(includeLocalizationBundles, previewLanguage, javaVersion) + .withBuild(buildTool[0], projectType[0]); GeneratorModel.create(selectedIde[0], selectedTemplate[0], appName, packageName, options).generate(); }); @@ -390,7 +428,8 @@ private Container createLocalizationPanel(boolean[] includeLocalizationBundles, return BoxLayout.encloseY(includeBundles, labeledField("Preview Language", languagePicker)); } - private Container createJavaOptionsPanel(ProjectOptions.JavaVersion[] javaVersion, Runnable onSelectionChanged) { + private Container createJavaOptionsPanel(ProjectOptions.JavaVersion[] javaVersion, RadioButton[] buttons, + Runnable onSelectionChanged) { Container selector = new Container(new GridLayout(2, 1)); selector.setUIID("InitializrChoicesGrid"); ButtonGroup group = new ButtonGroup(); @@ -399,6 +438,7 @@ private Container createJavaOptionsPanel(ProjectOptions.JavaVersion[] javaVersio for (int i = 0; i < versions.length; i++) { final ProjectOptions.JavaVersion version = versions[i]; RadioButton button = new RadioButton(labels[i]); + buttons[i] = button; button.setToggle(true); button.setUIID("InitializrChoice"); group.add(button); @@ -416,6 +456,86 @@ private Container createJavaOptionsPanel(ProjectOptions.JavaVersion[] javaVersio return selector; } + /// Maven or Gradle, and for Gradle what the project holds. Gradle projects are + /// Java 17 only, so choosing Gradle selects Java 17 and disables Java 8. A + /// Maven project always carries the backend module behind a profile, so the + /// project type is only offered for Gradle. + private Container createBuildToolPanel(ProjectOptions.BuildTool[] buildTool, + ProjectOptions.ProjectType[] projectType, + ProjectOptions.JavaVersion[] javaVersion, + RadioButton[] javaButtons, Runnable onSelectionChanged) { + Container tools = new Container(new GridLayout(1, 2)); + tools.setUIID("InitializrChoicesGrid"); + ButtonGroup toolGroup = new ButtonGroup(); + + Container types = new Container(new GridLayout(3, 1)); + types.setUIID("InitializrChoicesGrid"); + ButtonGroup typeGroup = new ButtonGroup(); + final RadioButton[] typeButtons = new RadioButton[ProjectOptions.ProjectType.values().length]; + for (ProjectOptions.ProjectType type : ProjectOptions.ProjectType.values()) { + RadioButton button = new RadioButton(type.label); + typeButtons[type.ordinal()] = button; + button.setToggle(true); + button.setUIID("InitializrChoice"); + typeGroup.add(button); + types.add(button); + if (type == projectType[0]) { + button.setSelected(true); + } + button.addActionListener(evt -> { + if (button.isSelected()) { + projectType[0] = type; + onSelectionChanged.run(); + } + }); + } + SpanLabel hint = new SpanLabel("Gradle projects target Java 17. Every Maven project already " + + "includes an optional backend module."); + hint.setUIID("InitializrTip"); + hint.setTextUIID("InitializrTip"); + final Container typeSection = BoxLayout.encloseY(labeledField("Project Type", types)); + typeSection.setHidden(true); + typeSection.setVisible(false); + + for (ProjectOptions.BuildTool tool : ProjectOptions.BuildTool.values()) { + RadioButton button = new RadioButton(tool.label); + button.setToggle(true); + button.setUIID("InitializrChoice"); + toolGroup.add(button); + tools.add(button); + if (tool == buildTool[0]) { + button.setSelected(true); + } + button.addActionListener(evt -> { + if (!button.isSelected()) { + return; + } + buildTool[0] = tool; + boolean gradle = tool == ProjectOptions.BuildTool.GRADLE; + typeSection.setHidden(!gradle); + typeSection.setVisible(gradle); + if (gradle) { + javaVersion[0] = ProjectOptions.JavaVersion.JAVA_17; + if (javaButtons[0] != null) { + javaButtons[0].setSelected(true); + } + } + if (javaButtons[1] != null) { + javaButtons[1].setEnabled(!gradle); + } + if (!gradle && projectType[0] == ProjectOptions.ProjectType.BACKEND_ONLY) { + // Maven has no backend-only project, and the type is hidden + // now, so a stale choice would fail generation with no way to + // correct it. Every Maven project carries the backend anyway. + projectType[0] = ProjectOptions.ProjectType.APP; + typeGroup.setSelected(typeButtons[ProjectOptions.ProjectType.APP.ordinal()]); + } + onSelectionChanged.run(); + }); + } + return BoxLayout.encloseY(tools, typeSection, hint); + } + private Container createPreviewWrap(TemplatePreviewPanel previewPanel) { Label dot = new Label(""); dot.setUIID("InitializrLiveDot"); @@ -650,6 +770,8 @@ private String createSummary(String appName, String packageName, Template templa + "Package " + safePackage + "\n" + "Language " + (template.IS_KOTLIN ? "KOTLIN" : "JAVA") + "\n" + "IDE " + ide.name() + "\n" + + "Build " + options.buildTool.label + + (options.isGradle() ? " (" + options.projectType.label + ")" : "") + "\n" + "Java " + options.javaVersion.label + "\n" + "Bundles " + (options.includeLocalizationBundles ? "INCLUDED" : "NONE") + "\n" + "Preview " + options.previewLanguage.label; diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index a3586a14124..8eafecf946c 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -44,6 +44,38 @@ public class GeneratorModel { private static final String CN1_PLUGIN_VERSION = "7.0.273"; + /// Whether the Codename One Gradle plugin is published at [CN1_PLUGIN_VERSION]. + /// A Gradle download resolves the plugin by that version, so until a release + /// carrying the plugin is what the initializr generates against, a Gradle + /// project could not build at all -- and the UI does not offer Gradle. + /// `update-cn1-version.sh` sets this with the version, from whether the + /// plugin's marker exists in the repository. + static final boolean GRADLE_PLUGIN_PUBLISHED = false; + + /// Whether the initializr offers Gradle projects; see [GRADLE_PLUGIN_PUBLISHED]. + public static boolean isGradleOffered() { + return GRADLE_PLUGIN_PUBLISHED; + } + /// The Kotlin version a Gradle Kotlin project builds with, for both the Kotlin + /// Gradle plugin and kotlin-stdlib (the plugin adds its own stdlib anyway, so + /// the two cannot usefully differ). + /// + /// Deliberately NOT kotlin-pom.xml's 1.6.0. The Kotlin Gradle plugin is tied + /// to the Gradle it runs in, and 1.6.0 cannot run in the Gradle 9 the shared + /// wrapper pins: it fails at configuration with NoClassDefFoundError + /// org/gradle/util/WrapUtil, and before that it registers a build listener the + /// configuration cache (on in the template's gradle.properties) rejects. 2.2.10 + /// was verified to compile the Kotlin template with the com.codenameone plugin + /// on that wrapper. The Maven template's kotlin-maven-plugin has no such tie. + /// + /// `GradleConversion.KOTLIN_VERSION` in maven/build-engine writes the same + /// line into a converted Kotlin project; bump the two together. + static final String KOTLIN_VERSION = "2.2.10"; + /// The Gradle project template, zipped at build time (common/pom.xml, + /// package-gradle-template) from maven/build-engine's + /// com/codename1/project/templates/gradle -- the directory the Maven and + /// Gradle plugins write projects from, so every generator emits the same files. + private static final String GRADLE_TEMPLATE_ZIP = "/gradle.zip"; private static final String PREVIEW_BUTTON_SELECTOR = "Button, InitializrLiveButtonDarkClean, " + "InitializrLiveButtonLightTealRound, InitializrLiveButtonLightTealSquare, " @@ -87,6 +119,10 @@ public class GeneratorModel { // its published name is SKILL.md; this one is renamed for the opposite reason. private static final String CLAUDE_SKILL_STUB_RESOURCE = "/agent-skill-claude-stub.md"; private static final String AGENTS_MD_RESOURCE = "/agent-skill-agents-md.md"; + // The Gradle variant is a second file rather than placeholders in the first: + // maven/cn1app-archetype stages agent-skill-agents-md.md verbatim, so a token + // there would reach every archetype-generated Maven project unrendered. + private static final String AGENTS_MD_GRADLE_RESOURCE = "/agent-skill-agents-md-gradle.md"; private final IDE ide; private final Template template; @@ -223,10 +259,14 @@ void writeProjectZip(OutputStream outputStream) throws IOException { /// localization, generated README/.gitignore/skills) into an ordered map of /// path -> bytes. This is the I/O phase, kept separate from the zip assembly. Map collectProjectEntries() throws IOException { + validateOptions(); + if (options.isGradle()) { + return collectGradleProjectEntries(); + } Map mergedEntries = new LinkedHashMap(); copyZipEntriesToMap(ide.ZIP, mergedEntries, ZipEntryType.IDE); - copyZipEntriesToMap("/common.zip", mergedEntries, ZipEntryType.COMMON); + copyZipEntriesToMap("/common.zip", mergedEntries, ZipEntryType.COMMON_ARCHIVE); copySingleTextEntryToMap(".gitignore", GENERATED_GITIGNORE, mergedEntries, ZipEntryType.COMMON); copySingleTextEntryToMap("README.md", buildReadmeMarkdown(), mergedEntries, ZipEntryType.COMMON); if (options.javaVersion == ProjectOptions.JavaVersion.JAVA_17) { @@ -243,6 +283,202 @@ Map collectProjectEntries() throws IOException { return mergedEntries; } + /// The Gradle form of [collectProjectEntries()]: one project at the root, laid + /// out the way the `com.codenameone` Gradle plugin reads it. The build files + /// come from the shared template in gradle.zip; the application's files are the + /// same common.zip and template entries a Maven download gets, moved out of + /// `common/`. + private Map collectGradleProjectEntries() throws IOException { + Map entries = new LinkedHashMap(); + Map scaffold = readZipResource(GRADLE_TEMPLATE_ZIP); + boolean backendOnly = options.projectType == ProjectOptions.ProjectType.BACKEND_ONLY; + + addGradleIdeEntries(entries); + putGradleText(entries, "settings.gradle.kts", gradleTemplate(scaffold, "settings.gradle.kts.txt", "")); + putGradleText(entries, "gradle.properties", gradleTemplate(scaffold, "gradle.properties.txt", "")); + putGradleText(entries, ".gitignore", gradleTemplate(scaffold, "gitignore.txt", "")); + // The wrapper (gradlew, gradlew.bat, gradle/wrapper/*) is every template file + // that is not a .txt template, copied byte for byte. + for (Map.Entry e : scaffold.entrySet()) { + if (!e.getKey().endsWith(".txt")) { + entries.put(e.getKey(), e.getValue()); + } + } + + if (backendOnly) { + // No client: the backend is the root project. No settings file, no CSS and + // no icon -- the plugin tells a backend from an app by the absence of + // codenameone_settings.properties and the presence of application.properties. + addGradleBackendEntries(entries, scaffold, "", ""); + } else { + putGradleText(entries, "build.gradle.kts", + gradleAppBuildScript(gradleTemplate(scaffold, "app/build.gradle.kts.txt", ""))); + copyZipEntriesToMap("/common.zip", entries, ZipEntryType.COMMON_ARCHIVE); + copyZipEntriesToMap(template.CSS, entries, ZipEntryType.TEMPLATE_CSS); + copyZipEntriesToMap(template.SOURCE_ZIP, entries, ZipEntryType.TEMPLATE_SOURCE); + addLocalizationEntries(entries); + if (options.projectType == ProjectOptions.ProjectType.APP_WITH_BACKEND) { + // What `./gradlew addBackend` writes, plus the backend's own build script + // so there is an obvious place for its dependencies. + addGradleBackendEntries(entries, scaffold, "backend/", ":backend:"); + } + } + copySingleTextEntryToMap("README.md", buildGradleReadmeMarkdown(), entries, ZipEntryType.COMMON); + if (!backendOnly) { + // The skill teaches Codename One UI authoring; a project with no client + // would only be told to run a simulator it does not have. + addAgentSkillEntries(entries); + } + validateGradleProject(entries); + return entries; + } + + private void addGradleBackendEntries(Map entries, Map scaffold, String dir, + String taskPrefix) throws IOException { + putGradleText(entries, dir + "build.gradle.kts", + gradleTemplate(scaffold, "backend/build.gradle.kts.txt", taskPrefix)); + putGradleText(entries, dir + "application.properties", + gradleTemplate(scaffold, "backend/application.properties.txt", taskPrefix)); + putGradleText(entries, dir + "application-dev.properties", + gradleTemplate(scaffold, "backend/application-dev.properties.txt", taskPrefix)); + putGradleText(entries, dir + "src/main/java/" + packageName.replace('.', '/') + "/Api.java", + gradleTemplate(scaffold, "backend/Api.java.txt", taskPrefix)); + putGradleText(entries, dir + "src/main/java/" + packageName.replace('.', '/') + "/Greeter.java", + gradleTemplate(scaffold, "backend/Greeter.java.txt", taskPrefix)); + } + + private void putGradleText(Map entries, String path, String content) throws IOException { + copySingleTextEntryToMap(path, content, entries, ZipEntryType.COMMON); + } + + /// A template from gradle.zip with its tokens filled in, exactly as + /// GradleProjectTemplate fills them for the Maven and Gradle plugins. + /// + /// @param taskPrefix how the backend's tasks are addressed from the root: + /// `:backend:` for a subproject, empty for a backend-only project + private String gradleTemplate(Map scaffold, String name, String taskPrefix) throws IOException { + byte[] data = scaffold.get(name); + if (data == null) { + throw new IOException("Missing Gradle project template " + name); + } + String text = StringUtil.newString(data); + text = StringUtil.replaceAll(text, "__CN1_VERSION__", CN1_PLUGIN_VERSION); + // The same name the Maven reactor gets as its artifactId (cn1app.name). + text = StringUtil.replaceAll(text, "__PROJECT_NAME__", toLowerCaseInvariant(appName)); + text = StringUtil.replaceAll(text, "__BACKEND__", taskPrefix); + text = StringUtil.replaceAll(text, "${package}", packageName); + return text; + } + + /// The application's build.gradle.kts: the template, plus what the chosen + /// template's pom adds -- the Kotlin plugin for a Kotlin project, and the + /// template's dependencies (cn1libs by coordinates, libraries). + String gradleAppBuildScript(String script) { + String[] dependencies = template.GRADLE_DEPENDENCIES; + if (dependencies.length > 0) { + StringBuilder lines = new StringBuilder(); + for (int i = 0; i < dependencies.length; i++) { + lines.append(" ").append(dependencies[i]).append('\n'); + } + script = insertAfter(script, "dependencies {\n", lines.toString() + "\n"); + } + if (template.IS_KOTLIN) { + // plugins {} has to be the first statement of a Kotlin build script, so it + // goes after the leading comment and before dependencies {}. + String plugins = "plugins {\n" + + " kotlin(\"jvm\") version \"" + KOTLIN_VERSION + "\"\n" + + "}\n\n"; + int deps = script.indexOf("dependencies {"); + script = deps < 0 ? plugins + script : script.substring(0, deps) + plugins + script.substring(deps); + } + return script; + } + + private static String insertAfter(String text, String marker, String insertion) { + int pos = text.indexOf(marker); + if (pos < 0) { + return text + "\ndependencies {\n" + insertion + "}\n"; + } + int at = pos + marker.length(); + return text.substring(0, at) + insertion + text.substring(at); + } + + /// The runtime guard for a Gradle download, in the spirit of + /// [validateGeneratedPomCoordinates(Map)]: gradle.zip is assembled from a + /// directory other generators share, so a token the initializr does not know + /// about, or a Maven file that slipped through the mapping, fails closed here + /// instead of reaching users. + void validateGradleProject(Map entries) throws IOException { + String settings = entries.get("settings.gradle.kts") == null ? null + : StringUtil.newString(entries.get("settings.gradle.kts")); + if (settings == null || settings.indexOf("id(\"com.codenameone\") version \"" + CN1_PLUGIN_VERSION + "\"") < 0 + || settings.indexOf("rootProject.name = \"" + toLowerCaseInvariant(appName) + "\"") < 0) { + throw new IOException("Refusing to generate project: settings.gradle.kts does not apply the " + + "Codename One plugin " + CN1_PLUGIN_VERSION + " to " + toLowerCaseInvariant(appName)); + } + if (entries.get("build.gradle.kts") == null || entries.get("gradlew") == null) { + throw new IOException("Refusing to generate project: the Gradle build script or wrapper is missing"); + } + String[] tokens = {"__CN1_VERSION__", "__PROJECT_NAME__", "__BACKEND__", "${package}"}; + for (Map.Entry e : entries.entrySet()) { + String path = e.getKey(); + if (path.startsWith("common/") || path.endsWith("pom.xml") || path.startsWith(".mvn/") + || path.startsWith("mvnw")) { + throw new IOException("Refusing to generate project: Maven file " + path + " in a Gradle project"); + } + if (path.endsWith(".kts") || path.endsWith(".properties") || path.endsWith(".java") + || path.endsWith(".md") || path.endsWith(".xml") || path.endsWith(".json")) { + String text = StringUtil.newString(e.getValue()); + for (int i = 0; i < tokens.length; i++) { + if (text.indexOf(tokens[i]) >= 0) { + throw new IOException("Refusing to generate project: " + path + + " still contains the template token " + tokens[i]); + } + } + } + } + } + + private Map readZipResource(String zipResource) throws IOException { + Map out = new LinkedHashMap(); + InputStream in = getResourceAsStream(zipResource); + if (in == null) { + throw new IOException("Missing resource " + zipResource); + } + try (ZipInputStream zis = new ZipInputStream(in)) { + ZipEntry entry = zis.getNextEntry(); + while (entry != null) { + if (!entry.isDirectory()) { + // Defensive normalization: ant may produce backslashes on Windows. + out.put(StringUtil.replaceAll(entry.getName(), "\\", "/"), readToBytesNoClose(zis)); + } + zis.closeEntry(); + entry = zis.getNextEntry(); + } + } + return out; + } + + /// Refuses a combination of options that has no project to generate. The UI never + /// offers these; the check is here so that no caller (tests, fixtures, a future UI) + /// can produce a download that fails on its first build. + void validateOptions() throws IOException { + if (options.isGradle()) { + if (options.javaVersion != ProjectOptions.JavaVersion.JAVA_17) { + throw new IOException("Gradle projects target Java 17. Choose Java 17, or the Maven build " + + "for a project that must target Java 8."); + } + if (options.projectType != ProjectOptions.ProjectType.BACKEND_ONLY && !template.supportsGradle()) { + throw new IOException(template.GRADLE_UNSUPPORTED_REASON + " Choose the Maven build for this template."); + } + return; + } + if (options.projectType == ProjectOptions.ProjectType.BACKEND_ONLY) { + throw new IOException("A backend-only project is generated for Gradle. Every Maven project already " + + "carries the backend module; build it with -Dcodename1.platform=backend."); + } + } + /// Refuses to publish a generated download when one of the embedded module POMs /// still points at the Initializr application itself, or otherwise drifts from /// the generated root project's Maven coordinates. This is intentionally a @@ -405,7 +641,8 @@ private void addAgentSkillEntries(Map mergedEntries) throws IOEx } // Top-level AGENTS.md so agents that follow the (emerging) AGENTS.md convention // discover the skill without having to know our directory layout. - copySingleTextEntryToMap("AGENTS.md", readResourceToString(AGENTS_MD_RESOURCE), + copySingleTextEntryToMap("AGENTS.md", + readResourceToString(options.isGradle() ? AGENTS_MD_GRADLE_RESOURCE : AGENTS_MD_RESOURCE), mergedEntries, ZipEntryType.COMMON); // Claude Code stub. Frontmatter so the skill shows up in /skills, body redirects // to the canonical vendor-neutral content. @@ -422,7 +659,7 @@ private void addLocalizationEntries(Map mergedEntries) throws IO // anywhere else (e.g. src/main/resources) they are NOT baked into the resource file // and Resources.getGlobalResources().getL10N("messages", lang) returns null at runtime. copySingleTextEntryToMap( - "common/src/main/l10n/messages.properties", + appDir() + "src/main/l10n/messages.properties", readResourceToString("/messages.properties"), mergedEntries, ZipEntryType.COMMON @@ -432,7 +669,7 @@ private void addLocalizationEntries(Map mergedEntries) throws IO continue; } copySingleTextEntryToMap( - "common/src/main/l10n/messages_" + language.bundleSuffix + ".properties", + appDir() + "src/main/l10n/messages_" + language.bundleSuffix + ".properties", readResourceToString("/messages_" + language.bundleSuffix + ".properties"), mergedEntries, ZipEntryType.COMMON @@ -458,6 +695,9 @@ private void copyZipEntriesToMap(String zipResource, Map mergedE private void copyEntryToMap(String sourceName, byte[] sourceData, Map mergedEntries, ZipEntryType zipType) throws IOException { String targetName = mapTargetPath(sourceName, zipType); + if (targetName == null) { + return; + } byte[] targetData = applyDataReplacements(targetName, sourceData); mergedEntries.put(targetName, targetData); } @@ -467,26 +707,43 @@ private void copySingleTextEntryToMap(String targetPath, String content, Map entries) throws IOException { + String[][] tasks = gradleIdeTasks(); + if (ide == IDE.INTELLIJ) { + // gradle.xml links the build, so opening the folder imports it rather than + // treating .idea/ as a plain IntelliJ project with no build attached. + putGradleText(entries, ".idea/gradle.xml", + "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + "\n"); + for (int i = 0; i < tasks.length; i++) { + putGradleText(entries, ".idea/runConfigurations/" + fileNameFor(tasks[i][0]) + ".xml", + intellijGradleRunConfiguration(tasks[i][0], tasks[i][1])); + } + return; + } + if (ide == IDE.VS_CODE) { + StringBuilder json = new StringBuilder(); + json.append("{\n \"version\": \"2.0.0\",\n \"tasks\": [\n"); + for (int i = 0; i < tasks.length; i++) { + json.append(" {\n") + .append(" \"label\": \"").append(tasks[i][0]).append("\",\n") + .append(" \"type\": \"shell\",\n") + .append(" \"command\": \"./gradlew\",\n") + .append(" \"windows\": { \"command\": \".\\\\gradlew.bat\" },\n") + .append(" \"args\": [\"").append(tasks[i][1]).append("\"],\n") + .append(" \"problemMatcher\": []\n") + .append(" }").append(i + 1 < tasks.length ? "," : "").append('\n'); + } + json.append(" ]\n}\n"); + putGradleText(entries, ".vscode/tasks.json", json.toString()); + putGradleText(entries, ".vscode/extensions.json", + "{\n \"recommendations\": [\n" + + " \"vscjava.vscode-java-pack\",\n" + + " \"vscjava.vscode-gradle\"\n" + + " ]\n}\n"); + } + } + + private static String intellijGradleRunConfiguration(String name, String task) { + return "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " true\n" + + " true\n" + + " false\n" + + " false\n" + + " \n" + + " \n" + + "\n"; + } + + private static String fileNameFor(String label) { + StringBuilder out = new StringBuilder(label.length()); + for (int i = 0; i < label.length(); i++) { + char c = label.charAt(i); + boolean keep = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + out.append(keep ? c : '_'); + } + return out.toString(); + } + + private String buildGradleReadmeMarkdown() { + boolean backendOnly = options.projectType == ProjectOptions.ProjectType.BACKEND_ONLY; + StringBuilder out = new StringBuilder(); + out.append("# Codename One Project\n\n"); + if (backendOnly) { + out.append("This is a Gradle project for a Codename One backend: a server written in Java, ") + .append("run on the JVM while you develop and packaged as a single native binary.\n\n"); + } else { + out.append("This is a Gradle project for a Codename One app. ") + .append("You can write the app in Java and/or Kotlin, and build for Android, iOS, desktop, and web.\n\n"); + } + out.append("The only Codename One line the build needs is the `com.codenameone` plugin in ") + .append("`settings.gradle.kts`. Bump its version there, or run `./gradlew cn1Update`.\n\n") + .append("## Getting Started\n\n") + .append("Use JDK 17 or newer, and set JAVA_HOME for terminal builds. Gradle projects target Java 17. ") + .append("Extract the entire ZIP, then open the folder in your IDE as a Gradle project.\n\n"); + String first = backendOnly ? "runBackend" : "run"; + out.append("macOS/Linux:\n\n```\n./gradlew ").append(first).append("\n```\n\n") + .append("Windows PowerShell or Command Prompt:\n\n```\n.\\gradlew.bat ").append(first).append("\n```\n\n") + .append("The first build downloads Gradle and the dependencies and can take several minutes. ") + .append("If a download fails, check your connection or Gradle proxy settings and retry.\n\n"); + + if (backendOnly) { + out.append("## Tasks\n\n") + .append("- `./gradlew runBackend` runs the server on this JVM; it starts in seconds.\n") + .append("- `CN1_PROFILE=dev ./gradlew runBackend` also reads `application-dev.properties` ") + .append("(an in-memory database).\n") + .append("- `./gradlew backendPackage` builds a single native binary.\n") + .append("- `./gradlew tasks` lists everything else.\n\n") + .append("Routes are the annotated methods in `src/main/java/") + .append(packageName.replace('.', '/')).append("/Api.java`. Settings live in ") + .append("`application.properties` beside the build files.\n\n"); + } else { + out.append("## Tasks\n\n") + .append("- `./gradlew run` runs the app in the simulator; `./gradlew debug` waits for a debugger on port 5005.\n") + .append("- `./gradlew buildAndroid`, `./gradlew buildIos` and `./gradlew buildIosRelease` send ") + .append("device builds to the build server.\n") + .append("- `./gradlew buildJavascriptLocal` builds the web app locally; `./gradlew buildJavascript` ") + .append("submits a hosted build.\n") + .append("- `./gradlew buildAndroidGradleProject` and `./gradlew buildIosXcodeProject` generate native ") + .append("IDE projects locally.\n") + .append("- `./gradlew buildMacDesktop` and `./gradlew buildWindowsDesktop` build desktop apps.\n") + .append("- `./gradlew settings` opens Codename One Settings; `./gradlew tasks` lists everything else.\n\n") + .append("Complete the browser login when a build asks for it. Local tasks such as `run` and ") + .append("`buildJavascriptLocal` do not create a cloud build.\n\n") + .append("## Libraries\n\n") + .append("Declare Codename One libraries (cn1libs) by their Maven coordinates in `build.gradle.kts`:\n\n") + .append("```\ndependencies {\n cn1lib(\"com.codenameone:googlemaps-lib:1.0\")\n}\n```\n\n") + .append("Legacy `.cn1lib` files are not supported by the Gradle build.\n\n"); + if (options.projectType == ProjectOptions.ProjectType.APP_WITH_BACKEND) { + out.append("## Backend\n\n") + .append("`backend/` is the app's server. Run it with `./gradlew :backend:runBackend` and ") + .append("package it with `./gradlew :backend:backendPackage`.\n\n"); + } else { + out.append("## Backend\n\n") + .append("`./gradlew addBackend` adds a `backend/` server project to this build.\n\n"); + } + } + + appendGradleIdeSection(out); + + if (!backendOnly) { + out.append("## Signing\n\n") + .append("Use the Certificate Wizard to configure Apple signing assets, Android keystores, ") + .append("and desktop signing settings:\n\n") + .append("```\n./gradlew certificateWizard\n```\n\n"); + } + out.append("## Help and Support\n\n") + .append("- Codename One website: https://www.codenameone.com\n") + .append("- Codename One GitHub: https://github.com/codenameone/CodenameOne\n"); + return out.toString(); + } + + private void appendGradleIdeSection(StringBuilder out) { + if (ide == IDE.INTELLIJ) { + out.append("## IntelliJ Users\n\n") + .append("Open the project folder; IntelliJ imports the Gradle build. The Run menu lists ") + .append("configurations for the common tasks, and the Gradle tool window has the rest.\n\n"); + return; + } + if (ide == IDE.ECLIPSE) { + out.append("## Eclipse Users\n\n") + .append("Choose File > Import > Gradle > Existing Gradle Project and select this folder. ") + .append("Eclipse's Gradle support (Buildship) imports the build; run tasks from the ") + .append("Gradle Tasks view.\n\n"); + return; + } + if (ide == IDE.NETBEANS) { + out.append("## NetBeans Users\n\n") + .append("Choose File > Open Project and select this folder. NetBeans opens Gradle builds ") + .append("directly; run tasks from the project's Navigator or context menu.\n\n"); + return; + } + out.append("## VS Code Users\n\n") + .append("Install the recommended extensions (Extension Pack for Java, Gradle for Java), then use ") + .append("Terminal > Run Task for the common tasks.\n\n"); + } + public static String buildThemeOverrides(ProjectOptions options) { ProjectOptions effective = options == null ? ProjectOptions.defaults() : options; String customCss = normalizeCustomCss(effective.customThemeCss); @@ -1097,6 +1577,7 @@ private static boolean isTextFile(String path) { || path.endsWith(".bat") || path.endsWith(".cmd") || path.endsWith(".sh") + || path.endsWith(".kts") || "mvnw".equals(path); } diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java index 2ae1e3bb2cd..ff3768dd53a 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java @@ -1,3 +1,25 @@ +/* + * 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.initializr.model; public final class ProjectOptions { @@ -59,6 +81,47 @@ public String toString() { } } + /// The build tool the generated project uses. Maven is the default and the + /// only one that offers Java 8; Gradle projects are single-project builds + /// driven by the `com.codenameone` Gradle plugin and always target Java 17. + public enum BuildTool { + MAVEN("Maven"), + GRADLE("Gradle"); + + public final String label; + + BuildTool(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } + + /// What the generated project holds. Only a Gradle project has a choice: a + /// Maven download always carries the backend module behind the `backend` + /// profile (it builds only when asked for, so an app pays nothing for it), + /// which makes APP and APP_WITH_BACKEND the same download there, and there is + /// no backend-only Maven scaffold -- GeneratorModel refuses that combination. + public enum ProjectType { + APP("App"), + APP_WITH_BACKEND("App + backend"), + BACKEND_ONLY("Backend only"); + + public final String label; + + ProjectType(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } + public final ThemeMode themeMode; public final Accent accent; public final boolean roundedButtons; @@ -66,6 +129,8 @@ public String toString() { public final PreviewLanguage previewLanguage; public final JavaVersion javaVersion; public final String customThemeCss; + public final BuildTool buildTool; + public final ProjectType projectType; public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons, boolean includeLocalizationBundles, PreviewLanguage previewLanguage, @@ -76,6 +141,14 @@ public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons, boolean includeLocalizationBundles, PreviewLanguage previewLanguage, JavaVersion javaVersion, String customThemeCss) { + this(themeMode, accent, roundedButtons, includeLocalizationBundles, previewLanguage, javaVersion, + customThemeCss, BuildTool.MAVEN, ProjectType.APP); + } + + public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons, + boolean includeLocalizationBundles, PreviewLanguage previewLanguage, + JavaVersion javaVersion, String customThemeCss, + BuildTool buildTool, ProjectType projectType) { this.themeMode = themeMode; this.accent = accent; this.roundedButtons = roundedButtons; @@ -83,6 +156,18 @@ public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons this.previewLanguage = previewLanguage == null ? PreviewLanguage.ENGLISH : previewLanguage; this.javaVersion = javaVersion == null ? JavaVersion.JAVA_17 : javaVersion; this.customThemeCss = customThemeCss; + this.buildTool = buildTool == null ? BuildTool.MAVEN : buildTool; + this.projectType = projectType == null ? ProjectType.APP : projectType; + } + + /// A copy of these options with a different build tool and project type. + public ProjectOptions withBuild(BuildTool buildTool, ProjectType projectType) { + return new ProjectOptions(themeMode, accent, roundedButtons, includeLocalizationBundles, previewLanguage, + javaVersion, customThemeCss, buildTool, projectType); + } + + public boolean isGradle() { + return buildTool == BuildTool.GRADLE; } public static ProjectOptions defaults() { diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectZipPermissions.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectZipPermissions.java index 216b641fad5..f12cca78fc1 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectZipPermissions.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectZipPermissions.java @@ -49,7 +49,9 @@ static void apply(byte[] zip) throws IOException { int next = offset + 46 + nameLength + readShort(zip, offset + 30) + readShort(zip, offset + 32); if (next > end) throw new IOException("Truncated project ZIP entry"); String name = new String(zip, offset + 46, nameLength, "UTF-8"); - boolean executable = "build.sh".equals(name) || "run.sh".equals(name) || "mvnw".equals(name); + // The launchers and wrappers, Maven's and Gradle's, must extract executable. + boolean executable = "build.sh".equals(name) || "run.sh".equals(name) || "mvnw".equals(name) + || "gradlew".equals(name); int mode = name.endsWith("/") ? 040755 : (executable ? 0100755 : 0100644); zip[offset + 5] = 3; // version-made-by host: Unix int attributes = (mode << 16) | (name.endsWith("/") ? 0x10 : 0); diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java index 3a81b6cc7a3..b8a9f3d8238 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java @@ -1,10 +1,66 @@ +/* + * 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.initializr.model; public enum Template { - BAREBONES(false, false, "/barebones-src.zip", "/barebones-css.zip", "/barebones-pom.xml", null, "com.example.myapp", "MyAppName", null), - KOTLIN(true, false, "/kotlin-src.zip", "/barebones-css.zip", "/kotlin-pom.xml", null, "com.example.myapp", "MyAppName", null), - GRUB(false, true, "/grub-src.zip", "/grub-css.zip", "/grub-pom.xml", "/grub-cn1libs.zip", "com.codename1.demos.grub", "Grub", "grub.png"), - TWEET(false, true, "/tweet-src.zip", "/tweet-css.zip", "/tweet-pom.xml", null, "com.example.myapp", "MyAppName", "tweet.png"); + BAREBONES(false, false, "/barebones-src.zip", "/barebones-css.zip", "/barebones-pom.xml", null, "com.example.myapp", "MyAppName", null, + null, new String[0]), + // The same two libraries kotlin-pom.xml's kotlin profile declares (at the Gradle + // Kotlin version, see GeneratorModel.KOTLIN_VERSION). The Kotlin Gradle plugin + // itself is added to the plugins {} block by GeneratorModel. + KOTLIN(true, false, "/kotlin-src.zip", "/barebones-css.zip", "/kotlin-pom.xml", null, "com.example.myapp", "MyAppName", null, + null, new String[] { + "implementation(\"org.jetbrains.kotlin:kotlin-stdlib:" + GeneratorModel.KOTLIN_VERSION + "\")", + "implementation(\"org.jetbrains:annotations:13.0\")" + }), + // Grub is written against CodeRAD 1 (its models extend the CodeRAD 1 Entity + // class, which CodeRAD 2 turned into an interface), and that CodeRAD is the + // bundled grub-cn1libs.zip build -- a jar with no published coordinates. A + // Gradle project consumes cn1libs by Maven coordinates only, so there is + // nothing to point cn1lib(...) at. + GRUB(false, true, "/grub-src.zip", "/grub-css.zip", "/grub-pom.xml", "/grub-cn1libs.zip", "com.codename1.demos.grub", "Grub", "grub.png", + "Grub needs the bundled CodeRAD 1 library, which is not published to a Maven repository; " + + "Gradle projects can only use cn1libs by Maven coordinates.", + new String[0]), + // Tweet's cn1libs are published, so its pom's dependencies translate directly, + // and the Gradle plugin's generateGuiSources turns src/main/rad/views into the + // Abstract* view classes. What stops it is the next step: the classes its sources + // import (SignupPage, *ModelWrapper, *Controller) are written by + // coderad-annotation-processor 2.0.5 inside javac, and that processor locates the + // project by walking up from user.dir for a pom.xml + // (HelperFunctions.findPom(new File(System.getProperty("user.dir"))) then + // .getParentFile()) -- under Gradle there is none, so compileJava fails with a + // NullPointerException. Measured against the 8.0-SNAPSHOT plugin. Clear the + // reason once CodeRAD's processor can find a Gradle project; the lines below are + // already the right dependencies. + TWEET(false, true, "/tweet-src.zip", "/tweet-css.zip", "/tweet-pom.xml", null, "com.example.myapp", "MyAppName", "tweet.png", + "The Tweet template's CodeRAD annotation processor (2.0.5) finds its project through a pom.xml, " + + "so it cannot run in a Gradle build yet.", + new String[] { + "cn1lib(\"com.codenameone:coderad-lib:2.0.5\")", + "cn1lib(\"com.codenameone:tweet-app-ui-kit-lib:1.0-pre1\")", + "annotationProcessor(\"com.codenameone:coderad-annotation-processor:2.0.5\")" + }); public final String IMAGE_NAME; public final boolean IS_KOTLIN; @@ -15,9 +71,14 @@ public enum Template { public final String CN1LIB_ZIP; public final String SOURCE_PACKAGE; public final String SOURCE_MAIN_CLASS; + /// Why this template cannot be generated as a Gradle project, or null when it can. + public final String GRADLE_UNSUPPORTED_REASON; + /// Kotlin DSL lines for the `dependencies {}` block of a Gradle project's + /// build.gradle.kts -- the Gradle form of what the template's pom declares. + public final String[] GRADLE_DEPENDENCIES; Template(boolean isKotlin, boolean usesCodeRad, String sourceZip, String css, String pomXml, String cn1libZip, String sourcePackage, String sourceMainClass, - String imageName) { + String imageName, String gradleUnsupportedReason, String[] gradleDependencies) { IS_KOTLIN = isKotlin; USES_CODERAD = usesCodeRad; SOURCE_ZIP = sourceZip; @@ -27,5 +88,11 @@ public enum Template { SOURCE_PACKAGE = sourcePackage; SOURCE_MAIN_CLASS = sourceMainClass; IMAGE_NAME = imageName; + GRADLE_UNSUPPORTED_REASON = gradleUnsupportedReason; + GRADLE_DEPENDENCIES = gradleDependencies; + } + + public boolean supportsGradle() { + return GRADLE_UNSUPPORTED_REASON == null; } } diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ZipEntryType.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ZipEntryType.java index 2af39fcd633..8f583ad943d 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ZipEntryType.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ZipEntryType.java @@ -1,8 +1,31 @@ +/* + * 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.initializr.model; enum ZipEntryType { IDE, COMMON, + COMMON_ARCHIVE, TEMPLATE_POM, TEMPLATE_CN1LIB, TEMPLATE_CSS, diff --git a/scripts/initializr/common/src/main/resources/agent-skill-agents-md-gradle.md b/scripts/initializr/common/src/main/resources/agent-skill-agents-md-gradle.md new file mode 100644 index 00000000000..79a3ce23d2c --- /dev/null +++ b/scripts/initializr/common/src/main/resources/agent-skill-agents-md-gradle.md @@ -0,0 +1,41 @@ +# AGENTS.md + +This project is a Codename One cross-platform mobile app (Java 17 / Gradle / +ParparVM-iOS / Android / JavaScript / desktop). It is a single Gradle project +built by the `com.codenameone` plugin declared in `settings.gradle.kts`. A +vendor-neutral authoring skill is bundled in this repository for any AI agent: + +- **Start here:** `.agent-skills/codename-one/SKILL.md` +- **Topical references:** `.agent-skills/codename-one/references/` +- **Runnable utilities (Java 17 single-file source mode):** `.agent-skills/codename-one/tools/` + +Tool integrations (Claude Code, Cursor, etc.) may also pick this skill up via +their own conventions; the canonical source of truth is `.agent-skills/`. + +This is the **Gradle** layout. Wherever the skill shows a Maven command or a +`common/` path, use the Gradle column of its tables: there is no `common/` +module, the app lives at the project root, and every command is a `./gradlew` +task. + +## Quick orientation for an agent + +- App source lives in `src/main/java/` (Kotlin in `src/main/kotlin/`). +- Theme/styling lives in `src/main/css/theme.css` (Codename One CSS - a + deliberate subset, see `.agent-skills/codename-one/references/css.md`). +- Build hints and app metadata live in `codenameone_settings.properties` at the root. +- Run the simulator with `./gradlew run`. +- Run tests with `./gradlew cn1Test` (on Linux CI use `xvfb-run -a`). +- You can drive the RUNNING simulator yourself over MCP (read the screen, type, + tap) - see `.agent-skills/codename-one/references/mcp-agent-control.md`. +- A bug that only reproduces on an Android phone or an iPhone is still debuggable: + attach a Java debugger to the device build and drive it over MCP the same way; + see `.agent-skills/codename-one/references/on-device-debugging.md`. +- Native cloud builds are tasks: `./gradlew buildAndroid`, `./gradlew buildIos`, + `./gradlew buildJavascript`, `./gradlew buildMacDesktop`; `./gradlew tasks` lists them all. +- cn1libs are declared by Maven coordinates in `build.gradle.kts`: + `dependencies { cn1lib("group:artifact-lib:version") }`. +- A `backend/` directory, when present, is the app's server: + `./gradlew :backend:runBackend`. `./gradlew addBackend` creates one. + +When in doubt, open `.agent-skills/codename-one/SKILL.md` and follow the +reference table at the bottom. diff --git a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md index 0e383520c0b..4afe68eaf57 100644 --- a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md +++ b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md @@ -1,6 +1,6 @@ --- name: codename-one -description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` module (`cn1:backend`, `com.codename1.backend.*`, `@RestController`, `@Service`, `@Transactional`), or when the user asks to build a UI, write screen tests, generate screenshots, build a server or full-stack feature, or compare to Swing/HTML/Android. +description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven or Gradle, ParparVM/Android/iOS/JavaScript). Use when the project contains a `codenameone_settings.properties` (in `common/` for Maven, at the root for Gradle), a `settings.gradle.kts` applying the `com.codenameone` plugin, depends on `com.codenameone:codenameone-core`, edits CSS files under `src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build` or `./gradlew run`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` project (`cn1:backend`, `./gradlew runBackend`, `com.codename1.backend.*`, `@RestController`, `@Service`, `@Transactional`), or when the user asks to build a UI, write screen tests, generate screenshots, build a server or full-stack feature, or compare to Swing/HTML/Android. metadata: type: skill --- diff --git a/scripts/initializr/common/src/main/resources/common.zip b/scripts/initializr/common/src/main/resources/common.zip index eec64cf9c49..54a6d2a95a6 100644 Binary files a/scripts/initializr/common/src/main/resources/common.zip and b/scripts/initializr/common/src/main/resources/common.zip differ diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 9bcc8b358e4..75ba2b06a90 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: codename-one -description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` module (`cn1:backend`, `com.codename1.backend.*`, `@RestController`, `@Service`, `@Transactional`, `@Scheduled`), or when the user asks to build a UI, write screen tests, generate screenshots, build a server or full-stack feature, or compare to Swing/HTML. +description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven or Gradle, ParparVM/Android/iOS/JavaScript). Use when the project contains a `codenameone_settings.properties` (in `common/` for Maven, at the root for Gradle), a `settings.gradle.kts` applying the `com.codenameone` plugin, depends on `com.codenameone:codenameone-core`, edits CSS files under `src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build` or `./gradlew run`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` project (`cn1:backend`, `./gradlew runBackend`, `com.codename1.backend.*`, `@RestController`, `@Service`, `@Transactional`, `@Scheduled`), or when the user asks to build a UI, write screen tests, generate screenshots, build a server or full-stack feature, or compare to Swing/HTML. metadata: type: skill --- @@ -12,8 +12,8 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform **Use this skill when**: - A file you are editing imports `com.codename1.ui.*`, `com.codename1.io.*`, `com.codename1.testing.*`, or extends `com.codename1.system.Lifecycle`. -- You are editing a file in `common/src/main/css/` (CN1 CSS). -- You are running `cn1:run`, `cn1:debug`, `cn1:test`, or `cn1:build` Maven goals. +- You are editing a file in `src/main/css/` (CN1 CSS) -- `common/src/main/css/` in a Maven project. +- You are running `cn1:run`, `cn1:debug`, `cn1:test`, or `cn1:build` Maven goals, or `./gradlew run`, `debug`, `cn1Test`, `buildAndroid`, `buildIos`, ... Gradle tasks. - The user asks for a UI screen, a screenshot test, a responsive layout, or wants to convert a Swing/HTML snippet to CN1. ## How this skill is organized @@ -50,9 +50,64 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform When the user's task hits any one of those topics, **read the matching reference before generating code**. Do not paste large snippets without checking. -## Project layout (multi-module Maven) +## Step 0: detect the build tool -A CN1 project generated by the initializr has these modules: +A Codename One project is built with **Maven** or with **Gradle**, and the two lay the app out differently. Check before running anything or quoting a path: + +- **Gradle** -- a `settings.gradle.kts` (or `build.gradle.kts`) that names the `com.codenameone` plugin, `gradlew` at the root, `codenameone_settings.properties` at the root, and **no** `pom.xml`. +- **Maven** -- a root `pom.xml` and a `common/` directory holding `codenameone_settings.properties`, plus `mvnw`. + +Every path in this skill and its references is written relative to the **app directory**: `common/` in a Maven project, the project root in a Gradle project. So `src/main/css/theme.css` means `common/src/main/css/theme.css` under Maven and `src/main/css/theme.css` under Gradle. Where a reference shows only a Maven command, translate it with the table below. + +| To... | Maven (from the project root) | Gradle (from the project root) | +| --- | --- | --- | +| Run in the simulator | `mvn -pl common cn1:run` | `./gradlew run` | +| Run with a debugger (port 5005) | `mvn -pl common cn1:debug` | `./gradlew debug` | +| Run the `AbstractTest` runner | `mvn -pl common cn1:test` | `./gradlew cn1Test` | +| Run JUnit tests | `mvn test` | `./gradlew test` | +| Compile the CSS theme | `mvn -pl common cn1:css` (runs with every build) | `./gradlew cn1Css` | +| Android cloud build | `mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device` | `./gradlew buildAndroid` | +| iOS cloud build (debug / App Store) | `mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device` (`ios-device-release`) | `./gradlew buildIos` / `./gradlew buildIosRelease` | +| Xcode / Android Studio project, locally | `-Dcodename1.buildTarget=ios-source` / `android-source` | `./gradlew buildIosXcodeProject` / `./gradlew buildAndroidGradleProject` | +| Web app, locally / in the cloud | `mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript` (`javascript`) | `./gradlew buildJavascriptLocal` / `./gradlew buildJavascript` | +| Desktop app (JVM) | `-Dcodename1.buildTarget=mac-os-x-desktop` / `windows-desktop` | `./gradlew buildMacDesktop` / `./gradlew buildWindowsDesktop` | +| Native desktop | `windows-device` / `linux-device` / `mac-os-x-native` | `./gradlew buildWindowsDevice` / `buildLinuxDevice` / `buildMacNative` | +| Any other build target | `-Dcodename1.platform=

-Dcodename1.buildTarget=` | `./gradlew cn1Build -Pcodename1.platform=

-Pcodename1.buildTarget=` | +| Regenerate GUI builder / CodeRAD view sources | `mvn -pl common generate-sources` | `./gradlew generateGuiSources` (also runs before every compile) | +| Generate native interface stubs | `mvn cn1:generate-native-interfaces` | `./gradlew generateNativeInterfaces` | +| Codename One Settings / GUI builder / Game Builder / Certificate Wizard | `mvn cn1:settings` / `cn1:guibuilder` / `cn1:gamebuilder` / `cn1:certificatewizard` | `./gradlew settings` / `guibuilder` / `gameBuilder` / `certificateWizard` | +| Update Codename One | `mvn cn1:update` | `./gradlew cn1Update` | +| Add a cn1lib | a `` in `common/pom.xml` | `cn1lib("group:artifact-lib:version")` in `build.gradle.kts` | +| Run the app's backend | `mvn -pl backend -Dcodename1.platform=backend cn1:backend` | `./gradlew :backend:runBackend` (`./gradlew addBackend` creates it) | +| Package the backend as a native binary | `mvn -pl backend -Dcodename1.platform=backend cn1:backend-package` | `./gradlew :backend:backendPackage` | + +`./gradlew tasks` lists every task. A `-Dcodename1.arg.=` override on a Maven command is `-Pcodename1.arg.=` on a Gradle task. On Windows use `.\gradlew.bat` (or `mvnw.cmd`). + +## Project layout + +### Gradle (single project) + +``` +my-app/ +├── settings.gradle.kts # plugins { id("com.codenameone") version "..." } -- the version is pinned here. +├── build.gradle.kts # The app's dependencies, including cn1lib("group:artifact:version"). +├── gradle.properties +├── gradlew, gradlew.bat, gradle/wrapper/ +├── codenameone_settings.properties +├── icon.png +├── src/main/ +│ ├── java//.java # THIS IS WHERE THE APP LIVES (Kotlin: src/main/kotlin/). +│ ├── css/theme.css # CN1 CSS (NOT regular web CSS - see references/css.md) +│ ├── l10n/ # i18n bundles (NOT src/main/resources!) +│ └── resources/ +├── src/test/java/ +├── src/// # Native interface implementations; created by generateNativeInterfaces. +└── backend/ # Optional server subproject (./gradlew addBackend). +``` + +Gradle projects always target Java 17; the plugin sets the compiler release itself. + +### Maven (multi-module) ``` my-app/ @@ -78,7 +133,7 @@ my-app/ ## Java version and language features -This project targets **Java 17** (`17` / `17` in `common/pom.xml`, plus `codename1.arg.java.version=17` in `codenameone_settings.properties`). Use: +This project targets **Java 17** (`17` / `17` in `common/pom.xml` under Maven, set by the plugin under Gradle, plus `codename1.arg.java.version=17` in `codenameone_settings.properties`). Use: - `var` for local variable type inference - Text blocks (`"""..."""`) @@ -87,7 +142,7 @@ This project targets **Java 17** (`17` / `17` - `switch` expressions - Lambdas, method references, `Stream`s -**Caveat — the build server cross-compiles to bytecode that ParparVM/TeaVM can consume.** Codename One ships a curated subset of the JDK, **not** the full `java.*` namespace. The `cn1:bytecode-compliance` Maven goal runs on every compile and fails the build if you call an unsupported API. The most common gotchas: +**Caveat — the build server cross-compiles to bytecode that ParparVM/TeaVM can consume.** Codename One ships a curated subset of the JDK, **not** the full `java.*` namespace. The bytecode compliance check (`cn1:bytecode-compliance` under Maven; part of every build under Gradle) runs on every compile and fails the build if you call an unsupported API. The most common gotchas: - No `java.nio.file.*` — use `com.codename1.io.FileSystemStorage` and `Storage`. - No `java.net.http.*` / `java.net.URLConnection` — use `com.codename1.io.rest.Rest` (preferred) or `ConnectionRequest`. @@ -157,7 +212,7 @@ f.show(); ## CSS in Codename One -CN1 ships with a CSS compiler that bakes `common/src/main/css/theme.css` into the binary theme resource (`theme.res`). It supports a deliberate **subset** of web CSS: +CN1 ships with a CSS compiler that bakes `src/main/css/theme.css` (`common/src/main/css/theme.css` under Maven) into the binary theme resource (`theme.res`). It supports a deliberate **subset** of web CSS: ```css Form { @@ -209,7 +264,7 @@ See `references/mobile-adaptability.md` for patterns: phone-vs-tablet master-det CN1 supports two compatible test styles in the same project: -1. **Legacy `AbstractTest` + `cn1:test`.** Required for tests that must also run on a device (`mvn cn1:test -Dtarget=ios`). Compiles under the device subset (no reflection, no JavaSE APIs). See `references/testing-and-screenshots.md`. +1. **Legacy `AbstractTest` + `cn1:test`** (`./gradlew cn1Test` under Gradle). Required for tests that must also run on a device (`mvn cn1:test -Dtarget=ios`). Compiles under the device subset (no reflection, no JavaSE APIs). See `references/testing-and-screenshots.md`. 2. **Standard JUnit 5 + `@CodenameOneTest`.** Runs only in the simulator JVM via Surefire, so you get reflection, Mockito, AssertJ, IDE green-bar integration, `-Dtest=Foo#bar` filtering. Faster startup. See `references/junit-testing.md`. Both runners coexist — `cn1:test` discovers `UnitTest` implementers, Surefire discovers `@Test` methods, they don't trip over each other. Pick per test class. @@ -228,7 +283,7 @@ public class LoginFormTest extends AbstractTest { } } -// JUnit 5 -- simulator-only, runs via `mvn test` / Surefire. +// JUnit 5 -- simulator-only, runs via `mvn test` / Surefire (`./gradlew test`). @CodenameOneTest class GreetingFormTest { @Test @@ -240,7 +295,7 @@ class GreetingFormTest { } ``` -Run with `mvn -pl common cn1:test` (cn1:test runner only) or `mvn test` (both runners). The cn1app archetype already wires up Surefire + JUnit Jupiter in the generated POMs. +Run with `mvn -pl common cn1:test` (cn1:test runner only) or `mvn test` (both runners); under Gradle, `./gradlew cn1Test` and `./gradlew test`. The cn1app archetype already wires up Surefire + JUnit Jupiter in the generated POMs. `screenshotTest(name)` captures the current form, compares against a stored baseline under `Storage`, and returns `true` if within tolerance. First run records the baseline. See `references/testing-and-screenshots.md` for the tolerance algorithm and how to validate UI you just wrote. @@ -250,7 +305,18 @@ Run with `mvn -pl common cn1:test` (cn1:test runner only) or `mvn test` (both ru ## Build and run commands -From the project root: +From the project root. **Gradle:** + +```bash +./gradlew run # the desktop simulator (JDK 17+) +./gradlew debug # suspended, waiting for a debugger on port 5005 +./gradlew cn1Test # the CN1 test runner +./gradlew buildAndroid # cloud builds (require CN1 build server creds) +./gradlew buildIos +./gradlew buildJavascriptLocal # web bundle, built locally; buildJavascript uses the cloud +``` + +**Maven:** ```bash # Run in the desktop simulator (requires JDK 11–25 at runtime; build still uses JDK 17 source level) @@ -283,7 +349,7 @@ See `references/build-and-run.md` for the local-vs-cloud matrix, automated-build - Don't use `java.awt.Color` / `java.awt.Font` / `javax.swing.*` — CN1 has its own `Color` constants (just `int` ARGB), `Font.createTrueTypeFont`, and `Component` hierarchy. - Don't add CSS that references web-only properties (`display`, `flex`, `position`, `transform`, `@media`) — the CN1 CSS compiler will silently ignore them or fail. -- Don't put localization bundles under `common/src/main/resources/`. The CN1 plugin scans `common/src/main/l10n/` (or `common/src/main/i18n/`); bundles placed anywhere else are NOT baked into `theme.res` and `Resources.getL10N("messages", lang)` returns `null` at runtime. +- Don't put localization bundles under `src/main/resources/`. The CN1 plugin scans `src/main/l10n/` (or `src/main/i18n/`) of the app directory (`common/` under Maven); bundles placed anywhere else are NOT baked into `theme.res` and `Resources.getL10N("messages", lang)` returns `null` at runtime. - Don't spin up `new Thread(...)` for UI work — use `Display.getInstance().callSerially(...)` or `Display.startThread(...)`. - Don't mutate UI off the EDT. Symptoms: random repaint glitches, native crashes on iOS. - Don't write screenshot tests where the baseline was just generated by the same code you are validating — that proves nothing. @@ -292,7 +358,7 @@ See `references/build-and-run.md` for the local-vs-cloud matrix, automated-build For any UI-altering change: -1. Run `mvn -pl common cn1:run` in the simulator and click through the changed flow. +1. Run the simulator (`./gradlew run`, or `mvn -pl common cn1:run` under Maven) and click through the changed flow. 2. Inspect at least one screenshot (capture via the simulator menu → Save Screenshot, or generate via a test). 3. Resize the simulator window or toggle a different skin to confirm the layout doesn't break on a different form factor. 4. If you wrote a `screenshotTest`, delete the auto-generated baseline once if the screen has changed, then re-run twice — the second run should pass with `true`. diff --git a/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index 0af0fd2a365..9d0edc8d747 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -20,6 +20,17 @@ mvn -pl backend -Dcodename1.platform=backend cn1:backend-package `-Dcodename1.platform=backend` is required: the module sits in a Maven profile. +In a **Gradle** project the same two commands are tasks. The backend is the +`backend` subproject of an app (`./gradlew addBackend` creates it), or the root +project of a backend-only application, where you drop the `:backend:` prefix: + +```bash +CN1_PROFILE=dev ./gradlew :backend:runBackend +./gradlew :backend:backendPackage +``` + +Everything else in this file applies to both build tools. + ## Rules that differ from a normal Java server - **Java 8 language level, backend class library only.** The native build compiles @@ -220,7 +231,7 @@ Custom instruments: `Metrics.counter(...)`, `Metrics.histogram(...)`, `OTEL_EXPORTER_OTLP_ENDPOINT` at a collector. - Management endpoints: `/manage/health`, `/manage/metrics`, `/manage/prometheus`, `/manage/jobs`, `/manage/managed`, and `POST /manage/managed/{bean}/{operation}`. - Always present in the `cn1:backend` dev run. A **packaged** binary contains them + Always present in the `cn1:backend` / `runBackend` dev run. A **packaged** binary contains them only if the build asked -- `@EnableManagement` on a class or `cn1.management.enabled=true` in a properties file -- otherwise the code is not in the binary at all. Outside dev they also need `cn1.management.token` (env). @@ -228,7 +239,7 @@ Custom instruments: `Metrics.counter(...)`, `Metrics.histogram(...)`, ## MCP: the running backend as a tool server -On the dev profile, `cn1:backend` serves MCP at `http://127.0.0.1:8080/mcp` and +On the dev profile, `cn1:backend` (Gradle: `runBackend`) serves MCP at `http://127.0.0.1:8080/mcp` and prints the URL at start-up. Register it once: ```bash @@ -306,6 +317,7 @@ The `test` profile is a development profile: in-memory SQLite, tables created. ## Loop before reporting "done" 1. `CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend` + (Gradle: `CN1_PROFILE=dev ./gradlew :backend:runBackend`) 2. `backend_routes` / `backend_beans` — the wiring is what you meant. 3. `backend_call` each changed endpoint; `backend_requests` with `failuresOnly`. 4. `backend_sql` to confirm what was written. diff --git a/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md b/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md index 7965ce7a0a9..5356ce402ad 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md +++ b/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md @@ -2,18 +2,22 @@ ## Local builds vs cloud builds +> **Maven or Gradle?** A project with `settings.gradle.kts` + `gradlew` and no `pom.xml` is a Gradle project: the app lives at the root instead of in `common/`, and every command below is a `./gradlew` task (see the "Gradle task" column and the Gradle cheat sheet). Everything else in this file applies to both. + A Codename One project can produce four kinds of artifacts. Some build entirely on your machine; others rely on the Codename One **build server** (cloud) because cross-compiling iOS/Android natively on every developer's box would be unrealistic. -| Output | Where it builds | Maven goal | -| --- | --- | --- | -| Desktop simulator | Local (your machine, JVM only) | `mvn -pl common cn1:run` / `cn1:debug` | -| Unit tests | Local (the CN1 test runner inside a JVM) | `mvn -pl common cn1:test` | -| Standalone desktop app (`.jar` + bundled JRE for Mac/Win/Linux) | Cloud (build server packages a JRE for each OS) | `mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=mac-os-x-desktop` (or `windows-desktop`, `linux-desktop`) | -| Android APK / AAB | Cloud by default; **also** locally if you run `cn1:install-android-sdk` and use the local Android build path | `mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device` | -| iOS app | Cloud, **or** locally as an Xcode project via `ios-source` | `mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device` (cloud) or `…-Dcodename1.buildTarget=ios-source` (local Xcode project) | -| Mac Native app (AOT-compiled, same pipeline as iOS) | Cloud, **or** locally as an Xcode project via `mac-source` | `mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-os-x-native` (cloud) or `…-Dcodename1.buildTarget=mac-source` (local Xcode project) | -| Native Windows `.exe` (`win32`, ParparVM → clang-cl, no JVM) | Cloud (Linux build server cross-compiles); **also** locally on Windows, or as a project via `windows-source` | `mvn -pl common package -Dcodename1.platform=windows -Dcodename1.buildTarget=windows-device` (cloud) or `…-Dcodename1.buildTarget=local-windows-device` (local). A regular build returns x64 + arm64 release exes; add the `windows.debug` build hint for a single x64 debug exe. | -| JavaScript / web bundle | Local (ParparVM to JavaScript translator, available to all users). Cloud builds use ParparVM by default and accept `javascript.port=teavm` as a compatibility fallback. | `mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript` | +| Output | Where it builds | Maven goal | Gradle task | +| --- | --- | --- | --- | +| Desktop simulator | Local (your machine, JVM only) | `mvn -pl common cn1:run` / `cn1:debug` | `./gradlew run` / `debug` | +| Unit tests | Local (the CN1 test runner inside a JVM) | `mvn -pl common cn1:test` | `./gradlew cn1Test` | +| Standalone desktop app (`.jar` + bundled JRE for Mac/Win/Linux) | Cloud (build server packages a JRE for each OS) | `mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=mac-os-x-desktop` (or `windows-desktop`, `linux-desktop`) | `./gradlew buildMacDesktop` / `buildWindowsDesktop` | +| Android APK / AAB | Cloud by default; **also** locally if you run `cn1:install-android-sdk` and use the local Android build path | `mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device` | `./gradlew buildAndroid` (local project: `buildAndroidGradleProject`) | +| iOS app | Cloud, **or** locally as an Xcode project via `ios-source` | `mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device` (cloud) or `…-Dcodename1.buildTarget=ios-source` (local Xcode project) | `./gradlew buildIos` / `buildIosRelease` (local project: `buildIosXcodeProject`) | +| Mac Native app (AOT-compiled, same pipeline as iOS) | Cloud, **or** locally as an Xcode project via `mac-source` | `mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-os-x-native` (cloud) or `…-Dcodename1.buildTarget=mac-source` (local Xcode project) | `./gradlew buildMacNative` | +| Native Windows `.exe` (`win32`, ParparVM → clang-cl, no JVM) | Cloud (Linux build server cross-compiles); **also** locally on Windows, or as a project via `windows-source` | `mvn -pl common package -Dcodename1.platform=windows -Dcodename1.buildTarget=windows-device` (cloud) or `…-Dcodename1.buildTarget=local-windows-device` (local). A regular build returns x64 + arm64 release exes; add the `windows.debug` build hint for a single x64 debug exe. | `./gradlew buildWindowsDevice` | +| JavaScript / web bundle | Local (ParparVM to JavaScript translator, available to all users). Cloud builds use ParparVM by default and accept `javascript.port=teavm` as a compatibility fallback. | `mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript` | `./gradlew buildJavascriptLocal` (cloud: `buildJavascript`) | + +Any target without its own Gradle task (`linux-desktop`, `mac-source`, `local-windows-device`, ...) is `./gradlew cn1Build -Pcodename1.platform= -Pcodename1.buildTarget=`. The two big "local-only" outputs are the **simulator** and **tests** — those are everything you need for ordinary development and CI feedback loops. You only invoke the cloud builds when you want a deployable native artifact. @@ -25,7 +29,7 @@ The two big "local-only" outputs are the **simulator** and **tests** — those a | Running `cn1:run` / `cn1:debug` (the simulator) | **JDK 17–25** (CN1 plugin aborts on older JDKs with a friendly error) | | Cross-compiling for Android **locally with Gradle 8** | The same JDK 17+ already used as `JAVA_HOME` is reused automatically; you only need to set `JAVA17_HOME` if Maven is running on an older JVM | | Cross-compiling for iOS locally (`ios-source`) | macOS only — see Xcode prerequisites below | -| Cloud builds (any target) | None — the cloud server holds the toolchain. Local `JAVA_HOME` only runs Maven. | +| Cloud builds (any target) | None — the cloud server holds the toolchain. Local `JAVA_HOME` only runs Maven or Gradle. | `codename1.arg.java.version=17` in `codenameone_settings.properties` selects the **build server's** JDK 17 toolchain. Keep it set even when you upgrade your local JDK (21, 25, …) — the local JDK only runs Maven and the simulator; the property routes the cloud-build target. @@ -112,6 +116,34 @@ mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=windo mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=linux-desktop ``` +## Gradle task cheat sheet + +A Gradle project applies the `com.codenameone` plugin from `settings.gradle.kts`; its version there is the Codename One version. Run tasks from the project root (`.\gradlew.bat` on Windows). `./gradlew tasks` lists them all. + +```bash +# --- Local-only --- +./gradlew run # the simulator, with hot CSS reload +./gradlew debug # the simulator, suspended until a debugger attaches on port 5005 +./gradlew cn1Test # the CN1 test runner; ./gradlew test runs JUnit +./gradlew cn1Css # compile theme.css into theme.res without running the app +./gradlew generateNativeInterfaces # stubs under src/// for every NativeInterface +./gradlew buildJavascriptLocal # web bundle, locally +./gradlew buildIosXcodeProject # local Xcode project (macOS) +./gradlew buildAndroidGradleProject # local Android Studio project +./gradlew settings # Codename One Settings; also guibuilder, certificateWizard +./gradlew cn1Update # move settings.gradle.kts to the latest Codename One + +# --- Cloud builds --- +./gradlew buildIos # debug .ipa; buildIosRelease for the App Store +./gradlew buildAndroid +./gradlew buildJavascript +./gradlew buildMacNative +./gradlew buildMacDesktop # buildWindowsDesktop, buildWindowsDevice, buildLinuxDevice +./gradlew cn1Build -Pcodename1.platform=javase -Pcodename1.buildTarget=linux-desktop # any other target +``` + +A build hint can be overridden for one build with `-Pcodename1.arg.=`. The OpenAPI / gRPC / GraphQL client generators above are Maven goals; there is no Gradle task for them yet. + ## Hot reload — Java edits without restarting the simulator The simulator (`cn1:run` / `cn1:debug`) supports three reload modes, picked from the **Hot Reload** menu in the simulator's window (the setting persists per-project in Java preferences): @@ -119,12 +151,12 @@ The simulator (`cn1:run` / `cn1:debug`) supports three reload modes, picked from | Mode | What it does | When to use | | --- | --- | --- | | **Disabled** (default) | No reload — restart `cn1:run` to pick up Java edits. CSS still live-reloads regardless. | Production-style runs. | -| **Reload Simulator** | A file watcher (`SourceChangeWatcher`) recompiles changed `.java` files in `common/src/main/java/` via the IDE's incremental compiler and triggers a simulator restart. The simulator keeps the same JVM but rebuilds the form stack. | The most reliable mode — works on any edit (new class, signature change, new field). | +| **Reload Simulator** | A file watcher (`SourceChangeWatcher`) recompiles changed `.java` files in `src/main/java/` (`common/src/main/java/` under Maven) via the IDE's incremental compiler and triggers a simulator restart. The simulator keeps the same JVM but rebuilds the form stack. | The most reliable mode — works on any edit (new class, signature change, new field). | | **Reload Current Form** | Requires the **HotswapAgent** JVM agent (set up via `cn1:debug` + an IDE that pushes class file redefinitions through JDWP) **plus** CodeRAD wiring on your forms. When a `.java` file is recompiled, the simulator calls `FormController.tryCloneAndReplaceCurrentForm()` and re-shows the current form on the patched class. | Fastest iteration for UI tweaks inside a single form — preserves global state and the navigation stack, only the visible form re-renders. Method-body edits work; adding fields or methods falls back to a full restart. | Behind the scenes: -- **CSS reload** is always on. Edits to `common/src/main/css/theme.css` are watched and `theme.res` is regenerated + re-injected into the running simulator without restarting the JVM. This works in every mode. +- **CSS reload** is always on. Edits to `src/main/css/theme.css` (`common/src/main/css/theme.css` under Maven) are watched and `theme.res` is regenerated + re-injected into the running simulator without restarting the JVM. This works in every mode. - **Java reload (mode 2)** is driven by the standard JVM HotSwap protocol (`-agentlib:jdwp=...,redefinitions=true`) plus [HotswapAgent](https://github.com/HotswapProjects/HotswapAgent) for the deeper redefinitions (added/removed methods, new classes). The IDE compiles the `.java` to a `.class` and JDWP-pushes it; the simulator notices via a system property and re-clones the form. - The watcher only sees files written by the IDE — running `mvn compile` from a separate shell doesn't trigger reload because the IDE's incremental compiler is what writes the `.class` to `target/classes/`. @@ -132,7 +164,7 @@ Method-body edits feel instantaneous; structural edits cost a form rebuild but n ## Tests in CI/CD -For **logic, UI, and screenshot tests**, run `mvn -pl common cn1:test` (the CN1 test runner). It executes inside a local JVM via the simulator, so CI/CD does not need a Codename One account, a build server, or platform tooling — any GitHub Actions runner with a JDK 17+ can run it. This is the right loop for fast feedback. +For **logic, UI, and screenshot tests**, run `mvn -pl common cn1:test` or `./gradlew cn1Test` (the CN1 test runner). It executes inside a local JVM via the simulator, so CI/CD does not need a Codename One account, a build server, or platform tooling — any GitHub Actions runner with a JDK 17+ can run it. This is the right loop for fast feedback. ### Framebuffer requirement on headless Linux runners @@ -149,11 +181,12 @@ jobs: with: { distribution: 'temurin', java-version: '17' } - name: Run CN1 tests under a virtual framebuffer run: xvfb-run -a ./mvnw -pl common test-compile cn1:test + # Gradle: run: xvfb-run -a ./gradlew cn1Test ``` `xvfb-run -a` automatically picks a free display number (`-a` = autodisplay). Without it the test JVM aborts with `java.awt.HeadlessException` or "Can't connect to X11 display". -On `macos-latest` / `windows-latest` runners just call `./mvnw -pl common test-compile cn1:test` directly — no wrapper needed. +On `macos-latest` / `windows-latest` runners just call `./mvnw -pl common test-compile cn1:test` (or `./gradlew cn1Test`) directly — no wrapper needed. You only need the cloud build path when you want to **produce a native artifact** (.ipa / .apk / desktop installer / web bundle) — see the next section. @@ -164,6 +197,10 @@ You only need the cloud build path when you want to **produce a native artifact* ```bash mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device -Dautomated=true mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device -Dautomated=true + +# Gradle +./gradlew buildIos -Pautomated=true +./gradlew buildAndroid -Pautomated=true ``` This is the right tool **specifically for producing native artifacts in unattended pipelines** (release builds, nightly TestFlight uploads, LLM-driven builds that need to verify a target compiles). It is **not** needed for running tests — tests are local. @@ -192,7 +229,7 @@ After the project is generated, you can also run `xcodebuild -workspace .xc ## `codenameone_settings.properties` — the configuration source of truth -This file lives at `common/codenameone_settings.properties`. The most useful keys: +This file lives at `common/codenameone_settings.properties` in a Maven project and at the project root in a Gradle project. The most useful keys: ```properties codename1.packageName=com.example.myapp @@ -210,6 +247,8 @@ Anything prefixed `codename1.arg..` is forwarded to the build ser ## Layout invariants +Paths below are Maven's; in a Gradle project drop the leading `common/` (and `common/target/` is `build/`). + - `common/src/main/css/theme.css` — **the** CSS file. Compiled to `common/target/classes/theme.res`. - `common/src/main/l10n/messages*.properties` — i18n bundles. **MUST** live here (not in `src/main/resources`), or the CSS compiler will not bake them in. - `common/src/main/java//.java` — entry point. Class name must match `codename1.mainName`. @@ -219,15 +258,16 @@ Anything prefixed `codename1.arg..` is forwarded to the build ser ## Common build errors and fixes - **`Resources.getGlobalResources().getL10N(...)` returns null at runtime** → your bundles are under `common/src/main/resources/` instead of `common/src/main/l10n/`. Move them. -- **`Cannot find symbol class XxxView`** after using the GUI builder → run `mvn -pl common generate-sources` to regenerate. +- **`Cannot find symbol class XxxView`** after using the GUI builder → run `mvn -pl common generate-sources` (Gradle: `./gradlew generateGuiSources`, which also runs before every compile) to regenerate. - **`OutOfMemoryError` running `cn1:test`** → bump `-Xmx2g` in the surefire/cn1 plugin config in `common/pom.xml`. - **Build server returns "build args invalid"** → check `codename1.arg.*` keys against [`build-hints.md`](build-hints.md) or the Developer Guide. +- **`Codename One Gradle projects compile for Java 17`** → a Gradle build script set a lower `release`; remove it. Java 8 projects need the Maven build. - **`When using gradle 8, you must set the JAVA17_HOME environment variable`** for a local Android build → only happens when Maven itself is running on a JDK older than 17. Re-launch Maven with a JDK 17+ `JAVA_HOME` and the builder reuses it automatically. - **`-Dautomated=true` exits 0 even though the build failed** → make sure you're on a recent `cn1.plugin.version`; older versions of the plugin swallowed errors. ## The build client jar -The build server protocol is driven by `CodeNameOneBuildClient.jar`. The Codename One Maven plugin places it under `~/.codenameone/CodeNameOneBuildClient.jar` automatically the first time you run a `cn1:build` / `package` goal. If it ever goes missing, download the canonical copy: +The build server protocol is driven by `CodeNameOneBuildClient.jar`. The Codename One Maven (and Gradle) plugin places it under `~/.codenameone/CodeNameOneBuildClient.jar` automatically the first time you run a `cn1:build` / `package` goal. If it ever goes missing, download the canonical copy: diff --git a/scripts/initializr/common/src/main/resources/skill/references/build-hints.md b/scripts/initializr/common/src/main/resources/skill/references/build-hints.md index bee3847b865..87035a23b4f 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/build-hints.md +++ b/scripts/initializr/common/src/main/resources/skill/references/build-hints.md @@ -1,6 +1,8 @@ # Build Hints Reference -Build hints are key/value pairs in `common/codenameone_settings.properties` that are forwarded to the Codename One build server. Every key starts with `codename1.arg.` (the build server strips that prefix). They control native platform behaviour that cannot be expressed in Java/CSS: permissions, frameworks, splash screens, signing, platform SDK versions, etc. +Build hints are key/value pairs in `codenameone_settings.properties` (`common/` in a Maven project, the root of a Gradle project) that are forwarded to the Codename One build server. Every key starts with `codename1.arg.` (the build server strips that prefix). They control native platform behaviour that cannot be expressed in Java/CSS: permissions, frameworks, splash screens, signing, platform SDK versions, etc. + +To override a hint for a single build, pass it on the command line: `-Dcodename1.arg.=` to a Maven goal, `-Pcodename1.arg.=` to a Gradle task. A Gradle project can also set hints in `build.gradle.kts` with `codenameone { buildHints.put("", "") }`, which wins over the settings file. This file is a curated index of the most commonly needed hints. The complete authoritative reference is in the Codename One Developer Guide: @@ -118,7 +120,7 @@ Two things to get right. Node ids must be stable for the life of the item -- the If all you want is the app's own documents folder visible in Files, you need none of this -- set `ios.plistInject` with `UIFileSharingEnabled` and `LSSupportsOpeningDocumentsInPlace` instead. -The extension needs its own App ID and provisioning profile; `mvn cn1:certificatewizard` creates both, along with the App Group. +The extension needs its own App ID and provisioning profile; `mvn cn1:certificatewizard` (Gradle: `./gradlew certificateWizard`) creates both, along with the App Group. ## State restoration and continuity @@ -168,5 +170,5 @@ codename1.arg.ios.pods.platform=${var.iosDeploy} ## How to discover the right hint 1. Search the [Developer Guide](https://www.codenameone.com/developer-guide/) for the platform feature you need. -2. Or run the simulator (`mvn -pl common cn1:run`) and use the **Build Hints** menu — it lists every hint the plugin understands, with descriptions. -3. Or grep the project's existing `codename1.arg.*` keys in `common/codenameone_settings.properties` to see what's already wired. +2. Or run the simulator (`mvn -pl common cn1:run`, or `./gradlew run`) and use the **Build Hints** menu — it lists every hint the plugin understands, with descriptions. +3. Or grep the project's existing `codename1.arg.*` keys in `codenameone_settings.properties` to see what's already wired. diff --git a/scripts/initializr/common/src/main/resources/skill/references/cn1libs.md b/scripts/initializr/common/src/main/resources/skill/references/cn1libs.md index 745592283c2..c532c199ffe 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/cn1libs.md +++ b/scripts/initializr/common/src/main/resources/skill/references/cn1libs.md @@ -2,8 +2,8 @@ A **cn1lib** is a reusable Codename One library: Java/Kotlin source, optional CSS, optional resources, and platform-native code (Objective-C for iOS, Java/Kotlin for Android, JavaScript for the web port, plain Java for the desktop simulator) packaged into a single artifact. CN1 has two cn1lib distribution formats: -- **Maven (modern, preferred for new libraries)** — published to Maven Central or a private Maven repo, consumed via a regular `` in `common/pom.xml`. -- **`.cn1lib` binary (legacy)** — a self-contained zip of jars and native source trees, dropped into the consuming project's `cn1libs/` directory. Still in active use; many older CN1 libraries (ZipSupport, CodeRAD, push integrations) ship this way. +- **Maven (modern, preferred for new libraries)** — published to Maven Central or a private Maven repo, consumed via a regular `` in `common/pom.xml`, or a `cn1lib("...")` line in a Gradle project's `build.gradle.kts`. +- **`.cn1lib` binary (legacy)** — a self-contained zip of jars and native source trees, dropped into the consuming project's `cn1libs/` directory. Still in active use; many older CN1 libraries (ZipSupport, CodeRAD, push integrations) ship this way. **Maven projects only**: the Gradle build consumes cn1libs by Maven coordinates and does not read `cn1libs/`. This guide covers both directions: **creating** a new cn1lib and **consuming** existing ones. @@ -26,6 +26,18 @@ The `pom` matters — Maven cn1libs aggregate multiple per-platform Rebuild: `mvn -pl common compile`. The cn1lib's CSS, resources, and per-platform native sources are wired in by the CN1 maven plugin. +### Maven cn1lib in a Gradle project + +Declare the same coordinates with `cn1lib(...)` in `build.gradle.kts` (there is no `pom` to remember; the plugin reads the cn1lib's pom and picks each platform's classifier jars itself): + +```kotlin +dependencies { + cn1lib("com.example.libs:my-cn1lib:1.0.0") +} +``` + +Rebuild with `./gradlew classes` (or just `./gradlew run`). A library that is published only as a `.cn1lib` file cannot be used from Gradle; switch that project to Maven or publish the library to a Maven repository. + ### Legacy `.cn1lib` binary Drop the `.cn1lib` file into `/cn1libs/`: @@ -47,6 +59,10 @@ To verify a cn1lib is wired in correctly, run the bytecode compliance check — mvn -pl common compile cn1:bytecode-compliance ``` +Under Gradle the check runs as the last step of every compile, so `./gradlew classes` is enough. + +Creating and publishing a cn1lib (the rest of this file) is done with the Maven project layout; a Gradle app consumes the published result. + ## Creating a new cn1lib (Maven) The fastest path is the Maven archetype: diff --git a/scripts/initializr/common/src/main/resources/skill/references/css.md b/scripts/initializr/common/src/main/resources/skill/references/css.md index 2767353c9f2..8083bba22dd 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/css.md +++ b/scripts/initializr/common/src/main/resources/skill/references/css.md @@ -1,6 +1,8 @@ # Codename One CSS Reference -`common/src/main/css/theme.css` is the entry point. The CN1 plugin's CSS compiler (`compile-css` goal, runs in `process-resources`) parses it and bakes the result into `common/target/classes/theme.res`. The runtime then loads styles by UIID from that binary resource — no CSS exists at runtime. +`src/main/css/theme.css` of the app is the entry point: `common/src/main/css/theme.css` in a Maven project, `src/main/css/theme.css` at the root of a Gradle project. The CN1 plugin's CSS compiler (the `cn1:css` goal in `process-resources` under Maven, the `cn1Css` task under Gradle) parses it and bakes the result into `theme.res` (`common/target/classes/` under Maven, the build directory under Gradle). The runtime then loads styles by UIID from that binary resource — no CSS exists at runtime. + +Paths in the rest of this file are Maven's; in a Gradle project drop the leading `common/`. This is **not** web CSS. It is a deliberate subset designed for native rendering on mobile. Treat any unfamiliar property as "probably unsupported" until you check. @@ -557,7 +559,7 @@ Painters are for **drawing** (custom backgrounds, decorations), not for animatin | Padding ignored on the Form | You're setting padding on the Form itself; set it on its ContentPane or wrap content in your own UIID. | | Border radius animates jaggy | Border radius is rasterized at compile when using image fallbacks; switch to `RoundRectBorder` and animate via `Form.animateLayout(...)`. | | `text-align` does nothing | Add the `align` fallback (the initializr appends one automatically for `text-align`). | -| New CSS only takes effect after restart | The build cache may be stale — `mvn -pl common clean compile`. | +| New CSS only takes effect after restart | The build cache may be stale — `mvn -pl common clean compile` (Gradle: `./gradlew clean cn1Css`). | | 9-piece border looks blurry on iPhone Pro | Expected — 9-piece images are rasterized at the bundled resolution. Use a vector border (`RoundBorder`/`RoundRectBorder`) instead. | | Custom TTF doesn't render on device but works in simulator | The `@font-face` `src:` filename and the JS-side `Font.createTrueTypeFont(name, file)` filename must match exactly, and the file must end up packaged with the app. Re-check spelling and confirm the file is under `common/src/main/css/` (beside `theme.css` or in a subdirectory of it). | | Build fails with "Invalid @font-face rules" | Read the listed reasons — a container the runtime can't load (`.woff`; `.ttf` and `.otf` are both fine), a font outside `common/src/main/css/`, a missing file, a file that isn't a readable font, a font with no PostScript name, or two rules whose files share a name. | diff --git a/scripts/initializr/common/src/main/resources/skill/references/debugging.md b/scripts/initializr/common/src/main/resources/skill/references/debugging.md index b65e2bf7616..f01c6e92a50 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/debugging.md +++ b/scripts/initializr/common/src/main/resources/skill/references/debugging.md @@ -29,6 +29,8 @@ From the project root: mvn -pl common cn1:debug -Djpda.address=8000 ``` +In a **Gradle** project the same thing is `./gradlew debug`: the simulator starts suspended and listens on port **5005** (not configurable through `jpda.address`), so every `8000` below becomes `5005` and every `-sourcepath common/src/main/java` becomes `-sourcepath src/main/java`. + Output looks like: ``` diff --git a/scripts/initializr/common/src/main/resources/skill/references/full-stack-loop.md b/scripts/initializr/common/src/main/resources/skill/references/full-stack-loop.md index 48ca244fce1..c45811624cb 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/full-stack-loop.md +++ b/scripts/initializr/common/src/main/resources/skill/references/full-stack-loop.md @@ -1,6 +1,6 @@ # The Full-Stack Loop — App and Backend Together -Use this when a change spans the app (`common/`) and the server (`backend/`): a new +Use this when a change spans the app (`common/` in Maven, the root project in Gradle) and the server (`backend/`): a new screen that needs a new endpoint, a form whose data must land in the database, a bug that could be on either side. The loop runs both halves locally and gives you **two MCP servers**: @@ -15,6 +15,8 @@ row — without asking a human to click anything. ```bash CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend +# Gradle: +CN1_PROFILE=dev ./gradlew :backend:runBackend ``` Run it in the background: it blocks, and it prints @@ -35,7 +37,7 @@ claude mcp add --transport http cn1-backend http://127.0.0.1:8080/mcp ``` For the simulator, follow `references/mcp-agent-control.md`: run -`mvn -pl common cn1:run`, then **MCP -> Expose This Tool To Agents** and +`mvn -pl common cn1:run` (Gradle: `./gradlew run`), then **MCP -> Expose This Tool To Agents** and **MCP -> Install in MCP Hosts...**. Restart the MCP host after registering either. ## 3. Point the app at the local backend @@ -45,7 +47,7 @@ start-up) so switching between local and deployed is one edit: | Where the app runs | URL that reaches the local backend | | --- | --- | -| Simulator (`cn1:run`) | `http://127.0.0.1:8080` | +| Simulator (`cn1:run` / `./gradlew run`) | `http://127.0.0.1:8080` | | Android emulator | `http://10.0.2.2:8080` | | iOS simulator | `http://127.0.0.1:8080` | | A physical phone | `http://:8080` | @@ -97,7 +99,7 @@ When a step fails, the tools tell you which side to fix: - **Backend**: unit-test services by constructing them; integration-test the wired server by starting the generated `BackendWiring` on a free port — both are shown in `references/backend.md`. -- **App**: screen tests with `cn1:test` (`references/testing-and-screenshots.md`). +- **App**: screen tests with `cn1:test` / `./gradlew cn1Test` (`references/testing-and-screenshots.md`). Keep them independent of a running backend — fake the client, or point it at a backend the test itself starts — so the suite does not depend on whatever you left running. @@ -106,6 +108,7 @@ When a step fails, the tools tell you which side to fix: ```bash mvn -pl backend -Dcodename1.platform=backend cn1:backend-package # native server binary +./gradlew :backend:backendPackage # the same, in a Gradle project ``` and the app's platform builds as in `references/build-and-run.md`. Before diff --git a/scripts/initializr/common/src/main/resources/skill/references/games.md b/scripts/initializr/common/src/main/resources/skill/references/games.md index c60f804a74e..0e815b86cc9 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/games.md +++ b/scripts/initializr/common/src/main/resources/skill/references/games.md @@ -180,7 +180,8 @@ view.start(); Scaffold a scene with `mvn cn1:create-game-scene -DclassName=com.example.Level1 -Dmode=2d` (writes `.game` + a `GameSceneView` companion), then edit it visually with -`mvn cn1:gamebuilder` (the standalone level/map editor; Java-17 CN1 projects only). +`mvn cn1:gamebuilder` (the standalone level/map editor; Java-17 CN1 projects only). In a Gradle +project the editor is `./gradlew gameBuilder`; the scene scaffold is a Maven goal only for now. ## Platform support diff --git a/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md b/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md index 2c07947cac3..1c0c7e6a0e3 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md +++ b/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md @@ -11,6 +11,8 @@ Pick per test class. Both run in the same project from the same `common/src/test See `references/testing-and-screenshots.md` for the AbstractTest path, including the `screenshotTest` baseline algorithm. +**Gradle projects** keep tests in `src/test/java` and run JUnit with `./gradlew test` (filter with `--tests 'GreetingFormTest.formShowsExpectedTitle'`) and the CN1 runner with `./gradlew cn1Test`. The plugin puts `codenameone-core` and `codenameone-javase` on the test classpath; if JUnit is not wired in yet, add `testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")`, `testRuntimeOnly("org.junit.platform:junit-platform-launcher")` and `tasks.test { useJUnitPlatform() }` to `build.gradle.kts`. The Maven setup below does not apply. + ## Project setup The cn1app archetype generates a `common/pom.xml` and `javase/pom.xml` that already pull in `junit-jupiter` and `codenameone-javase` at test scope. If your project predates that wiring, add these two blocks yourself: diff --git a/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md b/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md index 4e641d4de0a..a9e58cdbc1c 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md +++ b/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md @@ -8,7 +8,7 @@ This is the loop to reach for when a screen "looks right" in a screenshot but yo Two halves, both one-time: -1. **Serve.** Run the simulator (`mvn -pl common cn1:run`), then in its menu bar choose **MCP -> Expose This Tool To Agents**. The simulator starts an MCP server on `127.0.0.1:8765`. +1. **Serve.** Run the simulator (`./gradlew run`, or `mvn -pl common cn1:run` in a Maven project), then in its menu bar choose **MCP -> Expose This Tool To Agents**. The simulator starts an MCP server on `127.0.0.1:8765`. 2. **Register.** In the same menu choose **MCP -> Install in MCP Hosts...**. This writes a server entry into the configuration of every MCP host it finds on the machine: | Host | Configuration it writes | diff --git a/scripts/initializr/common/src/main/resources/skill/references/mockup-comparison.md b/scripts/initializr/common/src/main/resources/skill/references/mockup-comparison.md index 0b1a11bcf94..78697c2bdfb 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/mockup-comparison.md +++ b/scripts/initializr/common/src/main/resources/skill/references/mockup-comparison.md @@ -43,14 +43,16 @@ to a predictable path for the comparison tool to read. 2. **Build the screen** from the layout map. 3. **Capture the render.** Either drive the simulator and use *Simulator menu -> Save Screenshot*, - or copy `templates/MockupComparisonTest.java` into `common/src/test/java//`, point it at + or copy `templates/MockupComparisonTest.java` into `common/src/test/java//` (`src/test/java//` + in a Gradle project), point it at your screen, and run it: ```bash mvn -pl common test -Dtest=MockupComparisonTest + # Gradle: ./gradlew test --tests '*MockupComparisonTest' # writes target/mockup-compare/home.png ``` -4. **Score against the mockup.** Put mockups in `common/src/test/resources/mockups/`: +4. **Score against the mockup.** Put mockups in `common/src/test/resources/mockups/` (`src/test/resources/mockups/` under Gradle): ```bash java .claude/skills/codename-one/tools/CompareToMockup.java \ target/mockup-compare/home.png \ @@ -185,6 +187,7 @@ layout, text). Three self-contained tools read that model: ```bash CP="common/target/classes:$(mvn -q -pl common dependency:build-classpath -Dmdep.outputFile=/dev/stdout | tail -1)" +# Gradle: CP="$(./gradlew -q printSimulatorClasspath)" (the task is in tools/README.md) java -cp "$CP" tools/DumpForm.java com.example.MyApp --out target/form-model.tsv # capture (desktop mode) java tools/DescribeForm.java target/form-model.tsv # concise designer-language outline of the screen java tools/AlignmentCheck.java target/form-model.tsv # the alignment grid + elements nudged just off it diff --git a/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md b/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md index 75d16576b02..b07f58190d3 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md +++ b/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md @@ -1,13 +1,13 @@ # Native Interfaces -A **Native Interface** in Codename One is a Java interface in `common/` that has a per-platform implementation outside of `common/` — Objective-C (or Swift) for iOS, Java (or Kotlin) for Android, JavaScript for the JS port, plain Java for the desktop simulator. At runtime CN1 wires the Java interface to the right platform implementation. This is how you call out to platform APIs CN1 doesn't expose directly (NFC, native ads, Bluetooth, vendor SDKs). +A **Native Interface** in Codename One is a Java interface in the app's sources (`common/` under Maven, `src/main/java/` under Gradle) that has a per-platform implementation outside of them — Objective-C (or Swift) for iOS, Java (or Kotlin) for Android, JavaScript for the JS port, plain Java for the desktop simulator. At runtime CN1 wires the Java interface to the right platform implementation. This is how you call out to platform APIs CN1 doesn't expose directly (NFC, native ads, Bluetooth, vendor SDKs). > **The GPS example below is for illustration only.** Codename One already ships a portable **`com.codename1.location`** API (`LocationManager.getLocationManager().getCurrentLocation()`, `setLocationListener(...)`, geofence + background tracking) — you should never roll your own GPS bridge. Maps are likewise already covered by the **`cn1-google-maps`** cn1lib (Google Maps for iOS and Android) and the built-in `com.codename1.maps.MapComponent` for OpenStreetMap. Use native interfaces for things the framework actually doesn't expose; reach for them last, not first. ## The workflow 1. **Write the Java interface in `common/`** as an extension of `com.codename1.system.NativeInterface`. -2. **Generate stubs** with `mvn cn1:generate-native-interfaces`. The plugin scans `common/target/classes/` for classes that extend `NativeInterface` and emits per-platform stub files into the matching module's source tree. +2. **Generate stubs** with `mvn cn1:generate-native-interfaces` (Gradle: `./gradlew generateNativeInterfaces`). The plugin scans the compiled classes for classes that extend `NativeInterface` and emits per-platform stub files into the matching platform source tree -- a platform module under Maven, `src///` under Gradle. 3. **Run the target platform first, against the empty stubs**, so you can see exactly where the stub lands and what the file looks like before you implement it. 4. **Implement the stubs** — fill in the native code for each platform you care about. 5. **Look up the bridge from Java** with `NativeLookup.create(YourInterface.class)`. @@ -41,21 +41,25 @@ Constraints on the interface: ## 2. Generate the stubs ```bash +# Maven mvn -pl common compile # the generator needs the interface compiled mvn -pl common cn1:generate-native-interfaces + +# Gradle (compiles first on its own) +./gradlew generateNativeInterfaces ``` -This creates files like: +This creates files like (Maven, then Gradle): ``` -ios/src/main/objectivec/com_example_myapp_native_GpsBridgeImpl.h -ios/src/main/objectivec/com_example_myapp_native_GpsBridgeImpl.m -android/src/main/java/com/example/myapp/native_/GpsBridgeImpl.java -javase/src/main/java/com/example/myapp/native_/GpsBridgeImpl.java -javascript/src/main/javascript/com_example_myapp_native_GpsBridgeImpl.js +ios/src/main/objectivec/com_example_myapp_native_GpsBridgeImpl.h src/ios/objectivec/com_example_myapp_native_GpsBridgeImpl.h +ios/src/main/objectivec/com_example_myapp_native_GpsBridgeImpl.m src/ios/objectivec/com_example_myapp_native_GpsBridgeImpl.m +android/src/main/java/com/example/myapp/native_/GpsBridgeImpl.java src/android/java/com/example/myapp/native_/GpsBridgeImpl.java +javase/src/main/java/com/example/myapp/native_/GpsBridgeImpl.java src/javase/java/com/example/myapp/native_/GpsBridgeImpl.java +javascript/src/main/javascript/com_example_myapp_native_GpsBridgeImpl.js src/javascript/javascript/com_example_myapp_native_GpsBridgeImpl.js ``` -The plugin will **not overwrite existing stubs** by default. Pass `-Dcn1.generateNativeInterfaces.overwrite=true` if you really want to regenerate. To emit Swift instead of Objective-C, or Kotlin instead of Java, add `-Dcn1.generateNativeInterfaces.swift=true` / `-Dcn1.generateNativeInterfaces.kotlin=true`. +The plugin will **not overwrite existing stubs** by default. Pass `-Dcn1.generateNativeInterfaces.overwrite=true` (Gradle: `-Pcn1.overwrite=true`) if you really want to regenerate. To emit Swift instead of Objective-C, or Kotlin instead of Java, add `-Dcn1.generateNativeInterfaces.swift=true` / `-Dcn1.generateNativeInterfaces.kotlin=true` (Gradle: `-Pcn1.swift=true` / `-Pcn1.kotlin=true`). ## 3. Verify the stub layout BEFORE implementing @@ -74,12 +78,20 @@ mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarg # Desktop simulator — just run cn1:run and observe the bridge boots without errors. mvn -pl common cn1:run + +# Gradle equivalents +./gradlew buildIosXcodeProject +./gradlew buildAndroid -Pautomated=true +./gradlew buildJavascriptLocal +./gradlew run ``` This step matters because every platform has a different stub layout, naming convention, and dependency story. Running the empty stubs first is how you learn what the real implementation file will look like — file path, class/file name, method signatures — without spending time on logic that may need to be reworked. ## 4. Implement the stubs +Paths below are Maven's; under Gradle `ios/src/main/objectivec/` is `src/ios/objectivec/`, `android/src/main/java/` is `src/android/java/`, and so on (see the table in step 2). + ### iOS (Objective-C) `ios/src/main/objectivec/com_example_myapp_native_GpsBridgeImpl.m`: @@ -296,7 +308,7 @@ navigator.geolocation.watchPosition(function(pos) { ## Common pitfalls -- **Method signature mismatch between the interface and the stub** — happens after you edit the Java interface but forget to regenerate. Re-run `mvn cn1:generate-native-interfaces -Dcn1.generateNativeInterfaces.overwrite=true` and re-apply your platform code. +- **Method signature mismatch between the interface and the stub** — happens after you edit the Java interface but forget to regenerate. Re-run `mvn cn1:generate-native-interfaces -Dcn1.generateNativeInterfaces.overwrite=true` (Gradle: `./gradlew generateNativeInterfaces -Pcn1.overwrite=true`) and re-apply your platform code. - **Returning Java objects** — not supported by the bridge marshaler. Return primitives, `String`, `byte[]`, or `PeerComponent` only. - **`PeerComponent` on iOS without ARC** — peer-component implementations can dangle if you treat the bridge like an ARC-managed Swift method. Retain natively, or wrap returned views in a static holder. - **Permissions / Info.plist** — the build server happily accepts a native interface that calls a privacy-protected API, but the App Store / Play Store reject it. Set `codename1.arg.ios.plistInject` and `codename1.arg.android.xpermissions` (see `references/build-hints.md`). diff --git a/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md b/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md index 81168f39238..d9249bcacb6 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md +++ b/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md @@ -6,6 +6,8 @@ Both platforms need a build made with an on-device-debug build hint, because the flag is baked into the binary. A release build cannot be attached to. +**Gradle projects.** The settings file is `codenameone_settings.properties` at the root and the sources are `src/main/java` (drop the `common/` below). The builds are Gradle tasks: `./gradlew buildAndroid -Pcodename1.arg.android.onDeviceDebug=true` or `./gradlew buildAndroidGradleProject` for Android, and `./gradlew buildIos -Pcodename1.arg.ios.onDeviceDebug=true` or `./gradlew buildIosXcodeProject` for iOS. The device-side helpers -- `cn1:android-on-device-debugging` (install, launch, forward JDWP) and `cn1:ios-on-device-debugging` (the JDWP proxy) -- are Maven goals with no Gradle task yet; under Gradle, do the Android steps with `adb` by hand (`adb install`, `adb shell am set-debug-app -w `, `adb forward tcp:5005 jdwp:`) and say so if the iOS proxy is needed. + ## Android Android's runtime already exposes a JDWP socket per debuggable process, so there is no proxy — the Maven goal just drives `adb`. @@ -43,7 +45,7 @@ It prints the adb it picked, the target device, the app's PID, and a banner conf ```bash jdb -attach localhost:5005 \ - -sourcepath common/src/main/java + -sourcepath common/src/main/java # src/main/java under Gradle ``` By default the goal runs `am set-debug-app -w`, so the app sits paused at startup until you attach. Everything the simulator debugger does works here — this is the real Android runtime, not a proxy. @@ -101,7 +103,7 @@ mvn cn1:ios-on-device-debugging ```bash jdb -attach localhost:8000 \ - -sourcepath common/src/main/java + -sourcepath common/src/main/java # src/main/java under Gradle ``` **Two ports, and mixing them up is the usual mistake:** the *app* dials the proxy on **55333**; the *debugger* attaches to the proxy on **8000**. Override them with `-Dcn1.onDeviceDebug.devicePort` and `-Dcn1.onDeviceDebug.jdwpPort` if either is taken. diff --git a/scripts/initializr/common/src/main/resources/skill/references/react-to-cn1.md b/scripts/initializr/common/src/main/resources/skill/references/react-to-cn1.md index 9bf97c74326..96d44870a3f 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/react-to-cn1.md +++ b/scripts/initializr/common/src/main/resources/skill/references/react-to-cn1.md @@ -125,6 +125,7 @@ without screenshots: ```bash CP="common/target/classes:$(mvn -q -pl common dependency:build-classpath -Dmdep.outputFile=/dev/stdout | tail -1)" +# Gradle: CP="$(./gradlew -q printSimulatorClasspath)" (the task is in tools/README.md) java -cp "$CP" tools/DumpForm.java com.example.MyApp --out target/form-model.tsv java tools/DescribeForm.java target/form-model.tsv # a concise, vision-free outline of the screen java tools/AlignmentCheck.java target/form-model.tsv # designer "guides": elements nudged off the grid diff --git a/scripts/initializr/common/src/main/resources/skill/references/snapshot-builds.md b/scripts/initializr/common/src/main/resources/skill/references/snapshot-builds.md index 62bf29f6c0a..5692056bf98 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/snapshot-builds.md +++ b/scripts/initializr/common/src/main/resources/skill/references/snapshot-builds.md @@ -58,11 +58,32 @@ Edit your app's root `pom.xml`: (Substitute whatever version is in `~/CodenameOne/maven/pom.xml`.) The properties are referenced from every `` and the `codenameone-maven-plugin` block — you should not need to edit individual coordinates. +**Gradle project:** set the plugin version in `settings.gradle.kts` to the SNAPSHOT and let Gradle find it in the local Maven repository: + +```kotlin +pluginManagement { + repositories { + mavenLocal() // the SNAPSHOT plugin you just installed + maven("https://repo.codenameone.com/maven2") + gradlePluginPortal() + } +} + +plugins { + id("com.codenameone") version "8.0-SNAPSHOT" +} +``` + +and point the framework dependencies at the same repository with `codename1.repository=/home/you/.m2/repository` in `gradle.properties` (or `-Pcodename1.repository=...` on one build). That property replaces the Codename One repository the plugin adds. + ### 4. Rebuild the app and verify ```bash mvn -pl common clean compile # confirms the plugin + framework jars resolve and compile mvn -pl common cn1:run # simulator boot against the snapshot + +./gradlew clean classes --refresh-dependencies # Gradle: the same two checks +./gradlew run ``` If the build fails with "Could not find artifact com.codenameone:codenameone-core:jar:8.0-SNAPSHOT", the framework's `mvn install` didn't complete. Re-run step 2 and watch for build errors. diff --git a/scripts/initializr/common/src/main/resources/skill/references/testing-and-screenshots.md b/scripts/initializr/common/src/main/resources/skill/references/testing-and-screenshots.md index b72b78d32ea..6d14f071311 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/testing-and-screenshots.md +++ b/scripts/initializr/common/src/main/resources/skill/references/testing-and-screenshots.md @@ -22,7 +22,7 @@ Either way, the `TestUtils` helpers below (`waitForFormTitle`, `clickButtonByLab common/src/test/java//MyTest.java ``` -Tests in this folder are picked up by the CN1 test runner. The runner can execute on the desktop (simulator) and also on real devices via `cn1:test`. Surefire (`mvn test`) is mapped to `cn1:test` in the standard initializr POM so the usual `mvn test` works. +(`src/test/java//MyTest.java` in a Gradle project.) Tests in this folder are picked up by the CN1 test runner -- `mvn -pl common cn1:test`, or `./gradlew cn1Test` under Gradle. The runner can execute on the desktop (simulator) and also on real devices via `cn1:test`. Surefire (`mvn test`) is mapped to `cn1:test` in the standard initializr POM so the usual `mvn test` works. ## The `AbstractTest` base class @@ -124,6 +124,7 @@ rm ~/.codenameone/com.example.myapp.MyAppName/settings-screen.png # Run twice — first records baseline, second confirms it's stable: mvn -pl common cn1:test mvn -pl common cn1:test +# Gradle: ./gradlew cn1Test, twice ``` The two-run pattern is important: a single passing run only proves "we wrote a file", not "we compare equal across runs". The second run is the one that actually validates determinism. @@ -135,7 +136,7 @@ The two-run pattern is important: a single passing run only proves "we wrote a f The right loop: 1. Write/modify the UI. -2. Run the simulator (`mvn -pl common cn1:run`) and **visually confirm** the screen looks right at least once. +2. Run the simulator (`mvn -pl common cn1:run`, or `./gradlew run`) and **visually confirm** the screen looks right at least once. 3. *Then* write a `screenshotTest("name")` and run it twice to lock in the baseline. 4. On the next CI run, the screen rendering must continue to match — that's where the test earns its keep. diff --git a/scripts/initializr/common/src/main/resources/skill/references/ui-components.md b/scripts/initializr/common/src/main/resources/skill/references/ui-components.md index bd0982686a5..941d4d07c06 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/ui-components.md +++ b/scripts/initializr/common/src/main/resources/skill/references/ui-components.md @@ -600,4 +600,4 @@ The generated barebones starter is intentionally tiny — typically a `Form` wit ## When to reach for the GUI builder -CN1 has an optional GUI builder (`*.gui` XML) that generates view classes. It's powerful but the Java-first approach above is usually cleaner for new screens. If a project already uses GUI builder XML, edit those files and run `mvn -pl common generate-sources` to regenerate the Java. +CN1 has an optional GUI builder (`*.gui` XML) that generates view classes. It's powerful but the Java-first approach above is usually cleaner for new screens. If a project already uses GUI builder XML, edit those files and run `mvn -pl common generate-sources` (Gradle: `./gradlew generateGuiSources`) to regenerate the Java. diff --git a/scripts/initializr/common/src/main/resources/skill/templates/MockupComparisonTest.java b/scripts/initializr/common/src/main/resources/skill/templates/MockupComparisonTest.java index a76eb297b5c..b81a24a2085 100644 --- a/scripts/initializr/common/src/main/resources/skill/templates/MockupComparisonTest.java +++ b/scripts/initializr/common/src/main/resources/skill/templates/MockupComparisonTest.java @@ -1,8 +1,31 @@ +/* + * 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. + */ // =========================================================================== // COPY-PASTE TEMPLATE - not compiled where it ships. // // To use it: -// 1. Copy this file into common/src/test/java// +// 1. Copy this file into common/src/test/java// (Maven) +// or src/test/java// (Gradle) // 2. Replace the package below with your app's package. // 3. Replace `YourMainClass` with your Lifecycle main class and adjust the // navigation in each capture method to reach the screen you want. diff --git a/scripts/initializr/common/src/main/resources/skill/tools/DumpForm.java b/scripts/initializr/common/src/main/resources/skill/tools/DumpForm.java index 825e4fb76e7..111ad16f523 100644 --- a/scripts/initializr/common/src/main/resources/skill/tools/DumpForm.java +++ b/scripts/initializr/common/src/main/resources/skill/tools/DumpForm.java @@ -1,3 +1,25 @@ +/* + * 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. + */ import com.codename1.impl.javase.JavaSEPort; import com.codename1.system.Lifecycle; import com.codename1.ui.Button; @@ -33,6 +55,7 @@ /// /// CP="common/target/classes:$(mvn -q -pl common dependency:build-classpath \ /// -Dmdep.outputFile=/dev/stdout | tail -1)" +/// # Gradle: CP="$(./gradlew -q printSimulatorClasspath)" -- the task is in tools/README.md /// java -cp "$CP" tools/DumpForm.java com.example.myapp.MyApp > target/form-model.tsv /// # then analyse it: /// java tools/DescribeForm.java target/form-model.tsv diff --git a/scripts/initializr/common/src/main/resources/skill/tools/IsApiSupported.java b/scripts/initializr/common/src/main/resources/skill/tools/IsApiSupported.java index 6b9af5f1586..9862eafbf16 100644 --- a/scripts/initializr/common/src/main/resources/skill/tools/IsApiSupported.java +++ b/scripts/initializr/common/src/main/resources/skill/tools/IsApiSupported.java @@ -1,3 +1,25 @@ +/* + * 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. + */ import java.io.IOException; import java.nio.file.DirectoryStream; import java.nio.file.Files; @@ -17,8 +39,10 @@ /// java tools/IsApiSupported.java java.nio.file.Files /// java tools/IsApiSupported.java java.util.HashMap#put /// -/// The tool resolves the highest-versioned `java-runtime` jar under -/// ~/.m2/repository/com/codenameone/java-runtime/ and looks the class up there. +/// The tool resolves the highest-versioned `java-runtime` jar in the local Maven +/// repository (~/.m2/repository/com/codenameone/java-runtime/) or the Gradle +/// cache (~/.gradle/caches/modules-2/files-2.1/com.codenameone/java-runtime/) and +/// looks the class up there. /// /// Exit codes: /// @@ -45,11 +69,12 @@ public static void main(String[] args) throws IOException { method = target.substring(hash + 1); } - Path jar = findLatestJavaRuntimeJar(); + Path jar = findLatestJar("java-runtime"); if (jar == null) { - System.err.println("Could not locate java-runtime jar under " - + System.getProperty("user.home") + "/.m2/repository/com/codenameone/java-runtime/. " - + "Run a Codename One build (or `mvn -pl common compile`) to populate the local cache."); + System.err.println("Could not locate a java-runtime jar under " + + System.getProperty("user.home") + "/.m2/repository/com/codenameone/java-runtime/ or in the " + + "Gradle cache. Build the project once (`./gradlew classes` or `mvn -pl common compile`) " + + "to populate the local cache."); System.exit(2); } System.err.println("[IsApiSupported] using " + jar); @@ -71,31 +96,52 @@ public static void main(String[] args) throws IOException { } } - private static Path findLatestJavaRuntimeJar() throws IOException { - Path baseDir = Paths.get(System.getProperty("user.home"), - ".m2", "repository", "com", "codenameone", "java-runtime"); - if (!Files.isDirectory(baseDir)) return null; - List candidates = new ArrayList<>(); + /// A jar of a com.codenameone artifact, with the version it was published as. + record Candidate(String version, Path jar) { } + + /// The newest jar of a com.codenameone artifact in either local cache: the + /// Maven repository (~/.m2/repository, filled by a Maven build) or the Gradle + /// cache ($GRADLE_USER_HOME, default ~/.gradle, filled by a Gradle build). A + /// project built with either tool therefore works without further setup. + private static Path findLatestJar(String artifactId) throws IOException { + List candidates = new ArrayList<>(); + String home = System.getProperty("user.home"); + collect(Paths.get(home, ".m2", "repository", "com", "codenameone", artifactId), artifactId, false, candidates); + String gradleHome = System.getenv("GRADLE_USER_HOME"); + Path gradleBase = gradleHome != null && !gradleHome.isEmpty() ? Paths.get(gradleHome) : Paths.get(home, ".gradle"); + collect(gradleBase.resolve(Paths.get("caches", "modules-2", "files-2.1", "com.codenameone", artifactId)), + artifactId, true, candidates); + if (candidates.isEmpty()) return null; + candidates.sort((a, b) -> compareVersions(b.version(), a.version())); + return candidates.get(0).jar(); + } + + /// Adds the jars under `baseDir//` (Maven) or + /// `baseDir///` (Gradle) to `out`. + private static void collect(Path baseDir, String artifactId, boolean hashed, List out) throws IOException { + if (!Files.isDirectory(baseDir)) return; try (DirectoryStream versions = Files.newDirectoryStream(baseDir)) { for (Path versionDir : versions) { if (!Files.isDirectory(versionDir)) continue; - try (DirectoryStream jars = Files.newDirectoryStream(versionDir, "java-runtime-*.jar")) { - for (Path jar : jars) { - if (jar.getFileName().toString().endsWith("-sources.jar")) continue; - if (jar.getFileName().toString().endsWith("-javadoc.jar")) continue; - candidates.add(jar); + List dirs = new ArrayList<>(); + if (hashed) { + try (DirectoryStream hashes = Files.newDirectoryStream(versionDir)) { + for (Path h : hashes) if (Files.isDirectory(h)) dirs.add(h); + } + } else { + dirs.add(versionDir); + } + for (Path dir : dirs) { + try (DirectoryStream jars = Files.newDirectoryStream(dir, artifactId + "-*.jar")) { + for (Path jar : jars) { + String n = jar.getFileName().toString(); + if (n.endsWith("-sources.jar") || n.endsWith("-javadoc.jar")) continue; + out.add(new Candidate(versionDir.getFileName().toString(), jar)); + } } } } } - if (candidates.isEmpty()) return null; - candidates.sort((a, b) -> compareVersions(versionOf(b), versionOf(a))); - return candidates.get(0); - } - - private static String versionOf(Path jar) { - Path versionDir = jar.getParent(); - return versionDir == null ? "" : versionDir.getFileName().toString(); } private static int compareVersions(String left, String right) { diff --git a/scripts/initializr/common/src/main/resources/skill/tools/IsCssValid.java b/scripts/initializr/common/src/main/resources/skill/tools/IsCssValid.java index 39478288780..2dab5e35f6c 100644 --- a/scripts/initializr/common/src/main/resources/skill/tools/IsCssValid.java +++ b/scripts/initializr/common/src/main/resources/skill/tools/IsCssValid.java @@ -1,3 +1,25 @@ +/* + * 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. + */ import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.Method; @@ -12,17 +34,20 @@ import java.util.List; /// Validates that a Codename One theme.css file compiles cleanly via the -/// `com.codename1.ui.css.CSSThemeCompiler` API. Run **with** the codename-one -/// jars on the classpath; the tool auto-discovers the latest version in -/// ~/.m2/repository. +/// `com.codename1.ui.css.CSSThemeCompiler` API. The tool auto-discovers the +/// latest codename-one jars in the local Maven repository (~/.m2/repository) or +/// the Gradle cache (~/.gradle/caches), whichever build filled them. /// -/// Usage: +/// Usage, from the project root: /// -/// java tools/IsCssValid.java common/src/main/css/theme.css +/// java tools/IsCssValid.java # finds the project's theme.css +/// java tools/IsCssValid.java src/main/css/theme.css # Gradle project +/// java tools/IsCssValid.java common/src/main/css/theme.css # Maven project /// -/// The tool locates `~/.m2/repository/com/codenameone/codenameone-core// -/// codenameone-core-.jar` (and a matching `java-runtime` jar) and runs -/// the compiler via reflection. No need to pre-set `-cp` — the tool builds the +/// With no argument it uses `src/main/css/theme.css` (a Gradle project) or +/// `common/src/main/css/theme.css` (a Maven project) under the current directory. +/// It locates the newest `codenameone-core` jar (and a matching `java-runtime` +/// jar) and runs the compiler via reflection. No need to pre-set `-cp` -- the tool builds the /// classpath internally. /// /// Exit codes: @@ -32,11 +57,16 @@ /// 2 — discovery / classpath / usage error public class IsCssValid { public static void main(String[] args) throws Exception { - if (args.length != 1) { - System.err.println("Usage: java IsCssValid.java "); + if (args.length > 1) { + System.err.println("Usage: java IsCssValid.java [path/to/theme.css]"); + System.exit(2); + } + Path cssFile = args.length == 1 ? Paths.get(args[0]) : findThemeCss(); + if (cssFile == null) { + System.err.println("No theme.css found at src/main/css/theme.css (Gradle) or " + + "common/src/main/css/theme.css (Maven). Pass its path."); System.exit(2); } - Path cssFile = Paths.get(args[0]); if (!Files.isRegularFile(cssFile)) { System.err.println("Not a file: " + cssFile); System.exit(2); @@ -46,7 +76,8 @@ public static void main(String[] args) throws Exception { Path coreJar = findLatestJar("codenameone-core"); Path runtimeJar = findLatestJar("java-runtime"); if (coreJar == null) { - System.err.println("Could not locate codenameone-core jar in ~/.m2. Run `mvn -pl common compile` first."); + System.err.println("Could not locate a codenameone-core jar in ~/.m2 or the Gradle cache. Build the " + + "project once first (`./gradlew classes` or `mvn -pl common compile`)."); System.exit(2); } System.err.println("[IsCssValid] using core=" + coreJar @@ -94,32 +125,61 @@ public static void main(String[] args) throws Exception { System.out.println("VALID"); } + /// A jar of a com.codenameone artifact, with the version it was published as. + record Candidate(String version, Path jar) { } + + /// The newest jar of a com.codenameone artifact in either local cache: the + /// Maven repository (~/.m2/repository, filled by a Maven build) or the Gradle + /// cache ($GRADLE_USER_HOME, default ~/.gradle, filled by a Gradle build). A + /// project built with either tool therefore works without further setup. private static Path findLatestJar(String artifactId) throws IOException { - Path baseDir = Paths.get(System.getProperty("user.home"), - ".m2", "repository", "com", "codenameone", artifactId); - if (!Files.isDirectory(baseDir)) return null; - List candidates = new ArrayList<>(); + List candidates = new ArrayList<>(); + String home = System.getProperty("user.home"); + collect(Paths.get(home, ".m2", "repository", "com", "codenameone", artifactId), artifactId, false, candidates); + String gradleHome = System.getenv("GRADLE_USER_HOME"); + Path gradleBase = gradleHome != null && !gradleHome.isEmpty() ? Paths.get(gradleHome) : Paths.get(home, ".gradle"); + collect(gradleBase.resolve(Paths.get("caches", "modules-2", "files-2.1", "com.codenameone", artifactId)), + artifactId, true, candidates); + if (candidates.isEmpty()) return null; + candidates.sort((a, b) -> compareVersions(b.version(), a.version())); + return candidates.get(0).jar(); + } + + /// Adds the jars under `baseDir//` (Maven) or + /// `baseDir///` (Gradle) to `out`. + private static void collect(Path baseDir, String artifactId, boolean hashed, List out) throws IOException { + if (!Files.isDirectory(baseDir)) return; try (DirectoryStream versions = Files.newDirectoryStream(baseDir)) { for (Path versionDir : versions) { if (!Files.isDirectory(versionDir)) continue; - try (DirectoryStream jars = Files.newDirectoryStream(versionDir, - artifactId + "-*.jar")) { - for (Path jar : jars) { - String n = jar.getFileName().toString(); - if (n.endsWith("-sources.jar") || n.endsWith("-javadoc.jar")) continue; - candidates.add(jar); + List dirs = new ArrayList<>(); + if (hashed) { + try (DirectoryStream hashes = Files.newDirectoryStream(versionDir)) { + for (Path h : hashes) if (Files.isDirectory(h)) dirs.add(h); + } + } else { + dirs.add(versionDir); + } + for (Path dir : dirs) { + try (DirectoryStream jars = Files.newDirectoryStream(dir, artifactId + "-*.jar")) { + for (Path jar : jars) { + String n = jar.getFileName().toString(); + if (n.endsWith("-sources.jar") || n.endsWith("-javadoc.jar")) continue; + out.add(new Candidate(versionDir.getFileName().toString(), jar)); + } } } } } - if (candidates.isEmpty()) return null; - candidates.sort((a, b) -> compareVersions(versionOf(b), versionOf(a))); - return candidates.get(0); } - private static String versionOf(Path jar) { - Path versionDir = jar.getParent(); - return versionDir == null ? "" : versionDir.getFileName().toString(); + /// The app's theme.css in whichever layout the current directory has. + private static Path findThemeCss() { + for (String candidate : new String[] {"src/main/css/theme.css", "common/src/main/css/theme.css"}) { + Path p = Paths.get(candidate); + if (Files.isRegularFile(p)) return p; + } + return null; } private static int compareVersions(String left, String right) { diff --git a/scripts/initializr/common/src/main/resources/skill/tools/README.md b/scripts/initializr/common/src/main/resources/skill/tools/README.md index 6a365f17358..4e01416e3ad 100644 --- a/scripts/initializr/common/src/main/resources/skill/tools/README.md +++ b/scripts/initializr/common/src/main/resources/skill/tools/README.md @@ -8,7 +8,9 @@ java tools/.java Each tool prints a one-line result on stdout and exits with status `0` for success, `1` for negative answer, `2` for a usage / discovery error. Diagnostic messages go to stderr. -The tools require **a Java 17+ JDK on `PATH`**. They auto-discover the Codename One jars under `~/.m2/repository/` — run `mvn -pl common compile` once in the consuming app to make sure that cache is populated. +The tools require **a Java 17+ JDK on `PATH`**. They auto-discover the Codename One jars in the local Maven repository (`~/.m2/repository/`) or the Gradle cache (`~/.gradle/caches/`, or `$GRADLE_USER_HOME`) — build the consuming app once (`./gradlew classes` in a Gradle project, `mvn -pl common compile` in a Maven one) to make sure a cache is populated. + +Paths in the examples are a Maven project's; in a Gradle project the app is at the root, so `common/src/main/css/theme.css` is `src/main/css/theme.css` and `common/target/classes` is `build/classes/java/main`. ## Available tools @@ -27,7 +29,7 @@ java tools/IsApiSupported.java java.util.HashMap#put # YES (class present at java-runtime-7.0.242.jar!java/util/HashMap.class — for method-level confirmation run `javap -p -classpath …` and grep for `put`) ``` -Useful when porting code from desktop Java and you want a quick "is this safe to use" check before discovering it at `mvn cn1:bytecode-compliance` time. +Useful when porting code from desktop Java and you want a quick "is this safe to use" check before discovering it at compile time (`mvn cn1:bytecode-compliance`, or any Gradle compile). For the full picture of what's supported and what isn't, see `references/java-api-subset.md`. @@ -36,13 +38,15 @@ For the full picture of what's supported and what isn't, see `references/java-ap Validates a `theme.css` file by running it through the Codename One CSS compiler. Reports `VALID` if the compiler produced a theme, `INVALID` plus the compiler's error message otherwise. ```bash +java tools/IsCssValid.java # finds src/main/css/theme.css or common/src/main/css/theme.css +# VALID java tools/IsCssValid.java common/src/main/css/theme.css # VALID ``` -The tool loads the latest `codenameone-core` jar from `~/.m2` and invokes the compiler via reflection — no manual `-cp` setup needed. +The tool loads the latest `codenameone-core` jar from `~/.m2` or the Gradle cache and invokes the compiler via reflection — no manual `-cp` setup needed. -This is **not** a substitute for running the simulator and looking at the result; it catches CSS *syntax* errors and unsupported properties, but a syntactically-valid file that styles the wrong UIID will still pass. Use it as a fast pre-flight before `mvn -pl common cn1:run`. +This is **not** a substitute for running the simulator and looking at the result; it catches CSS *syntax* errors and unsupported properties, but a syntactically-valid file that styles the wrong UIID will still pass. Use it as a fast pre-flight before `./gradlew run` / `mvn -pl common cn1:run`. ### `CompareToMockup.java` @@ -100,6 +104,20 @@ CP="common/target/classes:$(mvn -q -pl common dependency:build-classpath -Dmdep. java -cp "$CP" tools/DumpForm.java com.example.myapp.MyApp --out target/form-model.tsv [--width 1180] [--height 800] [--dark] ``` +Under **Gradle** there is no `dependency:build-classpath`; add this task to `build.gradle.kts` once and print the same classpath with it (the app's classes, its libraries and the simulator): + +```kotlin +tasks.register("printSimulatorClasspath") { + val main = project.extensions.getByType()["main"] + val cp = main.runtimeClasspath + main.compileClasspath + configurations.getByName("cn1Simulator") + doLast { println(cp.asPath) } +} +``` + +```bash +CP="$(./gradlew -q printSimulatorClasspath)" +``` + ### `DescribeForm.java` Turns a `DumpForm` model into a concise, designer-oriented outline so an LLM can reason about diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java index c7fc516ecdf..0a7909d1227 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java @@ -54,6 +54,10 @@ public boolean runTest() throws Exception { Path java8Or11 = findJava8Or11Home(); Path java17 = findJavaHomeForMajor(17); + // The Gradle half needs no build client (nothing is sent to the build server), + // so it runs before the Maven half's early return. + buildGeneratedGradleProjects(java17); + Path buildClient = findBuildClientJar(); if (buildClient == null) { // Every goal in the generated project's compile runs out of this jar, so @@ -109,6 +113,104 @@ private void buildGeneratedProject(ProjectOptions.JavaVersion version, Path java assertLocalizationBakedIntoThemeRes(projectDir, version); } + /// Compiles generated Gradle projects (App, App + backend, Backend only) with + /// `./gradlew classes`, against the Maven repository named by the + /// CN1_GRADLE_PLUGIN_REPO environment variable -- a directory holding the + /// `com.codenameone` plugin (its marker and jar) and the framework, e.g. a + /// checkout's `maven/.m2-repo` after `mvn install`. The generated settings name + /// a released plugin version that only exists on repo.codenameone.com, so + /// without that repository there is nothing to build against and the check is + /// skipped, as the Maven half skips without a build client. + /// CN1_GRADLE_PLUGIN_VERSION, when set, replaces the plugin version in the + /// copy under test (the repository usually holds a SNAPSHOT). + private void buildGeneratedGradleProjects(Path java17) throws Exception { + String repo = System.getenv("CN1_GRADLE_PLUGIN_REPO"); + if (repo == null || repo.length() == 0) { + System.out.println("[WARN] Skipping Gradle integration build checks. Set CN1_GRADLE_PLUGIN_REPO to a " + + "Maven repository holding the com.codenameone Gradle plugin."); + return; + } + if (java17 == null) { + System.out.println("[WARN] Skipping Gradle integration build checks. No JDK 17 found."); + return; + } + Path repoDir = Paths.get(repo).toAbsolutePath(); + String version = System.getenv("CN1_GRADLE_PLUGIN_VERSION"); + // Every project type with the Java template, plus each template whose build + // script adds something of its own (the Kotlin plugin, Tweet's cn1libs). + Object[][] cases = new Object[][] { + {Template.BAREBONES, ProjectOptions.ProjectType.APP}, + {Template.BAREBONES, ProjectOptions.ProjectType.APP_WITH_BACKEND}, + {Template.BAREBONES, ProjectOptions.ProjectType.BACKEND_ONLY}, + {Template.KOTLIN, ProjectOptions.ProjectType.APP}, + // cn1libs by coordinates, CodeRAD XML views and an annotation processor. + {Template.TWEET, ProjectOptions.ProjectType.APP} + }; + for (int i = 0; i < cases.length; i++) { + Template template = (Template) cases[i][0]; + ProjectOptions.ProjectType type = (ProjectOptions.ProjectType) cases[i][1]; + if (!template.supportsGradle()) { + // Listed so it is built as soon as the template is enabled for Gradle. + System.out.println("[WARN] Skipping " + template + ": " + template.GRADLE_UNSUPPORTED_REASON); + continue; + } + String suffix = "gradle" + i; + ProjectOptions options = new ProjectOptions(ProjectOptions.ThemeMode.LIGHT, ProjectOptions.Accent.DEFAULT, + true, true, ProjectOptions.PreviewLanguage.ENGLISH, ProjectOptions.JavaVersion.JAVA_17, null, + ProjectOptions.BuildTool.GRADLE, type); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + GeneratorModel.create(IDE.INTELLIJ, template, "IntegrationGradle" + i + "App", + "com.acme.initializr." + suffix, options).writeProjectZip(output); + Path projectDir = Files.createTempDirectory("initializr-integration-" + suffix + "-"); + unzipProject(output.toByteArray(), projectDir); + injectPluginRepository(projectDir.resolve("settings.gradle.kts"), repoDir, version); + + int exit = runGradleClasses(projectDir, repoDir, java17); + assertTrue(exit == 0, "Generated Gradle project should compile. Template=" + template + " Type=" + + type.label + " | exitCode=" + exit); + } + } + + /// Puts `repoDir` first in the copy's pluginManagement repositories, so the + /// plugin resolves from it; -Pcodename1.repository does the same for the + /// framework the plugin adds. + private void injectPluginRepository(Path settings, Path repoDir, String version) throws IOException { + String text = new String(Files.readAllBytes(settings), "UTF-8"); + String marker = " repositories {\n"; + assertTrue(text.indexOf(marker) >= 0, "settings.gradle.kts should declare pluginManagement repositories"); + text = text.replace(marker, marker + " maven(uri(\"" + repoDir.toUri() + "\"))\n"); + if (version != null && version.length() > 0) { + text = text.replaceAll("id\\(\"com\\.codenameone\"\\) version \"[^\"]*\"", + "id(\"com.codenameone\") version \"" + version + "\""); + } + Files.write(settings, text.getBytes("UTF-8")); + } + + private int runGradleClasses(Path projectDir, Path repoDir, Path javaHome) throws Exception { + Path gradlew = projectDir.resolve("gradlew"); + gradlew.toFile().setExecutable(true); + boolean windows = File.separatorChar == '\\'; + List command = new ArrayList(); + if (windows) { + command.add("cmd"); + command.add("/c"); + command.add(projectDir.resolve("gradlew.bat").toString()); + } else { + command.add(gradlew.toString()); + } + command.add("--no-daemon"); + command.add("--stacktrace"); + command.add("classes"); + command.add("-Pcodename1.repository=" + repoDir); + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(projectDir.toFile()); + pb.redirectErrorStream(true); + Map env = pb.environment(); + env.put("JAVA_HOME", javaHome.toString()); + env.put("PATH", javaHome.resolve("bin") + File.pathSeparator + env.get("PATH")); + return runAndReport(pb); + } + private void assertLocalizationBakedIntoThemeRes(Path projectDir, ProjectOptions.JavaVersion version) throws Exception { Path themeRes = projectDir.resolve("common/target/classes/theme.res"); assertTrue(Files.isRegularFile(themeRes), @@ -161,6 +263,11 @@ private int runMavenCompile(Path projectDir, Path homeDir, Path javaHome) throws env.put("JAVA_HOME", javaHome.toString()); env.put("PATH", javaHome.resolve("bin") + File.pathSeparator + env.get("PATH")); + return runAndReport(pb); + } + + /// Runs the process, and prints its output when it fails. + private int runAndReport(ProcessBuilder pb) throws Exception { List output = new ArrayList(); Process process = pb.start(); try (InputStream in = process.getInputStream()) { diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index 3bab228f0df..19b061dde6a 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -48,6 +48,21 @@ public boolean runTest() throws Exception { validateLegacyJava8Generation(); validateCoordinateGuardRejectsBrokenArtifacts(); validateLocaleIndependentArtifactIds(); + validateGradleRefusesUnsupportedCombinations(); + validateMavenIgnoresAppProjectType(); + validateGradleTemplateDependencies(); + validateAgentsMdMatchesBuildTool(); + validateGradleLocalization(); + for (Template template : Template.values()) { + if (!template.supportsGradle()) { + continue; + } + for (ProjectOptions.ProjectType type : ProjectOptions.ProjectType.values()) { + for (IDE ide : IDE.values()) { + validateGradleCombination(template, type, ide); + } + } + } for (Template template : Template.values()) { for (IDE ide : IDE.values()) { validateCombination(template, ide); @@ -61,6 +76,291 @@ public boolean runTest() throws Exception { + private static ProjectOptions gradleOptions(ProjectOptions.ProjectType type) { + return ProjectOptions.defaults().withBuild(ProjectOptions.BuildTool.GRADLE, type); + } + + /// Every Gradle download, per template, project type and IDE: a single project + /// at the root with the shared template's build files, and nothing of Maven's. + private void validateGradleCombination(Template template, ProjectOptions.ProjectType type, IDE ide) throws Exception { + String mainClassName = "Grd" + template.ordinal() + type.ordinal() + ide.ordinal() + "App"; + String packageName = "com.acme.g" + template.ordinal() + ".t" + type.ordinal() + ".i" + ide.ordinal(); + String packagePath = packageName.replace('.', '/'); + String label = template + "/" + type + "/" + ide + ": "; + byte[] zipData = createProjectZip(ide, template, mainClassName, packageName, gradleOptions(type)); + Map entries = readZipEntries(zipData); + + for (String path : entries.keySet()) { + assertFalse(path.startsWith("common/"), label + "Gradle projects have no common/ module: " + path); + assertFalse(path.endsWith("pom.xml"), label + "Gradle projects carry no pom: " + path); + assertFalse(path.startsWith("mvnw") || path.startsWith(".mvn/"), label + "Maven wrapper leaked: " + path); + assertFalse(path.equals("build.sh") || path.equals("run.sh") || path.equals("build.bat") + || path.equals("run.bat"), label + "Maven launcher leaked: " + path); + String[] mavenModules = {"android/", "ios/", "javase/", "javascript/", "linux/", "win/", "cn1libs/"}; + for (int i = 0; i < mavenModules.length; i++) { + assertFalse(path.startsWith(mavenModules[i]), label + "Maven module leaked: " + path); + } + // Native directories (src//) are created on demand by + // generateNativeInterfaces, never shipped empty or pre-populated. + if (path.startsWith("src/") || path.startsWith("backend/src/")) { + String rel = path.startsWith("backend/") ? path.substring("backend/".length()) : path; + assertTrue(rel.startsWith("src/main/"), label + "unexpected source set: " + path); + } + assertFalse(path.indexOf("com/example/myapp") >= 0, label + "Unrefactored placeholder path found: " + path); + } + + assertNotNull(entries.get("gradlew"), label + "missing gradlew"); + assertNotNull(entries.get("gradlew.bat"), label + "missing gradlew.bat"); + assertNotNull(entries.get("gradle/wrapper/gradle-wrapper.jar"), label + "missing wrapper jar"); + assertNotNull(entries.get("gradle/wrapper/gradle-wrapper.properties"), label + "missing wrapper properties"); + assertTrue(unixMode(zipData, "gradlew") == 0100755, label + "gradlew must extract executable"); + assertTrue(unixMode(zipData, "gradlew.bat") == 0100644, label + "gradlew.bat is not a Unix executable"); + + String settings = getText(entries, "settings.gradle.kts"); + assertContains(settings, "id(\"com.codenameone\") version \"7.0.273\"", + label + "settings.gradle.kts should apply the Codename One plugin at the generated version"); + assertContains(settings, "rootProject.name = \"" + GeneratorModel.toLowerCaseInvariant(mainClassName) + "\"", + label + "settings.gradle.kts should name the project like the Maven artifactId"); + assertContains(getText(entries, "gradle.properties"), "org.gradle.configuration-cache=true", + label + "gradle.properties should come from the shared template"); + String gitIgnore = getText(entries, ".gitignore"); + assertContains(gitIgnore, ".gradle/", label + ".gitignore should ignore the Gradle cache"); + assertContains(gitIgnore, "build/", label + ".gitignore should ignore build outputs"); + for (Map.Entry e : entries.entrySet()) { + if (e.getKey().endsWith(".kts") || e.getKey().endsWith(".properties") || e.getKey().endsWith(".java")) { + String text = StringUtil.newString(e.getValue()); + assertFalse(text.indexOf("__CN1_VERSION__") >= 0 || text.indexOf("__PROJECT_NAME__") >= 0 + || text.indexOf("__BACKEND__") >= 0 || text.indexOf("${package}") >= 0, + label + "unrendered template token in " + e.getKey()); + } + } + + String readme = getText(entries, "README.md"); + assertContains(readme, "./gradlew", label + "README should document Gradle commands"); + assertFalse(readme.indexOf("mvn") >= 0, label + "Gradle README must not mention Maven commands"); + assertFalse(readme.indexOf("build.sh") >= 0, label + "Gradle README must not mention the Maven launchers"); + + String apiPath; + if (type == ProjectOptions.ProjectType.BACKEND_ONLY) { + assertNull(entries.get("codenameone_settings.properties"), + label + "a backend has no Codename One app settings"); + assertNull(entries.get("icon.png"), label + "a backend has no icon"); + assertNull(entries.get("src/main/css/theme.css"), label + "a backend has no theme"); + assertNull(entries.get("AGENTS.md"), label + "the UI authoring skill is not shipped with a backend"); + assertNotNull(entries.get("application.properties"), label + "backend settings belong at the root"); + assertNotNull(entries.get("application-dev.properties"), label + "backend dev profile belongs at the root"); + assertContains(getText(entries, "build.gradle.kts"), "runBackend", + label + "a backend-only root build script is the backend template"); + apiPath = "src/main/java/" + packagePath + "/Api.java"; + String api = getText(entries, apiPath); + assertContains(api, "./gradlew runBackend", label + "backend-only tasks are addressed from the root"); + assertFalse(api.indexOf(":backend:") >= 0, label + "backend-only has no :backend: subproject"); + assertContains(getText(entries, "src/main/java/" + packagePath + "/Greeter.java"), + "package " + packageName + ";", label + "the service the Api is given ships beside it"); + assertContains(readme, "./gradlew runBackend", label + "backend README should say how to run it"); + } else { + String settingsProps = getText(entries, "codenameone_settings.properties"); + assertContains(settingsProps, "codename1.packageName=" + packageName, label + "settings package"); + assertContains(settingsProps, "codename1.mainName=" + mainClassName, label + "settings main class"); + assertContains(settingsProps, "codename1.kotlin=" + template.IS_KOTLIN, label + "settings kotlin flag"); + assertContains(settingsProps, "codename1.arg.java.version=17", label + "Gradle projects are Java 17"); + assertNotNull(entries.get("icon.png"), label + "icon belongs at the root"); + String themeCss = getText(entries, "src/main/css/theme.css"); + assertContains(themeCss, "useLargerTextScaleBool: true;", label + "theme defaults apply at the root too"); + String mainPath = template.IS_KOTLIN + ? "src/main/kotlin/" + packagePath + "/" + mainClassName + ".kt" + : "src/main/java/" + packagePath + "/" + mainClassName + ".java"; + assertContains(getText(entries, mainPath), "package " + packageName, label + "main source package"); + String buildScript = getText(entries, "build.gradle.kts"); + if (template.IS_KOTLIN) { + assertContains(buildScript, "kotlin(\"jvm\") version \"" + GeneratorModel.KOTLIN_VERSION + "\"", + label + "Kotlin projects apply the Kotlin Gradle plugin"); + assertContains(buildScript, "org.jetbrains.kotlin:kotlin-stdlib:" + GeneratorModel.KOTLIN_VERSION, + label + "Kotlin projects declare kotlin-stdlib, as the Maven template does"); + assertTrue(buildScript.indexOf("plugins {") < buildScript.indexOf("dependencies {"), + label + "plugins {} must precede every other block"); + } else { + assertFalse(buildScript.indexOf("kotlin(") >= 0, label + "Java projects do not apply Kotlin"); + } + assertContains(getText(entries, "AGENTS.md"), "./gradlew run", label + "AGENTS.md should use Gradle"); + assertNotNull(entries.get(".agent-skills/codename-one/SKILL.md"), label + "the skill ships with Gradle apps"); + apiPath = "backend/src/main/java/" + packagePath + "/Api.java"; + if (type == ProjectOptions.ProjectType.APP_WITH_BACKEND) { + assertNotNull(entries.get("backend/application.properties"), label + "backend settings"); + assertNotNull(entries.get("backend/application-dev.properties"), label + "backend dev profile"); + assertNotNull(entries.get("backend/build.gradle.kts"), label + "backend build script"); + String api = getText(entries, apiPath); + assertContains(api, "package " + packageName + ";", label + "backend shares the app package"); + assertContains(api, "./gradlew :backend:runBackend", label + "backend tasks are addressed as a subproject"); + assertContains(getText(entries, "backend/src/main/java/" + packagePath + "/Greeter.java"), + "package " + packageName + ";", label + "the service the Api is given ships beside it"); + assertContains(getText(entries, "backend/application.properties"), ":backend:runBackend", + label + "backend settings comment should name the subproject task"); + } else { + for (String path : entries.keySet()) { + assertFalse(path.startsWith("backend/"), label + "an App project has no backend: " + path); + } + assertContains(readme, "./gradlew addBackend", label + "README should say how to add a backend"); + } + } + + if (ide == IDE.INTELLIJ) { + assertContains(getText(entries, ".idea/gradle.xml"), "GradleProjectSettings", + label + "IntelliJ should link the Gradle build"); + assertNull(entries.get(".idea/workspace.xml"), label + "Maven run configurations must not ship with Gradle"); + if (type == ProjectOptions.ProjectType.BACKEND_ONLY) { + assertContains(getText(entries, ".idea/runConfigurations/Run_Backend.xml"), + "