From 7a860967e9c4596b89ae1deb433bbaccf5f82937 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:47:52 +0200 Subject: [PATCH 1/3] Android: 16 KB aligned injected dependencies, and a gate that resolves them CameraX 1.3.4, injected for CodeScanner and the vision views, ships a 4 KB aligned libimage_processing_util_jni.so, and Google Play rejects the upload. check-16k-page-alignment.py reads tracked files only, so it never saw it. - CameraX 1.4.2; ML Kit, LiteRT and ONNX Runtime raised to aligned releases (ONNX lifts the Stable Diffusion floor to minSdk 24). - rootbeer-lib 0.1.2 (0.1.0 is misaligned); rootCheck lifts minSdk to 21. - play-services-panorama / -safetynet were misspelled and never resolved; safetynet now reads its own default. - android.playService.indexing fails with a clear message: the artifact ended at 9.8.0 and no version works beside a current play-services base. - scripts/check-android-dependency-alignment.py resolves every injected coordinate with Gradle, each group alone, and runs the ELF check; AndroidSixteenKbPageFloorTest holds measured floors and ratchets the lister against every coordinate the builder sources name. Co-Authored-By: Claude Opus 5.5 --- .../check-android-dependency-alignment.yml | 108 ++++++++ CLAUDE.md | 22 ++ .../builders/AndroidGradleBuilder.java | 26 +- .../AndroidDependencyCoordinates.java | 227 ++++++++++++++++ .../AndroidSixteenKbPageFloorTest.java | 243 +++++++++++++++++ .../HighLevelVisionDependencyTest.java | 2 +- .../build/shared/BuildHintsAndroid.java | 2 +- .../build/shared/PlatformFeatureCatalog.java | 98 +++++-- .../shared/PlatformFeatureCatalogTest.java | 13 +- scripts/check-android-dependency-alignment.py | 249 ++++++++++++++++++ ...test_check_android_dependency_alignment.py | 167 ++++++++++++ 11 files changed, 1118 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/check-android-dependency-alignment.yml create mode 100644 maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java create mode 100644 maven/build-engine/src/test/java/com/codename1/builders/AndroidSixteenKbPageFloorTest.java create mode 100755 scripts/check-android-dependency-alignment.py create mode 100755 scripts/test_check_android_dependency_alignment.py diff --git a/.github/workflows/check-android-dependency-alignment.yml b/.github/workflows/check-android-dependency-alignment.yml new file mode 100644 index 00000000000..b43d4383b72 --- /dev/null +++ b/.github/workflows/check-android-dependency-alignment.yml @@ -0,0 +1,108 @@ +name: Check Android dependency 16 KB alignment + +# check-16k-page-alignment.yml reads every TRACKED artifact. Most native code a +# Codename One Android app ships is not tracked: the builder writes Maven +# coordinates into the generated build.gradle (PlatformFeatureCatalog entries, +# the play-services defaults) and Gradle downloads them on the build machine. +# CameraX 1.3.4 reached every CodeScanner / VisionCameraView app that way with +# a 4 KB aligned libimage_processing_util_jni.so, Google Play refused the +# upload, and the tree gate stayed green because it never saw the file. +# +# This resolves every one of those coordinates with Gradle, each catalog entry +# on its own (Gradle's highest-version-wins would otherwise let one entry's new +# pin hide another's old one), and runs the same ELF check over the result. +# It needs the network, so it runs only when something that decides the set +# changes; AndroidSixteenKbPageFloorTest is the offline half (version floors, +# and a ratchet that fails when the builder sources name a coordinate the +# lister does not list). It runs here first, and in every build-engine test run. +# +# The cloud builder (BuildDaemon) has its own copy of the catalog and the +# defaults and runs this same script over its own list. + +permissions: + contents: read + +on: + workflow_dispatch: + pull_request: + branches: [ master ] + paths: + - 'maven/platform-feature-catalog/**' + - 'maven/build-engine/src/main/java/com/codename1/builders/**' + - 'maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java' + - 'maven/build-engine/src/test/java/com/codename1/builders/AndroidSixteenKbPageFloorTest.java' + - 'maven/build-hint-catalog/**' + - 'scripts/check-android-dependency-alignment.py' + - 'scripts/test_check_android_dependency_alignment.py' + - 'scripts/check-16k-page-alignment.py' + - '.github/workflows/check-android-dependency-alignment.yml' + push: + branches: [ master ] + paths: + - 'maven/platform-feature-catalog/**' + - 'maven/build-engine/src/main/java/com/codename1/builders/**' + - 'maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java' + - 'maven/build-engine/src/test/java/com/codename1/builders/AndroidSixteenKbPageFloorTest.java' + - 'maven/build-hint-catalog/**' + - 'scripts/check-android-dependency-alignment.py' + - 'scripts/test_check_android_dependency_alignment.py' + - 'scripts/check-16k-page-alignment.py' + - '.github/workflows/check-android-dependency-alignment.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check-android-dependency-alignment: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Unit-test the gate + # Run before anything is resolved, so a broken gate is reported as a + # broken gate rather than as a clean dependency set. + run: python3 scripts/test_check_android_dependency_alignment.py + + # Installed FIRST so the JDK 8 below is the one JAVA_HOME names. + - 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" + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.13' + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + java-version: '8' + distribution: 'temurin' + cache: 'maven' + + - name: Build build-engine, run the floor test, list its Android dependencies + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + set -euo pipefail + cd maven + bash ../scripts/ci/retry.sh mvn -B -pl build-engine -am install \ + -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase + mvn -B -pl build-engine test -Dtest=AndroidSixteenKbPageFloorTest \ + -Dsurefire.failIfNoSpecifiedTests=false -Plocal-dev-javase + mvn -B -q -pl build-engine test-compile dependency:build-classpath \ + -Dmdep.outputFile="$RUNNER_TEMP/build-engine.cp" -Plocal-dev-javase + java -cp "build-engine/target/classes:build-engine/target/test-classes:$(cat "$RUNNER_TEMP/build-engine.cp")" \ + com.codename1.builders.AndroidDependencyCoordinates "$RUNNER_TEMP/android-coordinates.txt" + wc -l "$RUNNER_TEMP/android-coordinates.txt" + + - name: Resolve every dependency and check 16 KB page alignment + run: | + JAVA_HOME="$GRADLE_JAVA_HOME" GRADLE="$(command -v gradle)" \ + python3 scripts/check-android-dependency-alignment.py \ + --coordinates "$RUNNER_TEMP/android-coordinates.txt" \ + --work "$RUNNER_TEMP/dependency-alignment" --verbose diff --git a/CLAUDE.md b/CLAUDE.md index d5d36659cb7..ed716865002 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -626,6 +626,28 @@ scripts/check-16k-page-alignment.py # every tracked artifact scripts/check-16k-page-alignment.py --verbose # list everything scanned ``` +**That gate reads tracked files only, and most of an app's natives are not +tracked.** The builder writes Maven coordinates into the generated +`build.gradle` -- `PlatformFeatureCatalog` entries and the play-services +defaults -- and Gradle downloads them on the build machine. CameraX 1.3.4 +reached every `CodeScanner` app that way, 4 KB aligned, and Play refused the +upload with every check green. `scripts/check-android-dependency-alignment.py` +(`check-android-dependency-alignment.yml`) resolves that whole list with Gradle, +**each catalog entry in its own configuration** -- pooled, Gradle's +highest-version-wins upgrades an old pin to another entry's newer one and the +old one reads clean -- and runs the same ELF check. It needs the network; +`AndroidSixteenKbPageFloorTest` in build-engine is the offline half, a table of +measured first-aligned versions. A new native dependency in the catalog needs +both: run the script, then add the floor. The cloud builder (BuildDaemon) keeps +its own catalog and runs the same script over its own list. + +```bash +cd maven && mvn -pl build-engine test-compile dependency:build-classpath -Dmdep.outputFile=/tmp/cp +java -cp build-engine/target/classes:build-engine/target/test-classes:$(cat /tmp/cp) \ + com.codename1.builders.AndroidDependencyCoordinates /tmp/coords.txt +cd .. && JAVA_HOME= scripts/check-android-dependency-alignment.py --coordinates /tmp/coords.txt +``` + #### ParparVM native names are checked, and getting one wrong is silent ParparVM encodes the **whole Java signature in the C function name**. For diff --git a/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 34021ce4db9..d4db46ef121 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -2652,6 +2652,11 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc // ("uses-sdk:minSdkVersion 19 cannot be smaller than version 21"). minSDK = maxInt("21", minSDK); } + if (rootCheck) { + // rootbeer-lib 0.1.0 ships 4 KB aligned arm64/x86_64 natives, which Play + // rejects; 0.1.2 is aligned but declares minSdk 21 in its manifest. + minSDK = maxInt("21", minSDK); + } if (googleAdUnitId != null && googleAdUnitId.length() > 0) { minSDK = maxInt("9", minSDK); if (!userXapplication.contains("com.google.android.gms.version")) { @@ -7067,7 +7072,7 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { String rootCheckCall = ""; if (rootCheck) { if (!request.getArg("gradleDependencies", "").contains("com.scottyab:rootbeer-lib")) { - String rootbeerVersion = request.getArg("android.rootbeerVersion", "0.1.0"); + String rootbeerVersion = request.getArg("android.rootbeerVersion", "0.1.2"); request.putArgument( "gradleDependencies", request.getArg("gradleDependencies", "") + @@ -8119,7 +8124,12 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-identity:"+getDefaultPlayServiceVersion("identity")+"'\n"; } if(playServicesIndexing){ - additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-appindexing:"+getDefaultPlayServiceVersion("appindexing")+"'\n"; + // play-services-appindexing ended at 9.8.0 and continued as firebase-appindexing + // under another package. Any newer version names nothing, and 9.8.0 beside a + // current play-services base breaks at run time, so the hint cannot work. + throw new BuildException("android.playService.indexing is no longer supported: " + + "Google discontinued play-services-appindexing after 9.8.0; use " + + "com.google.firebase:firebase-appindexing through android.gradleDep instead"); } if(playServicesInvite){ additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-appinvite:"+getDefaultPlayServiceVersion("appinvite")+"'\n"; @@ -8155,13 +8165,13 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-nearby:"+getDefaultPlayServiceVersion("nearby")+"'\n"; } if(playServicesSafetyPanorama){ - additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-panaroma:"+getDefaultPlayServiceVersion("panorama")+"'\n"; + additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-panorama:"+getDefaultPlayServiceVersion("panorama")+"'\n"; } if(playServicesGames){ additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-games:"+getDefaultPlayServiceVersion("games")+"'\n"; } if(playServicesSafetyNet){ - additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-safenet:"+getDefaultPlayServiceVersion("safenet")+"'\n"; + additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-safetynet:"+getDefaultPlayServiceVersion("safetynet")+"'\n"; } if(playServicesWallet){ additionalDependencies += " "+compile+" 'com.google.android.gms:play-services-wallet:"+getDefaultPlayServiceVersion("wallet")+"'\n"; @@ -12398,6 +12408,14 @@ private void createAndroidStudioProject(File dest) throws IOException { } + /// The play-services defaults, keyed by the name after `play-services-`. + /// Read by `AndroidDependencyCoordinates`, which lists every coordinate + /// this builder can inject so CI can check its native libraries for + /// 16 KB page alignment. + static Map defaultPlayServiceVersionsView() { + return Collections.unmodifiableMap(defaultPlayServiceVersions); + } + private String getDefaultPlayServiceVersion(String playService) { if (playServiceVersions.containsKey(playService)) { return playServiceVersions.get(playService); diff --git a/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java new file mode 100644 index 00000000000..1bbf17282a1 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/builders/AndroidDependencyCoordinates.java @@ -0,0 +1,227 @@ +/* + * 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.builders; + +import com.codename1.build.shared.PlatformFeatureCatalog; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// Lists every Android dependency this builder can write into a generated +/// `build.gradle`, for `scripts/check-android-dependency-alignment.py`. +/// +/// One line per dependency, ``. A group is what an app +/// gets together: one catalog entry, one play-services call site, or one other +/// injected module. The script resolves each group by itself, because +/// resolving them together lets Gradle's highest-version-wins hide an old pin +/// behind a newer one. +/// +/// Four sources, and `AndroidSixteenKbPageFloorTest` holds the last two to +/// the builder's source so a new coordinate cannot slip past the list: +/// +/// - `PlatformFeatureCatalog`, read through its API. +/// - Each `play-services-:" + getDefaultPlayServiceVersion("")` +/// call site, read from the builder source, at the decoupled default and at +/// the coupled `android.playServicesVersion` default (every play service +/// then ships at that one version). +/// - [#INJECTED]: every other module the builders write, at the version the +/// builder uses when the app sets no hint. +/// - [#NOT_SHIPPED]: coordinates in the source that are not app dependencies. +/// +/// What it cannot see: versions an app picks with a hint, `android.gradleDep` +/// and cn1lib dependencies. Those are the developer's own choice. +/// +/// Usage: `java ... AndroidDependencyCoordinates [output-file]` (stdout when +/// no file is given), run from `maven/build-engine` or with +/// `-Dcn1.builders.src=`. +public final class AndroidDependencyCoordinates { + + /// The coupled-mode default, `AndroidGradleBuilder.playServicesVersion`. + static final String COUPLED_PLAY_SERVICES_VERSION = "12.0.1"; + + /// group:artifact -> the version written when no hint overrides it. + static final Map INJECTED = new LinkedHashMap(); + + /// group:artifact prefixes the builder source names that are not app + /// dependencies, with why. + static final Map NOT_SHIPPED = new LinkedHashMap(); + + static { + INJECTED.put("androidx.appcompat:appcompat", "1.6.1"); + INJECTED.put("androidx.browser:browser", "1.8.0"); + INJECTED.put("androidx.car.app:app", "1.4.0"); + INJECTED.put("androidx.car.app:app-projected", "1.4.0"); + INJECTED.put("androidx.concurrent:concurrent-futures", "1.1.0"); + INJECTED.put("androidx.credentials:credentials", "1.3.0"); + INJECTED.put("androidx.credentials:credentials-play-services-auth", "1.3.0"); + INJECTED.put("androidx.health.connect:connect-client", "1.1.0-alpha07"); + INJECTED.put("androidx.legacy:legacy-support-v4", "1.0.0"); + INJECTED.put("androidx.multidex:multidex", "2.0.1"); + INJECTED.put("androidx.wear.protolayout:protolayout", "1.2.1"); + INJECTED.put("androidx.wear.protolayout:protolayout-material", "1.2.1"); + INJECTED.put("androidx.wear.tiles:tiles", "1.4.1"); + INJECTED.put("androidx.wear.watchface:watchface-complications-data-source", "1.2.1"); + INJECTED.put("androidx.window:window", "1.3.0"); + INJECTED.put("com.android.billingclient:billing", PlayBillingVersions.DEFAULT_VERSION); + INJECTED.put("com.android.support:appcompat-v7", "28.0.0"); + INJECTED.put("com.android.support:multidex", "1.0.3"); + INJECTED.put("com.android.support:support-v4", "28.0.0"); + INJECTED.put("com.android.tools:desugar_jdk_libs", "2.1.5"); + INJECTED.put("com.facebook.android:facebook-android-sdk", "16.2.0"); + INJECTED.put("com.google.android.gms:play-services", "6.5.87"); + INJECTED.put("com.google.android.gms:play-services-auth", "20.6.0"); + INJECTED.put("com.google.android.gms:play-services-home", "16.0.0-beta1"); + INJECTED.put("com.google.android.gms:play-services-maps", "18.2.0"); + INJECTED.put("com.google.android.play:integrity", "1.4.0"); + INJECTED.put("com.google.android.play:review", "2.0.1"); + INJECTED.put("com.google.firebase:firebase-analytics", "21.5.0"); + INJECTED.put("com.google.firebase:firebase-messaging", "23.2.1"); + INJECTED.put("com.google.guava:guava", "31.1-android"); + INJECTED.put("com.huawei.hms:maps", "6.11.0.300"); + INJECTED.put("com.huawei.hms:push", "6.3.0.302"); + INJECTED.put("com.scottyab:rootbeer-lib", "0.1.2"); + INJECTED.put("org.jetbrains.kotlin:kotlin-stdlib", "1.9.22"); + INJECTED.put("org.unifiedpush.android:connector", "3.3.5"); + + NOT_SHIPPED.put("com.android.tools.build:gradle", "the Android Gradle plugin"); + NOT_SHIPPED.put("com.google.gms:google-services", "a Gradle plugin"); + NOT_SHIPPED.put("com.huawei.agconnect:agcp", "a Gradle plugin"); + NOT_SHIPPED.put("org.jetbrains.kotlin:kotlin-gradle-plugin", "a Gradle plugin"); + } + + private static final Pattern PLAY_SERVICE_CALL = Pattern.compile( + "com\\.google\\.android\\.gms:play-services-([a-z0-9\\-]+):\"\\s*\\+\\s*" + + "getDefaultPlayServiceVersion\\(\"([a-z0-9\\-]+)\"\\)"); + + /// A Maven coordinate prefix in a Java string literal: `'group:artifact:` + /// or `"group:artifact:`. + static final Pattern COORDINATE_PREFIX = Pattern.compile( + "[\"']([a-z][a-z0-9_\\-]*(?:\\.[a-z0-9_\\-]+)+:[A-Za-z0-9_.\\-]+):"); + + private AndroidDependencyCoordinates() { + } + + static File buildersSource() { + return new File(System.getProperty("cn1.builders.src", + "src/main/java/com/codename1/builders")); + } + + static String read(File file) throws IOException { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.ISO_8859_1); + } + + /// play-services artifact -> the key its version is looked up under. + static Map playServiceCallSites() throws IOException { + Map out = new TreeMap(); + Matcher m = PLAY_SERVICE_CALL.matcher(read(new File(buildersSource(), "AndroidGradleBuilder.java"))); + while (m.find()) { + out.put(m.group(1), m.group(2)); + } + return out; + } + + /// Every group:artifact a builder source names in a string literal. + static Set coordinatePrefixesInSource() throws IOException { + Set out = new LinkedHashSet(); + File[] files = buildersSource().listFiles(); + if (files == null) { + throw new IOException("no builder sources at " + buildersSource()); + } + for (File file : files) { + if (!file.getName().endsWith(".java")) { + continue; + } + Matcher m = COORDINATE_PREFIX.matcher(read(file)); + while (m.find()) { + out.add(m.group(1)); + } + } + return out; + } + + /// @return the lines, in a stable order + static List lines() throws IOException { + List out = new ArrayList(); + for (PlatformFeatureCatalog.Entry entry : PlatformFeatureCatalog.entries()) { + for (String dependency : entry.androidGradleDeps()) { + out.add("catalog:" + entry.classPrefix() + "\t" + dependency); + } + } + Map defaults = AndroidGradleBuilder.defaultPlayServiceVersionsView(); + for (Map.Entry site : playServiceCallSites().entrySet()) { + // getDefaultPlayServiceVersion falls back to the coupled version + // for a key with no default of its own, as it does in coupled mode. + String version = defaults.get(site.getValue()); + String artifact = "com.google.android.gms:play-services-" + site.getKey(); + if (version != null) { + out.add("play-services:" + site.getKey() + "\t" + artifact + ":" + version); + } + out.add("play-services-coupled:" + site.getKey() + "\t" + artifact + ":" + + COUPLED_PLAY_SERVICES_VERSION); + } + for (Map.Entry module : INJECTED.entrySet()) { + // The Wear modules go in together (watchSurfaceDependencyBlock), and + // complications resolves its protolayout-proto only beside them. + String group = module.getKey().startsWith("androidx.wear.") + ? "injected:wear" : "injected:" + module.getKey(); + out.add(group + "\t" + module.getKey() + ":" + module.getValue()); + } + return Collections.unmodifiableList(out); + } + + public static void main(String[] args) throws IOException { + Writer target = args.length > 0 + ? new OutputStreamWriter(new FileOutputStream(args[0]), "UTF-8") + : new OutputStreamWriter(System.out, "UTF-8"); + PrintWriter writer = new PrintWriter(target); + try { + for (String line : lines()) { + writer.println(line); + } + } finally { + writer.flush(); + if (args.length > 0) { + writer.close(); + } + } + if (writer.checkError()) { + throw new IOException("could not write the coordinates"); + } + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/builders/AndroidSixteenKbPageFloorTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidSixteenKbPageFloorTest.java new file mode 100644 index 00000000000..76704a026ad --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/builders/AndroidSixteenKbPageFloorTest.java @@ -0,0 +1,243 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// Every Android dependency the builder injects that carries native code is +/// pinned at or above a release measured to be linked for 16 KB pages. +/// +/// The authority is `scripts/check-android-dependency-alignment.py`, which +/// downloads the AARs and reads their ELF headers; it needs the network, so it +/// runs in its own workflow. This is the offline half: it fails the ordinary +/// unit-test run when a pin drops below what was measured, with no network. +/// Google Play refuses an upload containing a 4 KB aligned 64-bit library, and +/// the developer cannot fix one that the build added. CameraX 1.3.4 reached +/// every `CodeScanner` app that way. +class AndroidSixteenKbPageFloorTest { + + /// group:artifact -> the lowest release measured with every 64-bit library, + /// including transitive ones, at `p_align` 0x4000 or more. Measured, never + /// guessed: raise a floor only with a run of the script behind it. + private static final Map FLOORS = new LinkedHashMap(); + + static { + // camera-core carries the natives; the other CameraX modules depend on + // it at their own version, so each is held to the same floor. + FLOORS.put("androidx.camera:camera-core", "1.4.0"); + FLOORS.put("androidx.camera:camera-camera2", "1.4.0"); + FLOORS.put("androidx.camera:camera-lifecycle", "1.4.0"); + FLOORS.put("androidx.camera:camera-view", "1.4.0"); + FLOORS.put("androidx.camera:camera-video", "1.4.0"); + FLOORS.put("com.google.mlkit:barcode-scanning", "17.3.0"); + FLOORS.put("com.google.mlkit:face-detection", "16.1.7"); + FLOORS.put("com.google.mlkit:image-labeling", "17.0.9"); + FLOORS.put("com.google.mlkit:text-recognition", "16.0.1"); + FLOORS.put("com.google.mlkit:pose-detection", "18.0.0-beta5"); + FLOORS.put("com.google.mlkit:segmentation-selfie", "16.0.0-beta6"); + FLOORS.put("com.google.ai.edge.litert:litert", "1.4.2"); + FLOORS.put("com.microsoft.onnxruntime:onnxruntime-android", "1.21.1"); + // 0.1.0 is 4 KB aligned; 0.1.1 is the first aligned release. + FLOORS.put("com.scottyab:rootbeer-lib", "0.1.1"); + } + + /// group:artifact prefixes that never resolve to 16 KB aligned natives: + /// TensorFlow's own coordinates stop at 4 KB aligned releases (2.16.1 / + /// 0.4.4; later ones relocate to LiteRT 1.0.1, also 4 KB aligned). + private static final String[] BANNED = { + "org.tensorflow:tensorflow-lite:", + "org.tensorflow:tensorflow-lite-support:", + }; + + @Test + void everyNativePinIsAtOrAboveItsMeasuredFloor() throws Exception { + Map seen = new LinkedHashMap(); + for (String floor : FLOORS.keySet()) { + seen.put(floor, 0); + } + List below = new ArrayList(); + for (String line : AndroidDependencyCoordinates.lines()) { + String coordinate = line.substring(line.indexOf('\t') + 1); + for (String banned : BANNED) { + if (coordinate.startsWith(banned)) { + below.add(line + " (no 16 KB aligned release exists)"); + } + } + String[] parts = coordinate.replace("@aar", "").split(":"); + String module = parts[0] + ":" + parts[1]; + String floor = FLOORS.get(module); + if (floor == null) { + continue; + } + seen.put(module, seen.get(module) + 1); + if (compareVersions(parts[2], floor) < 0) { + below.add(line + " (needs " + floor + " or later)"); + } + } + if (!below.isEmpty()) { + fail("4 KB aligned native pins, which Google Play rejects:\n " + + String.join("\n ", below)); + } + for (Map.Entry floor : seen.entrySet()) { + // A floor nothing pins is a table that stopped describing the + // builder; drop it, or find where the pin went. + assertTrue(floor.getValue() > 0, floor.getKey() + + " has a 16 KB floor but no builder dependency uses it"); + } + } + + @Test + void theListerCoversTheCatalogAndThePlayServiceDefaults() throws Exception { + List lines = AndroidDependencyCoordinates.lines(); + boolean camera = false; + boolean playService = false; + for (String line : lines) { + String[] parts = line.split("\t"); + assertEquals(2, parts.length, "not : " + line); + assertTrue(parts[1].matches("[A-Za-z0-9_.\\-]+:[A-Za-z0-9_.\\-]+:[A-Za-z0-9_.\\-+]+(@aar)?"), + "not a resolvable coordinate: " + line); + camera |= line.startsWith("catalog:com/codename1/ai/vision/CodeScanner\t" + + "androidx.camera:camera-core:"); + playService |= line.startsWith("play-services:vision\t"); + } + assertTrue(camera, "CodeScanner's CameraX is missing from the list"); + assertTrue(playService, "the play-services defaults are missing from the list"); + } + + @Test + void everyCoordinateTheBuilderSourceNamesIsListed() throws Exception { + // The ratchet: a new hard-coded dependency in any builder source fails + // here, offline, until it is added to the lister (and so reaches the + // networked check) or recorded as not shipped. + Set known = new LinkedHashSet(); + known.addAll(AndroidDependencyCoordinates.INJECTED.keySet()); + known.addAll(AndroidDependencyCoordinates.NOT_SHIPPED.keySet()); + for (String artifact : AndroidDependencyCoordinates.playServiceCallSites().keySet()) { + known.add("com.google.android.gms:play-services-" + artifact); + } + assertTrue(AndroidDependencyCoordinates.playServiceCallSites().size() >= 15, + "the play-services call-site pattern stopped matching the builder"); + List unlisted = new ArrayList(); + for (String prefix : AndroidDependencyCoordinates.coordinatePrefixesInSource()) { + if (!known.contains(prefix)) { + unlisted.add(prefix); + } + } + assertTrue(unlisted.isEmpty(), "builder sources name Android dependencies the" + + " 16 KB check never resolves; add them to AndroidDependencyCoordinates." + + "INJECTED (or NOT_SHIPPED): " + unlisted); + } + + @Test + void everyCameraXModuleIsPinnedAtOneVersion() throws Exception { + // camera-view at 1.4.2 beside camera-core at 1.3.4 would pull the 4 KB + // aligned core back in transitively while every top-level pin read + // clean. + Set versions = new LinkedHashSet(); + for (String line : AndroidDependencyCoordinates.lines()) { + String coordinate = line.substring(line.indexOf('\t') + 1); + if (coordinate.startsWith("androidx.camera:")) { + versions.add(coordinate.substring(coordinate.lastIndexOf(':') + 1)); + } + } + assertEquals(1, versions.size(), "CameraX modules pinned at " + versions); + } + + @Test + void versionsCompareNumericallyAndPreReleasesSortBeforeTheRelease() { + assertTrue(compareVersions("1.10.0", "1.4.0") > 0); + assertTrue(compareVersions("1.3.4", "1.4.0") < 0); + assertTrue(compareVersions("18.0.0-beta5", "18.0.0-beta5") == 0); + assertTrue(compareVersions("18.0.0-beta3", "18.0.0-beta5") < 0); + assertTrue(compareVersions("18.0.0-beta10", "18.0.0-beta5") > 0); + assertTrue(compareVersions("18.0.0", "18.0.0-beta5") > 0); + assertTrue(compareVersions("17.0.0-beta1", "21.0.0") < 0); + } + + /// Numeric segments compare as numbers, a letter run as text, and a + /// version that ends where the other continues with a qualifier + /// (`-beta5`) is the later one, because a pre-release precedes its release. + static int compareVersions(String a, String b) { + List left = tokens(a); + List right = tokens(b); + for (int i = 0; i < Math.max(left.size(), right.size()); i++) { + if (i >= left.size()) { + return right.get(i) instanceof String ? 1 : -1; + } + if (i >= right.size()) { + return left.get(i) instanceof String ? -1 : 1; + } + Object l = left.get(i); + Object r = right.get(i); + int c; + if (l instanceof Long && r instanceof Long) { + c = ((Long) l).compareTo((Long) r); + } else if (l instanceof String && r instanceof String) { + c = ((String) l).compareTo((String) r); + } else { + // A number against a qualifier: the release segment wins. + c = l instanceof Long ? 1 : -1; + } + if (c != 0) { + return c; + } + } + return 0; + } + + private static List tokens(String version) { + List out = new ArrayList(); + StringBuilder run = new StringBuilder(); + Boolean digits = null; + for (char ch : (version + ".").toCharArray()) { + boolean digit = ch >= '0' && ch <= '9'; + boolean letter = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); + if ((digit || letter) && (digits == null || digits == digit)) { + run.append(ch); + digits = digit; + continue; + } + if (run.length() > 0) { + out.add(digits ? (Object) Long.valueOf(run.toString()) : run.toString()); + run.setLength(0); + } + digits = null; + if (digit || letter) { + run.append(ch); + digits = digit; + } + } + return out; + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/builders/HighLevelVisionDependencyTest.java b/maven/build-engine/src/test/java/com/codename1/builders/HighLevelVisionDependencyTest.java index 27b21adddeb..2d61fb01c06 100644 --- a/maven/build-engine/src/test/java/com/codename1/builders/HighLevelVisionDependencyTest.java +++ b/maven/build-engine/src/test/java/com/codename1/builders/HighLevelVisionDependencyTest.java @@ -207,7 +207,7 @@ void aVisionClassThatOpensTheCameraCarriesTheCameraDependencies() // not provision. for (String simple : cameraOpeningClasses()) { List deps = androidDeps(simple); - assertTrue(deps.contains("androidx.camera:camera-core:1.3.4"), + assertTrue(deps.contains("androidx.camera:camera-core:1.4.2"), simple + " opens a camera but the catalog adds no CameraX" + " artifact for it"); boolean permission = false; diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 16621e8c739..35436e3fa20 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -992,7 +992,7 @@ static void register(List h) { h.add(new Hint("android.rootbeerVersion") .group(HintGroup.ANDROID) .type(HintType.VERSION) - .def("0.1.0") + .def("0.1.2") .platform("android")); h.add(new Hint("android.shareFilter") diff --git a/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java b/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java index db1724d5d06..b5f118c8432 100644 --- a/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java +++ b/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java @@ -48,6 +48,49 @@ */ public final class PlatformFeatureCatalog { + // Every Android artifact below that carries native code is pinned at or + // above the first release whose 64-bit .so files are linked for 16 KB + // pages (p_align 0x4000 on arm64-v8a AND x86_64). Google Play rejects an + // upload containing a 4 KB aligned library, and the app cannot fix it: + // alignment is settled when Google links the .so. The previous pins + // (barcode 17.2.0, face 16.1.5, labeling 17.0.7, text 16.0.0, pose + // 18.0.0-beta3, selfie 16.0.0-beta5, LiteRT 1.0.1, TFLite 2.13.0) all + // shipped 0x1000 libraries, directly or through vision-internal-vkp, + // mediapipe-internal or text-recognition-bundled-common. Do not lower + // one of these without checking the AAR and its transitive natives. + // scripts/check-android-dependency-alignment.py (CodenameOne) resolves + // every coordinate in this table and fails CI on a 4 KB aligned library. + private static final String MLKIT_BARCODE_SCANNING = + "com.google.mlkit:barcode-scanning:17.3.0"; + private static final String MLKIT_FACE_DETECTION = + "com.google.mlkit:face-detection:16.1.7"; + private static final String MLKIT_IMAGE_LABELING = + "com.google.mlkit:image-labeling:17.0.9"; + private static final String MLKIT_TEXT_RECOGNITION = + "com.google.mlkit:text-recognition:16.0.1"; + private static final String MLKIT_POSE_DETECTION = + "com.google.mlkit:pose-detection:18.0.0-beta5"; + private static final String LITERT = "com.google.ai.edge.litert:litert:1.4.2"; + // TensorFlow Lite moved to LiteRT and kept its org.tensorflow.lite + // packages. The org.tensorflow coordinates stop at 2.16.1 / 0.4.4, both + // 4 KB aligned; tensorflow-lite 2.17.0 and tensorflow-lite-support 0.5.0 + // are relocation-only POMs that Gradle follows to LiteRT 1.0.1, which is + // still 4 KB aligned on x86_64. So the legacy cn1lib names LiteRT itself. + private static final String LITERT_SUPPORT = + "com.google.ai.edge.litert:litert-support:1.4.2"; + // CameraX, for com.codename1.camera and the camera-driven vision classes. + // 1.3.4 shipped camera-core's libimage_processing_util_jni.so with 4 KB + // pages, so every CodeScanner / VisionCameraView app was refused by Play. + // 1.4.0 is the first aligned line; 1.4.2 keeps minSdk 21 and the + // minCompileSdk 34 that 1.3.4 already declared. 1.5 would raise minSdk to + // 23. AndroidCameraImpl reaches CameraX by reflection, so a bump has to + // keep every class and method name it looks up. + private static final String CAMERAX_VERSION = "1.4.2"; + // ONNX Runtime measured 4 KB aligned at 1.16.3, 1.19.2, 1.20.0 and 1.21.0; + // 1.21.1 is aligned. It and 1.22.0 both declare minSdk 24, which the + // StableDiffusion entry therefore raises the app to. + private static final String ONNX_RUNTIME = + "com.microsoft.onnxruntime:onnxruntime-android:1.21.1"; private static final String MLKIT_LANGUAGE_ID = "com.google.mlkit:language-id:17.0.6"; private static final String MLKIT_TRANSLATE = @@ -55,7 +98,7 @@ public final class PlatformFeatureCatalog { private static final String MLKIT_SMART_REPLY = "com.google.mlkit:smart-reply:17.0.4"; private static final String MLKIT_SELFIE_SEGMENTATION = - "com.google.mlkit:segmentation-selfie:16.0.0-beta5"; + "com.google.mlkit:segmentation-selfie:16.0.0-beta6"; /** Shared version of the per-script ML Kit text recognition bundles. */ private static final String MLKIT_TEXT_SCRIPT_VERSION = "16.0.1"; private static final List ENTRIES; @@ -100,7 +143,7 @@ public final class PlatformFeatureCatalog { .iosMinimumDeploymentTarget("15.5") .iosDependenciesUnsupportedOnMacCatalyst() .iosDependenciesUnsupportedOnArm64Simulator() - .androidGradle("com.google.mlkit:text-recognition:16.0.0") + .androidGradle(MLKIT_TEXT_RECOGNITION) .androidMinimumSdk(21) .iosPlist("NSCameraUsageDescription", "Used to recognise text from your camera.") @@ -110,7 +153,7 @@ public final class PlatformFeatureCatalog { .iosMinimumDeploymentTarget("15.5") .iosDependenciesUnsupportedOnMacCatalyst() .iosDependenciesUnsupportedOnArm64Simulator() - .androidGradle("com.google.mlkit:barcode-scanning:17.2.0") + .androidGradle(MLKIT_BARCODE_SCANNING) .androidMinimumSdk(21) .iosPlist("NSCameraUsageDescription", "Used to scan barcodes with your camera.") @@ -121,7 +164,7 @@ public final class PlatformFeatureCatalog { .iosMinimumDeploymentTarget("15.5") .iosDependenciesUnsupportedOnMacCatalyst() .iosDependenciesUnsupportedOnArm64Simulator() - .androidGradle("com.google.mlkit:face-detection:16.1.5") + .androidGradle(MLKIT_FACE_DETECTION) .androidMinimumSdk(21) .iosPlist("NSCameraUsageDescription", "Used to detect faces in images.") @@ -131,7 +174,7 @@ public final class PlatformFeatureCatalog { .iosMinimumDeploymentTarget("15.5") .iosDependenciesUnsupportedOnMacCatalyst() .iosDependenciesUnsupportedOnArm64Simulator() - .androidGradle("com.google.mlkit:image-labeling:17.0.7") + .androidGradle(MLKIT_IMAGE_LABELING) .androidMinimumSdk(21) .description("Legacy ML Kit Image Labeling cn1lib")); e.add(new Entry("com/codename1/ai/mlkit/translate/") @@ -163,7 +206,7 @@ public final class PlatformFeatureCatalog { .iosMinimumDeploymentTarget("15.5") .iosDependenciesUnsupportedOnMacCatalyst() .iosDependenciesUnsupportedOnArm64Simulator() - .androidGradle("com.google.mlkit:pose-detection:18.0.0-beta3") + .androidGradle(MLKIT_POSE_DETECTION) .androidMinimumSdk(21) .description("Legacy ML Kit Pose Detection cn1lib")); e.add(new Entry("com/codename1/ai/mlkit/segmentation/") @@ -189,9 +232,8 @@ public final class PlatformFeatureCatalog { .iosSpm("TensorFlowLiteSwift", "https://github.com/tensorflow/tensorflow.git", "from:2.13.0", "TensorFlowLite") - .androidGradle("org.tensorflow:tensorflow-lite:2.13.0") - .androidGradle( - "org.tensorflow:tensorflow-lite-support:0.4.4") + .androidGradle(LITERT) + .androidGradle(LITERT_SUPPORT) .androidMinimumSdk(21) .description("Legacy TensorFlow Lite interpreter cn1lib")); @@ -201,7 +243,7 @@ public final class PlatformFeatureCatalog { // so iOS only bundles ML Kit pods for features actually used. e.add(new Entry("com/codename1/ai/vision/TextRecognizer") .iosFrameworks("Vision", "CoreImage") - .androidGradle("com.google.mlkit:text-recognition:16.0.0") + .androidGradle(MLKIT_TEXT_RECOGNITION) .androidMinimumSdk(21) .description("Text recognition")); e.add(new Entry("com/codename1/ai/vision/TextRecognizer") @@ -246,7 +288,7 @@ public final class PlatformFeatureCatalog { e.add(new Entry("com/codename1/ai/vision/BarcodeScanner") .iosFrameworks("Vision", "CoreImage") - .androidGradle("com.google.mlkit:barcode-scanning:17.2.0") + .androidGradle(MLKIT_BARCODE_SCANNING) .androidMinimumSdk(21) .description("Barcode scanning")); e.add(new Entry("com/codename1/ai/vision/BarcodeScanner") @@ -267,7 +309,7 @@ public final class PlatformFeatureCatalog { // low-level camera entry. e.add(new Entry("com/codename1/ai/vision/CodeScanner") .iosFrameworks("Vision", "CoreImage") - .androidGradle("com.google.mlkit:barcode-scanning:17.2.0") + .androidGradle(MLKIT_BARCODE_SCANNING) .androidMinimumSdk(21) .description("Barcode scanning (ready-made scanner screen)")); e.add(new Entry("com/codename1/ai/vision/CodeScanner") @@ -282,7 +324,7 @@ public final class PlatformFeatureCatalog { e.add(new Entry("com/codename1/ai/vision/FaceDetector") .iosFrameworks("Vision", "CoreImage") - .androidGradle("com.google.mlkit:face-detection:16.1.5") + .androidGradle(MLKIT_FACE_DETECTION) .androidMinimumSdk(21) .description("Face detection")); e.add(new Entry("com/codename1/ai/vision/FaceDetector") @@ -296,7 +338,7 @@ public final class PlatformFeatureCatalog { e.add(new Entry("com/codename1/ai/vision/ImageLabeler") .iosFrameworks("Vision", "CoreML") - .androidGradle("com.google.mlkit:image-labeling:17.0.7") + .androidGradle(MLKIT_IMAGE_LABELING) .androidMinimumSdk(21) .description("Image labeling")); e.add(new Entry("com/codename1/ai/vision/ImageLabeler") @@ -310,7 +352,7 @@ public final class PlatformFeatureCatalog { e.add(new Entry("com/codename1/ai/vision/PoseDetector") .iosFrameworks("Vision", "CoreML") - .androidGradle("com.google.mlkit:pose-detection:18.0.0-beta3") + .androidGradle(MLKIT_POSE_DETECTION) .androidMinimumSdk(21) .description("Pose detection")); e.add(new Entry("com/codename1/ai/vision/PoseDetector") @@ -347,7 +389,7 @@ public final class PlatformFeatureCatalog { .iosPod("TensorFlowLiteObjC/CoreML") .iosDependenciesUnsupportedOnMacCatalyst() .iosFrameworks("CoreML", "Metal", "Accelerate") - .androidGradle("com.google.ai.edge.litert:litert:1.0.1") + .androidGradle(LITERT) .androidMinimumSdk(21) .description("LiteRT inference")); @@ -424,11 +466,11 @@ public final class PlatformFeatureCatalog { "android.permission.RECORD_AUDIO") .androidFeatures("android.hardware.camera", "android.hardware.camera.autofocus") - .androidGradle("androidx.camera:camera-core:1.3.4") - .androidGradle("androidx.camera:camera-camera2:1.3.4") - .androidGradle("androidx.camera:camera-lifecycle:1.3.4") - .androidGradle("androidx.camera:camera-view:1.3.4") - .androidGradle("androidx.camera:camera-video:1.3.4") + .androidGradle("androidx.camera:camera-core:" + CAMERAX_VERSION) + .androidGradle("androidx.camera:camera-camera2:" + CAMERAX_VERSION) + .androidGradle("androidx.camera:camera-lifecycle:" + CAMERAX_VERSION) + .androidGradle("androidx.camera:camera-view:" + CAMERAX_VERSION) + .androidGradle("androidx.camera:camera-video:" + CAMERAX_VERSION) .androidMinimumSdk(21) .description("Cross-platform camera (preview + frames + photo + video)")); @@ -458,11 +500,11 @@ public final class PlatformFeatureCatalog { .androidPermissions("android.permission.CAMERA") .androidFeatures("android.hardware.camera", "android.hardware.camera.autofocus") - .androidGradle("androidx.camera:camera-core:1.3.4") - .androidGradle("androidx.camera:camera-camera2:1.3.4") - .androidGradle("androidx.camera:camera-lifecycle:1.3.4") - .androidGradle("androidx.camera:camera-view:1.3.4") - .androidGradle("androidx.camera:camera-video:1.3.4") + .androidGradle("androidx.camera:camera-core:" + CAMERAX_VERSION) + .androidGradle("androidx.camera:camera-camera2:" + CAMERAX_VERSION) + .androidGradle("androidx.camera:camera-lifecycle:" + CAMERAX_VERSION) + .androidGradle("androidx.camera:camera-view:" + CAMERAX_VERSION) + .androidGradle("androidx.camera:camera-video:" + CAMERAX_VERSION) .androidMinimumSdk(21) .description("Camera for the " + cameraBacked[1])); } @@ -618,8 +660,8 @@ public final class PlatformFeatureCatalog { // message. e.add(new Entry("com/codename1/ai/imagegen/StableDiffusion") .iosFrameworks("CoreML", "Vision") - .androidGradle("com.microsoft.onnxruntime:onnxruntime-android:1.16.3") - .androidMinimumSdk(21) + .androidGradle(ONNX_RUNTIME) + .androidMinimumSdk(24) .markBigUpload() .description("On-device Stable Diffusion (local-build only)")); diff --git a/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java b/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java index 3790e9348f3..b20b514d1d4 100644 --- a/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java +++ b/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java @@ -377,8 +377,11 @@ void stableDiffusionFlagsBigUpload() { acc.consume("com/codename1/ai/imagegen/StableDiffusion"); assertTrue(acc.anyRequiresBigUpload(), "On-device SD ships a 1-2 GB Core ML model -- cloud builds must abort with a friendly message"); - assertEquals(21, acc.minimumAndroidSdk(), - "ONNX Runtime Android requires API 21"); + // Every 16 KB page aligned ONNX Runtime (1.21.1 and later) declares + // minSdk 24; without the floor the manifest merger rejects a default + // project, whose android.min_sdk_version is 19. + assertEquals(24, acc.minimumAndroidSdk(), + "ONNX Runtime Android 1.21.1 requires API 24"); } @Test @@ -540,8 +543,8 @@ void theReadyMadeScannerCarriesBarcodeScanningAndACamera() { permissions.addAll(entry.androidPermissions()); } - assertTrue(android.contains("com.google.mlkit:barcode-scanning:17.2.0")); - assertTrue(android.contains("androidx.camera:camera-core:1.3.4")); + assertTrue(android.contains("com.google.mlkit:barcode-scanning:17.3.0")); + assertTrue(android.contains("androidx.camera:camera-core:1.4.2")); assertTrue(permissions.contains("android.permission.CAMERA")); assertTrue(acc.iosFrameworks().contains("AVFoundation")); assertTrue(acc.iosFrameworks().contains("Vision")); @@ -566,7 +569,7 @@ void theLiveAnalyzerViewCarriesACameraButNoAnalyzer() { android.addAll(entry.androidGradleDeps()); } - assertTrue(android.contains("androidx.camera:camera-core:1.3.4")); + assertTrue(android.contains("androidx.camera:camera-core:1.4.2")); for (String dependency : android) { assertFalse(dependency.startsWith("com.google.mlkit:"), "the view must not pull a model the app never asked for: " diff --git a/scripts/check-android-dependency-alignment.py b/scripts/check-android-dependency-alignment.py new file mode 100755 index 00000000000..15df36ba588 --- /dev/null +++ b/scripts/check-android-dependency-alignment.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Reject Android dependencies the builder injects whose natives are 4 KB aligned. + +`check-16k-page-alignment.py` reads every *tracked* artifact. Most of the +native code a Codename One Android app ships is not tracked at all: the +builder writes Maven coordinates into the generated `build.gradle` +(`PlatformFeatureCatalog` entries, the play-services defaults) and Gradle +downloads the AARs on the build machine. CameraX 1.3.4 reached every +`CodeScanner` / `VisionCameraView` app that way with a 4 KB aligned +`libimage_processing_util_jni.so`, Play refused the upload, and the tree gate +stayed green because it never saw the file. + +So this resolves those coordinates the way the generated project does -- +Gradle, from `google()`, `mavenCentral()` and Huawei's repository, transitively -- and runs the same +ELF check over everything that comes back. + +Each group is resolved in a configuration of its own. Resolving them together +is wrong in a way that hides exactly this bug: Gradle's conflict resolution +picks the highest requested version of a module, so one entry pinning +`barcode-scanning:17.3.0` silently upgraded another's 4 KB aligned `17.2.0` +and the old pin read as clean. An app whose classes hit only that one entry +would have shipped the old version. + +Input is a text file, one dependency per line: ``. +The group names what the builder adds them for (a catalog class prefix, a +play-services hint) and is what a failure is reported under. Lines starting +with `#` are ignored. The builders' own listers write it: + + CodenameOne: com.codename1.builders.AndroidDependencyCoordinates (build-engine tests) + BuildDaemon: com.codename1.build.daemon.AndroidDependencyCoordinates + +Usage: + scripts/check-android-dependency-alignment.py --coordinates FILE \ + [--gradle PATH] [--work DIR] [--verbose] + +Needs network access, JDK 17+ and Gradle 8 (`--gradle`, `$GRADLE`, or `gradle` +on `PATH`). A coordinate that does not resolve is a failure, never a skip: a +pin that names nothing is a broken build waiting for an app, and an +unresolvable one is also one this gate has not read. +""" + +import importlib.util +import os +import re +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# A run that resolves this little has not looked at the builder's real +# dependency set, whatever it prints. Both builders list well over a hundred +# coordinates, dozens of them carrying natives. +MIN_GROUPS = 20 +MIN_LIBRARIES_SCANNED = 20 + + +def load_page_alignment_gate(): + path = os.path.join(HERE, "check-16k-page-alignment.py") + spec = importlib.util.spec_from_file_location("page_alignment_gate", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +COORDINATE = re.compile(r"^[A-Za-z0-9_.\-]+:[A-Za-z0-9_.\-]+:[A-Za-z0-9_.\-+]+(@aar)?$") + + +def read_groups(path): + """Returns an ordered list of (group, [coordinate, ...]).""" + groups = {} + order = [] + with open(path) as source: + for number, raw in enumerate(source, 1): + line = raw.rstrip("\n") + if not line.strip() or line.lstrip().startswith("#"): + continue + if "\t" not in line: + raise ValueError("%s:%d: expected : %r" + % (path, number, line)) + group, coordinate = [part.strip() for part in line.split("\t", 1)] + if not COORDINATE.match(coordinate): + raise ValueError("%s:%d: not a group:artifact:version coordinate: %r" + % (path, number, coordinate)) + if group not in groups: + groups[group] = [] + order.append(group) + if coordinate not in groups[group]: + groups[group].append(coordinate) + return [(group, groups[group]) for group in order] + + +BUILD_SCRIPT = r""" +// The builder adds Huawei's repository for the HMS push and map kits. +repositories { google(); mavenCentral(); maven { url 'https://developer.huawei.com/repo/' } } + +def groups = new File(projectDir, "groups.txt").readLines().findAll { it.trim() } +groups.eachWithIndex { line, index -> + def parts = line.split("\t") + def conf = configurations.create("g" + index) { + transitive = true + canBeConsumed = false + attributes { attribute(Attribute.of("org.gradle.usage", String), "java-runtime") } + } + parts[1].split(" ").each { dependencies.add(conf.name, it) } +} + +tasks.register("collect") { doLast { + groups.eachWithIndex { line, index -> + def out = new File(projectDir, "out/g" + index) + out.mkdirs() + def view = configurations.getByName("g" + index).incoming.artifactView { lenient = true } + def artifacts = view.artifacts + // One directory per artifact: two modules can share a file name + // (artifactId-version.aar under different groupIds), and a flat copy + // would let one overwrite the other unscanned. + artifacts.eachWithIndex { a, i -> ant.copy(file: a.file, todir: new File(out, "a" + i)) } + artifacts.failures.each { f -> + new File(out, "FAILURES.txt") << (f.message.replace("\n", " ") + "\n") + } + } +}} +""" + + +def find_gradle(explicit): + for candidate in (explicit, os.environ.get("GRADLE"), shutil.which("gradle")): + if candidate: + return candidate + return None + + +def main(argv): + verbose = "--verbose" in argv or "-v" in argv + args = [a for a in argv if a not in ("--verbose", "-v")] + options = {} + while args: + flag = args.pop(0) + if flag not in ("--coordinates", "--gradle", "--work") or not args: + print(__doc__, file=sys.stderr) + return 2 + options[flag] = args.pop(0) + if "--coordinates" not in options: + print(__doc__, file=sys.stderr) + return 2 + + try: + groups = read_groups(options["--coordinates"]) + except (IOError, ValueError) as error: + print("check-android-dependency-alignment: %s" % error, file=sys.stderr) + return 2 + if len(groups) < MIN_GROUPS: + print("check-android-dependency-alignment: %d dependency groups, " + "expected at least %d -- the lister produced next to nothing, " + "which is not the same as a clean builder." + % (len(groups), MIN_GROUPS), file=sys.stderr) + return 2 + + gradle = find_gradle(options.get("--gradle")) + if gradle is None: + print("check-android-dependency-alignment: no Gradle (pass --gradle, " + "set GRADLE, or put gradle on PATH)", file=sys.stderr) + return 2 + + work = options.get("--work") or tempfile.mkdtemp(prefix="cn1-dep-align-") + os.makedirs(work, exist_ok=True) + with open(os.path.join(work, "settings.gradle"), "w") as f: + f.write("rootProject.name = 'cn1-dependency-alignment'\n") + with open(os.path.join(work, "build.gradle"), "w") as f: + f.write(BUILD_SCRIPT) + with open(os.path.join(work, "groups.txt"), "w") as f: + for group, coordinates in groups: + f.write("%s\t%s\n" % (group, " ".join(coordinates))) + out_root = os.path.join(work, "out") + if os.path.isdir(out_root) and os.listdir(out_root): + print("check-android-dependency-alignment: %s is not empty; pass a " + "fresh --work directory so an earlier run's AARs are not read " + "as this one's" % out_root, file=sys.stderr) + return 2 + + result = subprocess.call([gradle, "-q", "--no-daemon", "-p", work, "collect"]) + if result != 0: + print("check-android-dependency-alignment: Gradle failed (%d)" % result, + file=sys.stderr) + return 2 + + gate = load_page_alignment_gate() + scanner = gate.Scanner(verbose) + unresolved = [] + for index, (group, coordinates) in enumerate(groups): + directory = os.path.join(out_root, "g%d" % index) + failures = os.path.join(directory, "FAILURES.txt") + if os.path.isfile(failures): + with open(failures) as f: + for message in f.read().splitlines(): + unresolved.append("%s: %s" % (group, message)) + files = [] + for root, _, names in os.walk(directory): + for name in names: + if os.path.join(root, name) != failures: + files.append(os.path.relpath(os.path.join(root, name), directory)) + files.sort() + if not files: + unresolved.append("%s: resolved to no artifacts (%s)" + % (group, " ".join(coordinates))) + before = len(scanner.failures) + for name in files: + scanner.scan_path(os.path.join(directory, name)) + # Name the group on every finding: the AAR file name says which + # library is broken, the group says which pin brought it in. + for i in range(before, len(scanner.failures)): + scanner.failures[i] = "[%s] %s" % ( + group, scanner.failures[i].replace(directory + os.sep, "")) + if verbose: + print("%s -> %d artifacts" % (group, len(files))) + + if unresolved: + print("Android dependencies that did not resolve:", file=sys.stderr) + for line in unresolved: + print(" %s" % line, file=sys.stderr) + if scanner.failures: + print("16 KB page alignment check failed for builder-injected " + "Android dependencies:", file=sys.stderr) + for failure in scanner.failures: + print(" %s" % failure, file=sys.stderr) + print("", file=sys.stderr) + print("Raise the pin to the first release whose 64-bit libraries are " + "linked for 16 KB pages, including its transitive natives. The " + "developer cannot fix this from the app.", file=sys.stderr) + if unresolved or scanner.failures: + return 1 + + if scanner.libraries_scanned < MIN_LIBRARIES_SCANNED: + print("check-android-dependency-alignment: only %d 64-bit native " + "libraries in %d groups, expected at least %d -- the resolution " + "returned something other than the builder's real dependencies." + % (scanner.libraries_scanned, len(groups), MIN_LIBRARIES_SCANNED), + file=sys.stderr) + return 2 + + print("check-android-dependency-alignment: %d groups, %d Android 64-bit " + "native libraries aligned to 0x%x or more" + % (len(groups), scanner.libraries_scanned, gate.REQUIRED_ALIGNMENT)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/test_check_android_dependency_alignment.py b/scripts/test_check_android_dependency_alignment.py new file mode 100755 index 00000000000..c8399e4b0dc --- /dev/null +++ b/scripts/test_check_android_dependency_alignment.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Pins the verdicts of check-android-dependency-alignment.py. + +Gradle is replaced by a stub that writes one synthetic AAR per group, chosen +by the artifact name in the coordinate, so the test needs no network and no +Gradle. What is pinned: + + - a 4 KB aligned library fails, and the failure names the group it came from + - each group is resolved into its own directory, never pooled + - a coordinate that does not resolve, or resolves to nothing, fails + - a run that saw too few groups or libraries is an error, not a pass + - a malformed coordinates file is an error, not an empty pass +""" + +import importlib.util +import io +import os +import shutil +import stat +import sys +import tempfile +import unittest +import zipfile +from contextlib import redirect_stderr, redirect_stdout + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def load(name, file_name): + spec = importlib.util.spec_from_file_location(name, os.path.join(HERE, file_name)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +GATE = load("dependency_gate", "check-android-dependency-alignment.py") +ELF = load("page_gate_tests", "test_check_16k_page_alignment.py") + +# The stub "Gradle": reads groups.txt from the -p directory and writes, per +# group, an AAR whose arm64 library is aligned when the artifact name says +# "good", misaligned for "bad", a FAILURES.txt for "missing", and nothing at +# all for "empty". +STUB = r'''#!/usr/bin/env python3 +import os, sys, zipfile +sys.path.insert(0, %(here)r) +project = sys.argv[sys.argv.index("-p") + 1] +spec = __import__("importlib.util").util.spec_from_file_location( + "t", os.path.join(%(here)r, "test_check_16k_page_alignment.py")) +t = __import__("importlib.util").util.module_from_spec(spec); spec.loader.exec_module(t) +for index, line in enumerate(open(os.path.join(project, "groups.txt")).read().splitlines()): + out = os.path.join(project, "out", "g%%d" %% index) + os.makedirs(out, exist_ok=True) + for coordinate in line.split("\t")[1].split(" "): + artifact = coordinate.split(":")[1] + if artifact.startswith("missing"): + open(os.path.join(out, "FAILURES.txt"), "a").write("Could not find " + coordinate + "\n") + continue + if artifact.startswith("empty"): + continue + align = 0x4000 if artifact.startswith("good") else 0x1000 + with zipfile.ZipFile(os.path.join(out, artifact + ".aar"), "w") as z: + z.writestr("jni/arm64-v8a/lib" + artifact + ".so", t.elf64(align)) +''' % {"here": HERE} + + +class DependencyGate(unittest.TestCase): + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.stub = os.path.join(self.tmp, "gradle") + with open(self.stub, "w") as f: + f.write(STUB) + os.chmod(self.stub, os.stat(self.stub).st_mode | stat.S_IEXEC) + self.saved = (GATE.MIN_GROUPS, GATE.MIN_LIBRARIES_SCANNED) + GATE.MIN_GROUPS = 1 + GATE.MIN_LIBRARIES_SCANNED = 1 + self.runs = 0 + + def tearDown(self): + GATE.MIN_GROUPS, GATE.MIN_LIBRARIES_SCANNED = self.saved + shutil.rmtree(self.tmp) + + def run_gate(self, lines): + self.runs += 1 + coordinates = os.path.join(self.tmp, "coords%d.txt" % self.runs) + with open(coordinates, "w") as f: + f.write("\n".join(lines) + "\n") + work = os.path.join(self.tmp, "work%d" % self.runs) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = GATE.main(["--coordinates", coordinates, "--gradle", self.stub, + "--work", work]) + return code, out.getvalue() + err.getvalue() + + def test_aligned_dependencies_pass(self): + code, output = self.run_gate(["a\tcom.x:good-one:1.0", "b\tcom.x:good-two:2.0"]) + self.assertEqual(0, code, output) + self.assertIn("2 groups, 2 Android 64-bit", output) + + def test_misaligned_dependency_fails_under_its_group(self): + code, output = self.run_gate(["catalog:com/codename1/camera/\tcom.x:bad-camera:1.3.4", + "other\tcom.x:good-one:1.0"]) + self.assertEqual(1, code, output) + self.assertIn("[catalog:com/codename1/camera/] bad-camera.aar!jni/arm64-v8a", output) + self.assertNotIn("[other]", output) + + def test_groups_are_resolved_apart(self): + # The point of per-group resolution: a group's own artifacts are the + # only ones scanned under its name. Pooled, "b" would be credited with + # "a"'s library and the stub would still see one directory. + code, output = self.run_gate(["a\tcom.x:bad-one:1.0", "b\tcom.x:good-two:1.0"]) + self.assertEqual(1, code, output) + self.assertIn("[a] bad-one.aar", output) + self.assertNotIn("[b]", output) + work = os.path.join(self.tmp, "work%d" % self.runs, "out") + self.assertEqual(["bad-one.aar"], os.listdir(os.path.join(work, "g0"))) + self.assertEqual(["good-two.aar"], os.listdir(os.path.join(work, "g1"))) + + def test_two_coordinates_of_one_group_resolve_together(self): + code, output = self.run_gate(["a\tcom.x:good-one:1.0", "a\tcom.x:bad-two:1.0"]) + self.assertEqual(1, code, output) + self.assertIn("[a] bad-two.aar", output) + + def test_unresolved_coordinate_fails(self): + code, output = self.run_gate(["a\tcom.x:missing-thing:9.9", "b\tcom.x:good-one:1.0"]) + self.assertEqual(1, code, output) + self.assertIn("a: Could not find com.x:missing-thing:9.9", output) + + def test_group_that_resolves_to_nothing_fails(self): + code, output = self.run_gate(["a\tcom.x:empty-thing:1.0", "b\tcom.x:good-one:1.0"]) + self.assertEqual(1, code, output) + self.assertIn("a: resolved to no artifacts", output) + + def test_too_few_groups_is_an_error_not_a_pass(self): + GATE.MIN_GROUPS = 5 + code, output = self.run_gate(["a\tcom.x:good-one:1.0"]) + self.assertEqual(2, code, output) + + def test_too_few_libraries_is_an_error_not_a_pass(self): + GATE.MIN_LIBRARIES_SCANNED = 5 + code, output = self.run_gate(["a\tcom.x:good-one:1.0"]) + self.assertEqual(2, code, output) + + def test_malformed_line_is_an_error(self): + code, output = self.run_gate(["a com.x:good-one:1.0"]) + self.assertEqual(2, code, output) + code, output = self.run_gate(["a\tnot a coordinate"]) + self.assertEqual(2, code, output) + + def test_comments_and_blank_lines_are_ignored(self): + code, output = self.run_gate(["# header", "", "a\tcom.x:good-one:1.0@aar"]) + self.assertEqual(0, code, output) + + def test_a_reused_work_directory_is_refused(self): + code, output = self.run_gate(["a\tcom.x:good-one:1.0"]) + self.assertEqual(0, code, output) + work = os.path.join(self.tmp, "work%d" % self.runs) + coordinates = os.path.join(self.tmp, "coords%d.txt" % self.runs) + err = io.StringIO() + with redirect_stdout(io.StringIO()), redirect_stderr(err): + code = GATE.main(["--coordinates", coordinates, "--gradle", self.stub, + "--work", work]) + self.assertEqual(2, code, err.getvalue()) + + +if __name__ == "__main__": + unittest.main() From 95999ed36e10f73aa0abfb15cee34d9417b05d9b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:23:10 +0200 Subject: [PATCH 2/3] ci: stage UpdateCodenameOne.jar for the dependency alignment workflow Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check-android-dependency-alignment.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/check-android-dependency-alignment.yml b/.github/workflows/check-android-dependency-alignment.yml index b43d4383b72..5ee1fa0cf05 100644 --- a/.github/workflows/check-android-dependency-alignment.yml +++ b/.github/workflows/check-android-dependency-alignment.yml @@ -89,6 +89,10 @@ jobs: RETRY_DELAY_SECONDS: '60' run: | set -euo pipefail + # The build runs UpdateCodenameOne.jar from ~/.codenameone, as in + # every other workflow that builds the framework. + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ cd maven bash ../scripts/ci/retry.sh mvn -B -pl build-engine -am install \ -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase From 040038185dd801695cca4055fe22b58198e48610 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:33:47 +0200 Subject: [PATCH 3/3] ci: point the dependency lister at the builder sources from maven/ The step runs in maven/, where the lister's default source path (relative to build-engine) does not exist. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check-android-dependency-alignment.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-android-dependency-alignment.yml b/.github/workflows/check-android-dependency-alignment.yml index 5ee1fa0cf05..dc574313d4b 100644 --- a/.github/workflows/check-android-dependency-alignment.yml +++ b/.github/workflows/check-android-dependency-alignment.yml @@ -100,7 +100,8 @@ jobs: -Dsurefire.failIfNoSpecifiedTests=false -Plocal-dev-javase mvn -B -q -pl build-engine test-compile dependency:build-classpath \ -Dmdep.outputFile="$RUNNER_TEMP/build-engine.cp" -Plocal-dev-javase - java -cp "build-engine/target/classes:build-engine/target/test-classes:$(cat "$RUNNER_TEMP/build-engine.cp")" \ + java -Dcn1.builders.src=build-engine/src/main/java/com/codename1/builders \ + -cp "build-engine/target/classes:build-engine/target/test-classes:$(cat "$RUNNER_TEMP/build-engine.cp")" \ com.codename1.builders.AndroidDependencyCoordinates "$RUNNER_TEMP/android-coordinates.txt" wc -l "$RUNNER_TEMP/android-coordinates.txt"