diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml
index 5a14f0bbd57..1519f1ec9e0 100644
--- a/.github/workflows/linux-build-run.yml
+++ b/.github/workflows/linux-build-run.yml
@@ -149,7 +149,7 @@ jobs:
cmake ninja-build pkg-config unzip xvfb fonts-dejavu-core \
libgtk-3-dev libcairo2-dev libpango1.0-dev libgdk-pixbuf-2.0-dev libglib2.0-dev \
libfontconfig1-dev libfreetype-dev \
- libcurl4-openssl-dev \
+ libcurl4-openssl-dev libssl-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
libwebkit2gtk-4.1-dev libsecret-1-dev libnotify-dev libgeoclue-2-dev \
libepoxy-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri
@@ -219,6 +219,18 @@ jobs:
# Full app stdout/stderr -- the only evidence when the suite wedges
# mid-run (uploaded with the screenshot artifact below).
CN1_APP_LOG_TEE: ${{ github.workspace }}/artifacts/linux-port/raw/app-output.log
+ # Wait for the suite's own completion marker instead of stopping when
+ # screenshots go quiet. The stabilization exit fired while DesktopMode,
+ # the VideoIO grid, the VR scene and the 360 panorama were still queued
+ # behind the slow non-rendering API tail, so the suite was force-killed
+ # and every trailing test was published as "never run".
+ #
+ # This is read with Boolean.parseBoolean, which answers false for '1',
+ # so the gate it describes has never actually been armed -- the suite
+ # could be force-killed with trailing tests unrun and nothing failed.
+ # The Windows pipeline passes a real boolean, which is why its gate
+ # works. 'true' arms it here.
+ CN1_REQUIRE_SUITE: 'true'
# Build the native ELF (and the demo) against an old glibc for portability.
CN1_CC: /usr/local/bin/cn1-zig-cc
# After the suite runs, the capture test relinks the same objects into a
@@ -238,6 +250,12 @@ jobs:
# Enable core dumps and post-mortem them into the artifact.
ulimit -c unlimited
echo '/tmp/cn1-cores/core.%e.%p' | sudo tee /proc/sys/kernel/core_pattern >/dev/null
+ # Let the harness attach gdb to the still-running suite when it gives up
+ # waiting. Ubuntu ships yama ptrace_scope=1, which restricts attaching to
+ # descendants, and the harness is a sibling of the app -- so without this
+ # the hang dump comes back "Could not attach to process" and a hang (as
+ # opposed to a crash, which leaves a core) yields no evidence at all.
+ echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope >/dev/null 2>&1 || true
mkdir -p /tmp/cn1-cores
rc=0
mvn -B clean package -pl JavaAPI -am -DskipTests
@@ -320,12 +338,13 @@ jobs:
-v "$GITHUB_WORKSPACE":/cn1 -w /cn1 \
-e CN1_SHOT_OUTPUT_DIR=/cn1/artifacts/linux-port/raw-musl \
-e CN1_APP_LOG_TEE=/cn1/artifacts/linux-port/raw-musl/app-output.log \
+ -e CN1_REQUIRE_SUITE=true \
-e LIBGL_ALWAYS_SOFTWARE=1 \
docker.io/library/alpine:3.20 sh -ec '
sed -i "s|^#\(.*/community\)|\1|" /etc/apk/repositories
apk add --no-cache build-base cmake samurai pkgconf bash git openjdk8 openjdk17 maven \
gtk+3.0-dev cairo-dev pango-dev gdk-pixbuf-dev glib-dev fontconfig-dev freetype-dev \
- curl-dev libepoxy-dev mesa-dev mesa-gles mesa-egl mesa-gbm mesa-dri-gallium \
+ curl-dev openssl-dev libepoxy-dev mesa-dev mesa-gles mesa-egl mesa-gbm mesa-dri-gallium \
webkit2gtk-4.1-dev gstreamer-dev gst-plugins-base-dev \
libsecret-dev libnotify-dev geoclue-dev xvfb ttf-dejavu
# JDK 8 runs the translator/maven; JDK 17 is needed to compile the
@@ -362,7 +381,16 @@ jobs:
compare-comment:
name: screenshot-comment
needs: build-run
- if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+ # !cancelled() rather than a plain event check: without it GitHub skips this
+ # job whenever a build-run leg fails, and skipping it is precisely the wrong
+ # response to a failing suite. Normalization is what publishes the fail /
+ # not-run counts, so being skipped leaves the public table showing the last
+ # green report -- a failure masked as a pass. The workflow still goes red
+ # because build-run itself failed; this only keeps the reporting honest.
+ if: >-
+ !cancelled() &&
+ (github.event_name == 'pull_request' || github.event_name == 'push' ||
+ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
permissions:
contents: read
@@ -415,6 +443,13 @@ jobs:
# Gate the Linux port (both arches): fail on any mismatch/error or a new
# screenshot that has no committed golden (missing_expected).
export CN1SS_FAIL_ON_MISMATCH=1
+ # A test that fails an assertion, or never runs at all, has to fail
+ # this workflow the way it already does on iOS, JavaScript and Mac.
+ # Without it a suite that stopped early published its trailing tests
+ # as "never run" and the job stayed green -- a result that reads as
+ # success while hiding both the tests that failed and the fact that
+ # they stopped running.
+ export CN1SS_FAIL_ON_TEST_PROBLEMS=1
export CN1SS_ALLOWED_MISSING=0
if [ "${{ github.event_name }}" != "pull_request" ]; then export CN1SS_SKIP_COMMENT=1; fi
for arch in x64 arm64; do
@@ -425,7 +460,20 @@ jobs:
[ -f "$f" ] || continue
entries+=("$(basename "$f" .png)=$f")
done
- [ ${#entries[@]} -eq 0 ] && continue
+ if [ ${#entries[@]} -eq 0 ]; then
+ # A leg that crashed or timed out before its first PNG still uploads
+ # app-output.log, and that log is precisely what normalization reads to
+ # produce the fail / not-run counts. Skipping the arch outright meant the
+ # run that most needed reporting produced none, so the table went on
+ # serving the previous -- green -- report. Carry on with an empty entry
+ # list when there is a log to read; only a leg that produced nothing at
+ # all has nothing to say.
+ if [ ! -f "$raw/app-output.log" ]; then
+ echo "[linux-gtk-$arch] no screenshots and no app log; nothing to normalize"
+ continue
+ fi
+ echo "[linux-gtk-$arch] no screenshots captured; normalizing from the app log so the failure reaches the table"
+ fi
if [ "$arch" = "arm64" ]; then REF="scripts/linux/screenshots-arm"; else REF="scripts/linux/screenshots"; fi
echo "Posting ${#entries[@]} screenshot(s) for $arch (baseline $REF)"
mkdir -p "$ART/previews-$arch"
@@ -441,7 +489,7 @@ jobs:
"Native Linux port ($arch)" \
"$ART/compare-$arch.json" "$ART/summary-$arch.txt" "$ART/comment-$arch.md" \
"$(pwd)/$REF" "$ART/previews-$arch" "$ART" \
- "${entries[@]}"
+ ${entries[@]+"${entries[@]}"}
gate_rc=$?
set -e
if [ "$gate_rc" -ne 0 ]; then
diff --git a/.github/workflows/port-status-nightly.yml b/.github/workflows/port-status-nightly.yml
index 3d22b52cb5d..b8893982343 100644
--- a/.github/workflows/port-status-nightly.yml
+++ b/.github/workflows/port-status-nightly.yml
@@ -11,6 +11,24 @@ permissions:
contents: write
jobs:
+ # port-status-publish.yml only publishes a report when a workflow_run event
+ # reaches it, and those events never arrive for some producers -- the Linux
+ # and Windows suites had not landed a single report, so the public table
+ # served their checked-in fallback until it aged out and the columns rendered
+ # as unknown. This sweep publishes from the newest master run of every
+ # producing workflow and fails when a port has no report inside the
+ # contract's staleness window.
+ publish-latest-port-reports:
+ if: github.ref == 'refs/heads/master'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v6
+ - name: Publish the newest master report for every port
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: scripts/hellocodenameone/conformance/backfill_port_status.sh
+
build-javascript-app:
runs-on: ubuntu-latest
timeout-minutes: 60
@@ -81,29 +99,47 @@ jobs:
if-no-files-found: error
publish-browser-evidence:
- if: always() && needs.build-javascript-app.result == 'success'
- needs: [build-javascript-app, browser-lifecycle]
+ # Ordered after the report sweep (and tolerant of it failing) so the site
+ # rebuild at the end of this job picks up everything published tonight.
+ # Runs whatever happened upstream. The browser-evidence steps still need the
+ # JavaScript build, and stay gated on it -- but the site rebuild at the end is
+ # the only thing in this workflow that dispatches website-docs.yml, and gating
+ # the whole job meant a failed JS build left every report the sweep had just
+ # published sitting on the data branch, absent from the public table until
+ # some later deployment. The two concerns are now separated.
+ if: always()
+ needs: [build-javascript-app, browser-lifecycle, publish-latest-port-reports]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v4
+ if: needs.build-javascript-app.result == 'success'
with:
pattern: browser-evidence-*
path: artifacts/browser-evidence
- name: Assemble static browser evidence
+ if: needs.build-javascript-app.result == 'success'
run: node scripts/website/collect_browser_evidence.mjs artifacts/browser-evidence artifacts/port-status-environment.json
- uses: actions/upload-artifact@v4
+ if: needs.build-javascript-app.result == 'success'
with:
name: port-status-environment
path: artifacts/port-status-environment.json
retention-days: 30
- name: Publish master evidence to the data branch
- if: github.ref == 'refs/heads/master'
+ if: needs.build-javascript-app.result == 'success' && github.ref == 'refs/heads/master'
env:
GH_TOKEN: ${{ github.token }}
run: scripts/hellocodenameone/conformance/publish_port_status_environment.sh artifacts/port-status-environment.json
- name: Rebuild the static website snapshot
- if: github.ref == 'refs/heads/master'
+ # Deliberately not gated on the JavaScript build, nor on the evidence
+ # steps above it: the reports published tonight reach the public table
+ # only through this dispatch. Without always() the step inherits the
+ # implicit success() requirement, so a failure while downloading,
+ # assembling, uploading or publishing the browser evidence left the
+ # sweep's freshly published reports off the public snapshot until some
+ # later deployment happened to rebuild it.
+ if: always() && github.ref == 'refs/heads/master'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run website-docs.yml --ref master -f deploy_production=true
diff --git a/.github/workflows/port-status-publish.yml b/.github/workflows/port-status-publish.yml
index ef3f8e5b10c..2d9536958e7 100644
--- a/.github/workflows/port-status-publish.yml
+++ b/.github/workflows/port-status-publish.yml
@@ -18,8 +18,18 @@ permissions:
jobs:
publish:
+ # A FAILED producer's report is the one that matters most: the strict gate
+ # fails the workflow precisely when a test failed or never ran, so demanding
+ # conclusion == 'success' here published only the good news. The Linux
+ # producer can run for 90 minutes from 01:45 while the nightly sweep
+ # snapshots completed runs at 02:35, so a failure finishing after that
+ # snapshot left the previous GREEN report public for a further day. Publish
+ # whatever normalized artifacts a completed run uploaded and let the
+ # acceptance gate judge them; cancelled runs are excluded because a
+ # superseded run's evidence is not current.
if: >-
- github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.conclusion != 'cancelled' &&
+ github.event.workflow_run.conclusion != 'skipped' &&
(github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') &&
github.event.workflow_run.head_branch == 'master'
runs-on: ubuntu-latest
@@ -41,6 +51,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PORT_STATUS_PUBLISH: '1'
+ WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
run: |
set -euo pipefail
found=0
@@ -49,8 +60,16 @@ jobs:
./scripts/hellocodenameone/conformance/publish_port_status.sh "$report"
done < <(find reports -type f -name 'port-status-*.json' | sort)
if [ "$found" -eq 0 ]; then
- echo "No normalized port reports were found in workflow artifacts." >&2
- exit 1
+ # A producer that died before normalization uploads nothing. That is
+ # a real defect for a successful run, but an expected outcome for a
+ # failed one -- the job may have crashed before the normalize step --
+ # and failing here would turn every such run into a second red X
+ # that says nothing the producer's own failure did not already say.
+ if [ "${WORKFLOW_CONCLUSION}" = "success" ]; then
+ echo "No normalized port reports were found in workflow artifacts." >&2
+ exit 1
+ fi
+ echo "No normalized reports from a ${WORKFLOW_CONCLUSION} run; nothing to publish."
fi
- name: Rebuild the static Port Status snapshot
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 955a23b06ae..8c1e016d691 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -598,14 +598,14 @@ jobs:
if: matrix.java-version == 8
run: zip -j result.zip CodenameOne/javadocs.zip CodenameOne/dist/CodenameOne.jar CodenameOne/updatedLibs.zip Ports/JavaSE/dist/JavaSE.jar build/CodenameOneDist/CodenameOne/demos/CodenameOne_SRC.zip
- - name: Copying Files to Server
+ # Handed to a separate job rather than deployed here. See deploy-dist.
+ - name: Upload the distribution bundle
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && matrix.java-version == 8 }}
- uses: marcodallasanta/ssh-scp-deploy@v1.0.5
+ uses: actions/upload-artifact@v7
with:
- host: ${{ secrets.WP_HOST }}
- user: ${{ secrets.WP_USER }}
- password: ${{ secrets.WP_PASSWORD }}
- local: result.zip
+ name: result.zip
+ path: result.zip
+ retention-days: 1
- name: Upload a Build Artifact
if: matrix.java-version == 8
@@ -614,3 +614,31 @@ jobs:
name: JavaSE.jar
path: Ports/JavaSE/dist/JavaSE.jar
+
+ deploy-dist:
+ # Gated at the JOB level, deliberately, and this is not a style preference.
+ # A Docker-based action is BUILT during job setup, before any step's `if` is
+ # evaluated -- so while this deploy step was inside build-test it made every
+ # matrix leg, including the Java 17 and 21 legs that could never satisfy its
+ # condition, pull alpine:latest from Docker Hub to build an action they were
+ # never going to run. A Docker Hub timeout then failed a pull-request job for
+ # a deploy that was not happening: exactly what killed build-test (17) with
+ # "failed to resolve source metadata for docker.io/library/alpine:latest"
+ # after three retries. A job-level condition skips setup entirely, so on a
+ # pull request the image is never fetched.
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
+ needs: build-test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download the distribution bundle
+ uses: actions/download-artifact@v7
+ with:
+ name: result.zip
+
+ - name: Copying Files to Server
+ uses: marcodallasanta/ssh-scp-deploy@v1.0.5
+ with:
+ host: ${{ secrets.WP_HOST }}
+ user: ${{ secrets.WP_USER }}
+ password: ${{ secrets.WP_PASSWORD }}
+ local: result.zip
diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml
index fee02d9c421..cacc624a7ab 100644
--- a/.github/workflows/scripts-ios.yml
+++ b/.github/workflows/scripts-ios.yml
@@ -104,6 +104,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
+ # The device runner reports logical test failures through CN1SS log
+ # markers, not through the build or the screenshot comparison. Make the
+ # normalized report authoritative so a failing or never-run compliance
+ # test cannot leave this workflow green and then be published from master.
+ CN1SS_FAIL_ON_TEST_PROBLEMS: '1'
GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
# Optional: when set, build-ios-app.sh writes it as a bundled resource so
# the GoogleWebMap screenshot test renders a live Google map; absent (e.g.
@@ -297,6 +302,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
+ # The device runner reports logical test failures through CN1SS log
+ # markers, not through the build or the screenshot comparison. Make the
+ # normalized report authoritative so a failing or never-run compliance
+ # test cannot leave this workflow green and then be published from master.
+ CN1SS_FAIL_ON_TEST_PROBLEMS: '1'
GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
# Optional: when set, build-ios-app.sh writes it as a bundled resource so
# the GoogleWebMap screenshot test renders a live Google map; absent (e.g.
@@ -578,6 +588,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
+ # The device runner reports logical test failures through CN1SS log
+ # markers, not through the build or the screenshot comparison. Make the
+ # normalized report authoritative so a failing or never-run compliance
+ # test cannot leave this workflow green and then be published from master.
+ CN1SS_FAIL_ON_TEST_PROBLEMS: '1'
GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
# Optional: when set, build-ios-app.sh writes it as a bundled resource so
# the GoogleWebMap screenshot test renders a live Google map; absent (e.g.
@@ -745,6 +760,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
+ # The device runner reports logical test failures through CN1SS log
+ # markers, not through the build or the screenshot comparison. Make the
+ # normalized report authoritative so a failing or never-run compliance
+ # test cannot leave this workflow green and then be published from master.
+ CN1SS_FAIL_ON_TEST_PROBLEMS: '1'
GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
steps:
diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml
index 333d71cabd1..ee008b610fb 100644
--- a/.github/workflows/scripts-javascript.yml
+++ b/.github/workflows/scripts-javascript.yml
@@ -77,6 +77,11 @@ jobs:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
+ # The device runner reports logical test failures through CN1SS log
+ # markers, not through the build or the screenshot comparison. Make the
+ # normalized report authoritative so a failing or never-run compliance
+ # test cannot leave this workflow green and then be published from master.
+ CN1SS_FAIL_ON_TEST_PROBLEMS: '1'
GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
ARTIFACTS_DIR: ${{ github.workspace }}/artifacts/javascript-ui-tests
# CN1_JS_TIMEOUT_SECONDS guards the per-suite SUITE:FINISHED wait.
diff --git a/.github/workflows/scripts-mac-native.yml b/.github/workflows/scripts-mac-native.yml
index 9ad0939de6d..709a368e114 100644
--- a/.github/workflows/scripts-mac-native.yml
+++ b/.github/workflows/scripts-mac-native.yml
@@ -93,6 +93,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
+ # The device runner reports logical test failures through CN1SS log
+ # markers, not through the build or the screenshot comparison. Make the
+ # normalized report authoritative so a failing or never-run compliance
+ # test cannot leave this workflow green and then be published from master.
+ CN1SS_FAIL_ON_TEST_PROBLEMS: '1'
GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
# Optional: when set, build-mac-native-app.sh writes it as a bundled
# resource so the GoogleWebMap screenshot test renders a live Google map;
diff --git a/.github/workflows/windows-cross-build-run.yml b/.github/workflows/windows-cross-build-run.yml
index 6b290a2cffc..3c9fefca47e 100644
--- a/.github/workflows/windows-cross-build-run.yml
+++ b/.github/workflows/windows-cross-build-run.yml
@@ -265,7 +265,17 @@ jobs:
compare-comment:
name: cross-compiled screenshot-comment
needs: run-on-windows
- if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+ # !cancelled(), for the same reason as linux-build-run.yml: without it GitHub
+ # skips this job whenever run-on-windows fails, so the run that most needs
+ # reporting uploads raw evidence but no port-status artifact. The nightly
+ # sweep then falls back to an older run, and while that one is still inside
+ # the freshness window the public table keeps showing it -- the current
+ # Windows failure masked by a stale pass. The workflow still goes red because
+ # run-on-windows itself failed.
+ if: >-
+ !cancelled() &&
+ (github.event_name == 'pull_request' || github.event_name == 'push' ||
+ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
permissions:
contents: read
@@ -309,8 +319,18 @@ jobs:
done
fi
if [ ${#entries[@]} -eq 0 ]; then
- echo "No screenshots were produced; skipping PR comment."
- exit 0
+ # A run that died before its first PNG still persists app-output.log,
+ # and that log is what normalization reads to produce the fail /
+ # not-run counts. Bailing out here left the failed capture with no
+ # port-status artifact at all, so the nightly sweep fell back to an
+ # older report and the table kept showing it. Carry on with an empty
+ # entry list when there is a log; only a run with neither has nothing
+ # to say. (Same handling as linux-build-run.yml.)
+ if [ ! -f "$ART/raw-x64/app-output.log" ]; then
+ echo "No screenshots and no app log; nothing to normalize."
+ exit 0
+ fi
+ echo "No screenshots were produced; normalizing from the app log so the failure reaches the table."
fi
echo "Posting ${#entries[@]} screenshot(s)"
mkdir -p "$ART/previews"
@@ -334,12 +354,19 @@ jobs:
export CN1SS_SUITE_LOG="$ART/raw-x64/app-output.log"
export CN1SS_BINARY_PATH="$ART/raw-x64/benchmark-app.exe"
if [ "${{ github.event_name }}" != "pull_request" ]; then export CN1SS_SKIP_COMMENT=1; fi
+ # Assertion-only failures count here as they do on every other producer.
+ # A test like CryptoApiTest can fail while the suite still emits its
+ # completion marker and every screenshot matches, and without this the
+ # step exited zero: the workflow stayed green and published a report
+ # containing failed tests. Linux, Android, JavaScript, iOS and Mac all
+ # set this; Windows was the one producer that did not.
+ export CN1SS_FAIL_ON_TEST_PROBLEMS=1
set +e
cn1ss_process_and_report \
"Native Windows port (cross-compiled)" \
"$ART/compare.json" "$ART/summary.txt" "$ART/comment.md" \
"$REF_DIR" "$ART/previews" "$ART" \
- "${entries[@]}"
+ ${entries[@]+"${entries[@]}"}
gate_rc=$?
set -e
fail=0
diff --git a/CodenameOne/src/com/codename1/components/Switch.java b/CodenameOne/src/com/codename1/components/Switch.java
index 4e6120ddbec..54af1ea2756 100644
--- a/CodenameOne/src/com/codename1/components/Switch.java
+++ b/CodenameOne/src/com/codename1/components/Switch.java
@@ -300,7 +300,15 @@ private static Image createRoundThumbImage(Component context, int pxDim, int col
int imgW = baseW + 2 * (shadowSpread + thumbInset);
int imgH = pxDim + 2 * shadowSpread;
Image img = ImageFactory.createImage(context, imgW, imgH, 0x0);
+ if (img == null) {
+ throw new IllegalStateException("Switch thumb: ImageFactory returned null for "
+ + imgW + "x" + imgH);
+ }
Graphics g = img.getGraphics();
+ if (g == null) {
+ throw new IllegalStateException("Switch thumb: no graphics for a " + imgW + "x"
+ + imgH + " image");
+ }
g.setAntiAliased(true);
int shadowOpacity = 200;
@@ -322,9 +330,21 @@ private static Image createRoundThumbImage(Component context, int pxDim, int col
g.translate(-iter, -iter);
}
if (Display.getInstance().isGaussianBlurSupported()) {
+ // A port whose blur answers null, or answers an image it cannot then
+ // draw on, used to surface as a NullPointerException two frames up in
+ // calcPreferredSize -- with no stack at all on ParparVM, which is how
+ // this cost days on Windows. Name it where it happens.
Image blured = Display.getInstance().gaussianBlurImage(img, shadowBlur / 2);
+ if (blured == null) {
+ throw new IllegalStateException("Switch thumb: gaussianBlurImage returned null for "
+ + imgW + "x" + imgH);
+ }
img = blured;
g = img.getGraphics();
+ if (g == null) {
+ throw new IllegalStateException("Switch thumb: no graphics on the blurred "
+ + img.getWidth() + "x" + img.getHeight() + " image");
+ }
g.setAntiAliased(true);
}
}
@@ -675,12 +695,26 @@ public void styleChanged(String propertyName, Style source) {
/// {@inheritDoc}
@Override
protected Dimension calcPreferredSize() {
+ // Each of these can be null if the platform failed to make an image, and
+ // the resulting NullPointerException names none of them.
+ requireImage(getCurrentThumbImage(), "thumb");
+ requireImage(getCurrentTrackOnImage(), "trackOn");
+ requireImage(getCurrentTrackOffImage(), "trackOff");
return new Dimension(
getStyle().getHorizontalPadding() + Math.max(getCurrentThumbImage().getWidth(), Math.max(getCurrentTrackOnImage().getWidth(), getCurrentTrackOffImage().getWidth())),
getStyle().getVerticalPadding() + Math.max(getCurrentThumbImage().getHeight(), Math.max(getCurrentTrackOnImage().getHeight(), getCurrentTrackOffImage().getHeight())));
}
+ /// Fails with the name of the image that could not be built, rather than a
+ /// bare NullPointerException from the arithmetic below it.
+ private void requireImage(Image img, String which) {
+ if (img == null) {
+ throw new IllegalStateException("Switch " + which + " image is null (uiid=" + getUIID()
+ + " on=" + value + " enabled=" + isEnabled() + ")");
+ }
+ }
+
/// {@inheritDoc}
@Override
protected void resetFocusable() {
diff --git a/CodenameOne/src/com/codename1/security/Cipher.java b/CodenameOne/src/com/codename1/security/Cipher.java
index efb7ca214c2..72e7bd4ba3f 100644
--- a/CodenameOne/src/com/codename1/security/Cipher.java
+++ b/CodenameOne/src/com/codename1/security/Cipher.java
@@ -71,6 +71,18 @@ public final class Cipher {
/// `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` -- recommended RSA encryption
/// transformation.
+ ///
+ /// SHA-256 is used for the label hash **and** for MGF1. That is worth stating
+ /// because the JCE reading of this name leaves MGF1 on SHA-1, and this API
+ /// deliberately does not: Web Crypto's `RSA-OAEP` takes a single hash and
+ /// applies it to both, as does Apple's SecKey, so the split pairing is not
+ /// expressible on the JavaScript or iOS ports at all. SHA-256 for both is the
+ /// only choice every port can produce, so it is the one that lets ciphertext
+ /// cross between them. The JavaSE and Android ports pass an explicit
+ /// `OAEPParameterSpec` rather than inheriting their provider's default.
+ ///
+ /// Interoperating with an external system that uses the JCE default (SHA-1
+ /// MGF1) therefore needs that system to name SHA-256 for the mask as well.
public static final String RSA_OAEP_SHA256 = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
/// `RSA/ECB/PKCS1Padding` -- legacy RSA padding, kept for interop.
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
index f29f75f5576..8c637079258 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
@@ -14060,13 +14060,50 @@ private static byte[] androidAes(String transformation, byte[] key, byte[] iv, b
}
}
+ /// The RSA transformations this port implements, matched exactly.
+ ///
+ /// A substring test for "OAEP" would answer every OAEP name -- including
+ /// RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- with the SHA-256 parameters below,
+ /// producing ciphertext no standards-compliant peer could read under the name
+ /// it asked for. The native ports already accept only these two, so refusing
+ /// anything else here keeps every port answering the same question.
+ private static boolean cn1IsOaepTransformation(String transformation) {
+ return com.codename1.security.Cipher.RSA_OAEP_SHA256.equals(transformation);
+ }
+
+ private static void cn1CheckRsaTransformation(String transformation) {
+ if (!cn1IsOaepTransformation(transformation)
+ && !com.codename1.security.Cipher.RSA_PKCS1.equals(transformation)) {
+ throw new RuntimeException("unsupported cipher transformation: " + transformation);
+ }
+ }
+
+ /// The OAEP parameters every port agrees on.
+ ///
+ /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on
+ /// SHA-1 by default, which no other backend here can reproduce: Web Crypto's
+ /// RSA-OAEP uses one hash for the label and the mask, and so does Apple's
+ /// SecKey. Naming SHA-256 for both is the only pairing all six ports can
+ /// produce, so it is what the portable constant means -- stated explicitly
+ /// rather than inherited from a provider default.
+ private static javax.crypto.spec.OAEPParameterSpec cn1OaepParameters() {
+ return new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1",
+ java.security.spec.MGF1ParameterSpec.SHA256,
+ javax.crypto.spec.PSource.PSpecified.DEFAULT);
+ }
+
@Override
public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) {
try {
javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation);
java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA");
java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509));
- cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key);
+ cn1CheckRsaTransformation(transformation);
+ if (cn1IsOaepTransformation(transformation)) {
+ cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters());
+ } else {
+ cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key);
+ }
return cipher.doFinal(plaintext);
} catch (java.security.GeneralSecurityException e) {
throw new RuntimeException("RSA encrypt failed: " + e.getMessage());
@@ -14079,7 +14116,12 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c
javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation);
java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA");
java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8));
- cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key);
+ cn1CheckRsaTransformation(transformation);
+ if (cn1IsOaepTransformation(transformation)) {
+ cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters());
+ } else {
+ cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key);
+ }
return cipher.doFinal(ciphertext);
} catch (java.security.GeneralSecurityException e) {
throw new RuntimeException("RSA decrypt failed: " + e.getMessage());
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index fbe717e5e41..21b80d0a7fe 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -20134,13 +20134,50 @@ private static byte[] javaseAes(String transformation, byte[] key, byte[] iv, by
}
}
+ /// The RSA transformations this port implements, matched exactly.
+ ///
+ /// A substring test for "OAEP" would answer every OAEP name -- including
+ /// RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- with the SHA-256 parameters below,
+ /// producing ciphertext no standards-compliant peer could read under the name
+ /// it asked for. The native ports already accept only these two, so refusing
+ /// anything else here keeps every port answering the same question.
+ private static boolean cn1IsOaepTransformation(String transformation) {
+ return com.codename1.security.Cipher.RSA_OAEP_SHA256.equals(transformation);
+ }
+
+ private static void cn1CheckRsaTransformation(String transformation) {
+ if (!cn1IsOaepTransformation(transformation)
+ && !com.codename1.security.Cipher.RSA_PKCS1.equals(transformation)) {
+ throw new RuntimeException("unsupported cipher transformation: " + transformation);
+ }
+ }
+
+ /// The OAEP parameters every port agrees on.
+ ///
+ /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on
+ /// SHA-1 by default, which no other backend here can reproduce: Web Crypto's
+ /// RSA-OAEP uses one hash for the label and the mask, and so does Apple's
+ /// SecKey. Naming SHA-256 for both is the only pairing all six ports can
+ /// produce, so it is what the portable constant means -- stated explicitly
+ /// rather than inherited from a provider default.
+ private static javax.crypto.spec.OAEPParameterSpec cn1OaepParameters() {
+ return new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1",
+ java.security.spec.MGF1ParameterSpec.SHA256,
+ javax.crypto.spec.PSource.PSpecified.DEFAULT);
+ }
+
@Override
public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) {
try {
javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation);
java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA");
java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509));
- cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key);
+ cn1CheckRsaTransformation(transformation);
+ if (cn1IsOaepTransformation(transformation)) {
+ cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters());
+ } else {
+ cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key);
+ }
return cipher.doFinal(plaintext);
} catch (java.security.GeneralSecurityException e) {
throw new RuntimeException("RSA encrypt failed: " + e.getMessage());
@@ -20153,7 +20190,12 @@ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] c
javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation);
java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA");
java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8));
- cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key);
+ cn1CheckRsaTransformation(transformation);
+ if (cn1IsOaepTransformation(transformation)) {
+ cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters());
+ } else {
+ cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key);
+ }
return cipher.doFinal(ciphertext);
} catch (java.security.GeneralSecurityException e) {
throw new RuntimeException("RSA decrypt failed: " + e.getMessage());
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux.h b/Ports/LinuxPort/nativeSources/cn1_linux.h
index 3aec9368d54..56dde128c7c 100644
--- a/Ports/LinuxPort/nativeSources/cn1_linux.h
+++ b/Ports/LinuxPort/nativeSources/cn1_linux.h
@@ -145,6 +145,15 @@ void cn1LinuxLog(const char* message);
*/
JAVA_OBJECT cn1LinuxNewByteArray(CODENAME_ONE_THREAD_STATE, const void* src, int n);
+/* Copy of a Java String's UTF-8 bytes, owned by the caller (free it).
+ *
+ * stringToUTF8 hands back one buffer per thread and overwrites it on every
+ * call, so a native that converts a second String silently repoints the first
+ * result at the second string. That is not theoretical: it made fileRename
+ * rename a file onto itself and sent every HTTP request header as
+ * "value: value". Any native converting more than one String must use this. */
+char* cn1LinuxJStrDup(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT value);
+
#ifdef __cplusplus
}
#endif
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_browser.c b/Ports/LinuxPort/nativeSources/cn1_linux_browser.c
index 3cadd982b42..63d941aa492 100644
--- a/Ports/LinuxPort/nativeSources/cn1_linux_browser.c
+++ b/Ports/LinuxPort/nativeSources/cn1_linux_browser.c
@@ -56,6 +56,9 @@ static __typeof__(jsc_value_to_string)* p
static __typeof__(webkit_web_view_load_html)* p_webkit_web_view_load_html;
static __typeof__(webkit_web_view_load_uri)* p_webkit_web_view_load_uri;
static __typeof__(webkit_web_view_run_javascript)* p_webkit_web_view_run_javascript;
+static __typeof__(webkit_user_script_new)* p_webkit_user_script_new;
+static __typeof__(webkit_user_content_manager_add_script)* p_webkit_user_content_manager_add_script;
+static __typeof__(webkit_user_script_unref)* p_webkit_user_script_unref;
static int cn1_wk_state = 0; /* 0 = untried, 1 = available, -1 = unavailable */
@@ -91,6 +94,14 @@ static int cn1LoadWebkit(void) {
CN1_WK_SYM(p_webkit_web_view_load_uri, "webkit_web_view_load_uri");
CN1_WK_SYM(p_webkit_web_view_run_javascript, "webkit_web_view_run_javascript");
#undef CN1_WK_SYM
+ /* Optional: the JS->Java bootstrap is an improvement on the navigation
+ * fallback, not a requirement for browsing, so a build missing any of these
+ * keeps a working BrowserComponent rather than reporting unsupported. */
+#define CN1_WK_OPT(ptr, name) do { *(void**)(&ptr) = dlsym(h, name); } while (0)
+ CN1_WK_OPT(p_webkit_user_script_new, "webkit_user_script_new");
+ CN1_WK_OPT(p_webkit_user_content_manager_add_script, "webkit_user_content_manager_add_script");
+ CN1_WK_OPT(p_webkit_user_script_unref, "webkit_user_script_unref");
+#undef CN1_WK_OPT
cn1_wk_state = ok ? 1 : -1;
if (!ok) { cn1LinuxStubOnce("WebKitGTK present but an expected symbol was missing; BrowserComponent unsupported"); }
return ok;
@@ -148,6 +159,27 @@ static void cn1BrowserCreateOnMain(void* p) {
pthread_mutex_init(&b->lock, 0);
p_webkit_user_content_manager_register_script_message_handler(mgr, "cn1");
g_signal_connect(mgr, "script-message-received::cn1", G_CALLBACK(cn1BrowserScriptMessage), b);
+ /* Give the page the object BrowserComponent.execute's generated JavaScript
+ * looks for. Without it that code falls through to its last resort,
+ * `window.location.href = "https://www.codenameone.com/..."`, so every
+ * execute() callback and every JS->Java message became a real navigation to
+ * the internet: the return value only came back if the runner could reach
+ * that host, and the page navigated away from the content under test. This
+ * is the same bootstrap the iOS port injects, routed to the "cn1" message
+ * handler registered above. */
+ if (p_webkit_user_script_new != 0 && p_webkit_user_content_manager_add_script != 0) {
+ WebKitUserScript* bootstrap = p_webkit_user_script_new(
+ "window.cn1application = window.cn1application || {};"
+ "window.cn1application.shouldNavigate = function(url) {"
+ " window.webkit.messageHandlers.cn1.postMessage(String(url));"
+ "};",
+ WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
+ WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, 0, 0);
+ p_webkit_user_content_manager_add_script(mgr, bootstrap);
+ if (p_webkit_user_script_unref != 0) {
+ p_webkit_user_script_unref(bootstrap);
+ }
+ }
b->view = p_webkit_web_view_new_with_user_content_manager(mgr);
g_signal_connect(b->view, "load-changed", G_CALLBACK(cn1BrowserLoadChanged), b);
cn1LinuxOverlayAdd(b->view, 0, 0, req->w > 0 ? req->w : 1, req->h > 0 ? req->h : 1);
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c
new file mode 100644
index 00000000000..91dfee77aab
--- /dev/null
+++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c
@@ -0,0 +1,610 @@
+/*
+ * 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.
+ */
+
+/*
+ * The crypto half of com.codename1.impl.CodenameOneImplementation, backed by
+ * OpenSSL's EVP layer (libcrypto, which the port already pulls in through
+ * libcurl).
+ *
+ * Key material crosses this boundary in the same DER encodings the portable
+ * API documents -- X.509 SubjectPublicKeyInfo for public keys and PKCS#8
+ * PrivateKeyInfo for private keys -- so nothing here has to parse ASN.1 by
+ * hand: d2i_PUBKEY and d2i_PKCS8_PRIV_KEY_INFO do it.
+ *
+ * Every entry point answers null (or false) on failure and records a reason
+ * retrievable through lastCryptoError, which the Java side turns into the
+ * CryptoException message. A silent empty result would look like a successful
+ * encryption of nothing.
+ */
+
+#include "cn1_linux.h"
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define CN1_GCM_TAG_BYTES 16
+
+/* Per-thread: crypto failures on different threads would otherwise overwrite
+ * each other and lastCryptoError() could answer with an unrelated call's
+ * message. */
+static __thread char cn1CryptoError[512];
+
+static void cn1CryptoFail(const char* what) {
+ unsigned long code = ERR_get_error();
+ char detail[256];
+ detail[0] = 0;
+ if (code != 0) {
+ ERR_error_string_n(code, detail, sizeof(detail));
+ }
+ snprintf(cn1CryptoError, sizeof(cn1CryptoError), "%s%s%s", what,
+ detail[0] ? ": " : "", detail);
+ ERR_clear_error();
+}
+
+/* Clears the per-thread message so a caller can tell whether the operation it
+ * just ran recorded one. verifyData answers false for an invalid signature --
+ * an ordinary result -- and also for an unusable algorithm or key, which is a
+ * configuration error the caller deserves to see. The only difference between
+ * them is whether an error was recorded, and that is only readable if the slot
+ * was empty beforehand. */
+JAVA_VOID com_codename1_impl_linux_LinuxNative_clearCryptoError__(CODENAME_ONE_THREAD_STATE) {
+ cn1CryptoError[0] = 0;
+}
+
+JAVA_OBJECT com_codename1_impl_linux_LinuxNative_lastCryptoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) {
+ return newStringFromCString(threadStateData,
+ cn1CryptoError[0] ? cn1CryptoError : "unknown crypto error");
+}
+
+static const unsigned char* cn1Bytes(JAVA_OBJECT array, int* length) {
+ if (array == JAVA_NULL) {
+ *length = 0;
+ return 0;
+ }
+ *length = (int) (*(JAVA_ARRAY) array).length;
+ return (const unsigned char*) (*(JAVA_ARRAY) array).data;
+}
+
+/* ------------------------------------------------------------ random */
+
+JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_secureRandomBytes___byte_1ARRAY_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) {
+ int length = 0;
+ unsigned char* data = (unsigned char*) cn1Bytes(out, &length);
+ if (data == 0 || length <= 0) {
+ return JAVA_TRUE;
+ }
+ if (RAND_bytes(data, length) != 1) {
+ // Report the failure rather than leaving the buffer as it stands:
+ // KeyGenerator hands this straight back as key material, so a quiet
+ // return would mint a predictable key.
+ cn1CryptoFail("secure random");
+ memset(data, 0, (size_t) length);
+ return JAVA_FALSE;
+ }
+ return JAVA_TRUE;
+}
+
+/* ------------------------------------------- advertised names, and only those
+ *
+ * JavaSE hands these strings to JCE, which either supports a name as written or
+ * refuses it. Matching loosely here -- picking CBC because a string contains
+ * neither "/GCM/" nor "/ECB/", or SHA-256 because it names no digest we know --
+ * means the same request encrypts or signs differently depending on which port
+ * runs it, and the caller is never told. A name outside the advertised set is
+ * refused instead.
+ */
+
+static int cn1IsAesTransformation(const char* transformation) {
+ return strcmp(transformation, "AES/GCM/NoPadding") == 0
+ || strcmp(transformation, "AES/CBC/PKCS5Padding") == 0
+ || strcmp(transformation, "AES/CBC/NoPadding") == 0
+ || strcmp(transformation, "AES/ECB/PKCS5Padding") == 0;
+}
+
+static int cn1IsRsaTransformation(const char* transformation) {
+ return strcmp(transformation, "RSA/ECB/OAEPWithSHA-256AndMGF1Padding") == 0
+ || strcmp(transformation, "RSA/ECB/PKCS1Padding") == 0;
+}
+
+/* The digest half of Signature's six advertised algorithms; 0 for anything
+ * else, which the callers turn into a failure. */
+static const EVP_MD* cn1SignatureDigestOrNull(const char* algorithm) {
+ if (strcmp(algorithm, "SHA256withRSA") == 0 || strcmp(algorithm, "SHA256withECDSA") == 0) {
+ return EVP_sha256();
+ }
+ if (strcmp(algorithm, "SHA384withRSA") == 0 || strcmp(algorithm, "SHA384withECDSA") == 0) {
+ return EVP_sha384();
+ }
+ if (strcmp(algorithm, "SHA512withRSA") == 0 || strcmp(algorithm, "SHA512withECDSA") == 0) {
+ return EVP_sha512();
+ }
+ return 0;
+}
+
+/* ------------------------------------------------------------ AES */
+
+static const EVP_CIPHER* cn1AesCipher(const char* transformation, int keyLength) {
+ int gcm = strstr(transformation, "/GCM/") != 0;
+ int ecb = strstr(transformation, "/ECB/") != 0;
+ switch (keyLength) {
+ case 16:
+ return gcm ? EVP_aes_128_gcm() : (ecb ? EVP_aes_128_ecb() : EVP_aes_128_cbc());
+ case 24:
+ return gcm ? EVP_aes_192_gcm() : (ecb ? EVP_aes_192_ecb() : EVP_aes_192_cbc());
+ case 32:
+ return gcm ? EVP_aes_256_gcm() : (ecb ? EVP_aes_256_ecb() : EVP_aes_256_cbc());
+ default:
+ return 0;
+ }
+}
+
+JAVA_OBJECT com_codename1_impl_linux_LinuxNative_aesCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY(
+ CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt,
+ JAVA_OBJECT keyArray, JAVA_OBJECT ivArray, JAVA_OBJECT aadArray, JAVA_OBJECT dataArray) {
+ const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation);
+ int keyLength = 0, ivLength = 0, aadLength = 0, dataLength = 0;
+ const unsigned char* key = cn1Bytes(keyArray, &keyLength);
+ const unsigned char* iv = cn1Bytes(ivArray, &ivLength);
+ const unsigned char* aad = cn1Bytes(aadArray, &aadLength);
+ const unsigned char* data = cn1Bytes(dataArray, &dataLength);
+ int gcm;
+ if (!cn1IsAesTransformation(mode)) {
+ cn1CryptoFail("unsupported cipher transformation");
+ return JAVA_NULL;
+ }
+ gcm = strstr(mode, "/GCM/") != 0;
+ int ecb = strstr(mode, "/ECB/") != 0;
+ int padded = strstr(mode, "NoPadding") == 0;
+ const EVP_CIPHER* cipher = cn1AesCipher(mode, keyLength);
+ EVP_CIPHER_CTX* ctx = 0;
+ unsigned char* out = 0;
+ unsigned char tag[CN1_GCM_TAG_BYTES];
+ int bodyLength = dataLength;
+ int outLength = 0, finalLength = 0, discard = 0;
+ JAVA_OBJECT result = JAVA_NULL;
+
+ if (cipher == 0) {
+ cn1CryptoFail("unsupported AES key length");
+ return JAVA_NULL;
+ }
+ // OpenSSL would otherwise silently keep the context's zeroed default IV for
+ // a missing GCM nonce -- repeating a nonce under one key destroys GCM --
+ // and read a whole block past a short CBC IV.
+ if (gcm && ivLength <= 0) {
+ cn1CryptoFail("AES-GCM requires a nonce");
+ return JAVA_NULL;
+ }
+ if (!gcm && !ecb && ivLength != 16) {
+ cn1CryptoFail("AES-CBC requires a 16 byte initialization vector");
+ return JAVA_NULL;
+ }
+ if (gcm && !encrypt) {
+ if (dataLength < CN1_GCM_TAG_BYTES) {
+ cn1CryptoFail("AES-GCM input is shorter than its authentication tag");
+ return JAVA_NULL;
+ }
+ bodyLength = dataLength - CN1_GCM_TAG_BYTES;
+ }
+
+ ctx = EVP_CIPHER_CTX_new();
+ if (ctx == 0) {
+ cn1CryptoFail("cipher context");
+ return JAVA_NULL;
+ }
+ /* Room for a full trailing block of padding, plus the tag when sealing. */
+ out = (unsigned char*) malloc((size_t) bodyLength + EVP_MAX_BLOCK_LENGTH + CN1_GCM_TAG_BYTES);
+ if (out == 0) {
+ EVP_CIPHER_CTX_free(ctx);
+ cn1CryptoFail("out of memory");
+ return JAVA_NULL;
+ }
+
+ if (EVP_CipherInit_ex(ctx, cipher, 0, 0, 0, encrypt ? 1 : 0) != 1) {
+ goto failed;
+ }
+ if (gcm && ivLength > 0 &&
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, ivLength, 0) != 1) {
+ goto failed;
+ }
+ if (EVP_CipherInit_ex(ctx, 0, 0, key, ivLength > 0 ? iv : 0, encrypt ? 1 : 0) != 1) {
+ goto failed;
+ }
+ if (EVP_CIPHER_CTX_set_padding(ctx, padded ? 1 : 0) != 1) {
+ goto failed;
+ }
+ if (gcm && aadLength > 0 && EVP_CipherUpdate(ctx, 0, &discard, aad, aadLength) != 1) {
+ goto failed;
+ }
+ if (bodyLength > 0 && EVP_CipherUpdate(ctx, out, &outLength, data, bodyLength) != 1) {
+ goto failed;
+ }
+ if (gcm && !encrypt &&
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_TAG, CN1_GCM_TAG_BYTES,
+ (void*) (data + bodyLength)) != 1) {
+ goto failed;
+ }
+ if (EVP_CipherFinal_ex(ctx, out + outLength, &finalLength) != 1) {
+ /* For GCM this is the tag check: a tampered message lands here. */
+ cn1CryptoFail(gcm && !encrypt ? "AES-GCM authentication failed" : "AES finalize");
+ free(out);
+ EVP_CIPHER_CTX_free(ctx);
+ return JAVA_NULL;
+ }
+ outLength += finalLength;
+ if (gcm && encrypt) {
+ if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, CN1_GCM_TAG_BYTES, tag) != 1) {
+ goto failed;
+ }
+ memcpy(out + outLength, tag, CN1_GCM_TAG_BYTES);
+ outLength += CN1_GCM_TAG_BYTES;
+ }
+ result = cn1LinuxNewByteArray(threadStateData, out, outLength);
+ free(out);
+ EVP_CIPHER_CTX_free(ctx);
+ return result;
+
+failed:
+ cn1CryptoFail("AES");
+ free(out);
+ EVP_CIPHER_CTX_free(ctx);
+ return JAVA_NULL;
+}
+
+/* ------------------------------------------------------------ keys */
+
+/* d2i_* stops at the end of the first object it recognises and reports how far
+ * it got, so a buffer holding a valid key followed by extra bytes -- a
+ * mis-sliced concatenation, say -- parses happily and the trailing data is
+ * never seen. JavaSE and Android refuse that through KeyFactory, so accepting
+ * it here would mean the same bytes validate on one port and not another. The
+ * whole input has to be the key. */
+static EVP_PKEY* cn1PublicKey(const unsigned char* der, int length) {
+ const unsigned char* cursor = der;
+ EVP_PKEY* key = d2i_PUBKEY(0, &cursor, (long) length);
+ if (key == 0) {
+ cn1CryptoFail("public key is not X.509 SubjectPublicKeyInfo DER");
+ return 0;
+ }
+ if (cursor != der + length) {
+ cn1CryptoFail("public key has trailing data after the SubjectPublicKeyInfo");
+ EVP_PKEY_free(key);
+ return 0;
+ }
+ return key;
+}
+
+static EVP_PKEY* cn1PrivateKey(const unsigned char* der, int length) {
+ const unsigned char* cursor = der;
+ EVP_PKEY* key = 0;
+ PKCS8_PRIV_KEY_INFO* info = d2i_PKCS8_PRIV_KEY_INFO(0, &cursor, (long) length);
+ if (info != 0) {
+ key = EVP_PKCS82PKEY(info);
+ PKCS8_PRIV_KEY_INFO_free(info);
+ }
+ if (key == 0) {
+ /* PKCS#8 only, deliberately. This used to fall back to
+ * d2i_AutoPrivateKey, which also accepts bare PKCS#1 RSA and SEC1 EC
+ * keys -- but the public entry point is PrivateKey.fromPkcs8(), JavaSE
+ * and Android feed it to PKCS8EncodedKeySpec and Windows imports it as
+ * NCRYPT_PKCS8_PRIVATE_KEY_BLOB, so all of those reject those
+ * encodings. Accepting them here meant the identical key loaded on
+ * Linux and nowhere else, which is a portability trap rather than a
+ * kindness: code written against this port would fail on every device
+ * it shipped to.
+ *
+ * The failed attempt above queued an entry on this thread's OpenSSL
+ * error queue; clear it so the next unrelated operation to read the
+ * queue does not report it. */
+ ERR_clear_error();
+ cn1CryptoFail("private key is not PKCS#8 DER");
+ return 0;
+ }
+ /* Same rule as the public side: the whole buffer has to be the key. */
+ if (cursor != der + length) {
+ cn1CryptoFail("private key has trailing data after the PKCS#8 structure");
+ EVP_PKEY_free(key);
+ return 0;
+ }
+ return key;
+}
+
+static int cn1ApplyRsaPadding(EVP_PKEY_CTX* ctx, const char* transformation) {
+ if (!cn1IsRsaTransformation(transformation)) {
+ cn1CryptoFail("unsupported cipher transformation");
+ return 0;
+ }
+ if (strstr(transformation, "OAEP") != 0) {
+ const EVP_MD* md = EVP_sha256();
+ // SHA-256 for the label hash AND the mask. The JCE transformation name
+ // leaves MGF1 on SHA-1 by default, but no other backend in this project
+ // can reproduce that: Web Crypto's RSA-OAEP takes a single hash and uses
+ // it for both, and so does Apple's SecKey. SHA-256 for both is the only
+ // pairing every port can produce, so it is the one the portable constant
+ // means -- the JavaSE and Android ports name it explicitly rather than
+ // inheriting their provider's default.
+ if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) <= 0 ||
+ EVP_PKEY_CTX_set_rsa_oaep_md(ctx, md) <= 0 ||
+ EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, md) <= 0) {
+ return 0;
+ }
+ return 1;
+ }
+ return EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_PADDING) > 0;
+}
+
+JAVA_OBJECT com_codename1_impl_linux_LinuxNative_rsaCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY(
+ CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt,
+ JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) {
+ const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation);
+ int keyLength = 0, dataLength = 0;
+ const unsigned char* keyDer = cn1Bytes(keyArray, &keyLength);
+ const unsigned char* data = cn1Bytes(dataArray, &dataLength);
+ EVP_PKEY* key = encrypt ? cn1PublicKey(keyDer, keyLength) : cn1PrivateKey(keyDer, keyLength);
+ EVP_PKEY_CTX* ctx = 0;
+ unsigned char* out = 0;
+ size_t outLength = 0;
+ JAVA_OBJECT result = JAVA_NULL;
+
+ if (key == 0) {
+ return JAVA_NULL;
+ }
+ ctx = EVP_PKEY_CTX_new(key, 0);
+ if (ctx == 0) {
+ cn1CryptoFail("RSA context");
+ EVP_PKEY_free(key);
+ return JAVA_NULL;
+ }
+ if ((encrypt ? EVP_PKEY_encrypt_init(ctx) : EVP_PKEY_decrypt_init(ctx)) <= 0 ||
+ !cn1ApplyRsaPadding(ctx, mode)) {
+ cn1CryptoFail("RSA init");
+ goto done;
+ }
+ if ((encrypt ? EVP_PKEY_encrypt(ctx, 0, &outLength, data, (size_t) dataLength)
+ : EVP_PKEY_decrypt(ctx, 0, &outLength, data, (size_t) dataLength)) <= 0) {
+ cn1CryptoFail("RSA size");
+ goto done;
+ }
+ out = (unsigned char*) malloc(outLength);
+ if (out == 0) {
+ cn1CryptoFail("out of memory");
+ goto done;
+ }
+ if ((encrypt ? EVP_PKEY_encrypt(ctx, out, &outLength, data, (size_t) dataLength)
+ : EVP_PKEY_decrypt(ctx, out, &outLength, data, (size_t) dataLength)) <= 0) {
+ cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt");
+ goto done;
+ }
+ result = cn1LinuxNewByteArray(threadStateData, out, (int) outLength);
+
+done:
+ free(out);
+ EVP_PKEY_CTX_free(ctx);
+ EVP_PKEY_free(key);
+ return result;
+}
+
+/* ------------------------------------------------------------ signatures */
+
+/* The signature algorithm's family must match the key that was actually handed
+ * over, read from the DER rather than from the caller's label. PrivateKey.
+ * fromPkcs8 and PublicKey.fromX509 take an arbitrary algorithm string and never
+ * check it against the bytes, so a Java-side comparison of that label can be
+ * satisfied while the encoded key is a different family entirely -- and the
+ * primitive below would then sign an "RSA" request with ECDSA. */
+static int cn1KeyFamilyMatches(const char* algorithm, EVP_PKEY* key) {
+ /* Name the family that is required rather than testing "not EC": every
+ * other key type would otherwise pass as RSA, so DSA bytes carrying an
+ * "RSA" label would reach EVP_DigestSign and come back as a DSA signature
+ * under a SHA256withRSA request. */
+ int id = EVP_PKEY_base_id(key);
+ if (strstr(algorithm, "ECDSA") != 0) {
+ return id == EVP_PKEY_EC;
+ }
+ return id == EVP_PKEY_RSA;
+}
+
+
+static const EVP_MD* cn1SignatureDigest(const char* algorithm) {
+ return cn1SignatureDigestOrNull(algorithm);
+}
+
+JAVA_OBJECT com_codename1_impl_linux_LinuxNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY(
+ CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) {
+ const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm);
+ int keyLength = 0, dataLength = 0;
+ const unsigned char* keyDer = cn1Bytes(keyArray, &keyLength);
+ const unsigned char* data = cn1Bytes(dataArray, &dataLength);
+ EVP_PKEY* key = cn1PrivateKey(keyDer, keyLength);
+ EVP_MD_CTX* ctx = 0;
+ unsigned char* out = 0;
+ size_t outLength = 0;
+ JAVA_OBJECT result = JAVA_NULL;
+
+ if (key == 0) {
+ return JAVA_NULL;
+ }
+ if (cn1SignatureDigest(name) == 0) {
+ /* Not one of Signature's advertised algorithms. Passing a null digest
+ * on would let OpenSSL choose one, which is how an unsupported name
+ * used to come back as a valid signature over a different digest. */
+ cn1CryptoFail("unsupported signature algorithm");
+ EVP_PKEY_free(key);
+ return JAVA_NULL;
+ }
+ if (!cn1KeyFamilyMatches(name, key)) {
+ cn1CryptoFail("the signature algorithm does not match the key");
+ EVP_PKEY_free(key);
+ return JAVA_NULL;
+ }
+ ctx = EVP_MD_CTX_new();
+ if (ctx == 0) {
+ cn1CryptoFail("digest context");
+ EVP_PKEY_free(key);
+ return JAVA_NULL;
+ }
+ if (EVP_DigestSignInit(ctx, 0, cn1SignatureDigest(name), 0, key) <= 0 ||
+ EVP_DigestSign(ctx, 0, &outLength, data, (size_t) dataLength) <= 0) {
+ cn1CryptoFail("sign init");
+ goto done;
+ }
+ out = (unsigned char*) malloc(outLength);
+ if (out == 0) {
+ cn1CryptoFail("out of memory");
+ goto done;
+ }
+ if (EVP_DigestSign(ctx, out, &outLength, data, (size_t) dataLength) <= 0) {
+ cn1CryptoFail("sign");
+ goto done;
+ }
+ result = cn1LinuxNewByteArray(threadStateData, out, (int) outLength);
+
+done:
+ free(out);
+ EVP_MD_CTX_free(ctx);
+ EVP_PKEY_free(key);
+ return result;
+}
+
+JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_verifyData___java_lang_String_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_boolean(
+ CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray,
+ JAVA_OBJECT dataArray, JAVA_OBJECT signatureArray) {
+ const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm);
+ int keyLength = 0, dataLength = 0, signatureLength = 0;
+ const unsigned char* keyDer = cn1Bytes(keyArray, &keyLength);
+ const unsigned char* data = cn1Bytes(dataArray, &dataLength);
+ const unsigned char* signature = cn1Bytes(signatureArray, &signatureLength);
+ EVP_PKEY* key = cn1PublicKey(keyDer, keyLength);
+ EVP_MD_CTX* ctx = 0;
+ JAVA_BOOLEAN result = JAVA_FALSE;
+
+ if (key == 0) {
+ return JAVA_FALSE;
+ }
+ if (cn1SignatureDigest(name) == 0) {
+ cn1CryptoFail("unsupported signature algorithm");
+ EVP_PKEY_free(key);
+ return JAVA_FALSE;
+ }
+ if (!cn1KeyFamilyMatches(name, key)) {
+ cn1CryptoFail("the signature algorithm does not match the key");
+ EVP_PKEY_free(key);
+ return JAVA_FALSE;
+ }
+ ctx = EVP_MD_CTX_new();
+ if (ctx == 0) {
+ cn1CryptoFail("digest context");
+ EVP_PKEY_free(key);
+ return JAVA_FALSE;
+ }
+ if (EVP_DigestVerifyInit(ctx, 0, cn1SignatureDigest(name), 0, key) <= 0) {
+ /* Setup failed, so nothing was verified. Returning a bare false here
+ * said "that signature is invalid" about a signature never examined. */
+ cn1CryptoFail("could not initialise signature verification");
+ } else {
+ /* EVP_DigestVerify is tri-state: 1 verified, 0 rejected, negative an
+ * internal error. Folding negative in with zero reported a broken
+ * verifier as an ordinary bad signature. */
+ int verified = EVP_DigestVerify(ctx, signature, (size_t) signatureLength,
+ data, (size_t) dataLength);
+ if (verified == 1) {
+ result = JAVA_TRUE;
+ } else if (verified < 0) {
+ cn1CryptoFail("signature verification failed to run");
+ } else {
+ /* A rejected signature is a normal answer, not a fault; clear the
+ * queue so it cannot be reported against a later operation. */
+ ERR_clear_error();
+ }
+ }
+ EVP_MD_CTX_free(ctx);
+ EVP_PKEY_free(key);
+ return result;
+}
+
+/* ------------------------------------------------------------ key pairs */
+
+/* Returns the pair as one array: a four-byte big-endian public-key length,
+ * the X.509 public key, then the PKCS#8 private key. A pair has to come from
+ * a single call -- two calls would produce two unrelated keys. */
+JAVA_OBJECT com_codename1_impl_linux_LinuxNative_generateRsaKeyPair___int_R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_INT bits) {
+ EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, 0);
+ EVP_PKEY* key = 0;
+ PKCS8_PRIV_KEY_INFO* info = 0;
+ unsigned char* publicDer = 0;
+ unsigned char* privateDer = 0;
+ unsigned char* blob = 0;
+ int publicLength = 0, privateLength = 0;
+ JAVA_OBJECT result = JAVA_NULL;
+
+ if (ctx == 0) {
+ cn1CryptoFail("RSA keygen context");
+ return JAVA_NULL;
+ }
+ if (EVP_PKEY_keygen_init(ctx) <= 0 ||
+ EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, bits) <= 0 ||
+ EVP_PKEY_keygen(ctx, &key) <= 0) {
+ cn1CryptoFail("RSA keygen");
+ goto done;
+ }
+ publicLength = i2d_PUBKEY(key, &publicDer);
+ info = EVP_PKEY2PKCS8(key);
+ if (info != 0) {
+ privateLength = i2d_PKCS8_PRIV_KEY_INFO(info, &privateDer);
+ }
+ if (publicLength <= 0 || privateLength <= 0) {
+ cn1CryptoFail("RSA key encoding");
+ goto done;
+ }
+ blob = (unsigned char*) malloc((size_t) publicLength + (size_t) privateLength + 4);
+ if (blob == 0) {
+ cn1CryptoFail("out of memory");
+ goto done;
+ }
+ blob[0] = (unsigned char) ((publicLength >> 24) & 0xff);
+ blob[1] = (unsigned char) ((publicLength >> 16) & 0xff);
+ blob[2] = (unsigned char) ((publicLength >> 8) & 0xff);
+ blob[3] = (unsigned char) (publicLength & 0xff);
+ memcpy(blob + 4, publicDer, (size_t) publicLength);
+ memcpy(blob + 4 + publicLength, privateDer, (size_t) privateLength);
+ result = cn1LinuxNewByteArray(threadStateData, blob, publicLength + privateLength + 4);
+
+done:
+ free(blob);
+ OPENSSL_free(publicDer);
+ OPENSSL_free(privateDer);
+ if (info != 0) {
+ PKCS8_PRIV_KEY_INFO_free(info);
+ }
+ if (key != 0) {
+ EVP_PKEY_free(key);
+ }
+ EVP_PKEY_CTX_free(ctx);
+ return result;
+}
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_io.c b/Ports/LinuxPort/nativeSources/cn1_linux_io.c
index 9be20a1d3ed..f81841494af 100644
--- a/Ports/LinuxPort/nativeSources/cn1_linux_io.c
+++ b/Ports/LinuxPort/nativeSources/cn1_linux_io.c
@@ -78,6 +78,21 @@ void cn1LinuxStubOnce(const char* tag) {
fflush(stderr);
}
+char* cn1LinuxJStrDup(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT value) {
+ const char* utf8 = value == JAVA_NULL ? 0 : stringToUTF8(threadStateData, value);
+ char* copy;
+ size_t length;
+ if (utf8 == 0) {
+ return 0;
+ }
+ length = strlen(utf8);
+ copy = (char*) malloc(length + 1);
+ if (copy != 0) {
+ memcpy(copy, utf8, length + 1);
+ }
+ return copy;
+}
+
JAVA_OBJECT cn1LinuxNewByteArray(CODENAME_ONE_THREAD_STATE, const void* src, int n) {
JAVA_OBJECT arr;
if (n < 0) {
@@ -153,15 +168,46 @@ static const char* cn1JStr(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT s) {
/* ------------------------------------------------------------ file io */
+/* Reason the last open failed. The port reports "could not open X" from Java,
+ * where errno is long gone; without this the only way to tell a missing
+ * directory from a permission problem was another CI round trip. */
+/* Per-thread: two threads failing an open at once would otherwise overwrite
+ * each other and lastIoError() could report the wrong reason, or a torn
+ * mixture of both. */
+static __thread char cn1LastIoError[512];
+
+static void cn1RecordIoError(const char* path) {
+ if (path == 0) {
+ /* No fopen was attempted, so errno belongs to some earlier unrelated
+ * call and strerror would name a plausible-looking wrong reason --
+ * worse than saying nothing, since the whole point of this buffer is
+ * to explain a failure without another CI round trip. */
+ snprintf(cn1LastIoError, sizeof(cn1LastIoError), "%s",
+ "no path supplied");
+ return;
+ }
+ snprintf(cn1LastIoError, sizeof(cn1LastIoError), "%s", strerror(errno));
+}
+
+JAVA_OBJECT com_codename1_impl_linux_LinuxNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) {
+ return newStringFromCString(threadStateData, cn1LastIoError[0] ? cn1LastIoError : "unknown error");
+}
+
JAVA_LONG com_codename1_impl_linux_LinuxNative_fileOpenRead___java_lang_String_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path) {
const char* p = cn1JStr(threadStateData, path);
FILE* f = p ? fopen(p, "rb") : 0;
+ if (f == 0) {
+ cn1RecordIoError(p);
+ }
return (JAVA_LONG) (intptr_t) f;
}
JAVA_LONG com_codename1_impl_linux_LinuxNative_fileOpenWrite___java_lang_String_boolean_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_BOOLEAN append) {
const char* p = cn1JStr(threadStateData, path);
FILE* f = p ? fopen(p, append ? "ab" : "wb") : 0;
+ if (f == 0) {
+ cn1RecordIoError(p);
+ }
return (JAVA_LONG) (intptr_t) f;
}
@@ -234,15 +280,22 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_fileMkdir___java_lang_String(CODE
}
JAVA_VOID com_codename1_impl_linux_LinuxNative_fileRename___java_lang_String_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_OBJECT newName) {
- const char* p = cn1JStr(threadStateData, path);
- const char* n;
+ /* Two conversions: copy the first, or stringToUTF8's shared per-thread
+ * buffer repoints it at the new name and the file is renamed onto itself. */
+ char* p = cn1LinuxJStrDup(threadStateData, path);
+ char* n = 0;
char dir[4096];
char dest[4096];
char* slash;
if (!p || newName == JAVA_NULL) {
+ free(p);
+ return;
+ }
+ n = cn1LinuxJStrDup(threadStateData, newName);
+ if (!n) {
+ free(p);
return;
}
- n = stringToUTF8(threadStateData, newName);
/* newName is a leaf name; rename within the same parent directory. */
strncpy(dir, p, sizeof(dir) - 1);
dir[sizeof(dir) - 1] = 0;
@@ -254,6 +307,8 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_fileRename___java_lang_String_jav
snprintf(dest, sizeof(dest), "%s", n);
}
rename(p, dest);
+ free(p);
+ free(n);
}
JAVA_OBJECT com_codename1_impl_linux_LinuxNative_fileList___java_lang_String_R_java_lang_String_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path) {
@@ -291,6 +346,30 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_fileList___java_lang_String_R_j
/* The per-user app storage directory ($XDG_DATA_HOME/codenameone, else
* ~/.local/share/codenameone), created on first use. Backs Storage + the
* FileSystemStorage app-home. */
+/* Creates every missing component of an absolute path. mkdir(2) only creates
+ * the leaf, so a home without an existing ~/.local/share -- which is the state
+ * of a fresh CI runner or a freshly created account -- left the storage
+ * directory absent. Every write into it then failed at fopen(), which the port
+ * used to swallow: Storage entries and files written through
+ * FileSystemStorage were silently discarded. */
+static void cn1MkdirParents(const char* path) {
+ char work[4096];
+ char* p;
+ size_t len = strlen(path);
+ if (len == 0 || len >= sizeof(work)) {
+ return;
+ }
+ memcpy(work, path, len + 1);
+ for (p = work + 1; *p; p++) {
+ if (*p == '/') {
+ *p = 0;
+ mkdir(work, 0755);
+ *p = '/';
+ }
+ }
+ mkdir(work, 0755);
+}
+
static const char* cn1StorageDir(void) {
static char dir[4096];
if (dir[0] == 0) {
@@ -304,9 +383,8 @@ static const char* cn1StorageDir(void) {
} else {
snprintf(share, sizeof(share), "/tmp");
}
- mkdir(share, 0755);
snprintf(dir, sizeof(dir), "%s/codenameone", share);
- mkdir(dir, 0755);
+ cn1MkdirParents(dir);
}
return dir;
}
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_net.c b/Ports/LinuxPort/nativeSources/cn1_linux_net.c
index e755bb3f3f8..ec740cc852f 100644
--- a/Ports/LinuxPort/nativeSources/cn1_linux_net.c
+++ b/Ports/LinuxPort/nativeSources/cn1_linux_net.c
@@ -197,16 +197,22 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetMethod___long_boolean(CODE
JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetHeader___long_java_lang_String_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection, JAVA_OBJECT key, JAVA_OBJECT value) {
CN1Http* c = (CN1Http*) (intptr_t) connection;
- const char* k;
- const char* v;
+ char* k;
+ char* v;
char line[8192];
if (!c || key == JAVA_NULL) {
return;
}
- k = stringToUTF8(threadStateData, key);
- v = value == JAVA_NULL ? "" : stringToUTF8(threadStateData, value);
- snprintf(line, sizeof(line), "%s: %s", k, v);
- c->reqHeaders = curl_slist_append(c->reqHeaders, line);
+ /* Copy the name: converting the value reuses stringToUTF8's per-thread
+ * buffer, which otherwise sent every header as "value: value". */
+ k = cn1LinuxJStrDup(threadStateData, key);
+ v = value == JAVA_NULL ? 0 : cn1LinuxJStrDup(threadStateData, value);
+ if (k != 0) {
+ snprintf(line, sizeof(line), "%s: %s", k, v == 0 ? "" : v);
+ c->reqHeaders = curl_slist_append(c->reqHeaders, line);
+ }
+ free(k);
+ free(v);
}
JAVA_INT com_codename1_impl_linux_LinuxNative_httpResponseCode___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection) {
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_print.c b/Ports/LinuxPort/nativeSources/cn1_linux_print.c
index b9a6671ab82..2fd8b562478 100644
--- a/Ports/LinuxPort/nativeSources/cn1_linux_print.c
+++ b/Ports/LinuxPort/nativeSources/cn1_linux_print.c
@@ -123,24 +123,36 @@ static int cn1PrintViaLp(const char* path, const char* job) {
JAVA_INT com_codename1_impl_linux_LinuxNative_printDocument___java_lang_String_java_lang_String_java_lang_String_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_OBJECT mimeType, JAVA_OBJECT jobName) {
CN1PrintReq req;
+ /* Each conversion overwrites stringToUTF8's per-thread buffer, so all
+ * three have to be copied before any of them is read. */
+ char* pathCopy = cn1LinuxJStrDup(threadStateData, path);
+ char* mimeCopy = cn1LinuxJStrDup(threadStateData, mimeType);
+ char* jobCopy = cn1LinuxJStrDup(threadStateData, jobName);
+ int result;
cn1PrintError[0] = 0;
- req.path = path == JAVA_NULL ? 0 : stringToUTF8(threadStateData, path);
- req.mime = mimeType == JAVA_NULL ? "" : stringToUTF8(threadStateData, mimeType);
- req.job = jobName == JAVA_NULL ? 0 : stringToUTF8(threadStateData, jobName);
+ req.path = pathCopy;
+ req.mime = mimeCopy == 0 ? "" : mimeCopy;
+ req.job = jobCopy;
req.result = 2;
if (!req.path) {
snprintf(cn1PrintError, sizeof(cn1PrintError), "null path");
+ free(pathCopy);
+ free(mimeCopy);
+ free(jobCopy);
return 2;
}
- if (strncmp(req.mime, "image", 5) == 0) {
- if (cn1LinuxWindowWidget() == 0) {
- return cn1PrintViaLp(req.path, req.job); /* headless: no dialog */
- }
+ if (strncmp(req.mime, "image", 5) == 0 && cn1LinuxWindowWidget() != 0) {
cn1LinuxRunOnMainAndWait(cn1PrintImageOnMain, &req);
- return req.result;
+ result = req.result;
+ } else {
+ /* Headless images and everything else: hand to CUPS, which
+ * rasterizes natively. */
+ result = cn1PrintViaLp(req.path, req.job);
}
- /* PDF and everything else: hand to CUPS, which rasterizes natively. */
- return cn1PrintViaLp(req.path, req.job);
+ free(pathCopy);
+ free(mimeCopy);
+ free(jobCopy);
+ return result;
}
JAVA_OBJECT com_codename1_impl_linux_LinuxNative_printLastError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) {
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c
index 131b22dc06e..285ee571980 100644
--- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c
+++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c
@@ -164,39 +164,65 @@ static int cn1LoadGeoclue(void) {
/* ----------------------------------------------------------- clipboard */
-JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetText___java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT text) {
- const char* t = text == JAVA_NULL ? "" : stringToUTF8(threadStateData, text);
+/* Every GtkClipboard call below runs on the GTK main thread via
+ * cn1LinuxRunOnMainAndWait, never inline on the calling (EDT) thread.
+ *
+ * This is not defensive style, it is required: the retrieval calls
+ * (gtk_clipboard_wait_for_text / _image / _uris) and gtk_clipboard_store all
+ * pump a nested main loop until the selection owner answers. Pumping the
+ * default GMainContext from a second thread while the GTK thread owns it makes
+ * the caller block in g_main_context_wait() for an acquire that only completes
+ * when the GTK thread happens to release the context -- so the EDT wedges for
+ * good whenever the timing lines up. It usually does not, which is exactly why
+ * this presented as an intermittent suite hang (the EDT stack was parked in
+ * gtk_clipboard_wait_for_text under ClipboardRoundTripTest).
+ *
+ * The GTK work therefore happens in the *OnMain helpers over plain C structs;
+ * every JAVA_OBJECT conversion stays on the calling thread, matching the file
+ * dialog / notification pattern used elsewhere in this file. */
+
+typedef struct { const char* text; } CN1ClipSetText;
+
+static void cn1ClipSetTextOnMain(void* p) {
+ CN1ClipSetText* r = (CN1ClipSetText*) p;
GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
- gtk_clipboard_set_text(cb, t, -1);
+ gtk_clipboard_set_text(cb, r->text, -1);
gtk_clipboard_store(cb);
}
+JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetText___java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT text) {
+ CN1ClipSetText r;
+ r.text = text == JAVA_NULL ? "" : stringToUTF8(threadStateData, text);
+ cn1LinuxRunOnMainAndWait(cn1ClipSetTextOnMain, &r);
+}
+
+typedef struct { gchar* text; } CN1ClipGetText;
+
+static void cn1ClipGetTextOnMain(void* p) {
+ CN1ClipGetText* r = (CN1ClipGetText*) p;
+ r->text = gtk_clipboard_wait_for_text(gtk_clipboard_get(GDK_SELECTION_CLIPBOARD));
+}
+
JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetText___R_java_lang_String(CODENAME_ONE_THREAD_STATE) {
- GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
- gchar* text = gtk_clipboard_wait_for_text(cb);
- JAVA_OBJECT result = text ? newStringFromCString(threadStateData, text) : JAVA_NULL;
- if (text) {
- g_free(text);
+ CN1ClipGetText r;
+ JAVA_OBJECT result;
+ r.text = NULL;
+ cn1LinuxRunOnMainAndWait(cn1ClipGetTextOnMain, &r);
+ result = r.text ? newStringFromCString(threadStateData, r.text) : JAVA_NULL;
+ if (r.text) {
+ g_free(r.text);
}
return result;
}
-JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetImage___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT png) {
- unsigned char* bytes;
- int len;
- GdkPixbufLoader* loader;
+typedef struct { const unsigned char* bytes; int len; } CN1ClipSetImage;
+
+static void cn1ClipSetImageOnMain(void* p) {
+ CN1ClipSetImage* r = (CN1ClipSetImage*) p;
+ GdkPixbufLoader* loader = gdk_pixbuf_loader_new();
GdkPixbuf* pix;
GtkClipboard* cb;
- if (png == JAVA_NULL) {
- return;
- }
- bytes = (unsigned char*) (*(JAVA_ARRAY) png).data;
- len = (int) (*(JAVA_ARRAY) png).length;
- if (len <= 0) {
- return;
- }
- loader = gdk_pixbuf_loader_new();
- if (!gdk_pixbuf_loader_write(loader, bytes, (gsize) len, NULL)) {
+ if (!gdk_pixbuf_loader_write(loader, r->bytes, (gsize) r->len, NULL)) {
gdk_pixbuf_loader_close(loader, NULL);
g_object_unref(loader);
return;
@@ -212,25 +238,48 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetImage___byte_1ARRAY(C
g_object_unref(loader);
}
-JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetImage___R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE) {
- GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
- GdkPixbuf* pix = gtk_clipboard_wait_for_image(cb);
- gchar* buf = NULL;
- gsize len = 0;
- JAVA_OBJECT result;
+JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetImage___byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT png) {
+ CN1ClipSetImage r;
+ if (png == JAVA_NULL) {
+ return;
+ }
+ /* The array stays reachable from this (blocked) frame for the whole call. */
+ r.bytes = (const unsigned char*) (*(JAVA_ARRAY) png).data;
+ r.len = (int) (*(JAVA_ARRAY) png).length;
+ if (r.len <= 0) {
+ return;
+ }
+ cn1LinuxRunOnMainAndWait(cn1ClipSetImageOnMain, &r);
+}
+
+typedef struct { gchar* buf; gsize len; } CN1ClipGetImage;
+
+static void cn1ClipGetImageOnMain(void* p) {
+ CN1ClipGetImage* r = (CN1ClipGetImage*) p;
+ GdkPixbuf* pix = gtk_clipboard_wait_for_image(gtk_clipboard_get(GDK_SELECTION_CLIPBOARD));
if (!pix) {
- return JAVA_NULL;
+ return;
}
- if (!gdk_pixbuf_save_to_buffer(pix, &buf, &len, "png", NULL, NULL) || buf == NULL) {
- if (buf) {
- g_free(buf);
+ if (!gdk_pixbuf_save_to_buffer(pix, &r->buf, &r->len, "png", NULL, NULL) || r->buf == NULL) {
+ if (r->buf) {
+ g_free(r->buf);
+ r->buf = NULL;
}
- g_object_unref(pix);
- return JAVA_NULL;
}
- result = cn1LinuxNewByteArray(threadStateData, buf, (int) len);
- g_free(buf);
g_object_unref(pix);
+}
+
+JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetImage___R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE) {
+ CN1ClipGetImage r;
+ JAVA_OBJECT result;
+ r.buf = NULL;
+ r.len = 0;
+ cn1LinuxRunOnMainAndWait(cn1ClipGetImageOnMain, &r);
+ if (!r.buf) {
+ return JAVA_NULL;
+ }
+ result = cn1LinuxNewByteArray(threadStateData, r.buf, (int) r.len);
+ g_free(r.buf);
return result;
}
@@ -258,14 +307,32 @@ static void cn1UriListClear(GtkClipboard* cb, gpointer userData) {
}
}
+/* Takes ownership of the CN1UriListData: either the clipboard holds it (and the
+ * clear-func frees it later) or it is released here. */
+static void cn1ClipSetFilesOnMain(void* p) {
+ CN1UriListData* data = (CN1UriListData*) p;
+ GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
+ GtkTargetList* tl = gtk_target_list_new(NULL, 0);
+ GtkTargetEntry* targets;
+ gint nTargets = 0;
+ gtk_target_list_add_uri_targets(tl, 0);
+ targets = gtk_target_table_new_from_list(tl, &nTargets);
+ if (!gtk_clipboard_set_with_data(cb, targets, nTargets, cn1UriListGet, cn1UriListClear, data)) {
+ cn1UriListClear(cb, data);
+ } else {
+ gtk_clipboard_set_can_store(cb, targets, nTargets);
+ gtk_clipboard_store(cb);
+ }
+ if (targets) {
+ gtk_target_table_free(targets, nTargets);
+ }
+ gtk_target_list_unref(tl);
+}
+
JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetFiles___java_lang_String_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT paths) {
int n, i;
JAVA_OBJECT* elements;
CN1UriListData* data;
- GtkClipboard* cb;
- GtkTargetList* tl;
- GtkTargetEntry* targets;
- gint nTargets = 0;
if (paths == JAVA_NULL) {
return;
}
@@ -287,29 +354,26 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_clipboardSetFiles___java_lang_Str
}
data->uris[n] = NULL;
- cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
- tl = gtk_target_list_new(NULL, 0);
- gtk_target_list_add_uri_targets(tl, 0);
- targets = gtk_target_table_new_from_list(tl, &nTargets);
- if (!gtk_clipboard_set_with_data(cb, targets, nTargets, cn1UriListGet, cn1UriListClear, data)) {
- cn1UriListClear(cb, data);
- } else {
- gtk_clipboard_set_can_store(cb, targets, nTargets);
- gtk_clipboard_store(cb);
- }
- if (targets) {
- gtk_target_table_free(targets, nTargets);
- }
- gtk_target_list_unref(tl);
+ cn1LinuxRunOnMainAndWait(cn1ClipSetFilesOnMain, data);
+}
+
+typedef struct { gchar** uris; } CN1ClipGetFiles;
+
+static void cn1ClipGetFilesOnMain(void* p) {
+ CN1ClipGetFiles* r = (CN1ClipGetFiles*) p;
+ r->uris = gtk_clipboard_wait_for_uris(gtk_clipboard_get(GDK_SELECTION_CLIPBOARD));
}
JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetFiles___R_java_lang_String_1ARRAY(CODENAME_ONE_THREAD_STATE) {
- GtkClipboard* cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
- gchar** uris = gtk_clipboard_wait_for_uris(cb);
+ CN1ClipGetFiles r;
+ gchar** uris;
int n = 0;
int i;
JAVA_OBJECT arr;
JAVA_OBJECT* elements;
+ r.uris = NULL;
+ cn1LinuxRunOnMainAndWait(cn1ClipGetFilesOnMain, &r);
+ uris = r.uris;
if (!uris) {
return JAVA_NULL;
}
@@ -389,10 +453,18 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_showNotification___java_lang_Stri
if (!cn1LoadNotify()) {
return;
}
- r.id = id == JAVA_NULL ? "" : stringToUTF8(threadStateData, id);
- r.title = title == JAVA_NULL ? "" : stringToUTF8(threadStateData, title);
- r.body = body == JAVA_NULL ? "" : stringToUTF8(threadStateData, body);
+ /* Copy each: stringToUTF8 reuses one buffer per thread, so converting the
+ * title would otherwise repoint the id at it, and the body at both. */
+ char* idCopy = cn1LinuxJStrDup(threadStateData, id);
+ char* titleCopy = cn1LinuxJStrDup(threadStateData, title);
+ char* bodyCopy = cn1LinuxJStrDup(threadStateData, body);
+ r.id = idCopy == 0 ? "" : idCopy;
+ r.title = titleCopy == 0 ? "" : titleCopy;
+ r.body = bodyCopy == 0 ? "" : bodyCopy;
cn1LinuxRunOnMainAndWait(cn1ShowNotifyOnMain, &r);
+ free(idCopy);
+ free(titleCopy);
+ free(bodyCopy);
}
JAVA_OBJECT com_codename1_impl_linux_LinuxNative_notificationPollClicked___R_java_lang_String(CODENAME_ONE_THREAD_STATE) {
@@ -553,15 +625,18 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_shareText___java_lang_String_j
/* No universal share sheet on the Linux desktop (xdg-desktop-portal's Share
* is not yet broadly available); fall back to composing a mail draft via the
* default mailto handler, which is the closest portable "share". */
- const char* t = text == JAVA_NULL ? "" : stringToUTF8(threadStateData, text);
- const char* subj = title == JAVA_NULL ? "" : stringToUTF8(threadStateData, title);
- char* body = g_uri_escape_string(t, NULL, FALSE);
- char* s = g_uri_escape_string(subj, NULL, FALSE);
+ /* Copy the text before converting the title -- they share one buffer. */
+ char* t = cn1LinuxJStrDup(threadStateData, text);
+ char* subj = cn1LinuxJStrDup(threadStateData, title);
+ char* body = g_uri_escape_string(t == 0 ? "" : t, NULL, FALSE);
+ char* s = g_uri_escape_string(subj == 0 ? "" : subj, NULL, FALSE);
char* uri = g_strconcat("mailto:?subject=", s, "&body=", body, NULL);
gboolean ok = g_app_info_launch_default_for_uri(uri, NULL, NULL);
g_free(body);
g_free(s);
g_free(uri);
+ free(t);
+ free(subj);
return ok ? JAVA_TRUE : JAVA_FALSE;
}
diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java
index 626e3be2f4f..cd175bb3f1d 100644
--- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java
+++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.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.impl.linux;
import com.codename1.ui.BrowserComponent;
@@ -9,15 +31,22 @@
import com.codename1.ui.geom.Dimension;
import com.codename1.ui.util.UITimer;
-/// Native Linux BrowserComponent peer backed by a WebView2 instance (the
-/// native lifecycle lives in cn1_linux_browser.cpp). The component is rendered
-/// from a cached image: the native side CapturePreview's the WebView2 to PNG
-/// bytes after each navigation, which `generatePeerImage()` turns into the peer
-/// image that `PeerComponent.paint()` draws (so it appears in the offscreen
-/// Direct2D screenshot, where the live WebView2 visual would not). The peer polls
-/// the native event queue to fire `onLoad` and to route the JS return-value
-/// bridge (a cancelled navigation to a `/!cn1return/` URL) into the
+/// Native Linux BrowserComponent peer backed by a WebKitGTK WebView (the native
+/// lifecycle lives in cn1_linux_browser.c). The peer polls the native event
+/// queue to fire `onLoad` and to route the JS return-value bridge into the
/// BrowserComponent's navigation callbacks.
+///
+/// `generatePeerImage()` is wired to a native PNG capture so the view can be
+/// drawn into an offscreen screenshot, where the live WebKit widget would not
+/// appear. That capture is **not implemented yet**: WebKit snapshots are async
+/// (webkit_web_view_get_snapshot) and `browserCapturePng` currently returns
+/// null pending that bridge, so `generatePeerImage()` returns null and the peer
+/// falls back to the live widget. Do not read this as a description of working
+/// behaviour -- it is the shape the capture will take once the snapshot bridge
+/// lands.
+///
+/// (This description previously named WebView2, Direct2D and a .cpp file, none
+/// of which exist here -- it had been copied from the Windows port.)
class LinuxBrowserComponent extends PeerComponent {
private final long peer;
private final BrowserComponent browser;
@@ -117,6 +146,15 @@ private void poll() {
browser.fireWebEvent(BrowserComponent.onLoad, new ActionEvent(""));
} else if (ev.startsWith("NAV|")) {
browser.fireBrowserNavigationCallbacks(ev.substring(4));
+ } else if (ev.startsWith("MSG|")) {
+ // The JS->Java bridge. The page's cn1application.shouldNavigate
+ // posts here rather than assigning window.location, so an
+ // execute() return value comes back through the message handler
+ // instead of a navigation to codenameone.com -- which needed
+ // working egress to deliver a callback and took the page under
+ // test away with it. Same sink as a navigation callback: the
+ // portable layer decodes the return-value URL either way.
+ browser.fireBrowserNavigationCallbacks(ev.substring(4));
}
}
}
diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
index 56d992e3baf..63bbf601b5f 100644
--- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
+++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
@@ -39,6 +39,7 @@
import com.codename1.ui.accessibility.AccessibilityNodeSnapshot;
import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
@@ -2378,8 +2379,8 @@ public int getContentLength(Object connection) {
@Override
public OutputStream openOutputStream(Object connection) throws IOException {
if (connection instanceof String) {
- long h = LinuxNative.fileOpenWrite(stripFileUrl((String) connection), false);
- return new LinuxOutputStream(h, false);
+ String path = stripFileUrl((String) connection);
+ return new LinuxOutputStream(openForWrite(path, false), false);
}
return new LinuxOutputStream(((LinuxHttpConnection) connection).peer, true);
}
@@ -2388,23 +2389,46 @@ public OutputStream openOutputStream(Object connection) throws IOException {
public OutputStream openOutputStream(Object connection, int offset) throws IOException {
// offset-based writing maps to opening the file for append/seek; the
// first cut appends, which covers the common resume-write case.
- long h = LinuxNative.fileOpenWrite(stripFileUrl((String) connection), true);
- return new LinuxOutputStream(h, false);
+ String path = stripFileUrl((String) connection);
+ return new LinuxOutputStream(openForWrite(path, true), false);
}
@Override
public InputStream openInputStream(Object connection) throws IOException {
if (connection instanceof String) {
- long h = LinuxNative.fileOpenRead(stripFileUrl((String) connection));
+ String path = stripFileUrl((String) connection);
+ long h = LinuxNative.fileOpenRead(path);
+ if (h == 0) {
+ // fopen() returns NULL for a missing (or unreadable) path.
+ // Wrapping that handle produced a stream that read as a
+ // legitimately empty file, so callers could not tell a missing
+ // file from an empty one -- the exact defect issue #1502
+ // reported against iOS.
+ throw new FileNotFoundException("No such file: " + path
+ + " (" + LinuxNative.lastIoError() + ")");
+ }
return new LinuxInputStream(h, false);
}
return new LinuxInputStream(((LinuxHttpConnection) connection).peer, true);
}
+ /// Opens `path` for writing, failing loudly when the platform cannot. A
+ /// null handle otherwise yields a stream that discards every write and
+ /// closes cleanly, which turns an unwritable path into a file that simply
+ /// never appears.
+ private long openForWrite(String path, boolean append) throws IOException {
+ long h = LinuxNative.fileOpenWrite(path, append);
+ if (h == 0) {
+ throw new IOException("Unable to open " + path + " for writing ("
+ + LinuxNative.lastIoError() + ")");
+ }
+ return h;
+ }
+
/**
* Resolves a classpath-style resource (e.g. {@code /theme.res}). The ParparVM
- * linux target embeds the app's classpath resources into the executable's PE
- * resource section, so they are served straight from the exe -- a single
+ * linux target embeds the app's classpath resources into the executable's
+ * data section, so they are served straight from the ELF -- a single
* self-contained binary, the Linux analog of the iOS .app bundle. Falls back
* to a file shipped next to the executable (a dev/debug convenience for
* resources that were staged rather than embedded). Returns null when absent.
@@ -2422,8 +2446,11 @@ public InputStream getResourceAsStream(Class cls, String resource) {
if (dir == null) {
return null;
}
+ // Classpath resources are already '/'-separated, which is what the
+ // filesystem wants here; this port was carrying the Windows port's
+ // backslash join, so the staged-resource fallback never resolved.
String name = resource.startsWith("/") ? resource.substring(1) : resource;
- String path = dir + "\\" + name.replace('/', '\\');
+ String path = dir + "/" + name;
long h = LinuxNative.fileOpenRead(path);
if (h == 0) {
return null;
@@ -2599,13 +2626,19 @@ public void deleteStorageFile(String name) {
@Override
public OutputStream createStorageOutputStream(String name) throws IOException {
- long h = LinuxNative.fileOpenWrite(storagePath(name), false);
- return new LinuxOutputStream(h, false);
+ // Same reason as openOutputStream: a discarded write that reports
+ // success loses the entry instead of reporting that it cannot be saved.
+ return new LinuxOutputStream(openForWrite(storagePath(name), false), false);
}
@Override
public InputStream createStorageInputStream(String name) throws IOException {
- long h = LinuxNative.fileOpenRead(storagePath(name));
+ String path = storagePath(name);
+ long h = LinuxNative.fileOpenRead(path);
+ if (h == 0) {
+ throw new FileNotFoundException("No such storage entry: " + name
+ + " (" + LinuxNative.lastIoError() + ")");
+ }
return new LinuxInputStream(h, false);
}
@@ -2634,7 +2667,49 @@ public String[] listFilesystemRoots() {
* is exactly why a recorded "tmpaudio.wav" came back as file:///null/tmpaudio.wav
* and would not play). Use the same writable per-user directory that Storage and
* capturePhoto already rely on.
+ *
+ * The result carries the {@code file://} scheme, as it does on Android and
+ * iOS. {@link com.codename1.io.File} treats any path without that scheme as
+ * relative to the app home and prepends the home to it, so returning a bare
+ * path made {@code new File(fs.getAppHomePath() + "x")} resolve to the home
+ * directory joined to itself -- every write through that class landed on a
+ * path that could not exist.
*/
+ /// A stable, filesystem-safe directory name for THIS application.
+ ///
+ /// storageDir() names the Codename One directory shared by every CN1 app
+ /// under this user account, so returning it as the app home gave two
+ /// applications the same getAppHomePath(): each could read and overwrite
+ /// the other's files. The base implementation appends
+ /// getProperty("AppName", packageName) for exactly this reason; the reason
+ /// this override exists at all is that the base builds it on an unwritable
+ /// filesystem root, not that the per-app component was wrong.
+ ///
+ /// Never returns the literal "null": an unset AppName and packageName is
+ /// what made the base implementation produce "/null/" in the first place.
+ private String appHomeDirName() {
+ String name = getProperty("AppName", null);
+ if (name == null || name.length() == 0) {
+ name = getPackageName();
+ }
+ if (name == null || name.length() == 0 || "null".equals(name)) {
+ return "CN1App";
+ }
+ StringBuilder safe = new StringBuilder(name.length());
+ for (int i = 0; i < name.length(); i++) {
+ char c = name.charAt(i);
+ // Reserved on Windows and awkward everywhere else; NTFS and ext4
+ // both accept the rest of the printable range.
+ if (c < ' ' || c == '\\' || c == '/' || c == ':' || c == '*' || c == '?'
+ || c == '"' || c == '<' || c == '>' || c == '|') {
+ safe.append('_');
+ } else {
+ safe.append(c);
+ }
+ }
+ return safe.toString();
+ }
+
@Override
public String getAppHomePath() {
String dir = LinuxNative.storageDir();
@@ -2644,7 +2719,17 @@ public String getAppHomePath() {
if (!dir.endsWith("/")) {
dir += "/";
}
- return dir;
+ dir += appHomeDirName() + "/";
+ String home = "file://" + dir;
+ if (!exists(home)) {
+ mkdir(home);
+ }
+ return home;
+ }
+
+ @Override
+ public String toNativePath(String path) {
+ return stripFileUrl(path);
}
@Override
@@ -2707,6 +2792,180 @@ public char getFileSystemSeparator() {
return '/';
}
+ /* ------------------------------------------------------------ crypto */
+
+ /**
+ * The crypto bridge, backed by OpenSSL. Every failure is reported as a
+ * RuntimeException carrying the library's own reason, which
+ * {@code com.codename1.security.Cipher} turns into a CryptoException --
+ * an authentication failure has to be an exception rather than an empty
+ * result, or a tampered message would read as an empty plaintext.
+ */
+ private static byte[] cryptoResult(byte[] value, String operation) {
+ if (value == null) {
+ throw new RuntimeException(operation + " failed: " + LinuxNative.lastCryptoError());
+ }
+ return value;
+ }
+
+ @Override
+ public void secureRandomBytes(byte[] out) {
+ // Fail loudly: KeyGenerator hands this buffer straight back as key
+ // material, so a quiet return after the platform RNG failed would mint
+ // a predictable key.
+ if (out != null && out.length > 0 && !LinuxNative.secureRandomBytes(out)) {
+ throw new RuntimeException("secure random failed: " + LinuxNative.lastCryptoError());
+ }
+ }
+
+ /// Rejects an initialization vector the mode cannot use. A GCM nonce that
+ /// is absent repeats across messages under one key, and a short CBC IV is
+ /// read as a whole block by the platform library.
+ /// AAD only binds to the ciphertext under an AEAD mode. CBC and ECB ignore it
+ /// entirely, so passing it here quietly dropped data the caller believed was
+ /// authenticated -- and produced ciphertext the other ports reject, because
+ /// JavaSE and Android route the same call through Cipher.updateAAD, which
+ /// refuses a non-AEAD mode. Refusing it keeps the ports answering alike and
+ /// keeps a caller from believing in a binding that was never made.
+ private static void checkAad(String transformation, byte[] aad) {
+ if (aad == null || aad.length == 0) {
+ return;
+ }
+ String mode = transformation == null ? "" : transformation;
+ if (mode.indexOf("/GCM/") < 0) {
+ throw new RuntimeException(
+ "Additional authenticated data requires an AEAD mode; " + mode
+ + " cannot bind it");
+ }
+ }
+
+ private static void checkIv(String transformation, byte[] iv) {
+ String mode = transformation == null ? "" : transformation;
+ if (mode.indexOf("/GCM/") >= 0) {
+ if (iv == null || iv.length == 0) {
+ throw new RuntimeException("AES-GCM requires a nonce");
+ }
+ } else if (mode.indexOf("/ECB/") >= 0) {
+ // ECB has no IV. The native ignores one, so passing it produced
+ // ciphertext while quietly discarding a parameter the caller thought
+ // mattered -- and JavaSE and Android reject the same call, because
+ // they hand a non-null iv to JCE as an IvParameterSpec and ECB
+ // refuses it. Refusing here keeps the ports answering alike.
+ // Any non-null array, including a zero-length one: JavaSE branches
+ // on iv != null rather than on its length, so new byte[0] builds an
+ // IvParameterSpec there and ECB throws. Accepting it here would have
+ // made the same call succeed on the desktop ports alone.
+ if (iv != null) {
+ throw new RuntimeException("AES-ECB cannot use an initialization vector");
+ }
+ } else if (iv == null || iv.length != 16) {
+ throw new RuntimeException("AES-CBC requires a 16 byte initialization vector");
+ }
+ }
+
+ @Override
+ public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) {
+ checkIv(transformation, iv);
+ checkAad(transformation, aad);
+ return cryptoResult(LinuxNative.aesCrypt(transformation, true, key, iv, aad, plaintext),
+ "AES encrypt");
+ }
+
+ @Override
+ public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) {
+ checkIv(transformation, iv);
+ checkAad(transformation, aad);
+ return cryptoResult(LinuxNative.aesCrypt(transformation, false, key, iv, aad, ciphertext),
+ "AES decrypt");
+ }
+
+ @Override
+ public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) {
+ return cryptoResult(LinuxNative.rsaCrypt(transformation, true, publicKeyX509, plaintext),
+ "RSA encrypt");
+ }
+
+ @Override
+ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] ciphertext) {
+ return cryptoResult(LinuxNative.rsaCrypt(transformation, false, privateKeyPkcs8, ciphertext),
+ "RSA decrypt");
+ }
+
+ @Override
+ public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) {
+ checkKeyFamily(algorithm, keyAlgorithm);
+ return cryptoResult(LinuxNative.signData(algorithm, privateKeyPkcs8, data), "sign");
+ }
+
+ @Override
+ public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509,
+ byte[] data, byte[] signature) {
+ checkKeyFamily(algorithm, keyAlgorithm);
+ // An invalid signature and an unusable algorithm or key both come back
+ // as false from the native. Only the second is a configuration error,
+ // and JavaSE and Android raise it -- Signature.verify turns the throw
+ // into a CryptoException -- so answering plain false here would let a
+ // mistyped algorithm or malformed key read as "someone tampered with
+ // this". Clearing the slot first is what makes the two distinguishable.
+ LinuxNative.clearCryptoError();
+ boolean verified = LinuxNative.verifyData(algorithm, publicKeyX509, data, signature);
+ if (!verified) {
+ String failure = LinuxNative.lastCryptoError();
+ if (failure != null && failure.length() > 0
+ && !"unknown crypto error".equals(failure)) {
+ throw new RuntimeException("verify failed: " + failure);
+ }
+ }
+ return verified;
+ }
+
+ /// The portable contract pairs an algorithm with a key of its own family,
+ /// and JavaSE rejects a mismatch when the Signature is initialised. The
+ /// native here reads the family off the DER key and takes only the digest
+ /// from the algorithm name, so `SHA256withRSA` handed an EC key would
+ /// quietly produce an ECDSA signature -- and the matching verify would
+ /// accept it, so nothing looks wrong until another port reads it. Refuse
+ /// the pairing rather than silently substituting the algorithm.
+ private static void checkKeyFamily(String algorithm, String keyAlgorithm) {
+ if (algorithm == null || keyAlgorithm == null) {
+ return;
+ }
+ // The label has to be one this runtime actually knows, not merely one
+ // whose prefix looks familiar. startsWith("EC") called "ECfoo" an EC key
+ // and every other string an RSA one, so PrivateKey.fromPkcs8("garbage",
+ // der) signed happily here while JavaSE and Android hand the same label
+ // to KeyFactory.getInstance and throw NoSuchAlgorithmException. Identical
+ // public API calls must not depend on the port.
+ String key = keyAlgorithm.toUpperCase();
+ if (!"RSA".equals(key) && !"EC".equals(key)) {
+ throw new RuntimeException("Unknown key algorithm: " + keyAlgorithm);
+ }
+ boolean wantsEc = algorithm.toUpperCase().indexOf("ECDSA") >= 0;
+ boolean keyIsEc = "EC".equals(key);
+ if (wantsEc != keyIsEc) {
+ throw new RuntimeException(algorithm + " cannot be used with a "
+ + keyAlgorithm + " key");
+ }
+ }
+
+ @Override
+ public byte[][] generateRsaKeyPair(int bits) {
+ byte[] blob = cryptoResult(LinuxNative.generateRsaKeyPair(bits), "RSA key generation");
+ if (blob.length < 4) {
+ throw new RuntimeException("RSA key generation returned a truncated pair");
+ }
+ int publicLength = ((blob[0] & 0xff) << 24) | ((blob[1] & 0xff) << 16)
+ | ((blob[2] & 0xff) << 8) | (blob[3] & 0xff);
+ if (publicLength < 0 || publicLength > blob.length - 4) {
+ throw new RuntimeException("RSA key generation returned a malformed pair");
+ }
+ byte[] publicKey = new byte[publicLength];
+ byte[] privateKey = new byte[blob.length - 4 - publicLength];
+ System.arraycopy(blob, 4, publicKey, 0, publicKey.length);
+ System.arraycopy(blob, 4 + publicLength, privateKey, 0, privateKey.length);
+ return new byte[][] { publicKey, privateKey };
+ }
+
/* ------------------------------------------------------------ platform */
@Override
diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java
index b8496b7097d..7fa0f44a9da 100644
--- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java
+++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java
@@ -388,6 +388,44 @@ public static native long editStringAt(int x, int y, int w, int h, String text,
public static native long fileOpenWrite(String path, boolean append);
+ /** Why the most recent open failed, for the exception the port raises. */
+ public static native String lastIoError();
+
+ /* ---------------------------------------------------------- crypto */
+
+ /** Fills {@code out} with fresh entropy; false when the platform RNG failed. */
+ public static native boolean secureRandomBytes(byte[] out);
+
+ /**
+ * AES in the mode named by {@code transformation}. For GCM the
+ * authentication tag is appended to the ciphertext, which is the
+ * convention the portable API documents.
+ */
+ public static native byte[] aesCrypt(String transformation, boolean encrypt,
+ byte[] key, byte[] iv, byte[] aad, byte[] data);
+
+ /** RSA with an X.509 public key when encrypting, PKCS#8 when decrypting. */
+ public static native byte[] rsaCrypt(String transformation, boolean encrypt,
+ byte[] key, byte[] data);
+
+ public static native byte[] signData(String algorithm, byte[] privateKeyPkcs8, byte[] data);
+
+ public static native boolean verifyData(String algorithm, byte[] publicKeyX509,
+ byte[] data, byte[] signature);
+
+ /**
+ * A fresh RSA pair as one array: a four-byte big-endian public-key length,
+ * the X.509 public key, then the PKCS#8 private key.
+ */
+ public static native byte[] generateRsaKeyPair(int bits);
+
+ /** Why the most recent crypto call failed, for the CryptoException message. */
+ public static native String lastCryptoError();
+
+ /// Empties the last-error slot so a following call's failure can be told
+ /// apart from a stale message.
+ public static native void clearCryptoError();
+
public static native int fileRead(long handle, byte[] buffer, int offset, int length);
public static native int fileWrite(long handle, byte[] buffer, int offset, int length);
diff --git a/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs b/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs
index c67db7bbdd2..46dbd125738 100644
--- a/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs
+++ b/Ports/UWP/VSProjectTemplate/UWPApp/App.xaml.cs
@@ -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.
+ */
using IKVM.Attributes;
using IKVM.Internal;
using System;
@@ -391,7 +413,13 @@ public override int getTimezoneOffset(string name, int year, int month, int day,
int minutes = timeOfDayMillis / 1000 / 60 - hours * 60;
int seconds = timeOfDayMillis / 1000 - (hours * 60 * 60) - (minutes * 60);
int millis = timeOfDayMillis % 1000;
- return (int)TimeZoneInfo.FindSystemTimeZoneById(name).GetUtcOffset(new DateTime(year, month, day, hours, minutes, seconds, DateTimeKind.Local)).TotalMilliseconds;
+ // The caller passes UTC fields -- the POSIX implementation of this
+ // native resolves them with timegm and the JavaScript one with
+ // Date.UTC -- so read them as UTC here too. DateTimeKind.Local
+ // shifted the instant by the host offset, which lands on the wrong
+ // side of a transition when the requested zone changes offset
+ // inside that window.
+ return (int)TimeZoneInfo.FindSystemTimeZoneById(name).GetUtcOffset(new DateTime(year, month, day, hours, minutes, seconds, DateTimeKind.Utc)).TotalMilliseconds;
}
public override int getTimezoneRawOffset(string name)
diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c
new file mode 100644
index 00000000000..9b39aa83524
--- /dev/null
+++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c
@@ -0,0 +1,1293 @@
+/*
+ * 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.
+ */
+
+/*
+ * The crypto half of com.codename1.impl.CodenameOneImplementation on Windows.
+ *
+ * CNG (bcrypt) provides the primitives and crypt32 the ASN.1: key material
+ * crosses this boundary in the encodings the portable API documents -- X.509
+ * SubjectPublicKeyInfo and PKCS#8 PrivateKeyInfo -- and CryptDecodeObjectEx /
+ * CryptEncodeObjectEx translate those to and from the BCRYPT_RSAKEY_BLOB form
+ * bcrypt wants, so no ASN.1 is parsed here by hand.
+ *
+ * Every entry point answers null (or false) on failure and records the status
+ * for lastCryptoError, which the Java side turns into the CryptoException
+ * message. A silent empty result would look like a successful encryption of
+ * nothing.
+ */
+
+#include "cn1_windows.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifndef STATUS_SUCCESS
+#define STATUS_SUCCESS ((NTSTATUS) 0x00000000L)
+#endif
+#ifndef STATUS_AUTH_TAG_MISMATCH
+#define STATUS_AUTH_TAG_MISMATCH ((NTSTATUS) 0xC000A002L)
+#endif
+#ifndef STATUS_INVALID_SIGNATURE
+#define STATUS_INVALID_SIGNATURE ((NTSTATUS) 0xC000A000L)
+#endif
+
+#define CN1_GCM_TAG_BYTES 16
+
+/* Key families, named so that "not EC" can never stand in for RSA. */
+#define CN1_KEY_OTHER 0
+#define CN1_KEY_RSA 1
+#define CN1_KEY_EC 2
+
+/* Per-thread: crypto failures on different threads would otherwise overwrite
+ * each other and lastCryptoError() could answer with an unrelated call's
+ * message. */
+static __declspec(thread) char cn1WinCryptoError[512];
+
+static void cn1CryptoFail(const char* what, NTSTATUS status) {
+ _snprintf(cn1WinCryptoError, sizeof(cn1WinCryptoError), "%s (status 0x%08lx)", what,
+ (unsigned long) status);
+ cn1WinCryptoError[sizeof(cn1WinCryptoError) - 1] = 0;
+}
+
+static void cn1CryptoFailLast(const char* what) {
+ cn1CryptoFail(what, (NTSTATUS) GetLastError());
+}
+
+/* Clears the per-thread message; see the Linux port's note. verifyData reports
+ * an invalid signature and an unusable algorithm or key the same way, and only
+ * a cleared slot makes the difference readable. */
+JAVA_VOID com_codename1_impl_windows_WindowsNative_clearCryptoError__(CODENAME_ONE_THREAD_STATE) {
+ cn1WinCryptoError[0] = 0;
+}
+
+JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastCryptoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) {
+ return newStringFromCString(threadStateData,
+ cn1WinCryptoError[0] ? cn1WinCryptoError : "unknown crypto error");
+}
+
+/* The Windows port has no shared byte-array helper, so keep a local one that
+ * matches how the rest of the port allocates arrays. */
+static JAVA_OBJECT cn1WinNewByteArray(CODENAME_ONE_THREAD_STATE, const void* src, int n) {
+ JAVA_OBJECT array;
+ if (n < 0) {
+ n = 0;
+ }
+ array = allocArray(threadStateData, n, &class_array1__JAVA_BYTE, sizeof(JAVA_ARRAY_BYTE), 1);
+ if (array != JAVA_NULL && n > 0 && src != 0) {
+ memcpy((*(JAVA_ARRAY) array).data, src, (size_t) n);
+ }
+ return array;
+}
+
+static unsigned char* cn1Bytes(JAVA_OBJECT array, int* length) {
+ if (array == JAVA_NULL) {
+ *length = 0;
+ return 0;
+ }
+ *length = (int) (*(JAVA_ARRAY) array).length;
+ return (unsigned char*) (*(JAVA_ARRAY) array).data;
+}
+
+/* ------------------------------------------------------------ random */
+
+JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_secureRandomBytes___byte_1ARRAY_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) {
+ int length = 0;
+ unsigned char* data = cn1Bytes(out, &length);
+ NTSTATUS status;
+ if (data == 0 || length <= 0) {
+ return JAVA_TRUE;
+ }
+ status = BCryptGenRandom(NULL, data, (ULONG) length, BCRYPT_USE_SYSTEM_PREFERRED_RNG);
+ if (status != STATUS_SUCCESS) {
+ /* Report the failure rather than leaving the buffer as it stands:
+ * KeyGenerator hands this straight back as key material, so a quiet
+ * return would mint a predictable key. */
+ cn1CryptoFail("secure random", status);
+ memset(data, 0, (size_t) length);
+ return JAVA_FALSE;
+ }
+ return JAVA_TRUE;
+}
+
+/* ------------------------------------------- advertised names, and only those
+ *
+ * JavaSE hands these strings to JCE, which either supports a name as written or
+ * refuses it. Matching loosely here -- picking CBC because a string contains
+ * neither "/GCM/" nor "/ECB/", or SHA-256 because it names no digest we know --
+ * means the same request encrypts or signs differently depending on which port
+ * runs it, and the caller is never told. A name outside the advertised set is
+ * refused instead.
+ */
+
+static int cn1IsAesTransformation(const char* transformation) {
+ return strcmp(transformation, "AES/GCM/NoPadding") == 0
+ || strcmp(transformation, "AES/CBC/PKCS5Padding") == 0
+ || strcmp(transformation, "AES/CBC/NoPadding") == 0
+ || strcmp(transformation, "AES/ECB/PKCS5Padding") == 0;
+}
+
+static int cn1IsRsaTransformation(const char* transformation) {
+ return strcmp(transformation, "RSA/ECB/OAEPWithSHA-256AndMGF1Padding") == 0
+ || strcmp(transformation, "RSA/ECB/PKCS1Padding") == 0;
+}
+
+/* ------------------------------------------------------------ AES */
+
+JAVA_OBJECT com_codename1_impl_windows_WindowsNative_aesCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY(
+ CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt,
+ JAVA_OBJECT keyArray, JAVA_OBJECT ivArray, JAVA_OBJECT aadArray, JAVA_OBJECT dataArray) {
+ const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation);
+ int keyLength = 0, ivLength = 0, aadLength = 0, dataLength = 0;
+ unsigned char* key = cn1Bytes(keyArray, &keyLength);
+ unsigned char* iv = cn1Bytes(ivArray, &ivLength);
+ unsigned char* aad = cn1Bytes(aadArray, &aadLength);
+ unsigned char* data = cn1Bytes(dataArray, &dataLength);
+ int gcm;
+ int ecb = strstr(mode, "/ECB/") != 0;
+ int padded = strstr(mode, "NoPadding") == 0;
+ int bodyLength = dataLength;
+ BCRYPT_ALG_HANDLE alg = NULL;
+ BCRYPT_KEY_HANDLE handle = NULL;
+ NTSTATUS status;
+ unsigned char* out = 0;
+ unsigned char* ivCopy = 0;
+ ULONG outLength = 0, produced = 0;
+ JAVA_OBJECT result = JAVA_NULL;
+ BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO auth;
+ unsigned char tag[CN1_GCM_TAG_BYTES];
+
+ if (!cn1IsAesTransformation(mode)) {
+ cn1CryptoFail("unsupported cipher transformation", 0);
+ return JAVA_NULL;
+ }
+ gcm = strstr(mode, "/GCM/") != 0;
+ /* A missing GCM nonce would otherwise repeat across messages under one
+ * key, which destroys the mode, and a short CBC IV is read as a whole
+ * block. */
+ if (gcm && ivLength <= 0) {
+ cn1CryptoFail("AES-GCM requires a nonce", 0);
+ return JAVA_NULL;
+ }
+ if (!gcm && !ecb && ivLength != 16) {
+ cn1CryptoFail("AES-CBC requires a 16 byte initialization vector", 0);
+ return JAVA_NULL;
+ }
+ if (gcm && !encrypt) {
+ if (dataLength < CN1_GCM_TAG_BYTES) {
+ cn1CryptoFail("AES-GCM input is shorter than its authentication tag", 0);
+ return JAVA_NULL;
+ }
+ bodyLength = dataLength - CN1_GCM_TAG_BYTES;
+ }
+
+ status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_AES_ALGORITHM, NULL, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("AES provider", status);
+ return JAVA_NULL;
+ }
+ status = BCryptSetProperty(alg, BCRYPT_CHAINING_MODE,
+ gcm ? (PUCHAR) BCRYPT_CHAIN_MODE_GCM
+ : (ecb ? (PUCHAR) BCRYPT_CHAIN_MODE_ECB
+ : (PUCHAR) BCRYPT_CHAIN_MODE_CBC),
+ gcm ? sizeof(BCRYPT_CHAIN_MODE_GCM)
+ : (ecb ? sizeof(BCRYPT_CHAIN_MODE_ECB)
+ : sizeof(BCRYPT_CHAIN_MODE_CBC)),
+ 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("AES chaining mode", status);
+ goto done;
+ }
+ status = BCryptGenerateSymmetricKey(alg, &handle, NULL, 0, key, (ULONG) keyLength, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("AES key", status);
+ goto done;
+ }
+
+ if (gcm) {
+ BCRYPT_INIT_AUTH_MODE_INFO(auth);
+ auth.pbNonce = iv;
+ auth.cbNonce = (ULONG) ivLength;
+ auth.pbAuthData = aadLength > 0 ? aad : NULL;
+ auth.cbAuthData = (ULONG) aadLength;
+ if (encrypt) {
+ auth.pbTag = tag;
+ auth.cbTag = CN1_GCM_TAG_BYTES;
+ } else {
+ auth.pbTag = data + bodyLength;
+ auth.cbTag = CN1_GCM_TAG_BYTES;
+ }
+ status = encrypt
+ ? BCryptEncrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, NULL, 0, &outLength, 0)
+ : BCryptDecrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, NULL, 0, &outLength, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("AES-GCM size", status);
+ goto done;
+ }
+ out = (unsigned char*) malloc((size_t) outLength + CN1_GCM_TAG_BYTES + 1);
+ if (out == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ status = encrypt
+ ? BCryptEncrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, out, outLength, &produced, 0)
+ : BCryptDecrypt(handle, data, (ULONG) bodyLength, &auth, NULL, 0, out, outLength, &produced, 0);
+ if (status != STATUS_SUCCESS) {
+ /* A tampered message or wrong associated data lands here. */
+ cn1CryptoFail(status == STATUS_AUTH_TAG_MISMATCH
+ ? "AES-GCM authentication failed" : "AES-GCM", status);
+ goto done;
+ }
+ if (encrypt) {
+ memcpy(out + produced, tag, CN1_GCM_TAG_BYTES);
+ produced += CN1_GCM_TAG_BYTES;
+ }
+ } else {
+ ULONG flags = padded ? BCRYPT_BLOCK_PADDING : 0;
+ /* CBC updates the IV in place, so hand the cipher its own copy. */
+ if (ivLength > 0) {
+ ivCopy = (unsigned char*) malloc((size_t) ivLength);
+ if (ivCopy == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ memcpy(ivCopy, iv, (size_t) ivLength);
+ }
+ status = encrypt
+ ? BCryptEncrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength,
+ NULL, 0, &outLength, flags)
+ : BCryptDecrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength,
+ NULL, 0, &outLength, flags);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("AES size", status);
+ goto done;
+ }
+ out = (unsigned char*) malloc((size_t) outLength + 1);
+ if (out == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ /* The size query above consumed the IV copy; restore it. */
+ if (ivLength > 0) {
+ memcpy(ivCopy, iv, (size_t) ivLength);
+ }
+ status = encrypt
+ ? BCryptEncrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength,
+ out, outLength, &produced, flags)
+ : BCryptDecrypt(handle, data, (ULONG) bodyLength, NULL, ivCopy, (ULONG) ivLength,
+ out, outLength, &produced, flags);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("AES", status);
+ goto done;
+ }
+ }
+ result = cn1WinNewByteArray(threadStateData, out, (int) produced);
+
+done:
+ free(out);
+ free(ivCopy);
+ if (handle != NULL) {
+ BCryptDestroyKey(handle);
+ }
+ if (alg != NULL) {
+ BCryptCloseAlgorithmProvider(alg, 0);
+ }
+ return result;
+}
+
+/* ------------------------------------------------------------ RSA keys */
+
+static BCRYPT_KEY_HANDLE cn1PublicKey(const unsigned char* der, int length) {
+ CERT_PUBLIC_KEY_INFO* info = 0;
+ DWORD infoLength = 0;
+ BCRYPT_KEY_HANDLE key = NULL;
+ if (!CryptDecodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, der, (DWORD) length,
+ CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) {
+ cn1CryptoFailLast("public key is not X.509 SubjectPublicKeyInfo DER");
+ return NULL;
+ }
+ if (!CryptImportPublicKeyInfoEx2(X509_ASN_ENCODING, info, 0, NULL, &key)) {
+ cn1CryptoFailLast("public key import");
+ key = NULL;
+ }
+ LocalFree(info);
+ return key;
+}
+
+/* Imports a PKCS#8 private key of either supported kind.
+ *
+ * The earlier version always decoded CNG_RSA_PRIVATE_KEY_BLOB, so an EC key
+ * failed to import and ECDSA signing could never work. NCrypt takes PKCS#8
+ * directly and reads the algorithm out of the key itself, which covers RSA and
+ * EC with one path; *isEc reports which arrived so the caller can pick the
+ * matching padding.
+ */
+static NCRYPT_KEY_HANDLE cn1PrivateKey(const unsigned char* der, int length, int* family) {
+ NCRYPT_PROV_HANDLE provider = 0;
+ NCRYPT_KEY_HANDLE key = 0;
+ SECURITY_STATUS status;
+ WCHAR algorithm[64];
+ DWORD algorithmBytes = 0;
+
+ if (family != 0) {
+ *family = CN1_KEY_OTHER;
+ }
+ status = NCryptOpenStorageProvider(&provider, MS_KEY_STORAGE_PROVIDER, 0);
+ if (status != ERROR_SUCCESS) {
+ cn1CryptoFail("key storage provider", (NTSTATUS) status);
+ return 0;
+ }
+ status = NCryptImportKey(provider, 0, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NULL, &key,
+ (PBYTE) der, (DWORD) length, NCRYPT_DO_NOT_FINALIZE_FLAG);
+ if (status != ERROR_SUCCESS) {
+ /* Retry without the no-finalize hint: ephemeral keys import directly. */
+ status = NCryptImportKey(provider, 0, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NULL, &key,
+ (PBYTE) der, (DWORD) length, 0);
+ } else {
+ status = NCryptFinalizeKey(key, 0);
+ }
+ NCryptFreeObject(provider);
+ if (status != ERROR_SUCCESS || key == 0) {
+ cn1CryptoFail("private key is not PKCS#8 DER", (NTSTATUS) status);
+ if (key != 0) {
+ NCryptFreeObject(key);
+ }
+ return 0;
+ }
+ if (family != 0 &&
+ NCryptGetProperty(key, NCRYPT_ALGORITHM_GROUP_PROPERTY, (PBYTE) algorithm,
+ sizeof(algorithm), &algorithmBytes, 0) == ERROR_SUCCESS) {
+ /* Name both families rather than reporting "EC or not": anything else
+ * -- DSA in particular -- would otherwise be indistinguishable from RSA
+ * and would sign a SHA256withRSA request with whatever it actually is. */
+ if (wcscmp(algorithm, NCRYPT_ECDSA_ALGORITHM_GROUP) == 0
+ || wcscmp(algorithm, NCRYPT_ECDH_ALGORITHM_GROUP) == 0) {
+ *family = CN1_KEY_EC;
+ } else if (wcscmp(algorithm, NCRYPT_RSA_ALGORITHM_GROUP) == 0) {
+ *family = CN1_KEY_RSA;
+ }
+ }
+ return key;
+}
+
+/* The family an X.509 SubjectPublicKeyInfo carries. Named explicitly for the
+ * same reason as the private-key side: treating every non-EC key as RSA lets a
+ * DSA key verify an RSA request. */
+static int cn1PublicKeyFamily(const unsigned char* der, int length) {
+ CERT_PUBLIC_KEY_INFO* info = 0;
+ DWORD infoLength = 0;
+ int family = CN1_KEY_OTHER;
+ if (CryptDecodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, der, (DWORD) length,
+ CRYPT_DECODE_ALLOC_FLAG, NULL, &info, &infoLength)) {
+ if (info->Algorithm.pszObjId != 0) {
+ if (strcmp(info->Algorithm.pszObjId, szOID_ECC_PUBLIC_KEY) == 0) {
+ family = CN1_KEY_EC;
+ } else if (strcmp(info->Algorithm.pszObjId, szOID_RSA_RSA) == 0) {
+ family = CN1_KEY_RSA;
+ }
+ }
+ LocalFree(info);
+ }
+ return family;
+}
+
+/* The digest half of Signature's six advertised algorithms; NULL for anything
+ * else, which the callers turn into a failure rather than signing with a digest
+ * nobody asked for. */
+static LPCWSTR cn1DigestAlgorithm(const char* algorithm) {
+ if (strcmp(algorithm, "SHA256withRSA") == 0 || strcmp(algorithm, "SHA256withECDSA") == 0) {
+ return BCRYPT_SHA256_ALGORITHM;
+ }
+ if (strcmp(algorithm, "SHA384withRSA") == 0 || strcmp(algorithm, "SHA384withECDSA") == 0) {
+ return BCRYPT_SHA384_ALGORITHM;
+ }
+ if (strcmp(algorithm, "SHA512withRSA") == 0 || strcmp(algorithm, "SHA512withECDSA") == 0) {
+ return BCRYPT_SHA512_ALGORITHM;
+ }
+ return NULL;
+}
+
+static int cn1DigestLength(LPCWSTR algorithm) {
+ if (algorithm == NULL) {
+ /* cn1DigestAlgorithm answers NULL for a name outside the advertised
+ * set; the callers check for that, but this is evaluated in their
+ * declarations, before the check runs. */
+ return 0;
+ }
+ if (wcscmp(algorithm, BCRYPT_SHA512_ALGORITHM) == 0) {
+ return 64;
+ }
+ if (wcscmp(algorithm, BCRYPT_SHA384_ALGORITHM) == 0) {
+ return 48;
+ }
+ if (wcscmp(algorithm, BCRYPT_SHA1_ALGORITHM) == 0) {
+ return 20;
+ }
+ return 32;
+}
+
+/* Hashes with the named algorithm into caller-provided storage. */
+static int cn1Digest(LPCWSTR algorithm, const unsigned char* data, int length,
+ unsigned char* digest, int digestLength) {
+ BCRYPT_ALG_HANDLE alg = NULL;
+ NTSTATUS status = BCryptOpenAlgorithmProvider(&alg, algorithm, NULL, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("digest provider", status);
+ return 0;
+ }
+ status = BCryptHash(alg, NULL, 0, (PUCHAR) data, (ULONG) length, digest, (ULONG) digestLength);
+ BCryptCloseAlgorithmProvider(alg, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("digest", status);
+ return 0;
+ }
+ return 1;
+}
+
+
+/* ------------------------------------------------- OAEP and ECDSA encodings
+ *
+ * Two shapes CNG cannot produce on its own:
+ *
+ * OAEP -- the padding is built here rather than handed to CNG so the block can
+ * be validated and reported on our own terms (see cn1OaepDecode's single
+ * generic failure). Both halves use SHA-256: the JCE transformation name
+ * leaves MGF1 on SHA-1 by default, but Web Crypto and Apple's SecKey each take
+ * one hash and use it for label and mask alike, so SHA-256 for both is the only
+ * pairing every port in this project can produce.
+ *
+ * ECDSA -- NCryptSignHash answers the fixed-width r||s of P1363, while the
+ * portable Signature contract (and Jwt.derToJoseEcdsa) expects ASN.1 DER, so
+ * signatures are converted in both directions.
+ */
+
+/* One digest over two buffers in sequence, without joining them first.
+ *
+ * MGF1's second call seeds from the whole masked DB -- 351 bytes for a
+ * 3072-bit key and 479 for a 4096-bit one, both of which KeyGenerator.rsa()
+ * supports -- so the seed cannot be staged in a buffer sized for a hash. This
+ * feeds the seed and the counter to the hash object directly instead. */
+static int cn1DigestPair(LPCWSTR algorithm, const unsigned char* first, int firstLength,
+ const unsigned char* second, int secondLength,
+ unsigned char* digest, int digestLength) {
+ BCRYPT_ALG_HANDLE alg = NULL;
+ BCRYPT_HASH_HANDLE hash = NULL;
+ NTSTATUS status = BCryptOpenAlgorithmProvider(&alg, algorithm, NULL, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("digest provider", status);
+ return 0;
+ }
+ status = BCryptCreateHash(alg, &hash, NULL, 0, NULL, 0, 0);
+ if (status == STATUS_SUCCESS) {
+ status = BCryptHashData(hash, (PUCHAR) first, (ULONG) firstLength, 0);
+ }
+ if (status == STATUS_SUCCESS) {
+ status = BCryptHashData(hash, (PUCHAR) second, (ULONG) secondLength, 0);
+ }
+ if (status == STATUS_SUCCESS) {
+ status = BCryptFinishHash(hash, digest, (ULONG) digestLength, 0);
+ }
+ if (hash != NULL) {
+ BCryptDestroyHash(hash);
+ }
+ BCryptCloseAlgorithmProvider(alg, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("digest", status);
+ return 0;
+ }
+ return 1;
+}
+
+static int cn1Mgf1(LPCWSTR digestAlgorithm, const unsigned char* seed, int seedLength,
+ unsigned char* mask, int maskLength) {
+ int digestLength = cn1DigestLength(digestAlgorithm);
+ unsigned char counter[4];
+ unsigned char digest[64];
+ int produced = 0;
+ unsigned int count = 0;
+ while (produced < maskLength) {
+ int chunk = maskLength - produced;
+ counter[0] = (unsigned char) ((count >> 24) & 0xff);
+ counter[1] = (unsigned char) ((count >> 16) & 0xff);
+ counter[2] = (unsigned char) ((count >> 8) & 0xff);
+ counter[3] = (unsigned char) (count & 0xff);
+ if (!cn1DigestPair(digestAlgorithm, seed, seedLength, counter, 4, digest, digestLength)) {
+ return 0;
+ }
+ if (chunk > digestLength) {
+ chunk = digestLength;
+ }
+ memcpy(mask + produced, digest, (size_t) chunk);
+ produced += chunk;
+ count++;
+ }
+ return 1;
+}
+
+/* EME-OAEP encoding of `message` into a `blockLength`-byte block. */
+static int cn1OaepEncode(LPCWSTR labelDigest, LPCWSTR maskDigest, const unsigned char* message,
+ int messageLength, unsigned char* block, int blockLength) {
+ int hashLength = cn1DigestLength(labelDigest);
+ int dbLength = blockLength - hashLength - 1;
+ unsigned char seed[64];
+ unsigned char* mask;
+ int i;
+ /* An OAEP block cannot be shorter than 2*hLen+2. A 512-bit key leaves a DB
+ * shorter than the label hash itself, and the hash would then be written
+ * and compared past the end of the block. */
+ if (blockLength < 2 * hashLength + 2) {
+ cn1CryptoFail("RSA-OAEP block does not fit the key", 0);
+ return 0;
+ }
+ /* Sized from the modulus, not a fixed array. A 1KB mask capped OAEP at
+ * about 8456-bit keys, and KeyGenerator.rsa() accepts every byte-aligned
+ * size from 1024 bits up while callers can import larger ones still -- so
+ * a key that works on every other port was refused here as though the
+ * block did not fit it. */
+ mask = (unsigned char*) malloc((size_t) dbLength);
+ if (mask == 0) {
+ cn1CryptoFail("out of memory", 0);
+ return 0;
+ }
+ if (messageLength > dbLength - hashLength - 1) {
+ cn1CryptoFail("RSA-OAEP message is too long for the key", 0);
+ free(mask);
+ return 0;
+ }
+ memset(block, 0, (size_t) blockLength);
+ /* DB = lHash || PS || 0x01 || M, with an empty label. */
+ if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, block + 1 + hashLength, hashLength)) {
+ free(mask);
+ return 0;
+ }
+ block[blockLength - messageLength - 1] = 0x01;
+ if (messageLength > 0) {
+ memcpy(block + blockLength - messageLength, message, (size_t) messageLength);
+ }
+ if (BCryptGenRandom(NULL, seed, (ULONG) hashLength, BCRYPT_USE_SYSTEM_PREFERRED_RNG)
+ != STATUS_SUCCESS) {
+ cn1CryptoFail("RSA-OAEP seed", 0);
+ free(mask);
+ return 0;
+ }
+ if (!cn1Mgf1(maskDigest, seed, hashLength, mask, dbLength)) {
+ free(mask);
+ return 0;
+ }
+ for (i = 0; i < dbLength; i++) {
+ block[1 + hashLength + i] ^= mask[i];
+ }
+ if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) {
+ free(mask);
+ return 0;
+ }
+ for (i = 0; i < hashLength; i++) {
+ block[1 + i] = (unsigned char) (seed[i] ^ mask[i]);
+ }
+ free(mask);
+ return 1;
+}
+
+/* All ones when a == b, zero otherwise, without branching on the values. */
+static unsigned int cn1CtEqMask(unsigned int a, unsigned int b) {
+ unsigned int diff = a ^ b;
+ /* 1 when diff is nonzero, 0 when it is zero; minus one turns that into a
+ * full-width mask without a comparison the compiler can branch on. */
+ unsigned int nonZero = (diff | (0u - diff)) >> 31;
+ return nonZero - 1u;
+}
+
+/* Reverses cn1OaepEncode, writing the recovered message and its length.
+ *
+ * Every check on the decrypted block feeds one accumulator and the function
+ * reports a single generic failure, rather than returning early with a
+ * distinct message per cause. An application that decrypts attacker-chosen
+ * ciphertext and surfaces the exception (WindowsImplementation.cryptoResult
+ * puts this text in it) would otherwise hand back which of the leading byte,
+ * the label hash or the delimiter was wrong -- and telling those apart is
+ * enough to mount the adaptive attacks OAEP exists to prevent. Only the
+ * block geometry, which follows the key and not the ciphertext, is allowed to
+ * bail early. */
+static int cn1OaepDecode(LPCWSTR labelDigest, LPCWSTR maskDigest, unsigned char* block,
+ int blockLength, unsigned char* message, int* messageLength) {
+ int hashLength = cn1DigestLength(labelDigest);
+ int dbLength = blockLength - hashLength - 1;
+ unsigned char* mask;
+ unsigned char labelHash[64];
+ unsigned char seed[64];
+ int i;
+ unsigned int bad = 0;
+ unsigned int seenDelimiter = 0;
+ unsigned int messageStart = 0;
+ /* An OAEP block cannot be shorter than 2*hLen+2. A 512-bit key leaves a DB
+ * shorter than the label hash itself, and the hash would then be written
+ * and compared past the end of the block. */
+ if (blockLength < 2 * hashLength + 2) {
+ cn1CryptoFail("RSA-OAEP block does not fit the key", 0);
+ return 0;
+ }
+ /* Sized from the modulus, not a fixed array. A 1KB mask capped OAEP at
+ * about 8456-bit keys, and KeyGenerator.rsa() accepts every byte-aligned
+ * size from 1024 bits up while callers can import larger ones still -- so
+ * a key that works on every other port was refused here as though the
+ * block did not fit it. */
+ mask = (unsigned char*) malloc((size_t) dbLength);
+ if (mask == 0) {
+ cn1CryptoFail("out of memory", 0);
+ return 0;
+ }
+ /* The leading byte must be zero; fold it in rather than returning here. */
+ bad |= (unsigned int) block[0];
+ if (!cn1Mgf1(maskDigest, block + 1 + hashLength, dbLength, mask, hashLength)) {
+ free(mask);
+ return 0;
+ }
+ for (i = 0; i < hashLength; i++) {
+ seed[i] = (unsigned char) (block[1 + i] ^ mask[i]);
+ }
+ if (!cn1Mgf1(maskDigest, seed, hashLength, mask, dbLength)) {
+ free(mask);
+ return 0;
+ }
+ for (i = 0; i < dbLength; i++) {
+ block[1 + hashLength + i] ^= mask[i];
+ }
+ if (!cn1Digest(labelDigest, (const unsigned char*) "", 0, labelHash, hashLength)) {
+ free(mask);
+ return 0;
+ }
+ for (i = 0; i < hashLength; i++) {
+ bad |= (unsigned int) (labelHash[i] ^ block[1 + hashLength + i]);
+ }
+ /* Walk the whole padding: zeros until one 0x01, then the message. The loop
+ * never stops early, so its timing follows the key size alone. */
+ for (i = 1 + hashLength + hashLength; i < blockLength; i++) {
+ unsigned int value = block[i];
+ unsigned int isDelimiter = cn1CtEqMask(value, 0x01);
+ unsigned int isZero = cn1CtEqMask(value, 0x00);
+ unsigned int firstDelimiter = isDelimiter & ~seenDelimiter;
+ messageStart |= ((unsigned int) (i + 1)) & firstDelimiter;
+ /* Ahead of the delimiter nothing but zeros is allowed. */
+ bad |= ~seenDelimiter & ~isDelimiter & ~isZero;
+ seenDelimiter |= isDelimiter;
+ }
+ bad |= ~seenDelimiter; /* no delimiter anywhere in the block */
+ if (bad != 0) {
+ cn1CryptoFail("RSA-OAEP decryption failed", 0);
+ free(mask);
+ return 0;
+ }
+ *messageLength = blockLength - (int) messageStart;
+ if (*messageLength > 0) {
+ memcpy(message, block + messageStart, (size_t) *messageLength);
+ }
+ free(mask);
+ return 1;
+}
+
+/* One DER INTEGER holding an unsigned big-endian value. */
+static int cn1DerInteger(const unsigned char* value, int length, unsigned char* out) {
+ int start = 0;
+ int written = 0;
+ int pad;
+ while (start < length - 1 && value[start] == 0) {
+ start++;
+ }
+ pad = (value[start] & 0x80) != 0 ? 1 : 0;
+ out[written++] = 0x02;
+ out[written++] = (unsigned char) (length - start + pad);
+ if (pad) {
+ out[written++] = 0x00;
+ }
+ memcpy(out + written, value + start, (size_t) (length - start));
+ return written + length - start;
+}
+
+/* P1363 r||s (as CNG produces) to the ASN.1 DER sequence the API expects.
+ *
+ * P-521 coordinates are 66 bytes each, so the sequence body runs to about 138
+ * bytes and DER requires the long form (0x81 followed by the length) for
+ * anything over 127. A single length byte there sets the high bit, which
+ * Jwt.derToJoseEcdsa and every conforming parser read as a long-form marker,
+ * and the ES512 signature is rejected. */
+static int cn1EcdsaToDer(const unsigned char* raw, int rawLength, unsigned char* der) {
+ int half = rawLength / 2;
+ unsigned char body[160];
+ int bodyLength = 0;
+ int written = 0;
+ if (rawLength <= 0 || (rawLength & 1) != 0 || half > 66) {
+ return 0;
+ }
+ bodyLength = cn1DerInteger(raw, half, body);
+ bodyLength += cn1DerInteger(raw + half, half, body + bodyLength);
+ der[written++] = 0x30;
+ if (bodyLength > 127) {
+ der[written++] = 0x81;
+ }
+ der[written++] = (unsigned char) bodyLength;
+ memcpy(der + written, body, (size_t) bodyLength);
+ return written + bodyLength;
+}
+
+/* Coordinate width of an EC key in bytes: 66 for P-521, whose 521 bits do not
+ * fill a whole byte count that any digest length happens to match. */
+static int cn1EcCoordinateBytes(BCRYPT_KEY_HANDLE key) {
+ DWORD bits = 0;
+ ULONG copied = 0;
+ if (BCryptGetProperty(key, BCRYPT_KEY_STRENGTH, (PUCHAR) &bits, sizeof(bits), &copied, 0)
+ != STATUS_SUCCESS || bits == 0) {
+ return 0;
+ }
+ return (int) ((bits + 7) / 8);
+}
+
+/* Inverse of cn1EcdsaToDer, padding each half back to `half` bytes. */
+/* Rejects anything that is not the one canonical encoding of this signature.
+ *
+ * Being liberal here is not harmless: CNG verifies the P1363 pair this
+ * produces, so a signature carrying trailing bytes, a falsified outer length
+ * or a non-minimal INTEGER would verify on Windows and be rejected by JavaSE
+ * and by any conforming verifier -- the same signature accepted on one port
+ * and refused on another. The whole input must be exactly one
+ * ECDSA-Sig-Value. */
+static int cn1EcdsaFromDer(const unsigned char* der, int derLength, unsigned char* raw, int half) {
+ int index = 1;
+ int part;
+ int bodyLength;
+ if (derLength < 8 || der[0] != 0x30) {
+ return 0;
+ }
+ /* Accept the long form the P-521 body needs, and only that one extra
+ * length byte -- a sequence of two integers never runs past 255 bytes.
+ * DER also requires the shortest form, so a 0x81 that encodes a length
+ * under 128 is not canonical. */
+ if (der[index] == 0x81) {
+ index++;
+ if (index >= derLength || der[index] < 0x80) {
+ return 0;
+ }
+ } else if ((der[index] & 0x80) != 0) {
+ return 0;
+ }
+ bodyLength = der[index];
+ index++;
+ /* The sequence must describe exactly the bytes that follow it. */
+ if (index + bodyLength != derLength) {
+ return 0;
+ }
+ memset(raw, 0, (size_t) (half * 2));
+ for (part = 0; part < 2; part++) {
+ int length, start, copy;
+ if (index + 2 > derLength || der[index] != 0x02) {
+ return 0;
+ }
+ length = der[index + 1];
+ index += 2;
+ if (length <= 0 || index + length > derLength) {
+ return 0;
+ }
+ /* Canonical INTEGER: no leading 0x00 unless it is there to keep the
+ * value positive, and never negative. */
+ if (length > 1 && der[index] == 0x00 && (der[index + 1] & 0x80) == 0) {
+ return 0;
+ }
+ if ((der[index] & 0x80) != 0) {
+ return 0;
+ }
+ start = 0;
+ while (start < length - 1 && der[index + start] == 0) {
+ start++;
+ }
+ copy = length - start;
+ if (copy > half) {
+ return 0;
+ }
+ memcpy(raw + part * half + (half - copy), der + index + start, (size_t) copy);
+ index += length;
+ }
+ /* Nothing may follow the second INTEGER. */
+ return index == derLength;
+}
+
+JAVA_OBJECT com_codename1_impl_windows_WindowsNative_rsaCrypt___java_lang_String_boolean_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY(
+ CODENAME_ONE_THREAD_STATE, JAVA_OBJECT transformation, JAVA_BOOLEAN encrypt,
+ JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) {
+ const char* mode = transformation == JAVA_NULL ? "" : stringToUTF8(threadStateData, transformation);
+ int keyLength = 0, dataLength = 0;
+ unsigned char* keyDer = cn1Bytes(keyArray, &keyLength);
+ unsigned char* data = cn1Bytes(dataArray, &dataLength);
+ BCRYPT_KEY_HANDLE publicKey = NULL;
+ NCRYPT_KEY_HANDLE privateKey = 0;
+ int oaepMode = strstr(mode, "OAEP") != 0;
+ LPCWSTR labelDigest = BCRYPT_SHA256_ALGORITHM;
+ unsigned char* out = 0;
+ unsigned char* block = 0;
+ ULONG outLength = 0, produced = 0;
+ DWORD modulusBytes = 0, propertyBytes = 0;
+ NTSTATUS status;
+ JAVA_OBJECT result = JAVA_NULL;
+
+ if (!cn1IsRsaTransformation(mode)) {
+ cn1CryptoFail("unsupported cipher transformation", 0);
+ return JAVA_NULL;
+ }
+ /* Import only once the transformation is known good. Importing first and
+ * then rejecting the mode returned without reaching the `done` cleanup, so
+ * every rejected call leaked a BCrypt/NCrypt key handle -- unbounded in a
+ * long-running app that keeps retrying a bad transformation. */
+ publicKey = encrypt ? cn1PublicKey(keyDer, keyLength) : NULL;
+ privateKey = encrypt ? 0 : cn1PrivateKey(keyDer, keyLength, 0);
+ if (encrypt ? (publicKey == NULL) : (privateKey == 0)) {
+ return JAVA_NULL;
+ }
+
+ if (oaepMode) {
+ /* CNG's padding info names one digest for both the label hash and the
+ * mask, so it cannot express the SHA-256 label with the SHA-1 mask that
+ * the JCE providers -- and therefore the JavaSE, Android and Linux
+ * ports -- use for this transformation. Pad here and run the key
+ * operation raw so ciphertext stays readable across ports. */
+ ULONG bits = 0;
+ if (encrypt) {
+ status = BCryptGetProperty(publicKey, BCRYPT_KEY_STRENGTH, (PUCHAR) &bits,
+ sizeof(bits), &propertyBytes, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("RSA key size", status);
+ goto done;
+ }
+ /* Round up: a modulus whose bit length is not a multiple of eight
+ * still occupies a whole final octet, and bits / 8 sized the OAEP
+ * block one byte short of it. The DER key material comes from the
+ * caller, so an unusual strength is reachable, and the raw CNG
+ * operation then fails or produces a block no other port can read. */
+ modulusBytes = (bits + 7) / 8;
+ } else {
+ if (NCryptGetProperty(privateKey, NCRYPT_LENGTH_PROPERTY, (PBYTE) &bits,
+ sizeof(bits), &propertyBytes, 0) != ERROR_SUCCESS) {
+ cn1CryptoFail("RSA key size", 0);
+ goto done;
+ }
+ modulusBytes = (bits + 7) / 8; /* see the public branch above */
+ }
+ block = (unsigned char*) malloc((size_t) modulusBytes + 1);
+ if (block == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ if (encrypt) {
+ if (!cn1OaepEncode(labelDigest, labelDigest, data, dataLength, block,
+ (int) modulusBytes)) {
+ goto done;
+ }
+ out = (unsigned char*) malloc((size_t) modulusBytes + 1);
+ if (out == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ status = BCryptEncrypt(publicKey, block, modulusBytes, NULL, NULL, 0, out,
+ modulusBytes, &produced, BCRYPT_PAD_NONE);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("RSA encrypt", status);
+ goto done;
+ }
+ } else {
+ DWORD recovered = 0;
+ int messageLength = 0;
+ if (NCryptDecrypt(privateKey, data, (DWORD) dataLength, NULL, block, modulusBytes,
+ &recovered, NCRYPT_NO_PADDING_FLAG) != ERROR_SUCCESS) {
+ cn1CryptoFail("RSA decrypt", 0);
+ goto done;
+ }
+ out = (unsigned char*) malloc((size_t) modulusBytes + 1);
+ if (out == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ if (!cn1OaepDecode(labelDigest, labelDigest, block, (int) modulusBytes,
+ out, &messageLength)) {
+ goto done;
+ }
+ produced = (ULONG) messageLength;
+ }
+ } else {
+ ULONG flags = BCRYPT_PAD_PKCS1;
+ status = encrypt
+ ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, NULL, NULL, 0, NULL, 0,
+ &outLength, flags)
+ : (NTSTATUS) NCryptDecrypt(privateKey, data, (DWORD) dataLength, NULL, NULL, 0,
+ (DWORD*) &outLength, flags);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("RSA size", status);
+ goto done;
+ }
+ out = (unsigned char*) malloc((size_t) outLength + 1);
+ if (out == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ status = encrypt
+ ? BCryptEncrypt(publicKey, data, (ULONG) dataLength, NULL, NULL, 0, out,
+ outLength, &produced, flags)
+ : (NTSTATUS) NCryptDecrypt(privateKey, data, (DWORD) dataLength, NULL, out,
+ outLength, (DWORD*) &produced, flags);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail(encrypt ? "RSA encrypt" : "RSA decrypt", status);
+ goto done;
+ }
+ }
+ result = cn1WinNewByteArray(threadStateData, out, (int) produced);
+
+done:
+ free(out);
+ free(block);
+ if (publicKey != NULL) {
+ BCryptDestroyKey(publicKey);
+ }
+ if (privateKey != 0) {
+ NCryptFreeObject(privateKey);
+ }
+ return result;
+}
+
+JAVA_OBJECT com_codename1_impl_windows_WindowsNative_signData___java_lang_String_byte_1ARRAY_byte_1ARRAY_R_byte_1ARRAY(
+ CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray, JAVA_OBJECT dataArray) {
+ const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm);
+ int keyLength = 0, dataLength = 0, keyFamily = CN1_KEY_OTHER;
+ unsigned char* keyDer = cn1Bytes(keyArray, &keyLength);
+ unsigned char* data = cn1Bytes(dataArray, &dataLength);
+ NCRYPT_KEY_HANDLE key = cn1PrivateKey(keyDer, keyLength, &keyFamily);
+ int isEc = keyFamily == CN1_KEY_EC;
+ LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name);
+ unsigned char digest[64];
+ int digestLength = cn1DigestLength(digestAlgorithm);
+ BCRYPT_PKCS1_PADDING_INFO padding;
+ /* ECDSA carries no padding parameters; RSA signs with PKCS#1. */
+ void* paddingInfo;
+ DWORD flags;
+ unsigned char* out = 0;
+ DWORD outLength = 0, produced = 0;
+ SECURITY_STATUS status;
+ JAVA_OBJECT result = JAVA_NULL;
+
+ if (key == 0) {
+ return JAVA_NULL;
+ }
+ if (digestAlgorithm == NULL) {
+ /* Not one of Signature's advertised algorithms. Falling back to SHA-256
+ * would return a valid signature over a digest the caller never asked
+ * for, which no other port would agree with. */
+ cn1CryptoFail("unsupported signature algorithm", 0);
+ goto done;
+ }
+ /* The family has to come from the DER, not from the caller's label:
+ * PrivateKey.fromPkcs8 takes an arbitrary algorithm string and never checks
+ * it against the bytes, so the Java-side comparison can be satisfied while
+ * the encoded key is a different family -- and NCrypt would then answer an
+ * "RSA" request with an ECDSA signature. */
+ if (keyFamily != (strstr(name, "ECDSA") != 0 ? CN1_KEY_EC : CN1_KEY_RSA)) {
+ cn1CryptoFail("the signature algorithm does not match the key", 0);
+ goto done;
+ }
+ if (!cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) {
+ goto done;
+ }
+ padding.pszAlgId = digestAlgorithm;
+ paddingInfo = isEc ? NULL : (void*) &padding;
+ flags = isEc ? 0 : BCRYPT_PAD_PKCS1;
+ status = NCryptSignHash(key, paddingInfo, digest, (DWORD) digestLength, NULL, 0,
+ &outLength, flags);
+ if (status != ERROR_SUCCESS) {
+ cn1CryptoFail("sign size", (NTSTATUS) status);
+ goto done;
+ }
+ out = (unsigned char*) malloc((size_t) outLength + 1);
+ if (out == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ status = NCryptSignHash(key, paddingInfo, digest, (DWORD) digestLength, out, outLength,
+ &produced, flags);
+ if (status != ERROR_SUCCESS) {
+ cn1CryptoFail("sign", (NTSTATUS) status);
+ goto done;
+ }
+ if (isEc) {
+ /* NCrypt answers the fixed-width r||s of P1363; the portable Signature
+ * contract, and Jwt.derToJoseEcdsa with it, expects ASN.1 DER. */
+ unsigned char der[160];
+ int derLength = cn1EcdsaToDer(out, (int) produced, der);
+ if (derLength <= 0) {
+ cn1CryptoFail("ECDSA signature encoding", 0);
+ goto done;
+ }
+ result = cn1WinNewByteArray(threadStateData, der, derLength);
+ } else {
+ result = cn1WinNewByteArray(threadStateData, out, (int) produced);
+ }
+
+done:
+ free(out);
+ NCryptFreeObject(key);
+ return result;
+}
+
+JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_verifyData___java_lang_String_byte_1ARRAY_byte_1ARRAY_byte_1ARRAY_R_boolean(
+ CODENAME_ONE_THREAD_STATE, JAVA_OBJECT algorithm, JAVA_OBJECT keyArray,
+ JAVA_OBJECT dataArray, JAVA_OBJECT signatureArray) {
+ const char* name = algorithm == JAVA_NULL ? "" : stringToUTF8(threadStateData, algorithm);
+ int keyLength = 0, dataLength = 0, signatureLength = 0;
+ unsigned char* keyDer = cn1Bytes(keyArray, &keyLength);
+ unsigned char* data = cn1Bytes(dataArray, &dataLength);
+ unsigned char* signature = cn1Bytes(signatureArray, &signatureLength);
+ /* CryptImportPublicKeyInfoEx2 handles both key kinds; only the padding
+ * differs, so read the algorithm out of the SubjectPublicKeyInfo. */
+ int keyFamily = cn1PublicKeyFamily(keyDer, keyLength);
+ int isEc = keyFamily == CN1_KEY_EC;
+ BCRYPT_KEY_HANDLE key = cn1PublicKey(keyDer, keyLength);
+ LPCWSTR digestAlgorithm = cn1DigestAlgorithm(name);
+ unsigned char digest[64];
+ int digestLength = cn1DigestLength(digestAlgorithm);
+ BCRYPT_PKCS1_PADDING_INFO padding;
+ JAVA_BOOLEAN result = JAVA_FALSE;
+
+ if (key == NULL) {
+ return JAVA_FALSE;
+ }
+ if (digestAlgorithm == NULL) {
+ cn1CryptoFail("unsupported signature algorithm", 0);
+ BCryptDestroyKey(key);
+ return JAVA_FALSE;
+ }
+ if (keyFamily != (strstr(name, "ECDSA") != 0 ? CN1_KEY_EC : CN1_KEY_RSA)) {
+ cn1CryptoFail("the signature algorithm does not match the key", 0);
+ BCryptDestroyKey(key);
+ return JAVA_FALSE;
+ }
+ if (cn1Digest(digestAlgorithm, data, dataLength, digest, digestLength)) {
+ unsigned char raw[132];
+ const unsigned char* toVerify = signature;
+ ULONG toVerifyLength = (ULONG) signatureLength;
+ int usable = 1;
+ padding.pszAlgId = digestAlgorithm;
+ if (isEc) {
+ /* Signatures arrive as DER; CNG verifies the P1363 pair. The half
+ * width is the curve's, read off the key -- deriving it from the
+ * named digest gets P-521 wrong, whose coordinates are 66 bytes
+ * while SHA-512 is 64, so a valid ES512 signature would be handed
+ * to CNG as a 128-byte pair instead of the required 132. */
+ int half = cn1EcCoordinateBytes(key);
+ if (half <= 0 || half * 2 > (int) sizeof(raw)) {
+ usable = 0;
+ } else {
+ usable = cn1EcdsaFromDer(signature, signatureLength, raw, half);
+ toVerify = raw;
+ toVerifyLength = (ULONG) (half * 2);
+ }
+ }
+ /* An RSA signature that is not exactly the modulus width cannot be a
+ * signature for this key at all. CNG answers STATUS_INVALID_PARAMETER
+ * for it, which the classification below would report as a
+ * configuration fault -- but a caller-supplied signature of the wrong
+ * size is bad input to verify(), not a broken runtime, and every other
+ * port answers false for it. */
+ if (usable && !isEc) {
+ DWORD expected = 0;
+ ULONG copied = 0;
+ if (BCryptGetProperty(key, BCRYPT_SIGNATURE_LENGTH, (PUCHAR) &expected,
+ sizeof(expected), &copied, 0) == STATUS_SUCCESS
+ && expected != 0 && toVerifyLength != expected) {
+ BCryptDestroyKey(key);
+ return JAVA_FALSE;
+ }
+ }
+ if (!usable) {
+ cn1CryptoFail("could not read the signature for verification", 0);
+ } else {
+ NTSTATUS verifyStatus = BCryptVerifySignature(key, isEc ? NULL : &padding, digest,
+ (ULONG) digestLength, (PUCHAR) toVerify,
+ toVerifyLength,
+ isEc ? 0 : BCRYPT_PAD_PKCS1);
+ if (verifyStatus == STATUS_SUCCESS) {
+ result = JAVA_TRUE;
+ } else if (verifyStatus != STATUS_INVALID_SIGNATURE
+ && verifyStatus != STATUS_INVALID_PARAMETER) {
+ /* STATUS_INVALID_SIGNATURE means "this signature is bad". An
+ * invalid handle or unsupported algorithm means verification
+ * never ran, and answering a bare false for those reported
+ * tampering where the real fault was configuration -- the same
+ * conflation the Linux port had. Recording the status is what
+ * makes cryptoVerify raise a CryptoException instead.
+ *
+ * STATUS_INVALID_PARAMETER is deliberately on the "bad
+ * signature" side. By this point the key imported, the digest
+ * algorithm is supported, the key family matches the algorithm,
+ * the digest computed and (for RSA) the signature is exactly
+ * the modulus width -- every configuration input has been
+ * checked, so the only thing left that CNG can object to is the
+ * signature's CONTENT. Verifying with the wrong key decrypts to
+ * essentially random bytes, and whether the PKCS#1 v1.5 parse of
+ * that garbage comes back INVALID_SIGNATURE or INVALID_PARAMETER
+ * depends on the garbage: CryptoApiTest's "rejects signature
+ * from wrong key" case passed nine runs and then raised
+ * "verification failed to run (status 0xc000000d)" on the tenth.
+ * Rejecting the signature is the correct answer for both. */
+ cn1CryptoFail("signature verification failed to run", verifyStatus);
+ }
+ }
+ }
+ BCryptDestroyKey(key);
+ return result;
+}
+
+/* ------------------------------------------------------------ key pairs */
+
+/* Returns the pair as one array: a four-byte big-endian public-key length,
+ * the X.509 public key, then the PKCS#8 private key. A pair has to come from
+ * a single call -- two calls would produce two unrelated keys. */
+JAVA_OBJECT com_codename1_impl_windows_WindowsNative_generateRsaKeyPair___int_R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_INT bits) {
+ BCRYPT_ALG_HANDLE alg = NULL;
+ BCRYPT_KEY_HANDLE key = NULL;
+ CERT_PUBLIC_KEY_INFO* publicInfo = 0;
+ DWORD publicInfoLength = 0;
+ unsigned char* publicDer = 0;
+ DWORD publicLength = 0;
+ unsigned char* privateBlob = 0;
+ ULONG privateBlobLength = 0;
+ unsigned char* pkcs1 = 0;
+ DWORD pkcs1Length = 0;
+ unsigned char* privateDer = 0;
+ DWORD privateLength = 0;
+ unsigned char* blob = 0;
+ CRYPT_PRIVATE_KEY_INFO keyInfo;
+ unsigned char derNull[2];
+ NTSTATUS status;
+ JAVA_OBJECT result = JAVA_NULL;
+
+ status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_RSA_ALGORITHM, NULL, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("RSA provider", status);
+ return JAVA_NULL;
+ }
+ status = BCryptGenerateKeyPair(alg, &key, (ULONG) bits, 0);
+ if (status == STATUS_SUCCESS) {
+ status = BCryptFinalizeKeyPair(key, 0);
+ }
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("RSA keygen", status);
+ goto done;
+ }
+
+ /* Public half: BCrypt handle -> CERT_PUBLIC_KEY_INFO -> X.509 SPKI DER. */
+ if (!CryptExportPublicKeyInfoFromBCryptKeyHandle(key, X509_ASN_ENCODING, NULL, 0, NULL,
+ NULL, &publicInfoLength)) {
+ cn1CryptoFailLast("public key export size");
+ goto done;
+ }
+ publicInfo = (CERT_PUBLIC_KEY_INFO*) malloc(publicInfoLength);
+ if (publicInfo == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ if (!CryptExportPublicKeyInfoFromBCryptKeyHandle(key, X509_ASN_ENCODING, NULL, 0, NULL,
+ publicInfo, &publicInfoLength) ||
+ !CryptEncodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, publicInfo,
+ CRYPT_ENCODE_ALLOC_FLAG, NULL, &publicDer, &publicLength)) {
+ cn1CryptoFailLast("public key encode");
+ goto done;
+ }
+
+ /* Private half: BCrypt blob -> PKCS#1 DER -> PKCS#8 PrivateKeyInfo DER. */
+ status = BCryptExportKey(key, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, NULL, 0, &privateBlobLength, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("private key export size", status);
+ goto done;
+ }
+ privateBlob = (unsigned char*) malloc(privateBlobLength);
+ if (privateBlob == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ status = BCryptExportKey(key, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, privateBlob, privateBlobLength,
+ &privateBlobLength, 0);
+ if (status != STATUS_SUCCESS) {
+ cn1CryptoFail("private key export", status);
+ goto done;
+ }
+ if (!CryptEncodeObjectEx(X509_ASN_ENCODING, CNG_RSA_PRIVATE_KEY_BLOB, privateBlob,
+ CRYPT_ENCODE_ALLOC_FLAG, NULL, &pkcs1, &pkcs1Length)) {
+ cn1CryptoFailLast("private key encode");
+ goto done;
+ }
+ memset(&keyInfo, 0, sizeof(keyInfo));
+ keyInfo.Version = 0;
+ keyInfo.Algorithm.pszObjId = (LPSTR) szOID_RSA_RSA;
+ /* rsaEncryption takes an explicit ASN.1 NULL parameter. */
+ derNull[0] = 0x05;
+ derNull[1] = 0x00;
+ keyInfo.Algorithm.Parameters.cbData = sizeof(derNull);
+ keyInfo.Algorithm.Parameters.pbData = derNull;
+ keyInfo.PrivateKey.cbData = pkcs1Length;
+ keyInfo.PrivateKey.pbData = pkcs1;
+ if (!CryptEncodeObjectEx(X509_ASN_ENCODING, PKCS_PRIVATE_KEY_INFO, &keyInfo,
+ CRYPT_ENCODE_ALLOC_FLAG, NULL, &privateDer, &privateLength)) {
+ cn1CryptoFailLast("PKCS#8 encode");
+ goto done;
+ }
+
+ blob = (unsigned char*) malloc((size_t) publicLength + (size_t) privateLength + 4);
+ if (blob == 0) {
+ cn1CryptoFail("out of memory", 0);
+ goto done;
+ }
+ blob[0] = (unsigned char) ((publicLength >> 24) & 0xff);
+ blob[1] = (unsigned char) ((publicLength >> 16) & 0xff);
+ blob[2] = (unsigned char) ((publicLength >> 8) & 0xff);
+ blob[3] = (unsigned char) (publicLength & 0xff);
+ memcpy(blob + 4, publicDer, publicLength);
+ memcpy(blob + 4 + publicLength, privateDer, privateLength);
+ result = cn1WinNewByteArray(threadStateData, blob, (int) (publicLength + privateLength + 4));
+
+done:
+ free(blob);
+ free(publicInfo);
+ free(privateBlob);
+ if (publicDer != 0) {
+ LocalFree(publicDer);
+ }
+ if (pkcs1 != 0) {
+ LocalFree(pkcs1);
+ }
+ if (privateDer != 0) {
+ LocalFree(privateDer);
+ }
+ if (key != NULL) {
+ BCryptDestroyKey(key);
+ }
+ if (alg != NULL) {
+ BCryptCloseAlgorithmProvider(alg, 0);
+ }
+ return result;
+}
diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp
index 4bf4654de57..4b9054c0fcc 100644
--- a/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp
+++ b/Ports/WindowsPort/nativeSources/cn1_windows_image.cpp
@@ -441,7 +441,14 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_createMutableImage___int_int_
D2D1_COLOR_F clearColor;
uint32_t a, r, g, b;
+ /* Refuse a degenerate extent, but say so. Returning a bare 0 makes
+ * Image.getGraphics() answer null and the caller die with a
+ * NullPointerException nowhere near the cause. Clamping to 1x1 would hide
+ * it just as badly: whatever asked for a zero-sized image has a real bug --
+ * a metric that came out zero -- and silently handing back a 1x1 surface
+ * would leave that unfixed and render wrongly instead of crashing. */
if (width <= 0 || height <= 0) {
+ cn1WindowsLog("createMutableImage refused a degenerate size");
return 0;
}
diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_io.c b/Ports/WindowsPort/nativeSources/cn1_windows_io.c
index 442889295fa..5ff42361033 100644
--- a/Ports/WindowsPort/nativeSources/cn1_windows_io.c
+++ b/Ports/WindowsPort/nativeSources/cn1_windows_io.c
@@ -82,17 +82,54 @@ static JAVA_OBJECT cn1WinWideToJavaString(CODENAME_ONE_THREAD_STATE, const WCHAR
/* ------------------------------------------------------------------- files */
+/* Reason the last open failed. The port reports "could not open X" from Java,
+ * where the thread's last-error value is long gone; without this the only way
+ * to tell a missing directory from a sharing violation was another CI run. */
+/* Per-thread: two threads failing an open at once would otherwise overwrite
+ * each other and lastIoError() could report the wrong reason. */
+static __declspec(thread) DWORD cn1WinLastIoError;
+/* Set when the open failed before CreateFileW was ever reached, so the thread's
+ * last-error value belongs to some earlier unrelated call. Reporting that value
+ * would name a plausible but wrong reason, which is worse than saying nothing:
+ * the whole point of this record is to explain a failure without another run. */
+static __declspec(thread) int cn1WinLastIoHadNoPath;
+
+static void cn1WinRecordIoError(DWORD error) {
+ cn1WinLastIoHadNoPath = 0;
+ cn1WinLastIoError = error;
+}
+
+static void cn1WinRecordMissingPath(void) {
+ cn1WinLastIoHadNoPath = 1;
+ cn1WinLastIoError = 0;
+}
+
+JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) {
+ char buffer[256];
+ if (cn1WinLastIoHadNoPath) {
+ return newStringFromCString(threadStateData, "no path supplied");
+ }
+ _snprintf(buffer, sizeof(buffer), "Windows error %lu", (unsigned long) cn1WinLastIoError);
+ buffer[sizeof(buffer) - 1] = 0;
+ return newStringFromCString(threadStateData, buffer);
+}
+
JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenRead___java_lang_String_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1Arg1) {
UINT32 len = 0;
WCHAR* path = cn1WinJavaStringToWide(threadStateData, __cn1Arg1, &len);
HANDLE h;
+ DWORD error;
if (path == NULL) {
+ cn1WinRecordMissingPath();
return 0;
}
h = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
+ /* Captured before free(), which is free to clobber the thread's last error. */
+ error = GetLastError();
free(path);
if (h == INVALID_HANDLE_VALUE) {
+ cn1WinRecordIoError(error);
return 0;
}
return (JAVA_LONG)(intptr_t)h;
@@ -102,7 +139,9 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenWrite___java_lang_Str
UINT32 len = 0;
WCHAR* path = cn1WinJavaStringToWide(threadStateData, __cn1Arg1, &len);
HANDLE h;
+ DWORD error;
if (path == NULL) {
+ cn1WinRecordMissingPath();
return 0;
}
if (__cn1Arg2) {
@@ -117,8 +156,10 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenWrite___java_lang_Str
h = CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, NULL,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
}
+ error = GetLastError();
free(path);
if (h == INVALID_HANDLE_VALUE) {
+ cn1WinRecordIoError(error);
return 0;
}
return (JAVA_LONG)(intptr_t)h;
@@ -239,14 +280,61 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_fileRename___java_lang_String
UINT32 len1 = 0, len2 = 0;
WCHAR* path = cn1WinJavaStringToWide(threadStateData, __cn1Arg1, &len1);
/*
- * Per the CN1 FileSystemStorage contract newName is the full target path,
- * so it is passed directly to MoveFileExW. If a caller ever passes a bare
- * leaf name the move would land in the process working directory; that
- * would be a contract violation on the Java side.
+ * FileSystemStorage.rename documents newName as "relative to the current
+ * folder" -- a leaf name -- and the Linux port resolves it that way. This
+ * passed it straight to MoveFileExW as a full target, so a leaf name moved
+ * the file into the process working directory instead of renaming it in
+ * place. WAVWriter.close() does exactly that (it renames the recording to
+ * .pcm via new File(...).getName()) and then reopens the .pcm, which
+ * was not where it expected: AudioMixerApiTest failed on Windows with
+ * "No such file" for a file the port had quietly moved elsewhere.
+ *
+ * A leaf name is now joined to the source's parent directory. An absolute
+ * target -- one carrying a drive letter, a UNC prefix or a leading
+ * separator -- is still honoured as-is, so any caller relying on the old
+ * behaviour keeps working.
*/
WCHAR* newName = cn1WinJavaStringToWide(threadStateData, __cn1Arg2, &len2);
+ WCHAR* target = NULL;
if (path != NULL && newName != NULL) {
- MoveFileExW(path, newName, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED);
+ int absolute = (newName[0] == L'\\' || newName[0] == L'/'
+ || (newName[0] != 0 && newName[1] == L':'));
+ if (absolute) {
+ MoveFileExW(path, newName, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED);
+ } else {
+ WCHAR* lastBack = wcsrchr(path, L'\\');
+ WCHAR* lastFwd = wcsrchr(path, L'/');
+ WCHAR* sep;
+ /* Resolve the absent cases before comparing. An ordinary Windows
+ * path has no forward slash and a normalized one has no backslash,
+ * so one of these is NULL almost every time -- and relational
+ * comparison of a null pointer against a pointer into `path` is
+ * undefined behaviour, not merely unusual. An optimizing build is
+ * free to decide it either way, and picking NULL would drop the
+ * parent directory and put the rename back in the working
+ * directory, which is the bug this join exists to fix. Both
+ * operands below point into `path`, where `>` is well defined. */
+ if (lastBack == NULL) {
+ sep = lastFwd;
+ } else if (lastFwd == NULL) {
+ sep = lastBack;
+ } else {
+ sep = lastBack > lastFwd ? lastBack : lastFwd;
+ }
+ size_t dirLen = sep != NULL ? (size_t) (sep - path) + 1 : 0;
+ size_t nameLen = wcslen(newName);
+ target = (WCHAR*) malloc((dirLen + nameLen + 1) * sizeof(WCHAR));
+ if (target != NULL) {
+ if (dirLen > 0) {
+ memcpy(target, path, dirLen * sizeof(WCHAR));
+ }
+ memcpy(target + dirLen, newName, (nameLen + 1) * sizeof(WCHAR));
+ MoveFileExW(path, target, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED);
+ }
+ }
+ }
+ if (target != NULL) {
+ free(target);
}
if (path != NULL) {
free(path);
diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp
index baeb61780d8..0fff03661e7 100644
--- a/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp
+++ b/Ports/WindowsPort/nativeSources/cn1_windows_video.cpp
@@ -36,8 +36,10 @@
#include
#include
#include
+#include
#include
#include
+#include
#include "cn1_windows.h"
using Microsoft::WRL::ComPtr;
@@ -73,6 +75,14 @@ static void cn1StripFileWide(const char* utf8, wchar_t* out, int outLen) {
struct CN1VideoReader {
ComPtr reader;
+ /* The source URL, so readAudio can open a reader of its own. A plain
+ * malloc'd copy rather than std::wstring: including drags in the
+ * MSVC STL, which hard-asserts on the compiler version (STL1000) and broke
+ * the cross-compile job, whose clang is not pinned to 19 the way the
+ * build+run job's is. Freed in the destructor below. */
+ wchar_t* url;
+ CN1VideoReader() : url(NULL) {}
+ ~CN1VideoReader() { free(url); }
int width;
int height;
LONGLONG durationMs;
@@ -86,14 +96,33 @@ struct CN1VideoReader {
struct CN1VideoWriter {
ComPtr writer;
+ unsigned long audioSamplesWritten;
+ unsigned long long audioBytesWritten;
DWORD videoStream;
DWORD audioStream;
int width;
int height;
float frameRate;
bool hasAudio;
+ /* The rate the AAC encoder was configured at. Media Foundation's AAC encoder
+ * accepts 44100 or 48000 Hz only, so a caller asking for anything else (the
+ * conformance suite records an 8 kHz tone) gets its PCM resampled on the way
+ * in rather than losing the audio stream. */
+ int audioEncRate;
+ int audioChannels;
+ HRESULT audioSetupHr;
};
+/* The sample rates Media Foundation's AAC encoder will accept. */
+static int cn1AacEncoderRate(int requested) {
+ if (requested == 44100 || requested == 48000) {
+ return requested;
+ }
+ /* 44100 is the closer target for anything derived from CD-family rates
+ * (11025/22050); everything else lands on 48000. */
+ return (requested % 11025) == 0 ? 44100 : 48000;
+}
+
// --------------------------------------------------------------------------
// Reader
// --------------------------------------------------------------------------
@@ -108,8 +137,26 @@ static JAVA_LONG cn1ReaderOpen(const wchar_t* url) {
return 0;
}
+ // Select the streams explicitly. Which streams a source reader starts with
+ // depends on the presentation descriptor, and SetCurrentMediaType succeeds on
+ // a stream that is not selected -- so the audio configuration below reported
+ // success, hasAudio became true, and ReadSample then produced nothing at all.
+ // That is exactly what VideoIORoundTripTest saw on Windows: "decoded clip
+ // reports audio but no PCM samples were returned". Deselect everything, then
+ // turn on the two streams this reader actually consumes.
+ reader->SetStreamSelection((DWORD) MF_SOURCE_READER_ALL_STREAMS, FALSE);
+ reader->SetStreamSelection((DWORD) MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
+ reader->SetStreamSelection((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE);
+
CN1VideoReader* st = new CN1VideoReader();
st->reader = reader;
+ if (url != NULL) {
+ size_t urlChars = wcslen(url) + 1;
+ st->url = (wchar_t*) malloc(urlChars * sizeof(wchar_t));
+ if (st->url != NULL) {
+ memcpy(st->url, url, urlChars * sizeof(wchar_t));
+ }
+ }
st->width = 0;
st->height = 0;
st->durationMs = -1;
@@ -250,11 +297,53 @@ static JAVA_OBJECT cn1ReaderReadAudio(CODENAME_ONE_THREAD_STATE, CN1VideoReader*
if (!st->hasAudio) {
return JAVA_NULL;
}
- // readAudio() promises the entire audio track. A prior frameAt()/readFrames()
- // may have repositioned the shared source reader via SetCurrentPosition, which
- // moves every stream (not just video), so rewind to the start before draining
- // the audio stream -- otherwise audio would begin at the last video seek.
- {
+ // readAudio() promises the entire audio track, and the shared reader has
+ // usually been driven to end-of-file by frameAt()/readFrames() first. Rewinding
+ // it with SetCurrentPosition did not bring the audio stream back: the very
+ // first ReadSample after the seek returned MF_SOURCE_READERF_ENDOFSTREAM with
+ // zero bytes (reads=1 lastFlags=0x2), which is what left VideoIORoundTripTest
+ // reporting "reports audio but no PCM samples were returned" on Windows.
+ //
+ // Open a reader of our own instead. A fresh source reader starts at the
+ // beginning by construction, so the audio track no longer depends on seek
+ // semantics or on what the video pass did to the shared position.
+ ComPtr audioReader;
+ if (st->url != NULL) {
+ ComPtr attrs;
+ if (SUCCEEDED(MFCreateAttributes(&attrs, 1))) {
+ MFCreateSourceReaderFromURL(st->url, attrs.Get(), &audioReader);
+ }
+ }
+ if (audioReader != NULL) {
+ /* Configure it completely or not at all. Every call here was previously
+ * unchecked, so a reader that opened but could not select its stream or
+ * negotiate PCM stayed non-null and was used anyway -- reading an
+ * unselected stream, or the source's COMPRESSED native format, while the
+ * bytes were handed back with the cached 16-bit PCM rate and channel
+ * count beside them. Silent garbage is worse than the seek path this
+ * replaced, so a partially configured reader is discarded and the shared
+ * reader is rewound instead. */
+ bool configured = false;
+ if (SUCCEEDED(audioReader->SetStreamSelection((DWORD) MF_SOURCE_READER_ALL_STREAMS, FALSE))
+ && SUCCEEDED(audioReader->SetStreamSelection(
+ (DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE))) {
+ ComPtr pcmType;
+ if (SUCCEEDED(MFCreateMediaType(&pcmType))) {
+ pcmType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
+ pcmType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_PCM);
+ pcmType->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16);
+ configured = SUCCEEDED(audioReader->SetCurrentMediaType(
+ (DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, NULL, pcmType.Get()));
+ }
+ }
+ if (!configured) {
+ printf("CN1SS:INFO:winAudio fresh reader configuration failed; rewinding the shared reader\n");
+ fflush(stdout);
+ audioReader.Reset();
+ }
+ }
+ if (audioReader == NULL) {
+ /* No usable fresh reader: fall back to rewinding the shared one. */
PROPVARIANT pos;
PropVariantInit(&pos);
pos.vt = VT_I8;
@@ -262,19 +351,35 @@ static JAVA_OBJECT cn1ReaderReadAudio(CODENAME_ONE_THREAD_STATE, CN1VideoReader*
st->reader->SetCurrentPosition(GUID_NULL, pos);
PropVariantClear(&pos);
}
+ IMFSourceReader* src = audioReader != NULL ? audioReader.Get() : st->reader.Get();
unsigned char* pcm = NULL;
size_t pcmLen = 0, pcmCap = 0;
+ /* Diagnostics: readAudio returning empty is reported by the suite as
+ * "reports audio but no PCM samples were returned", which says nothing about
+ * WHY Media Foundation produced nothing -- and this only reproduces on a
+ * Windows runner. Report the reason once so the next run names it instead of
+ * inviting another guess. */
+ int loops = 0, nullSamples = 0;
+ HRESULT lastHr = S_OK;
+ DWORD lastFlags = 0;
for (;;) {
DWORD streamFlags = 0;
LONGLONG timestamp = 0;
ComPtr sample;
- if (FAILED(st->reader->ReadSample((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, 0, NULL, &streamFlags, ×tamp, &sample))) {
+ loops++;
+ lastHr = src->ReadSample((DWORD) MF_SOURCE_READER_FIRST_AUDIO_STREAM, 0, NULL, &streamFlags, ×tamp, &sample);
+ lastFlags = streamFlags;
+ if (FAILED(lastHr)) {
break;
}
if (streamFlags & MF_SOURCE_READERF_ENDOFSTREAM) {
break;
}
if (sample == NULL) {
+ nullSamples++;
+ if (nullSamples > 512) {
+ break; /* a stream that only ever ticks would spin here forever */
+ }
continue;
}
ComPtr buffer;
@@ -299,6 +404,10 @@ static JAVA_OBJECT cn1ReaderReadAudio(CODENAME_ONE_THREAD_STATE, CN1VideoReader*
buffer->Unlock();
}
}
+ printf("CN1SS:INFO:winAudio reads=%d nullSamples=%d bytes=%u lastHr=0x%08lx lastFlags=0x%lx rate=%d ch=%d\n",
+ loops, nullSamples, (unsigned) pcmLen, (unsigned long) lastHr,
+ (unsigned long) lastFlags, st->audioRate, st->audioChannels);
+ fflush(stdout);
JAVA_OBJECT result = JAVA_NULL;
if (pcm != NULL && pcmLen > 0) {
result = allocArray(threadStateData, (int) pcmLen, &class_array1__JAVA_BYTE, sizeof(JAVA_ARRAY_BYTE), 1);
@@ -326,11 +435,16 @@ static JAVA_LONG cn1WriterOpen(const wchar_t* url, bool hevc, int width, int hei
CN1VideoWriter* st = new CN1VideoWriter();
st->writer = writer;
+ st->audioSamplesWritten = 0;
+ st->audioBytesWritten = 0;
st->width = width;
st->height = height;
st->frameRate = fps;
st->hasAudio = hasAudio;
st->audioStream = 0;
+ st->audioEncRate = 0;
+ st->audioChannels = channels > 0 ? channels : 1;
+ st->audioSetupHr = S_OK;
// ---- video output (H.264 / HEVC) ----
ComPtr videoOut;
@@ -363,24 +477,53 @@ static JAVA_LONG cn1WriterOpen(const wchar_t* url, bool hevc, int width, int hei
// ---- audio output (AAC) ----
if (hasAudio) {
+ /* Media Foundation's AAC encoder publishes input types at 44100 and
+ * 48000 Hz only. Asking it for the caller's rate made AddStream fail for
+ * an 8 kHz tone, and the failure was swallowed by clearing hasAudio: the
+ * file then carried a video stream alone while the writer reported
+ * success, so VideoIORoundTripTest saw a clip whose audio never arrived.
+ * Configure the encoder at a rate it accepts and convert on the way in.
+ * Byte rate is likewise constrained (the encoder publishes 12000, 16000,
+ * 20000 and 24000 bytes/sec), so an unlisted bit rate is rounded to the
+ * nearest supported one instead of failing the stream. */
+ static const UINT32 aacByteRates[] = { 12000, 16000, 20000, 24000 };
+ UINT32 wanted = (UINT32) (audioBitRate / 8);
+ UINT32 byteRate = aacByteRates[0];
+ for (size_t i = 1; i < sizeof(aacByteRates) / sizeof(aacByteRates[0]); i++) {
+ UINT32 best = byteRate > wanted ? byteRate - wanted : wanted - byteRate;
+ UINT32 here = aacByteRates[i] > wanted ? aacByteRates[i] - wanted : wanted - aacByteRates[i];
+ if (here < best) {
+ byteRate = aacByteRates[i];
+ }
+ }
+ st->audioEncRate = cn1AacEncoderRate(sampleRate);
+ st->audioChannels = channels > 0 ? channels : 1;
+
ComPtr audioOut;
MFCreateMediaType(&audioOut);
audioOut->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
audioOut->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC);
audioOut->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16);
- audioOut->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, (UINT32) sampleRate);
- audioOut->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, (UINT32) channels);
- audioOut->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, (UINT32) (audioBitRate / 8));
- if (SUCCEEDED(writer->AddStream(audioOut.Get(), &st->audioStream))) {
+ audioOut->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, (UINT32) st->audioEncRate);
+ audioOut->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, (UINT32) st->audioChannels);
+ audioOut->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, byteRate);
+ st->audioSetupHr = writer->AddStream(audioOut.Get(), &st->audioStream);
+ if (SUCCEEDED(st->audioSetupHr)) {
ComPtr audioIn;
MFCreateMediaType(&audioIn);
audioIn->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
audioIn->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_PCM);
audioIn->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, 16);
- audioIn->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, (UINT32) sampleRate);
- audioIn->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, (UINT32) channels);
- writer->SetInputMediaType(st->audioStream, audioIn.Get(), NULL);
- } else {
+ audioIn->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, (UINT32) st->audioEncRate);
+ audioIn->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, (UINT32) st->audioChannels);
+ /* The PCM input type is under-specified without these two: the
+ * encoder needs the frame size and byte rate to accept the type. */
+ audioIn->SetUINT32(MF_MT_AUDIO_BLOCK_ALIGNMENT, (UINT32) (2 * st->audioChannels));
+ audioIn->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND,
+ (UINT32) (st->audioEncRate * 2 * st->audioChannels));
+ st->audioSetupHr = writer->SetInputMediaType(st->audioStream, audioIn.Get(), NULL);
+ }
+ if (FAILED(st->audioSetupHr)) {
st->hasAudio = false;
}
}
@@ -533,9 +676,52 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_videoWriterAudio___long_byte_
}
BYTE* pcm = (BYTE*) (*(JAVA_ARRAY) pcmObj).data;
DWORD len = (DWORD) (*(JAVA_ARRAY) pcmObj).length;
- DWORD frames = len / (DWORD) (2 * (channels > 0 ? channels : 1));
+ int ch = channels > 0 ? channels : 1;
+ DWORD frames = len / (DWORD) (2 * ch);
+ BYTE* resampled = NULL;
+
+ /* The encoder runs at a rate it will accept (see cn1WriterOpen), so PCM
+ * recorded at any other rate is converted here. Linear interpolation between
+ * neighbouring frames: enough for the tone the conformance suite records,
+ * and it keeps the signal level -- and therefore the RMS the test measures --
+ * where the caller put it. */
+ if (sampleRate > 0 && st->audioEncRate > 0 && sampleRate != st->audioEncRate && frames > 0) {
+ double ratio = (double) st->audioEncRate / (double) sampleRate;
+ DWORD outFrames = (DWORD) (frames * ratio);
+ if (outFrames > 0) {
+ DWORD outLen = outFrames * (DWORD) (2 * ch);
+ resampled = (BYTE*) malloc(outLen);
+ if (resampled != NULL) {
+ const short* in = (const short*) pcm;
+ short* out = (short*) resampled;
+ for (DWORD f = 0; f < outFrames; f++) {
+ double srcPos = (double) f / ratio;
+ DWORD i0 = (DWORD) srcPos;
+ DWORD i1 = i0 + 1 < frames ? i0 + 1 : frames - 1;
+ double frac = srcPos - (double) i0;
+ for (int c = 0; c < ch; c++) {
+ double a = (double) in[i0 * ch + c];
+ double b = (double) in[i1 * ch + c];
+ out[f * ch + c] = (short) (a + (b - a) * frac);
+ }
+ }
+ pcm = resampled;
+ len = outLen;
+ frames = outFrames;
+ sampleRate = st->audioEncRate;
+ }
+ }
+ }
+
LONGLONG dur = sampleRate > 0 ? (LONGLONG) ((LONGLONG) frames * CN1_HNS_PER_SEC / sampleRate) : 0;
cn1WriterWriteSample(st, st->audioStream, pcm, len, (LONGLONG) ptsMs * CN1_HNS_PER_MS, dur);
+ /* A freshly opened reader cannot be positioned at EOF, yet it still reports
+ * ENDOFSTREAM on its first audio read -- so the file carries an audio stream
+ * header with no samples behind it, which points here rather than at the
+ * reader. Count what actually goes in. */
+ st->audioSamplesWritten++;
+ st->audioBytesWritten += (unsigned long long) len;
+ free(resampled);
}
JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_videoWriterClose___long_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) {
@@ -544,6 +730,11 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_videoWriterClose___long_R_
return JAVA_FALSE;
}
HRESULT hr = st->writer->Finalize();
+ printf("CN1SS:INFO:winWriter audioSamples=%lu audioBytes=%llu hasAudio=%d encRate=%d "
+ "audioSetupHr=0x%08lx finalizeHr=0x%08lx\n",
+ st->audioSamplesWritten, st->audioBytesWritten, st->hasAudio ? 1 : 0,
+ st->audioEncRate, (unsigned long) st->audioSetupHr, (unsigned long) hr);
+ fflush(stdout);
delete st;
return SUCCEEDED(hr) ? JAVA_TRUE : JAVA_FALSE;
}
diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
index d6ac8145b96..3f221ea8861 100644
--- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
+++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
@@ -39,6 +39,7 @@
import com.codename1.ui.accessibility.AccessibilityNodeSnapshot;
import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
@@ -1063,7 +1064,18 @@ public Image gaussianBlurImage(Image image, float radius) {
int b = Math.min(255, (p & 0xff) * 255 / a);
px[i] = (a << 24) | (r << 16) | (g << 8) | b;
}
- return Image.createImage(px, w, h);
+ // Callers composite ON TOP of the blur result -- Switch blurs its thumb's
+ // drop shadow and then draws the knob onto the blurred image -- so the
+ // result has to be drawable. Image.createImage(int[], w, h) builds an
+ // ARGB-backed CN1Image with no Direct2D render target, and
+ // getImageGraphics then answers 0: Graphics wraps a null peer, and every
+ // subsequent draw either vanishes or dereferences it. Blit the pixels
+ // into a mutable image so the port matches JavaSE, where the blur result
+ // is a BufferedImage and getGraphics has always worked.
+ Image blurred = Image.createImage(px, w, h);
+ Image drawable = Image.createImage(w, h, 0);
+ drawable.getGraphics().drawImage(blurred, 0, 0);
+ return drawable;
}
/** One separable box-blur pass over premultiplied ARGB; edges clamp. */
@@ -2392,8 +2404,8 @@ public int getContentLength(Object connection) {
@Override
public OutputStream openOutputStream(Object connection) throws IOException {
if (connection instanceof String) {
- long h = WindowsNative.fileOpenWrite(stripFileUrl((String) connection), false);
- return new WindowsOutputStream(h, false);
+ String path = stripFileUrl((String) connection);
+ return new WindowsOutputStream(openForWrite(path, false), false);
}
return new WindowsOutputStream(((WindowsHttpConnection) connection).peer, true);
}
@@ -2402,19 +2414,41 @@ public OutputStream openOutputStream(Object connection) throws IOException {
public OutputStream openOutputStream(Object connection, int offset) throws IOException {
// offset-based writing maps to opening the file for append/seek; the
// first cut appends, which covers the common resume-write case.
- long h = WindowsNative.fileOpenWrite(stripFileUrl((String) connection), true);
- return new WindowsOutputStream(h, false);
+ String path = stripFileUrl((String) connection);
+ return new WindowsOutputStream(openForWrite(path, true), false);
}
@Override
public InputStream openInputStream(Object connection) throws IOException {
if (connection instanceof String) {
- long h = WindowsNative.fileOpenRead(stripFileUrl((String) connection));
+ String path = stripFileUrl((String) connection);
+ long h = WindowsNative.fileOpenRead(path);
+ if (h == 0) {
+ // A null handle otherwise produced a stream that read as a
+ // legitimately empty file, so callers could not tell a missing
+ // file from an empty one -- the exact defect issue #1502
+ // reported against iOS.
+ throw new FileNotFoundException("No such file: " + path
+ + " (" + WindowsNative.lastIoError() + ")");
+ }
return new WindowsInputStream(h, false);
}
return new WindowsInputStream(((WindowsHttpConnection) connection).peer, true);
}
+ /// Opens `path` for writing, failing loudly when the platform cannot. A
+ /// null handle otherwise yields a stream that discards every write and
+ /// closes cleanly, which turns an unwritable path into a file that simply
+ /// never appears.
+ private long openForWrite(String path, boolean append) throws IOException {
+ long h = WindowsNative.fileOpenWrite(path, append);
+ if (h == 0) {
+ throw new IOException("Unable to open " + path + " for writing ("
+ + WindowsNative.lastIoError() + ")");
+ }
+ return h;
+ }
+
/**
* Resolves a classpath-style resource (e.g. {@code /theme.res}). The ParparVM
* windows target embeds the app's classpath resources into the executable's PE
@@ -2613,13 +2647,19 @@ public void deleteStorageFile(String name) {
@Override
public OutputStream createStorageOutputStream(String name) throws IOException {
- long h = WindowsNative.fileOpenWrite(storagePath(name), false);
- return new WindowsOutputStream(h, false);
+ // Same reason as openOutputStream: a discarded write that reports
+ // success loses the entry instead of reporting that it cannot be saved.
+ return new WindowsOutputStream(openForWrite(storagePath(name), false), false);
}
@Override
public InputStream createStorageInputStream(String name) throws IOException {
- long h = WindowsNative.fileOpenRead(storagePath(name));
+ String path = storagePath(name);
+ long h = WindowsNative.fileOpenRead(path);
+ if (h == 0) {
+ throw new FileNotFoundException("No such storage entry: " + name
+ + " (" + WindowsNative.lastIoError() + ")");
+ }
return new WindowsInputStream(h, false);
}
@@ -2638,6 +2678,79 @@ public String[] listFilesystemRoots() {
return WindowsNative.fileRoots();
}
+ /**
+ * Anchors the app home at the per-user storage directory, with the
+ * {@code file://} scheme Android, iOS and the Linux port also use.
+ *
+ * The inherited implementation builds {@code listFilesystemRoots()[0] +
+ * AppName}, which here is a drive root plus an app name that is literally
+ * "null" when neither the AppName property nor a package name is set --
+ * every path it produced pointed at an unwritable {@code C:\null\}. The
+ * scheme matters as well: {@link com.codename1.io.File} prepends the app
+ * home to any path that lacks it, so a bare path made
+ * {@code new File(fs.getAppHomePath() + "x")} resolve to the home
+ * directory joined to itself.
+ */
+ /// A stable, filesystem-safe directory name for THIS application.
+ ///
+ /// storageDir() names the Codename One directory shared by every CN1 app
+ /// under this user account, so returning it as the app home gave two
+ /// applications the same getAppHomePath(): each could read and overwrite
+ /// the other's files. The base implementation appends
+ /// getProperty("AppName", packageName) for exactly this reason; the reason
+ /// this override exists at all is that the base builds it on an unwritable
+ /// filesystem root, not that the per-app component was wrong.
+ ///
+ /// Never returns the literal "null": an unset AppName and packageName is
+ /// what made the base implementation produce "/null/" in the first place.
+ private String appHomeDirName() {
+ String name = getProperty("AppName", null);
+ if (name == null || name.length() == 0) {
+ name = getPackageName();
+ }
+ if (name == null || name.length() == 0 || "null".equals(name)) {
+ return "CN1App";
+ }
+ StringBuilder safe = new StringBuilder(name.length());
+ for (int i = 0; i < name.length(); i++) {
+ char c = name.charAt(i);
+ // Reserved on Windows and awkward everywhere else; NTFS and ext4
+ // both accept the rest of the printable range.
+ if (c < ' ' || c == '\\' || c == '/' || c == ':' || c == '*' || c == '?'
+ || c == '"' || c == '<' || c == '>' || c == '|') {
+ safe.append('_');
+ } else {
+ safe.append(c);
+ }
+ }
+ return safe.toString();
+ }
+
+ @Override
+ public String getAppHomePath() {
+ String dir = WindowsNative.storageDir();
+ if (dir == null || dir.length() == 0) {
+ dir = ".";
+ }
+ if (!dir.endsWith("\\") && !dir.endsWith("/")) {
+ dir += getFileSystemSeparator();
+ }
+ dir += appHomeDirName() + getFileSystemSeparator();
+ // com.codename1.io.File splits paths on '/' only, so a URL carrying
+ // backslashes would report the whole native path as a file's name and
+ // "file:/" as its parent. Native I/O accepts either separator.
+ String home = "file://" + dir.replace('\\', '/');
+ if (!exists(home)) {
+ mkdir(home);
+ }
+ return home;
+ }
+
+ @Override
+ public String toNativePath(String path) {
+ return stripFileUrl(path);
+ }
+
@Override
public String[] listFiles(String directory) throws IOException {
return WindowsNative.fileList(stripFileUrl(directory));
@@ -2698,6 +2811,180 @@ public char getFileSystemSeparator() {
return '\\';
}
+ /* ------------------------------------------------------------ crypto */
+
+ /**
+ * The crypto bridge, backed by CNG. Every failure is reported as a
+ * RuntimeException carrying the provider status, which
+ * {@code com.codename1.security.Cipher} turns into a CryptoException --
+ * an authentication failure has to be an exception rather than an empty
+ * result, or a tampered message would read as an empty plaintext.
+ */
+ private static byte[] cryptoResult(byte[] value, String operation) {
+ if (value == null) {
+ throw new RuntimeException(operation + " failed: " + WindowsNative.lastCryptoError());
+ }
+ return value;
+ }
+
+ @Override
+ public void secureRandomBytes(byte[] out) {
+ // Fail loudly: KeyGenerator hands this buffer straight back as key
+ // material, so a quiet return after the platform RNG failed would mint
+ // a predictable key.
+ if (out != null && out.length > 0 && !WindowsNative.secureRandomBytes(out)) {
+ throw new RuntimeException("secure random failed: " + WindowsNative.lastCryptoError());
+ }
+ }
+
+ /// Rejects an initialization vector the mode cannot use. A GCM nonce that
+ /// is absent repeats across messages under one key, and a short CBC IV is
+ /// read as a whole block by the platform library.
+ /// AAD only binds to the ciphertext under an AEAD mode. CBC and ECB ignore it
+ /// entirely, so passing it here quietly dropped data the caller believed was
+ /// authenticated -- and produced ciphertext the other ports reject, because
+ /// JavaSE and Android route the same call through Cipher.updateAAD, which
+ /// refuses a non-AEAD mode. Refusing it keeps the ports answering alike and
+ /// keeps a caller from believing in a binding that was never made.
+ private static void checkAad(String transformation, byte[] aad) {
+ if (aad == null || aad.length == 0) {
+ return;
+ }
+ String mode = transformation == null ? "" : transformation;
+ if (mode.indexOf("/GCM/") < 0) {
+ throw new RuntimeException(
+ "Additional authenticated data requires an AEAD mode; " + mode
+ + " cannot bind it");
+ }
+ }
+
+ private static void checkIv(String transformation, byte[] iv) {
+ String mode = transformation == null ? "" : transformation;
+ if (mode.indexOf("/GCM/") >= 0) {
+ if (iv == null || iv.length == 0) {
+ throw new RuntimeException("AES-GCM requires a nonce");
+ }
+ } else if (mode.indexOf("/ECB/") >= 0) {
+ // ECB has no IV. The native ignores one, so passing it produced
+ // ciphertext while quietly discarding a parameter the caller thought
+ // mattered -- and JavaSE and Android reject the same call, because
+ // they hand a non-null iv to JCE as an IvParameterSpec and ECB
+ // refuses it. Refusing here keeps the ports answering alike.
+ // Any non-null array, including a zero-length one: JavaSE branches
+ // on iv != null rather than on its length, so new byte[0] builds an
+ // IvParameterSpec there and ECB throws. Accepting it here would have
+ // made the same call succeed on the desktop ports alone.
+ if (iv != null) {
+ throw new RuntimeException("AES-ECB cannot use an initialization vector");
+ }
+ } else if (iv == null || iv.length != 16) {
+ throw new RuntimeException("AES-CBC requires a 16 byte initialization vector");
+ }
+ }
+
+ @Override
+ public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) {
+ checkIv(transformation, iv);
+ checkAad(transformation, aad);
+ return cryptoResult(WindowsNative.aesCrypt(transformation, true, key, iv, aad, plaintext),
+ "AES encrypt");
+ }
+
+ @Override
+ public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) {
+ checkIv(transformation, iv);
+ checkAad(transformation, aad);
+ return cryptoResult(WindowsNative.aesCrypt(transformation, false, key, iv, aad, ciphertext),
+ "AES decrypt");
+ }
+
+ @Override
+ public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) {
+ return cryptoResult(WindowsNative.rsaCrypt(transformation, true, publicKeyX509, plaintext),
+ "RSA encrypt");
+ }
+
+ @Override
+ public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] ciphertext) {
+ return cryptoResult(WindowsNative.rsaCrypt(transformation, false, privateKeyPkcs8, ciphertext),
+ "RSA decrypt");
+ }
+
+ @Override
+ public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) {
+ checkKeyFamily(algorithm, keyAlgorithm);
+ return cryptoResult(WindowsNative.signData(algorithm, privateKeyPkcs8, data), "sign");
+ }
+
+ @Override
+ public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509,
+ byte[] data, byte[] signature) {
+ checkKeyFamily(algorithm, keyAlgorithm);
+ // An invalid signature and an unusable algorithm or key both come back
+ // as false from the native. Only the second is a configuration error,
+ // and JavaSE and Android raise it -- Signature.verify turns the throw
+ // into a CryptoException -- so answering plain false here would let a
+ // mistyped algorithm or malformed key read as "someone tampered with
+ // this". Clearing the slot first is what makes the two distinguishable.
+ WindowsNative.clearCryptoError();
+ boolean verified = WindowsNative.verifyData(algorithm, publicKeyX509, data, signature);
+ if (!verified) {
+ String failure = WindowsNative.lastCryptoError();
+ if (failure != null && failure.length() > 0
+ && !"unknown crypto error".equals(failure)) {
+ throw new RuntimeException("verify failed: " + failure);
+ }
+ }
+ return verified;
+ }
+
+ /// The portable contract pairs an algorithm with a key of its own family,
+ /// and JavaSE rejects a mismatch when the Signature is initialised. The
+ /// native here reads the family off the DER key and takes only the digest
+ /// from the algorithm name, so `SHA256withRSA` handed an EC key would
+ /// quietly produce an ECDSA signature -- and the matching verify would
+ /// accept it, so nothing looks wrong until another port reads it. Refuse
+ /// the pairing rather than silently substituting the algorithm.
+ private static void checkKeyFamily(String algorithm, String keyAlgorithm) {
+ if (algorithm == null || keyAlgorithm == null) {
+ return;
+ }
+ // The label has to be one this runtime actually knows, not merely one
+ // whose prefix looks familiar. startsWith("EC") called "ECfoo" an EC key
+ // and every other string an RSA one, so PrivateKey.fromPkcs8("garbage",
+ // der) signed happily here while JavaSE and Android hand the same label
+ // to KeyFactory.getInstance and throw NoSuchAlgorithmException. Identical
+ // public API calls must not depend on the port.
+ String key = keyAlgorithm.toUpperCase();
+ if (!"RSA".equals(key) && !"EC".equals(key)) {
+ throw new RuntimeException("Unknown key algorithm: " + keyAlgorithm);
+ }
+ boolean wantsEc = algorithm.toUpperCase().indexOf("ECDSA") >= 0;
+ boolean keyIsEc = "EC".equals(key);
+ if (wantsEc != keyIsEc) {
+ throw new RuntimeException(algorithm + " cannot be used with a "
+ + keyAlgorithm + " key");
+ }
+ }
+
+ @Override
+ public byte[][] generateRsaKeyPair(int bits) {
+ byte[] blob = cryptoResult(WindowsNative.generateRsaKeyPair(bits), "RSA key generation");
+ if (blob.length < 4) {
+ throw new RuntimeException("RSA key generation returned a truncated pair");
+ }
+ int publicLength = ((blob[0] & 0xff) << 24) | ((blob[1] & 0xff) << 16)
+ | ((blob[2] & 0xff) << 8) | (blob[3] & 0xff);
+ if (publicLength < 0 || publicLength > blob.length - 4) {
+ throw new RuntimeException("RSA key generation returned a malformed pair");
+ }
+ byte[] publicKey = new byte[publicLength];
+ byte[] privateKey = new byte[blob.length - 4 - publicLength];
+ System.arraycopy(blob, 4, publicKey, 0, publicKey.length);
+ System.arraycopy(blob, 4 + publicLength, privateKey, 0, privateKey.length);
+ return new byte[][] { publicKey, privateKey };
+ }
+
/* ------------------------------------------------------------ platform */
@Override
diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java
index 7f27de2b42a..b19cb9d3b66 100644
--- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java
+++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java
@@ -396,6 +396,44 @@ public static native long editStringAt(int x, int y, int w, int h, String text,
public static native long fileOpenWrite(String path, boolean append);
+ /** Why the most recent open failed, for the exception the port raises. */
+ public static native String lastIoError();
+
+ /* ---------------------------------------------------------- crypto */
+
+ /** Fills {@code out} with fresh entropy; false when the platform RNG failed. */
+ public static native boolean secureRandomBytes(byte[] out);
+
+ /**
+ * AES in the mode named by {@code transformation}. For GCM the
+ * authentication tag is appended to the ciphertext, which is the
+ * convention the portable API documents.
+ */
+ public static native byte[] aesCrypt(String transformation, boolean encrypt,
+ byte[] key, byte[] iv, byte[] aad, byte[] data);
+
+ /** RSA with an X.509 public key when encrypting, PKCS#8 when decrypting. */
+ public static native byte[] rsaCrypt(String transformation, boolean encrypt,
+ byte[] key, byte[] data);
+
+ public static native byte[] signData(String algorithm, byte[] privateKeyPkcs8, byte[] data);
+
+ public static native boolean verifyData(String algorithm, byte[] publicKeyX509,
+ byte[] data, byte[] signature);
+
+ /**
+ * A fresh RSA pair as one array: a four-byte big-endian public-key length,
+ * the X.509 public key, then the PKCS#8 private key.
+ */
+ public static native byte[] generateRsaKeyPair(int bits);
+
+ /** Why the most recent crypto call failed, for the CryptoException message. */
+ public static native String lastCryptoError();
+
+ /// Empties the last-error slot so a following call's failure can be told
+ /// apart from a stale message.
+ public static native void clearCryptoError();
+
public static native int fileRead(long handle, byte[] buffer, int offset, int length);
public static native int fileWrite(long handle, byte[] buffer, int offset, int length);
diff --git a/Ports/iOSPort/nativeSources/CN1Crypto.m b/Ports/iOSPort/nativeSources/CN1Crypto.m
index 7de2179a7a6..caab266cba5 100644
--- a/Ports/iOSPort/nativeSources/CN1Crypto.m
+++ b/Ports/iOSPort/nativeSources/CN1Crypto.m
@@ -284,6 +284,11 @@ static int cn1_seckey_op(SecKeyRef key, SecKeyAlgorithm alg, int forEncrypt,
return (int) len;
}
+/* OAEPSHA256 uses SHA-256 for the label hash and for MGF1 alike, which is the
+ * pairing the portable RSA_OAEP_SHA256 constant means. The JCE transformation
+ * name it borrows leaves MGF1 on SHA-1 by default, but neither SecKey nor Web
+ * Crypto can express that split, so the JavaSE and Android ports name SHA-256
+ * for both explicitly and every port agrees. */
static SecKeyAlgorithm rsa_padding_alg(int paddingKind) {
return paddingKind == 2
? kSecKeyAlgorithmRSAEncryptionOAEPSHA256
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index 8e3a7053bdb..63a3c0dddc9 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -27,6 +27,7 @@
// end Pisces imports
#include "xmlvm.h"
#include "java_lang_String.h"
+#include
#import "CN1ES2compat.h"
#import "CN1JailbreakDetector.h"
#if TARGET_OS_WATCH
@@ -10042,8 +10043,18 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int
[comps setDay:day];
[comps setYear:year];
[comps setMonth:month];
- [comps setMinute:timeOfDayMillis/60000];
- NSCalendar* cal = [NSCalendar currentCalendar];
+ [comps setHour:timeOfDayMillis/3600000];
+ [comps setMinute:(timeOfDayMillis/60000)%60];
+ [comps setSecond:(timeOfDayMillis/1000)%60];
+ // The fields are UTC, not local standard time. That is this native's contract
+ // across every port -- the POSIX implementation resolves them with timegm()
+ // and the JavaScript and Android ports match -- and TimeApiTest pins it:
+ // asking about the local-standard instant instead resolves 2020-03-08T01:30
+ // EST as 02:30 EDT, jumping the spring transition. Reading them in the
+ // device's own zone (currentCalendar) is wrong for a different reason: it
+ // moves the instant by the device offset.
+ NSCalendar* cal = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian];
+ [cal setTimeZone:[NSTimeZone timeZoneWithAbbreviation:@"UTC"]];
NSDate *date = [cal dateFromComponents:comps];
JAVA_INT result = [tzone secondsFromGMTForDate:date] * 1000;
[comps release];
@@ -11162,12 +11173,31 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathStrokerGetConsumer___long(J
static BOOL rendererIsSetup = NO;
-JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreate___int_int_int_int_int(JAVA_OBJECT instanceObject, JAVA_INT pix_boundsX, JAVA_INT pix_boundsY, JAVA_INT pix_boundsWidth, JAVA_INT pix_boundsHeight, JAVA_INT windingRule)
-{
- if ( !rendererIsSetup ){
+static pthread_mutex_t rendererSetupLock = PTHREAD_MUTEX_INITIALIZER;
+
+// Renderer_setup installs process-wide globals (the subpixel constants and the
+// coverage->alpha table alphaMap) that every subsequent rasterisation reads.
+// The original guard set rendererIsSetup *before* calling it, so a second
+// thread entering here mid-setup saw the flag already raised, skipped the
+// initialisation and went straight to rasterising against half-built globals:
+// an alphaMap that was allocated but not yet filled, or still NULL. That
+// corrupts exactly the antialiased edge samples of a shape while leaving its
+// saturated interior correct.
+//
+// Serialise instead, and raise the flag only once setup has completed, so a
+// racing caller blocks until the globals are whole.
+static void cn1EnsureRendererSetup(JAVA_INT lgPositionsX, JAVA_INT lgPositionsY) {
+ pthread_mutex_lock(&rendererSetupLock);
+ if (!rendererIsSetup) {
+ Renderer_setup(lgPositionsX, lgPositionsY);
rendererIsSetup = YES;
- Renderer_setup(1,1);
}
+ pthread_mutex_unlock(&rendererSetupLock);
+}
+
+JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreate___int_int_int_int_int(JAVA_OBJECT instanceObject, JAVA_INT pix_boundsX, JAVA_INT pix_boundsY, JAVA_INT pix_boundsWidth, JAVA_INT pix_boundsHeight, JAVA_INT windingRule)
+{
+ cn1EnsureRendererSetup(1, 1);
Renderer *renderer = (Renderer*)malloc(sizeof(Renderer));
Renderer_init(renderer);
Renderer_reset(renderer, pix_boundsX, pix_boundsY, pix_boundsWidth, pix_boundsHeight, windingRule);
@@ -11177,11 +11207,7 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreate___int_int_in
//native void nativePathRendererSetup(int subpixelLgPositionsX, int subpixelLgPositionsY);
void com_codename1_impl_ios_IOSNative_nativePathRendererSetup___int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT subpixelLgPositionsX, JAVA_INT subpixelLgPositionsY)
{
- if ( !rendererIsSetup ){
- rendererIsSetup = YES;
-
- Renderer_setup(subpixelLgPositionsX, subpixelLgPositionsY);
- }
+ cn1EnsureRendererSetup(subpixelLgPositionsX, subpixelLgPositionsY);
}
//native void nativePathRendererCleanup(long ptr);
void com_codename1_impl_ios_IOSNative_nativePathRendererCleanup___long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG ptr)
diff --git a/Ports/iOSPort/nativeSources/Renderer.c b/Ports/iOSPort/nativeSources/Renderer.c
index 8c8717e2350..079eb3ae31f 100644
--- a/Ports/iOSPort/nativeSources/Renderer.c
+++ b/Ports/iOSPort/nativeSources/Renderer.c
@@ -610,12 +610,22 @@ void Renderer_produceAlphas(Renderer *pRenderer, AlphaConsumer *pAC) {
static jint sMaxAlpha = 0;
static void setMaxAlpha(jint maxalpha) {
jint i;
-
- sMaxAlpha = maxalpha;
- alphaMap = malloc(maxalpha+1);
+ jbyte *map;
+
+ // Fill the table before publishing it, and publish alphaMap before
+ // sMaxAlpha. produceAlphas reads both from whatever thread is painting:
+ // assigning alphaMap straight from malloc let a reader index a table that
+ // was still uninitialized (garbage coverage on antialiased edges), and
+ // setting sMaxAlpha first let a reader see a non-zero max while alphaMap
+ // was still NULL (a null dereference). The caller-side guard in
+ // IOSNative.m serializes setup properly; this ordering is the second line
+ // of defence.
+ map = malloc(maxalpha+1);
for (i = 0; i <= maxalpha; i++) {
- alphaMap[i] = (jbyte) ((i*255 + maxalpha/2)/maxalpha);
+ map[i] = (jbyte) ((i*255 + maxalpha/2)/maxalpha);
}
+ alphaMap = map;
+ sMaxAlpha = maxalpha;
}
static void setAndClearRelativeAlphas(AlphaConsumer *pAC,
diff --git a/docs/website/assets/css/extended/cn1-port-status.css b/docs/website/assets/css/extended/cn1-port-status.css
index 16c4e78741b..20e7179f398 100644
--- a/docs/website/assets/css/extended/cn1-port-status.css
+++ b/docs/website/assets/css/extended/cn1-port-status.css
@@ -271,6 +271,17 @@
.cn1-port-status__mark.is-fallback { color: #3976c5; }
.cn1-port-status__mark.is-unavailable { color: #7d8791; }
+/* Marks a pass whose only non-passing test is a skip the errata explain, so
+ the cell reads as green without hiding that something was not executed. */
+.cn1-port-status__note {
+ color: #27955b;
+ font-size: .8rem;
+ font-weight: 800;
+ line-height: 1;
+ margin-left: .1rem;
+ vertical-align: super;
+}
+
.cn1-port-status__ports {
display: grid;
gap: .75rem;
diff --git a/docs/website/data/port_status_environment.json b/docs/website/data/port_status_environment.json
index 8ab8ebc7aa2..65ad2826aa8 100644
--- a/docs/website/data/port_status_environment.json
+++ b/docs/website/data/port_status_environment.json
@@ -1,19 +1,19 @@
{
"schema_version": 1,
- "generated_at": "2026-07-16T15:45:26Z",
- "commit": "9df6f7a1ea0e10a19c84b8994725d9c9d9dd9899",
+ "generated_at": "2026-07-30T05:51:23Z",
+ "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e",
"browsers": [
{
"id": "chromium",
"name": "Chromium",
- "engine_version": "149.0.7827.55",
+ "engine_version": "151.0.7922.34",
"status": "pass",
"coverage": "Full compliance suite plus nightly lifecycle validation"
},
{
"id": "firefox",
"name": "Firefox",
- "engine_version": "151.0",
+ "engine_version": "153.0",
"status": "pass",
"coverage": "Nightly lifecycle validation"
},
diff --git a/docs/website/data/port_status_supplement.json b/docs/website/data/port_status_supplement.json
index 2f2ad1cd4d7..ab58385ae49 100644
--- a/docs/website/data/port_status_supplement.json
+++ b/docs/website/data/port_status_supplement.json
@@ -4,13 +4,74 @@
"test": "CameraApiTest",
"reason": "The unattended runner cannot respond to operating-system camera permission prompts or provide stable physical-camera input. The test therefore avoids opening a real session on targets where doing so could hang CI or produce nondeterministic frames.",
"platform_support": "A skipped camera test does not mean the Codename One port is unsupported. Every listed target remains a supported port; camera availability is a separate runtime capability and depends on the device, permissions, and camera backend.",
- "verification": "The native camera implementations are compiled in their port builds and exercised with granted permissions on real hardware or an interactive browser. Deterministic API assertions use the synthetic camera backend outside this portability table."
+ "verification": "The native camera implementations are compiled in their port builds and exercised with granted permissions on real hardware or an interactive browser. Deterministic API assertions use the synthetic camera backend outside this portability table.",
+ "reason_codes": [
+ {
+ "prefix": "needs-runtime-permission-on-"
+ },
+ {
+ "prefix": "no-host-webcam-capture-on-win",
+ "ports": [
+ "windows-x64",
+ "windows-arm64"
+ ]
+ },
+ {
+ "prefix": "no-camera-device-on-headless-runner",
+ "ports": [
+ "linux-x64",
+ "linux-arm64"
+ ]
+ }
+ ]
},
{
"test": "VideoIORoundTripTest",
"reason": "This assertion requires a working video encoder as well as a decoder. Apple simulator and constrained-device runners do not expose a stable encoder to the headless job, so the encode/decode round trip is skipped there.",
"platform_support": "The port remains supported. Video playback and frame decoding are measured separately by VideoIODecodedFramesScreenshotTest; this skip is limited to creating a new encoded video in that CI environment.",
- "verification": "Encoder-backed targets run the full counting-frame and audio round trip. Apple media playback and decoding are covered separately, while device-only recording is verified in signed hardware builds."
+ "verification": "Encoder-backed targets run the full counting-frame and audio round trip. Apple media playback and decoding are covered separately, while device-only recording is verified in signed hardware builds.",
+ "reason_codes": [
+ {
+ "prefix": "encode-unavailable-on-",
+ "ports": [
+ "ios-gl",
+ "ios-metal",
+ "mac-native",
+ "tvos",
+ "watchos"
+ ]
+ },
+ {
+ "prefix": "no-video-encoder-on-",
+ "ports": [
+ "ios-gl",
+ "ios-metal",
+ "mac-native",
+ "tvos",
+ "watchos"
+ ]
+ },
+ {
+ "prefix": "encode-write-failed-on-",
+ "ports": [
+ "ios-gl",
+ "ios-metal",
+ "mac-native",
+ "tvos",
+ "watchos"
+ ]
+ },
+ {
+ "prefix": "VideoIO-unsupported-on-",
+ "ports": [
+ "ios-gl",
+ "ios-metal",
+ "mac-native",
+ "tvos",
+ "watchos"
+ ]
+ }
+ ]
}
],
"features": [
@@ -22,12 +83,59 @@
"testing": "Build each native backend, then run permission-granted capture checks on hardware or an interactive browser and verify a real frame and saved image.",
"why_not_automated": "A headless portability run cannot accept consent dialogs, guarantee a camera device, or compare nondeterministic sensor frames.",
"coverage": [
- {"ports": ["android"], "state": "supported", "label": "CameraX", "detail": "Native CameraX backend; runtime permission and hardware required."},
- {"ports": ["ios-gl", "ios-metal", "mac-native"], "state": "supported", "label": "AVFoundation", "detail": "Native AVFoundation backend; signed build, entitlement, permission, and camera hardware required."},
- {"ports": ["javascript"], "state": "conditional", "label": "MediaDevices", "detail": "Browser mediaDevices backend when the page is secure and the user grants access."},
- {"ports": ["linux-x64", "linux-arm64"], "state": "conditional", "label": "Native camera", "detail": "Native desktop camera backend when a compatible host camera is available."},
- {"ports": ["windows-x64", "windows-arm64"], "state": "conditional", "label": "Media Foundation", "detail": "Native Media Foundation camera backend when the host exposes a camera."},
- {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No camera", "detail": "The target form factor does not expose a Codename One camera-capture backend."}
+ {
+ "ports": [
+ "android"
+ ],
+ "state": "supported",
+ "label": "CameraX",
+ "detail": "Native CameraX backend; runtime permission and hardware required."
+ },
+ {
+ "ports": [
+ "ios-gl",
+ "ios-metal",
+ "mac-native"
+ ],
+ "state": "supported",
+ "label": "AVFoundation",
+ "detail": "Native AVFoundation backend; signed build, entitlement, permission, and camera hardware required."
+ },
+ {
+ "ports": [
+ "javascript"
+ ],
+ "state": "conditional",
+ "label": "MediaDevices",
+ "detail": "Browser mediaDevices backend when the page is secure and the user grants access."
+ },
+ {
+ "ports": [
+ "linux-x64",
+ "linux-arm64"
+ ],
+ "state": "conditional",
+ "label": "Native camera",
+ "detail": "Native desktop camera backend when a compatible host camera is available."
+ },
+ {
+ "ports": [
+ "windows-x64",
+ "windows-arm64"
+ ],
+ "state": "conditional",
+ "label": "Media Foundation",
+ "detail": "Native Media Foundation camera backend when the host exposes a camera."
+ },
+ {
+ "ports": [
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "No camera",
+ "detail": "The target form factor does not expose a Codename One camera-capture backend."
+ }
]
},
{
@@ -38,10 +146,45 @@
"testing": "Use a signed permission-granted build, record a calibrated tone or voice sample, and verify duration, level, and playback on the target device.",
"why_not_automated": "Hosted runners do not provide consistent microphones and operating systems deliberately require interactive consent.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal", "mac-native"], "state": "supported", "label": "Native recorder", "detail": "Native media recorder with runtime permission and physical input."},
- {"ports": ["javascript"], "state": "conditional", "label": "Browser capture", "detail": "Available through browser media capture on a secure origin after consent."},
- {"ports": ["linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "Host audio", "detail": "Available when the native desktop media stack exposes an input device."},
- {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No suite backend", "detail": "No general-purpose Codename One recording backend is declared for this target."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal",
+ "mac-native"
+ ],
+ "state": "supported",
+ "label": "Native recorder",
+ "detail": "Native media recorder with runtime permission and physical input."
+ },
+ {
+ "ports": [
+ "javascript"
+ ],
+ "state": "conditional",
+ "label": "Browser capture",
+ "detail": "Available through browser media capture on a secure origin after consent."
+ },
+ {
+ "ports": [
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64"
+ ],
+ "state": "conditional",
+ "label": "Host audio",
+ "detail": "Available when the native desktop media stack exposes an input device."
+ },
+ {
+ "ports": [
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "No suite backend",
+ "detail": "No general-purpose Codename One recording backend is declared for this target."
+ }
]
},
{
@@ -52,11 +195,52 @@
"testing": "Run the AR or sensor sample on supported hardware, verify session startup and sensor updates, and compare motion against a controlled physical action.",
"why_not_automated": "Simulators do not provide a real camera pose, AR runtime, or reproducible physical movement; the automated row checks API contracts and unsupported fallbacks instead.",
"coverage": [
- {"ports": ["android"], "state": "conditional", "label": "ARCore / sensors", "detail": "Available on compatible devices with the required vendor services and sensors."},
- {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "ARKit / sensors", "detail": "Available on compatible iPhone or iPad hardware."},
- {"ports": ["watchos"], "state": "conditional", "label": "Motion sensors", "detail": "Motion sensing is form-factor dependent; general AR sessions are not applicable."},
- {"ports": ["javascript"], "state": "conditional", "label": "Browser sensors", "detail": "Browser sensor APIs depend on browser policy, secure context, consent, and hardware."},
- {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "tvos"], "state": "unavailable", "label": "Fallback contract", "detail": "The portable API reports that no live AR or motion backend is present."}
+ {
+ "ports": [
+ "android"
+ ],
+ "state": "conditional",
+ "label": "ARCore / sensors",
+ "detail": "Available on compatible devices with the required vendor services and sensors."
+ },
+ {
+ "ports": [
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "conditional",
+ "label": "ARKit / sensors",
+ "detail": "Available on compatible iPhone or iPad hardware."
+ },
+ {
+ "ports": [
+ "watchos"
+ ],
+ "state": "conditional",
+ "label": "Motion sensors",
+ "detail": "Motion sensing is form-factor dependent; general AR sessions are not applicable."
+ },
+ {
+ "ports": [
+ "javascript"
+ ],
+ "state": "conditional",
+ "label": "Browser sensors",
+ "detail": "Browser sensor APIs depend on browser policy, secure context, consent, and hardware."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "Fallback contract",
+ "detail": "The portable API reports that no live AR or motion backend is present."
+ }
]
},
{
@@ -67,10 +251,45 @@
"testing": "Install a signed build, grant the appropriate permission level, feed known routes or physically cross a geofence, and verify foreground and background callbacks.",
"why_not_automated": "The result depends on user permission, GPS/radio state, operating-system scheduling, and physical movement outside the hosted runner.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native location", "detail": "Native foreground and background location subject to OS permissions and policy."},
- {"ports": ["mac-native", "watchos"], "state": "conditional", "label": "Apple location", "detail": "Available where the target and entitlement expose Core Location behavior."},
- {"ports": ["javascript"], "state": "conditional", "label": "Geolocation", "detail": "Foreground browser geolocation on a secure origin after consent; background behavior is browser-limited."},
- {"ports": ["linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "tvos"], "state": "unavailable", "label": "No declared backend", "detail": "No complete location and geofencing backend is declared for this portability target."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "supported",
+ "label": "Native location",
+ "detail": "Native foreground and background location subject to OS permissions and policy."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "watchos"
+ ],
+ "state": "conditional",
+ "label": "Apple location",
+ "detail": "Available where the target and entitlement expose Core Location behavior."
+ },
+ {
+ "ports": [
+ "javascript"
+ ],
+ "state": "conditional",
+ "label": "Geolocation",
+ "detail": "Foreground browser geolocation on a secure origin after consent; background behavior is browser-limited."
+ },
+ {
+ "ports": [
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "No declared backend",
+ "detail": "No complete location and geofencing backend is declared for this portability target."
+ }
]
},
{
@@ -81,11 +300,52 @@
"testing": "Use a signed app and real provider credentials, register a device, send from the push service, and verify foreground, background, and launch delivery.",
"why_not_automated": "Push requires certificates or provider keys, an externally reachable service, a uniquely registered installation, and asynchronous OS delivery.",
"coverage": [
- {"ports": ["android"], "state": "supported", "label": "FCM", "detail": "Firebase Cloud Messaging with app credentials and notification permission."},
- {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "APNs", "detail": "Apple Push Notification service with signing entitlements and permission."},
- {"ports": ["javascript"], "state": "conditional", "label": "Web Push", "detail": "Supported by compatible browsers on HTTPS with service-worker and provider setup."},
- {"ports": ["mac-native"], "state": "conditional", "label": "Apple entitlement", "detail": "Depends on the signed macOS target and its notification entitlements."},
- {"ports": ["linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No standalone backend", "detail": "No current standalone Codename One remote-push backend is declared for this target."}
+ {
+ "ports": [
+ "android"
+ ],
+ "state": "supported",
+ "label": "FCM",
+ "detail": "Firebase Cloud Messaging with app credentials and notification permission."
+ },
+ {
+ "ports": [
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "supported",
+ "label": "APNs",
+ "detail": "Apple Push Notification service with signing entitlements and permission."
+ },
+ {
+ "ports": [
+ "javascript"
+ ],
+ "state": "conditional",
+ "label": "Web Push",
+ "detail": "Supported by compatible browsers on HTTPS with service-worker and provider setup."
+ },
+ {
+ "ports": [
+ "mac-native"
+ ],
+ "state": "conditional",
+ "label": "Apple entitlement",
+ "detail": "Depends on the signed macOS target and its notification entitlements."
+ },
+ {
+ "ports": [
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "No standalone backend",
+ "detail": "No current standalone Codename One remote-push backend is declared for this target."
+ }
]
},
{
@@ -96,10 +356,45 @@
"testing": "Create sandbox products, sign with a store account, complete purchase and restore flows, and validate receipts against the store or Commerce backend.",
"why_not_automated": "The flow requires store-side product configuration, signed identities, sandbox accounts, payment UI, and mutable server receipt state.",
"coverage": [
- {"ports": ["android"], "state": "supported", "label": "Play Billing", "detail": "Google Play purchase and subscription flow in a store-installed build."},
- {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "StoreKit", "detail": "Apple StoreKit purchase, restore, and subscription flow."},
- {"ports": ["mac-native"], "state": "conditional", "label": "StoreKit", "detail": "Requires a signed Mac App Store configuration and matching products."},
- {"ports": ["javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No store adapter", "detail": "No standalone Codename One store-purchase adapter is declared for this target."}
+ {
+ "ports": [
+ "android"
+ ],
+ "state": "supported",
+ "label": "Play Billing",
+ "detail": "Google Play purchase and subscription flow in a store-installed build."
+ },
+ {
+ "ports": [
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "supported",
+ "label": "StoreKit",
+ "detail": "Apple StoreKit purchase, restore, and subscription flow."
+ },
+ {
+ "ports": [
+ "mac-native"
+ ],
+ "state": "conditional",
+ "label": "StoreKit",
+ "detail": "Requires a signed Mac App Store configuration and matching products."
+ },
+ {
+ "ports": [
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "No store adapter",
+ "detail": "No standalone Codename One store-purchase adapter is declared for this target."
+ }
]
},
{
@@ -110,9 +405,38 @@
"testing": "Populate a device test account, grant access, exercise read/select/write operations, and verify the result in the native contacts or calendar application.",
"why_not_automated": "Access is permission-gated and the expected result lives in private user databases and native UI outside the test process.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native stores", "detail": "Native contacts and calendar services after the user grants access."},
- {"ports": ["mac-native"], "state": "conditional", "label": "Apple services", "detail": "Availability depends on the macOS target, entitlement, and user account."},
- {"ports": ["javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No portable store", "detail": "The target does not expose a complete Codename One personal-data backend."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "supported",
+ "label": "Native stores",
+ "detail": "Native contacts and calendar services after the user grants access."
+ },
+ {
+ "ports": [
+ "mac-native"
+ ],
+ "state": "conditional",
+ "label": "Apple services",
+ "detail": "Availability depends on the macOS target, entitlement, and user account."
+ },
+ {
+ "ports": [
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "No portable store",
+ "detail": "The target does not expose a complete Codename One personal-data backend."
+ }
]
},
{
@@ -123,9 +447,38 @@
"testing": "Enroll biometrics on hardware, run success, cancellation, lockout, and enrollment-change cases, and verify protected-secret invalidation.",
"why_not_automated": "Hosted runners have no enrolled biometric hardware and native prompts are intentionally controlled by the user and secure hardware.",
"coverage": [
- {"ports": ["android"], "state": "supported", "label": "BiometricPrompt", "detail": "Android BiometricPrompt or legacy fingerprint support, backed by Android Keystore."},
- {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "LocalAuthentication", "detail": "Face ID or Touch ID through LocalAuthentication and Keychain."},
- {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "NOT_AVAILABLE", "detail": "The portable API returns its documented non-supporting fallback."}
+ {
+ "ports": [
+ "android"
+ ],
+ "state": "supported",
+ "label": "BiometricPrompt",
+ "detail": "Android BiometricPrompt or legacy fingerprint support, backed by Android Keystore."
+ },
+ {
+ "ports": [
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "supported",
+ "label": "LocalAuthentication",
+ "detail": "Face ID or Touch ID through LocalAuthentication and Keychain."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "NOT_AVAILABLE",
+ "detail": "The portable API returns its documented non-supporting fallback."
+ }
]
},
{
@@ -136,9 +489,38 @@
"testing": "Present known physical tags and cards, verify payloads and errors, and use a certified reader for host-card-emulation exchanges.",
"why_not_automated": "NFC requires short-range hardware, physical tags or readers, user presentation, and platform entitlements that hosted runners lack.",
"coverage": [
- {"ports": ["android"], "state": "supported", "label": "Android NFC", "detail": "NDEF, tag technologies, and HCE on compatible hardware."},
- {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "Core NFC", "detail": "NDEF and selected tag technologies; HCE is restricted by iOS version and region."},
- {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "NOT_AVAILABLE", "detail": "The API returns its documented non-supporting fallback."}
+ {
+ "ports": [
+ "android"
+ ],
+ "state": "supported",
+ "label": "Android NFC",
+ "detail": "NDEF, tag technologies, and HCE on compatible hardware."
+ },
+ {
+ "ports": [
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "conditional",
+ "label": "Core NFC",
+ "detail": "NDEF and selected tag technologies; HCE is restricted by iOS version and region."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "NOT_AVAILABLE",
+ "detail": "The API returns its documented non-supporting fallback."
+ }
]
},
{
@@ -149,9 +531,38 @@
"testing": "Use a known peripheral or protocol simulator, scan and connect, then verify characteristic reads, writes, notifications, reconnects, and permission denial.",
"why_not_automated": "Radio state, nearby peripherals, pairing, permissions, and timing are external to the deterministic screenshot runner.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal"], "state": "conditional", "label": "cn1-bluetooth", "detail": "Maintained native library backend on supported phone and tablet hardware."},
- {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "Host-dependent", "detail": "Desktop hardware support depends on the selected maintained library backend and host adapter."},
- {"ports": ["javascript", "watchos", "tvos"], "state": "unavailable", "label": "No declared backend", "detail": "No maintained general-purpose Bluetooth backend is declared for this target."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "conditional",
+ "label": "cn1-bluetooth",
+ "detail": "Maintained native library backend on supported phone and tablet hardware."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64"
+ ],
+ "state": "conditional",
+ "label": "Host-dependent",
+ "detail": "Desktop hardware support depends on the selected maintained library backend and host adapter."
+ },
+ {
+ "ports": [
+ "javascript",
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "No declared backend",
+ "detail": "No maintained general-purpose Bluetooth backend is declared for this target."
+ }
]
},
{
@@ -162,9 +573,38 @@
"testing": "Install a signed build, configure the required modes, background or terminate it, and observe callbacks across OS throttling and restart scenarios.",
"why_not_automated": "Scheduling is deliberately nondeterministic, power-policy controlled, entitlement dependent, and often takes longer than a CI job window.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native scheduler", "detail": "Platform background mechanisms subject to OS quotas, permissions, and lifecycle policy."},
- {"ports": ["mac-native"], "state": "conditional", "label": "Target-dependent", "detail": "Availability depends on the macOS application mode and signed capabilities."},
- {"ports": ["javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "No equivalent contract", "detail": "No equivalent Codename One background-fetch contract is declared for this target."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "supported",
+ "label": "Native scheduler",
+ "detail": "Platform background mechanisms subject to OS quotas, permissions, and lifecycle policy."
+ },
+ {
+ "ports": [
+ "mac-native"
+ ],
+ "state": "conditional",
+ "label": "Target-dependent",
+ "detail": "Availability depends on the macOS application mode and signed capabilities."
+ },
+ {
+ "ports": [
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "No equivalent contract",
+ "detail": "No equivalent Codename One background-fetch contract is declared for this target."
+ }
]
},
{
@@ -175,9 +615,38 @@
"testing": "Use release-like signed builds, compromised and clean devices, fresh server nonces, and backend verification of vendor-signed verdicts.",
"why_not_automated": "Trust verdicts depend on hardware-backed keys, store-distributed builds, vendor services, device state, and an application backend.",
"coverage": [
- {"ports": ["android"], "state": "conditional", "label": "Play Integrity", "detail": "Root signals and optional Google Play Integrity when enabled and verified by a backend."},
- {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "App Attest", "detail": "Jailbreak signals and optional Apple App Attest when enabled and verified by a backend."},
- {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "unavailable", "label": "Unsupported fallback", "detail": "No hardware-backed Codename One attestation backend is declared for this target."}
+ {
+ "ports": [
+ "android"
+ ],
+ "state": "conditional",
+ "label": "Play Integrity",
+ "detail": "Root signals and optional Google Play Integrity when enabled and verified by a backend."
+ },
+ {
+ "ports": [
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "conditional",
+ "label": "App Attest",
+ "detail": "Jailbreak signals and optional Apple App Attest when enabled and verified by a backend."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "Unsupported fallback",
+ "detail": "No hardware-backed Codename One attestation backend is declared for this target."
+ }
]
},
{
@@ -188,8 +657,31 @@
"testing": "Seed platform storage, launch the native picker, select and cancel items, and verify temporary URI or security-scoped access after returning to the app.",
"why_not_automated": "The decisive UI and permissions belong to another process, and hosted runners do not share an identical populated media or document library.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal", "mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "Native picker", "detail": "Uses the target's file, document, or browser picker; available types and access rules vary by OS."},
- {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No general picker", "detail": "The form factor has no general-purpose Codename One document/gallery picker."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal",
+ "mac-native",
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64"
+ ],
+ "state": "conditional",
+ "label": "Native picker",
+ "detail": "Uses the target's file, document, or browser picker; available types and access rules vary by OS."
+ },
+ {
+ "ports": [
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "No general picker",
+ "detail": "The form factor has no general-purpose Codename One document/gallery picker."
+ }
]
},
{
@@ -200,9 +692,38 @@
"testing": "Open each native chooser or handler on a configured device, complete and cancel actions, and verify returned result metadata where the OS supplies it.",
"why_not_automated": "Installed applications, accounts, SIM capability, chooser UI, and user selection are outside the app process and differ per runner.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal"], "state": "supported", "label": "Native intents", "detail": "Native share and communication handlers when the device has a matching service."},
- {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "OS/browser handler", "detail": "Uses an installed desktop handler, browser capability, or portable fallback where available."},
- {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "Form-factor limited", "detail": "No general-purpose Codename One communication chooser is declared for this target."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "supported",
+ "label": "Native intents",
+ "detail": "Native share and communication handlers when the device has a matching service."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64"
+ ],
+ "state": "conditional",
+ "label": "OS/browser handler",
+ "detail": "Uses an installed desktop handler, browser capability, or portable fallback where available."
+ },
+ {
+ "ports": [
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "Form-factor limited",
+ "detail": "No general-purpose Codename One communication chooser is declared for this target."
+ }
]
},
{
@@ -213,9 +734,38 @@
"testing": "Use a store-eligible build and account, request the prompt under vendor quota rules, and verify fallback behavior separately.",
"why_not_automated": "Apple and Google intentionally decide whether the native prompt appears, so a request cannot deterministically assert visible store UI.",
"coverage": [
- {"ports": ["android"], "state": "supported", "label": "Play Review", "detail": "Google Play In-App Review when the build includes the service."},
- {"ports": ["ios-gl", "ios-metal"], "state": "supported", "label": "StoreKit", "detail": "StoreKit review request subject to Apple's display quota."},
- {"ports": ["mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "fallback", "label": "Portable fallback", "detail": "Codename One shows its built-in rating sheet when no native prompt is available."}
+ {
+ "ports": [
+ "android"
+ ],
+ "state": "supported",
+ "label": "Play Review",
+ "detail": "Google Play In-App Review when the build includes the service."
+ },
+ {
+ "ports": [
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "supported",
+ "label": "StoreKit",
+ "detail": "StoreKit review request subject to Apple's display quota."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "fallback",
+ "label": "Portable fallback",
+ "detail": "Codename One shows its built-in rating sheet when no native prompt is available."
+ }
]
},
{
@@ -226,9 +776,38 @@
"testing": "Use valid provider keys on a networked device, load known coordinates, exercise gestures and markers, and confirm provider attribution and fallback behavior.",
"why_not_automated": "Provider keys are secrets, map imagery changes independently, usage may be billed, and native peers or network tiles are not deterministic pixels.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal"], "state": "conditional", "label": "Native provider", "detail": "Native provider when configured, with the portable map renderer as fallback."},
- {"ports": ["javascript"], "state": "conditional", "label": "Web provider", "detail": "Browser map provider with API key and network access, plus portable fallback."},
- {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "fallback", "label": "Portable map", "detail": "Portable vector or tile rendering is used when no native provider is active."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "conditional",
+ "label": "Native provider",
+ "detail": "Native provider when configured, with the portable map renderer as fallback."
+ },
+ {
+ "ports": [
+ "javascript"
+ ],
+ "state": "conditional",
+ "label": "Web provider",
+ "detail": "Browser map provider with API key and network access, plus portable fallback."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "fallback",
+ "label": "Portable map",
+ "detail": "Portable vector or tile rendering is used when no native provider is active."
+ }
]
},
{
@@ -239,9 +818,38 @@
"testing": "Use provider test-unit identifiers in a signed build, complete consent flows, and verify fill, impression, click, and lifecycle callbacks.",
"why_not_automated": "Ad inventory, consent state, provider accounts, network policy, and SDK UI are external and nondeterministic; the first table uses deterministic mock ads.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal"], "state": "conditional", "label": "Provider SDK", "detail": "Supported through configured native ad libraries and provider test units."},
- {"ports": ["javascript"], "state": "conditional", "label": "Web integration", "detail": "Depends on the selected web advertising integration and hosting policy."},
- {"ports": ["mac-native", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "fallback", "label": "Mock/custom", "detail": "The portable ad component can be tested with mock or application-provided content; no bundled live network is claimed."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "conditional",
+ "label": "Provider SDK",
+ "detail": "Supported through configured native ad libraries and provider test units."
+ },
+ {
+ "ports": [
+ "javascript"
+ ],
+ "state": "conditional",
+ "label": "Web integration",
+ "detail": "Depends on the selected web advertising integration and hosting policy."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "fallback",
+ "label": "Mock/custom",
+ "detail": "The portable ad component can be tested with mock or application-provided content; no bundled live network is claimed."
+ }
]
},
{
@@ -252,12 +860,59 @@
"testing": "Install the correctly signed app or extension, publish timelines and actions, then inspect the launcher, lock screen, Dynamic Island, notification area, or desktop host.",
"why_not_automated": "The first table checks serialization, rasterization, timelines, and dispatch, but the final surface is rendered by a separate OS process with signing and entitlement requirements.",
"coverage": [
- {"ports": ["android"], "state": "supported", "label": "Widgets/notifications", "detail": "Lowers supported surfaces to Android widgets and ongoing notifications."},
- {"ports": ["ios-gl", "ios-metal"], "state": "conditional", "label": "WidgetKit/ActivityKit", "detail": "Requires supported OS versions, extension packaging, and entitlements."},
- {"ports": ["mac-native", "linux-x64", "linux-arm64"], "state": "conditional", "label": "Desktop surface", "detail": "Uses the desktop floating-window or preview presentation available to the port."},
- {"ports": ["windows-x64", "windows-arm64"], "state": "conditional", "label": "Windows surfaces", "detail": "Floating widgets are supported; Widgets Board integration requires MSIX and Windows App SDK packaging."},
- {"ports": ["javascript"], "state": "fallback", "label": "In-app preview", "detail": "Portable document and rasterizer behavior is available without an OS widget host."},
- {"ports": ["watchos", "tvos"], "state": "unavailable", "label": "No declared lowering", "detail": "No standalone Codename One external-surface lowering is declared for this target."}
+ {
+ "ports": [
+ "android"
+ ],
+ "state": "supported",
+ "label": "Widgets/notifications",
+ "detail": "Lowers supported surfaces to Android widgets and ongoing notifications."
+ },
+ {
+ "ports": [
+ "ios-gl",
+ "ios-metal"
+ ],
+ "state": "conditional",
+ "label": "WidgetKit/ActivityKit",
+ "detail": "Requires supported OS versions, extension packaging, and entitlements."
+ },
+ {
+ "ports": [
+ "mac-native",
+ "linux-x64",
+ "linux-arm64"
+ ],
+ "state": "conditional",
+ "label": "Desktop surface",
+ "detail": "Uses the desktop floating-window or preview presentation available to the port."
+ },
+ {
+ "ports": [
+ "windows-x64",
+ "windows-arm64"
+ ],
+ "state": "conditional",
+ "label": "Windows surfaces",
+ "detail": "Floating widgets are supported; Widgets Board integration requires MSIX and Windows App SDK packaging."
+ },
+ {
+ "ports": [
+ "javascript"
+ ],
+ "state": "fallback",
+ "label": "In-app preview",
+ "detail": "Portable document and rasterizer behavior is available without an OS widget host."
+ },
+ {
+ "ports": [
+ "watchos",
+ "tvos"
+ ],
+ "state": "unavailable",
+ "label": "No declared lowering",
+ "detail": "No standalone Codename One external-surface lowering is declared for this target."
+ }
]
},
{
@@ -268,8 +923,31 @@
"testing": "Enable the platform screen reader, traverse representative forms, verify announcements and actions, and repeat with dynamic content and input devices.",
"why_not_automated": "The first table validates semantics and API state, while the final speech, focus order, gestures, and switch-control behavior belong to external OS services.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal", "mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64"], "state": "conditional", "label": "OS assistive tech", "detail": "Semantic output is consumed by the screen reader or accessibility stack available on that OS."},
- {"ports": ["watchos", "tvos"], "state": "conditional", "label": "Form-factor service", "detail": "Behavior depends on the target's accessibility service and navigation model."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal",
+ "mac-native",
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64"
+ ],
+ "state": "conditional",
+ "label": "OS assistive tech",
+ "detail": "Semantic output is consumed by the screen reader or accessibility stack available on that OS."
+ },
+ {
+ "ports": [
+ "watchos",
+ "tvos"
+ ],
+ "state": "conditional",
+ "label": "Form-factor service",
+ "detail": "Behavior depends on the target's accessibility service and navigation model."
+ }
]
},
{
@@ -280,7 +958,24 @@
"testing": "Compile each architecture, inspect the generated package, sign with platform credentials, install it, and exercise the integrated native entry point or SDK in its target environment.",
"why_not_automated": "These checks happen before app startup or require private signing identities, store portals, proprietary SDK credentials, and platform-specific source rather than one shared runtime assertion.",
"coverage": [
- {"ports": ["android", "ios-gl", "ios-metal", "mac-native", "javascript", "linux-x64", "linux-arm64", "windows-x64", "windows-arm64", "watchos", "tvos"], "state": "supported", "label": "Target build", "detail": "The port has its own build, packaging, and native-integration path; exact signing and extension capabilities are target specific."}
+ {
+ "ports": [
+ "android",
+ "ios-gl",
+ "ios-metal",
+ "mac-native",
+ "javascript",
+ "linux-x64",
+ "linux-arm64",
+ "windows-x64",
+ "windows-arm64",
+ "watchos",
+ "tvos"
+ ],
+ "state": "supported",
+ "label": "Target build",
+ "detail": "The port has its own build, packaging, and native-integration path; exact signing and extension capabilities are target specific."
+ }
]
}
]
diff --git a/docs/website/layouts/_default/port-status.html b/docs/website/layouts/_default/port-status.html
index 8b6c9514160..e98ae000d65 100644
--- a/docs/website/layouts/_default/port-status.html
+++ b/docs/website/layouts/_default/port-status.html
@@ -86,7 +86,8 @@ {{ .name }}
✓ Passed
- − Partial or skipped
+ ✓* Passed, with a skip the errata account for
+ − Incomplete run or an unexplained skip
× Failed
? No current report
@@ -143,7 +144,7 @@ {{ .name }}
{{- range $contract.ports }}
{{- $report := index $reports .id -}}
- {{- partial "port-status-feature-status.html" (dict "feature" $feature "port" . "report" $report "contract" $contract "now" $snapshotTime) -}}
+ {{- partial "port-status-feature-status.html" (dict "feature" $feature "port" . "report" $report "contract" $contract "supplement" $supplement "now" $snapshotTime) -}}
{{- end }}
{{- end }}
@@ -167,7 +168,35 @@ Skipped-test errata
{{- with $report -}}
{{- $result := index .tests $erratum.test -}}
{{- with $result -}}
- {{- if eq .status "skip" -}}{{- $skippedPortNames = $skippedPortNames | append $port.name -}}{{- end -}}
+ {{- if eq .status "skip" -}}
+ {{- /* The same predicate the feature cell uses, not the test name
+ alone. Listing every skip of this test made the erratum claim
+ ports it does not cover: a VideoIORoundTripTest encoder
+ failure on Linux was filed under an erratum that explains an
+ Apple simulator limitation, which both misdiagnoses the
+ failure and contradicts the cell beside it, where that skip
+ is correctly left partial as unexplained. */ -}}
+ {{- $codes := $erratum.reason_codes -}}
+ {{- $reasons := slice -}}
+ {{- with .reasons -}}{{- $reasons = . -}}{{- end -}}
+ {{- $documented := true -}}
+ {{- if $codes -}}
+ {{- $documented = gt (len $reasons) 0 -}}
+ {{- range $reasons -}}
+ {{- $reason := . -}}
+ {{- $ok := false -}}
+ {{- range $codes -}}
+ {{- $portAllowed := true -}}
+ {{- with .ports -}}
+ {{- $portAllowed = in . $port.id -}}
+ {{- end -}}
+ {{- if and $portAllowed (hasPrefix $reason .prefix) -}}{{- $ok = true -}}{{- end -}}
+ {{- end -}}
+ {{- if not $ok -}}{{- $documented = false -}}{{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- if $documented -}}{{- $skippedPortNames = $skippedPortNames | append $port.name -}}{{- end -}}
+ {{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
@@ -260,10 +289,24 @@
{{ .name }} {{ .description }} Best measured duration |
{{- range $contract.ports }}
{{- $report := index $reports .id -}}
+ {{- /* Only a complete benchmark run is presented as a measurement.
+ A suite that crashed partway through CommonWorkloadBenchmarkTest
+ publishes whatever workloads it reached -- that report is
+ deliberately still published, because its fail / not-run counts
+ are the evidence this page exists to show -- but rendering those
+ partial timings identically to a finished run would put an
+ unfinished measurement beside complete ones with nothing to tell
+ them apart. */ -}}
+ {{- $measurement := "" -}}
+ {{- with $report -}}{{- with .performance -}}
+ {{- if eq .status "complete" -}}
+ {{- with .benchmarks -}}{{- with (index . $benchmark.id) -}}
+ {{- $measurement = printf "%.2f ms" (div (float .duration_ns) 1000000.0) -}}
+ {{- end -}}{{- end -}}
+ {{- end -}}
+ {{- end -}}{{- end -}}
- {{- with $report -}}{{- with .performance -}}{{- with .benchmarks -}}{{- with (index . $benchmark.id) -}}
- {{- printf "%.2f ms" (div (float .duration_ns) 1000000.0) -}}
- {{- else -}}Awaiting nightly{{- end -}}{{- else -}}Awaiting nightly{{- end -}}{{- else -}}Awaiting nightly{{- end -}}{{- else -}}Awaiting nightly{{- end -}}
+ {{- if $measurement -}}{{ $measurement }}{{- else -}}Awaiting nightly{{- end -}}
|
{{- end }}
diff --git a/docs/website/layouts/partials/port-status-feature-status.html b/docs/website/layouts/partials/port-status-feature-status.html
index 5d1572ef68e..251843fe25b 100644
--- a/docs/website/layouts/partials/port-status-feature-status.html
+++ b/docs/website/layouts/partials/port-status-feature-status.html
@@ -2,16 +2,19 @@
{{- $port := .port -}}
{{- $report := .report -}}
{{- $contract := .contract -}}
+{{- $supplement := .supplement -}}
{{- $now := .now -}}
{{- $state := "unknown" -}}
{{- $mark := "?" -}}
{{- $label := "No current report" -}}
+{{- $documentedSkips := slice -}}
{{- if $report -}}
{{- $passed := 0 -}}
{{- $failed := 0 -}}
{{- $skipped := 0 -}}
{{- $notRun := 0 -}}
{{- $failedTests := slice -}}
+ {{- $skippedTests := slice -}}
{{- range $feature.tests -}}
{{- $result := index $report.tests . -}}
{{- $status := "not-run" -}}
@@ -23,26 +26,85 @@
{{- $failedTests = $failedTests | append . -}}
{{- else if eq $status "skip" -}}
{{- $skipped = add $skipped 1 -}}
+ {{- $skippedTests = $skippedTests | append . -}}
{{- else -}}
{{- $notRun = add $notRun 1 -}}
{{- end -}}
{{- end -}}
+ {{- /* A skip only reads as green when the errata account for that test AND
+ for the reason the run actually gave. Matching on the test name alone
+ let any future skip of a named test render as documented: the video
+ round trip reports encoder trouble as a skip on that test, so an
+ encoder that regressed would have shown a green, explained cell that
+ said nothing about what went wrong. An unrecognized reason -- or a
+ skip carrying none -- stays a partial result. */ -}}
+ {{- $documented := gt (len $skippedTests) 0 -}}
+ {{- range $skippedTests -}}
+ {{- $test := . -}}
+ {{- $result := index $report.tests $test -}}
+ {{- $reasons := slice -}}
+ {{- with $result -}}{{- with .reasons -}}{{- $reasons = . -}}{{- end -}}{{- end -}}
+ {{- $found := false -}}
+ {{- range $supplement.skip_reasons -}}
+ {{- if eq .test $test -}}
+ {{- $codes := .reason_codes -}}
+ {{- if $codes -}}
+ {{- $allMatched := gt (len $reasons) 0 -}}
+ {{- range $reasons -}}
+ {{- $reason := . -}}
+ {{- $ok := false -}}
+ {{- range $codes -}}
+ {{- /* A code documents this skip only when the reason matches
+ AND, where the erratum names ports, this is one of them.
+ Encoder trouble is expected on the Apple simulators and
+ nowhere else, so the same code from a port whose encoder is
+ meant to work stays undocumented and cannot render green. */ -}}
+ {{- $portAllowed := true -}}
+ {{- with .ports -}}
+ {{- $portAllowed = in . $port.id -}}
+ {{- end -}}
+ {{- if and $portAllowed (hasPrefix $reason .prefix) -}}{{- $ok = true -}}{{- end -}}
+ {{- end -}}
+ {{- if not $ok -}}{{- $allMatched = false -}}{{- end -}}
+ {{- end -}}
+ {{- if $allMatched -}}{{- $found = true -}}{{- end -}}
+ {{- else -}}
+ {{- $found = true -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- if not $found -}}{{- $documented = false -}}{{- end -}}
+ {{- end -}}
{{- $bootstrapComplete := and (eq $report.bootstrap_source "successful-master-workflow") (eq $report.workflow_conclusion "success") -}}
{{- $complete := or $report.suite_finished $bootstrapComplete -}}
{{- $total := len $feature.tests -}}
{{- $state = "partial" -}}
{{- $mark = "−" -}}
{{- $label = printf "%d passed, %d skipped, %d not run" $passed $skipped $notRun -}}
+ {{- /* Evidence is per feature. A run that stopped early leaves its own
+ unreached tests as "not run" below, and the port card reports the
+ incomplete run; that is not a reason to withdraw the result of a
+ feature whose every mapped test reported back. */ -}}
+ {{- $incomplete := cond $complete "" " (the suite run stopped early)" -}}
{{- if gt $failed 0 -}}
{{- $state = "fail" -}}
{{- $mark = "×" -}}
{{- $label = printf "%d failed: %s" $failed (delimit $failedTests ", ") -}}
- {{- else if not $complete -}}
- {{- $label = "Suite did not finish" -}}
{{- else if eq $passed $total -}}
{{- $state = "pass" -}}
{{- $mark = "✓" -}}
- {{- $label = printf "All %d mapped test%s passed" $total (cond (eq $total 1) "" "s") -}}
+ {{- $label = printf "All %d mapped test%s passed%s" $total (cond (eq $total 1) "" "s") $incomplete -}}
+ {{- else if and $documented (eq (add $passed $skipped) $total) -}}
+ {{- $state = "pass" -}}
+ {{- $mark = "✓" -}}
+ {{- $documentedSkips = $skippedTests -}}
+ {{- if eq $passed 0 -}}
+ {{- $label = printf "%s skipped by the CI environment, see the skipped-test errata%s" (delimit $skippedTests ", ") $incomplete -}}
+ {{- else -}}
+ {{- $label = printf "%d of %d mapped tests passed; %s skipped by the CI environment, see the skipped-test errata%s" $passed $total (delimit $skippedTests ", ") $incomplete -}}
+ {{- end -}}
+ {{- else if not $complete -}}
+ {{- $label = printf "Suite did not finish; %d passed, %d skipped, %d not run" $passed $skipped $notRun -}}
{{- else if eq $skipped $total -}}
{{- $label = "All mapped tests skipped" -}}
{{- end -}}
@@ -53,12 +115,14 @@
{{- if ne $state "fail" -}}
{{- $state = "stale" -}}
{{- $mark = "!" -}}
+ {{- $documentedSkips = slice -}}
{{- end -}}
{{- $label = printf "Stale report. %s" $label -}}
{{- end -}}
{{- end -}}
{{- $label = printf "%s: %s" $port.name $label -}}
-
+ |
{{ $mark }}
+ {{- if $documentedSkips }}*{{ end }}
{{ $label }}
|
diff --git a/docs/website/layouts/partials/port-status-port-state.html b/docs/website/layouts/partials/port-status-port-state.html
index efce1323cdb..7250185b0d0 100644
--- a/docs/website/layouts/partials/port-status-port-state.html
+++ b/docs/website/layouts/partials/port-status-port-state.html
@@ -6,6 +6,7 @@
{{- $label := "No stored report" -}}
{{- if $report -}}
{{- $failed := int (default 0 $report.summary.fail) -}}
+ {{- $notRun := int (default 0 (index $report.summary "not-run")) -}}
{{- $bootstrapComplete := and (eq $report.bootstrap_source "successful-master-workflow") (eq $report.workflow_conclusion "success") -}}
{{- $complete := or $report.suite_finished $bootstrapComplete -}}
{{- $generated := time.AsTime $report.generated_at -}}
@@ -16,6 +17,14 @@
{{- if gt $failed 0 -}}
{{- $state = "fail" -}}
{{- $label = printf "%d failing test%s" $failed (cond (eq $failed 1) "" "s") -}}
+ {{- else if gt $notRun 0 -}}
+ {{- /* A test that never reported is not a pass. The strict producer gate
+ fails the workflow on not-run, so a report reaching here with zero
+ failures and a pile of unreported tests came from a RED run -- and
+ looking only at summary.fail rendered it as a green "Suite completed"
+ card while its feature cells sat incomplete. */ -}}
+ {{- $state = "partial" -}}
+ {{- $label = printf "%d test%s did not run" $notRun (cond (eq $notRun 1) "" "s") -}}
{{- else if not $complete -}}
{{- $state = "partial" -}}
{{- $label = "Run incomplete" -}}
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java
index 6e6c8a6b06d..21bd44838c3 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java
@@ -6037,11 +6037,23 @@ && compareVersions(declaredPlugin, kotlinFloor) < 0) {
if(useGradle8 || request.getArg("android.forceJava8Builder", "false").equals("true")) {
gradlePropertiesObject.setProperty("org.gradle.java.home", getGradleJavaHome());
}
+ // 4096m rather than 2048m. Packaging runs the zipflinger splitter inside a
+ // Gradle worker that inherits these args, and it holds each entry's bytes in
+ // memory -- an app carrying the larger native libraries (ARCore, barhopper,
+ // the face and image detectors) exhausted 2048m and failed :app:packageDebug
+ // with a bare "A failure occurred while executing
+ // PackageAndroidArtifact$IncrementalSplitterRunnable". The underlying
+ // "java.lang.OutOfMemoryError: Java heap space" only appears with
+ // --stacktrace, which is why this read as an unexplained intermittent build
+ // failure. -Xmx is a ceiling, not a reservation, so raising it costs nothing
+ // on builds that never needed the headroom.
+ String heapArgs = "-Xmx4096m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8";
if (useGradle8) {
- gradlePropertiesObject.setProperty("org.gradle.jvmargs", "-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8");
+ gradlePropertiesObject.setProperty("org.gradle.jvmargs", heapArgs);
gradleWrapperPropertiesObject.setProperty("distributionUrl", gradle8DistributionUrl);
} else {
- gradlePropertiesObject.setProperty("org.gradle.jvmargs", "-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8");
+ gradlePropertiesObject.setProperty("org.gradle.jvmargs",
+ "-Xmx4096m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8");
gradleWrapperPropertiesObject.setProperty("distributionUrl", gradleDistributionUrl);
}
if (useAndroidX) {
diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java
index 9eeb5fc32a2..e38775d7eb2 100644
--- a/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java
+++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthAwait.java
@@ -24,6 +24,10 @@
import com.codename1.ui.CN;
import com.codename1.util.AsyncResource;
+import com.codename1.util.SuccessCallback;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -69,6 +73,69 @@ public void run() {
return res;
}
+ /**
+ * Settles `res` and returns the failure it carries, or null if it
+ * succeeded.
+ *
+ * Five test classes each had their own copy of this, all of them
+ * registering an {@code except} callback and reading the result straight
+ * back on the assumption that a callback attached to an already-failed
+ * resource fires inline on the registering thread. That assumption no
+ * longer holds: health results are delivered on the EDT whether the
+ * listener arrives before the outcome or after it, so an off-EDT caller
+ * gets the failure queued. Waiting for it is the same thing {@link
+ * #settled} does one step earlier, and having one copy means the next
+ * change to the delivery rule has one place to land.
+ */
+ static Throwable errorOf(AsyncResource res) {
+ settled(res);
+ // Atomics rather than a one-element array: the callback runs on the
+ // EDT and the value is read from the test thread, so an unguarded
+ // field would be a data race that only misbehaves under CI timing.
+ final AtomicReference err = new AtomicReference();
+ final AtomicBoolean delivered = new AtomicBoolean();
+ res.except(new SuccessCallback() {
+ public void onSucess(Throwable t) {
+ err.set(t);
+ delivered.set(true);
+ }
+ });
+ // Both sides, because plenty of callers ask this of a resource that
+ // succeeded and expect null back. Only one of the two ever fires, so
+ // waiting on `except` alone would wait out the whole limit on every
+ // successful call.
+ res.ready(new SuccessCallback() {
+ public void onSucess(T value) {
+ delivered.set(true);
+ }
+ });
+ if (!delivered.get()) {
+ if (CN.isEdt()) {
+ CN.invokeAndBlock(new Runnable() {
+ public void run() {
+ pollDelivered(delivered);
+ }
+ });
+ } else {
+ pollDelivered(delivered);
+ }
+ }
+ assertTrue(delivered.get(),
+ "the failure must be delivered rather than hang");
+ return err.get();
+ }
+
+ private static void pollDelivered(AtomicBoolean delivered) {
+ long deadline = System.currentTimeMillis() + LIMIT_MILLIS;
+ while (!delivered.get() && System.currentTimeMillis() < deadline) {
+ try {
+ Thread.sleep(5L);
+ } catch (InterruptedException ex) {
+ return;
+ }
+ }
+ }
+
private static void poll(AsyncResource> res) {
long deadline = System.currentTimeMillis() + LIMIT_MILLIS;
while (!res.isDone() && System.currentTimeMillis() < deadline) {
diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java
index bcc8a179353..25191591bc4 100644
--- a/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java
@@ -202,18 +202,8 @@ private static void assertFailedWith(HealthError expected,
assertEquals(expected, ((HealthException) err).getError());
}
- /**
- * An {@code except} callback registered on an already-failed resource
- * fires synchronously, so the error can be read out without waiting --
- * the same trick {@code BtTestUtil} uses.
- */
+ /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */
private static Throwable errorOf(AsyncResource> r) {
- final Throwable[] err = new Throwable[1];
- r.except(new com.codename1.util.SuccessCallback() {
- public void onSucess(Throwable t) {
- err[0] = t;
- }
- });
- return err[0];
+ return HealthAwait.errorOf(r);
}
}
diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java
index 6d472eb223b..67552f3ba19 100644
--- a/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java
@@ -599,15 +599,9 @@ void seriesTypesAreDeletableOnAndroidEvenThoughTheyAreNotWritable() {
HealthDataType.SLEEP));
}
- private static Throwable errorOf(
- com.codename1.util.AsyncResource> r) {
- final Throwable[] err = new Throwable[1];
- r.except(new com.codename1.util.SuccessCallback() {
- public void onSucess(Throwable t) {
- err[0] = t;
- }
- });
- return err[0];
+ /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */
+ private static Throwable errorOf(com.codename1.util.AsyncResource> r) {
+ return HealthAwait.errorOf(r);
}
/**
diff --git a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java
index 06aa715ecb8..9ad70b56f35 100644
--- a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java
@@ -532,18 +532,9 @@ HealthDataType.STEPS, new HealthQuantity(2, HealthUnit.COUNT),
"and the record this build cannot read must survive");
}
+ /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */
private static Throwable errorOf(AsyncResource> r) {
- // Settled first. Results are delivered on the EDT on every backend
- // now, so an off-EDT caller sees the error queued rather than already
- // attached, and reading it without waiting found nothing.
- HealthAwait.settled(r);
- final Throwable[] err = new Throwable[1];
- r.except(new com.codename1.util.SuccessCallback() {
- public void onSucess(Throwable t) {
- err[0] = t;
- }
- });
- return err[0];
+ return HealthAwait.errorOf(r);
}
/** A store whose backing storage refuses to take anything. */
diff --git a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java
index 363a5b94b06..2edebc2ac61 100644
--- a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java
@@ -731,22 +731,9 @@ void sourceFilterExcludesOtherApps() {
filtered.get(0).getSource().getBundleId());
}
- /**
- * An {@code except} callback on an already-settled resource fires
- * synchronously, so the error can be read without waiting.
- */
+ /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */
private static Throwable errorOf(com.codename1.util.AsyncResource> r) {
- // Settled first. Results are delivered on the EDT on every backend
- // now, so an off-EDT caller sees the error queued rather than already
- // attached, and reading it without waiting found nothing.
- HealthAwait.settled(r);
- final Throwable[] err = new Throwable[1];
- r.except(new com.codename1.util.SuccessCallback() {
- public void onSucess(Throwable t) {
- err[0] = t;
- }
- });
- return err[0];
+ return HealthAwait.errorOf(r);
}
/**
diff --git a/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java b/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java
index 9420dd863cf..185f34b8335 100644
--- a/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java
@@ -134,18 +134,9 @@ void aTerminalSessionIsReleasedOnTransitionNotOnTheNextGetter()
next.discard();
}
+ /** Settles the resource and reads its failure; see {@link HealthAwait#errorOf}. */
private static Throwable errorOf(AsyncResource> r) {
- // Settled first: workout operations deliver on the EDT like every
- // other result, so an off-EDT caller sees the error queued rather
- // than already attached.
- HealthAwait.settled(r);
- final Throwable[] err = new Throwable[1];
- r.except(new SuccessCallback() {
- public void onSucess(Throwable t) {
- err[0] = t;
- }
- });
- return err[0];
+ return HealthAwait.errorOf(r);
}
private static WorkoutSession startedSession() {
diff --git a/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java b/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java
index cf8a180c84e..7e9e67de920 100644
--- a/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.java
+++ b/maven/core-unittests/src/test/java/com/codename1/junit/EDTTestInterceptor.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.junit;
import com.codename1.ui.CN;
@@ -52,10 +74,19 @@ private void runOnMyThread(Invocation invocation) throws Throwable {
}
});
+ // A deadline loop, not a single wait. Object.wait may return before the
+ // timeout for reasons of its own, and the previous `if` treated any such
+ // return as expiry -- reporting "timed out after 5000ms" without having
+ // waited 5000ms, from a run that was about to succeed.
try {
+ long deadline = System.currentTimeMillis() + DEFAULT_TIMEOUT_MILLIS;
synchronized (lock) {
- if (!completed[0]) {
- lock.wait(DEFAULT_TIMEOUT_MILLIS);
+ while (!completed[0]) {
+ long remaining = deadline - System.currentTimeMillis();
+ if (remaining <= 0) {
+ break;
+ }
+ lock.wait(remaining);
}
}
} catch (InterruptedException ie) {
@@ -64,7 +95,8 @@ private void runOnMyThread(Invocation invocation) throws Throwable {
}
if (!completed[0]) {
- throw new AssertionError("FormTest timed out after " + DEFAULT_TIMEOUT_MILLIS + "ms");
+ throw new AssertionError("FormTest timed out after " + DEFAULT_TIMEOUT_MILLIS
+ + "ms; edt=" + edtState());
}
Throwable t = thrown.get();
@@ -75,4 +107,25 @@ protected void beforePretest() {}
protected void pretest(String testName) {
}
+
+ /// Whether the dispatch thread was even running when the wait expired. A
+ /// timeout means one of two very different things -- the work was slow, or it
+ /// was never going to run -- and the message could not tell them apart.
+ private static String edtState() {
+ try {
+ if (!com.codename1.ui.Display.isInitialized()) {
+ return "display-not-initialized";
+ }
+ // Serial calls queued but undrained is the signature of a dispatch that
+ // was discarded rather than one that ran slowly.
+ java.lang.reflect.Field f =
+ com.codename1.ui.Display.class.getDeclaredField("pendingSerialCalls");
+ f.setAccessible(true);
+ Object pending = f.get(com.codename1.ui.Display.getInstance());
+ int queued = pending instanceof java.util.List ? ((java.util.List>) pending).size() : -1;
+ return "initialized pendingSerialCalls=" + queued;
+ } catch (Throwable unavailable) {
+ return "unavailable";
+ }
+ }
}
\ No newline at end of file
diff --git a/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java b/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java
index 8f6deb4af77..3f188b7a5a0 100644
--- a/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java
+++ b/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.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.junit;
import com.codename1.impl.ImplementationFactory;
@@ -98,13 +120,15 @@ protected void tearDownDisplay() throws Exception {
pending.clear();
}
- Field runningField = Display.class.getDeclaredField("runningSerialCallsQueue");
- runningField.setAccessible(true);
- @SuppressWarnings("unchecked")
- Deque running = (Deque) runningField.get(display);
- if (running != null) {
- running.clear();
- }
+ // runningSerialCallsQueue is NOT cleared. This teardown is itself
+ // dispatched onto the EDT by EDTTestInterceptor, so it runs while that
+ // queue is being drained -- and emptying it discards any sibling
+ // @AfterEach dispatch still waiting in it. The waiter for that dispatch
+ // then burns its full timeout and reports "FormTest timed out after
+ // 5000ms" from a test that was never slow, which is how FileTreeTest and
+ // SpanLabelWidthCapTest failed intermittently on CI and never locally.
+ // flushEdt() above has already drained the queue; there is nothing left
+ // to clear that is not someone else's work in flight.
} catch (Exception ignored) {
}
diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java
index 303cd57e09f..61cdc8000bd 100644
--- a/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java
+++ b/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java
@@ -34,6 +34,9 @@
import com.codename1.health.QuantitySample;
import com.codename1.health.SampleQuery;
import com.codename1.util.AsyncResource;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
import com.codename1.util.SuccessCallback;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -138,15 +141,46 @@ private static AsyncResource settled(AsyncResource r) {
return r;
}
- private static Throwable errorOf(AsyncResource> r) {
+ /// Settles `r` and returns the failure it carries, or null if it
+ /// succeeded.
+ ///
+ /// This used to read the error straight back, because a callback attached
+ /// to an already-failed resource fired inline on the registering thread.
+ /// Health results are delivered on the EDT whether the listener arrives
+ /// before the outcome or after it, so an off-EDT caller gets it queued and
+ /// has to wait -- the same wait `settled` makes one step earlier.
+ ///
+ /// Both sides are registered because most callers ask this of a resource
+ /// that succeeded and expect null; only one of the two ever fires, so
+ /// waiting on `except` alone would burn the whole limit on every
+ /// successful call.
+ private static Throwable errorOf(AsyncResource r) {
settled(r);
- final Throwable[] err = new Throwable[1];
+ // Atomics because the callback runs on the EDT while the value is read
+ // from the test thread.
+ final AtomicReference err = new AtomicReference();
+ final AtomicBoolean delivered = new AtomicBoolean();
r.except(new SuccessCallback() {
public void onSucess(Throwable t) {
- err[0] = t;
+ err.set(t);
+ delivered.set(true);
}
});
- return err[0];
+ r.ready(new SuccessCallback() {
+ public void onSucess(T value) {
+ delivered.set(true);
+ }
+ });
+ long deadline = System.currentTimeMillis() + 10_000L;
+ while (!delivered.get() && System.currentTimeMillis() < deadline) {
+ try {
+ Thread.sleep(5L);
+ } catch (InterruptedException ex) {
+ break;
+ }
+ }
+ assertTrue(delivered.get(), "the outcome must be delivered rather than hang");
+ return err.get();
}
/** The permissive baseline: data is there and comes back. */
diff --git a/scripts/copyright-header-exclusions.txt b/scripts/copyright-header-exclusions.txt
index 4990d7702a7..210cfbf13d6 100644
--- a/scripts/copyright-header-exclusions.txt
+++ b/scripts/copyright-header-exclusions.txt
@@ -22,3 +22,4 @@ Ports/JavaScriptPort/src/main/webapp/js/videojs/video.min.js | Video.js 7.4.1 an
Ports/JavaScriptPort/src/main/webapp/js/videojs/videojs.record.min.css | videojs-record 3.5.0 third-party stylesheet
Ports/JavaScriptPort/src/main/webapp/js/videojs/videojs.record.min.js | videojs-record 3.5.0 third-party bundle
Ports/JavaScriptPort/src/main/webapp/sw.js | Codename One service-worker adapter containing the UpUp 1.0.0 MIT-licensed service worker
+vm/JavaAPI/src/java/util/TimeZone.java | Apache Harmony source retaining its original Apache-2.0 notice
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BackgroundThreadUiAccessTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BackgroundThreadUiAccessTest.java
index 0e699f22118..cf1bce49cef 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BackgroundThreadUiAccessTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BackgroundThreadUiAccessTest.java
@@ -1,9 +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.codenameone.examples.hellocodenameone.tests;
import com.codename1.ui.Display;
import com.codename1.ui.plaf.UIManager;
public class BackgroundThreadUiAccessTest extends BaseTest {
+
+ /// Not safe for the runner's silent-timeout retry: it drives UI access from a background thread it starts itself,
+ /// and that worker outlives runTest(). A retry resets the shared
+ /// completion state, so a late done() from the first attempt's worker
+ /// would complete the second attempt and advance the suite early.
+ @Override
+ public boolean isRetrySafe() {
+ return false;
+ }
@Override
public boolean runTest() {
Thread worker = new Thread(() -> {
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BaseTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BaseTest.java
index c7d5e935f7d..960ec0ea5b5 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BaseTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BaseTest.java
@@ -58,6 +58,27 @@ public boolean shouldTakeScreenshot() {
return true;
}
+ /// Whether the runner's one-shot silent-timeout retry may re-run this test.
+ ///
+ /// Override to false in any test that starts work which OUTLIVES runTest()
+ /// -- a `new Thread(...).start()` or a `Display.startThread(...)`. The
+ /// retry calls resetForRetry() and runs the test again on the same
+ /// instance, so a late done() from the first attempt's worker would
+ /// complete the SECOND attempt, advance the suite before it had really
+ /// finished, and let that worker's side effects bleed into later tests --
+ /// masking exactly the timeout the retry was meant to survive.
+ ///
+ /// CN.callSerially work does not count: finalizeTest runs on the EDT, so
+ /// anything queued by an earlier attempt has already been drained by the
+ /// time a retry is decided.
+ ///
+ /// Find the tests that must override this with:
+ /// grep -lE 'new Thread[[:space:]]*\(' *Test.java (that also call .start())
+ /// grep -l 'startThread[[:space:]]*(' *Test.java
+ public boolean isRetrySafe() {
+ return true;
+ }
+
public synchronized void fail(String message) {
this.failed = true;
this.failMessage = message;
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BridgeBulkTransferGuardTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BridgeBulkTransferGuardTest.java
index c553fa11d4e..2feaaa9b7ef 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BridgeBulkTransferGuardTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BridgeBulkTransferGuardTest.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.codenameone.examples.hellocodenameone.tests;
import com.codename1.io.Storage;
@@ -26,6 +48,15 @@
/// the test passes trivially.
public class BridgeBulkTransferGuardTest extends BaseTest {
+ /// Not safe for the runner's silent-timeout retry: it starts a worker thread to exercise the bridge,
+ /// and that worker outlives runTest(). A retry resets the shared
+ /// completion state, so a late done() from the first attempt's worker
+ /// would complete the second attempt and advance the suite early.
+ @Override
+ public boolean isRetrySafe() {
+ return false;
+ }
+
@Override
public boolean runTest() {
new Thread(() -> {
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java
index 7b41de7db0c..70af3ff818c 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java
@@ -81,6 +81,10 @@ private static boolean isHtml5() {
return "HTML5".equals(Display.getInstance().getPlatformName());
}
+ private static boolean isLinux() {
+ return "linux".equals(Display.getInstance().getPlatformName());
+ }
+
private void checkReady() {
if (!loaded || readyRunnable == null) {
return;
@@ -106,11 +110,15 @@ public void onSucess(BrowserComponent.JSRef result) {
return;
}
- if (isHtml5() || CN.isDesktop()) {
+ if (isHtml5() || CN.isDesktop() || isLinux()) {
// Desktop screenshots intentionally cannot include the native web
// peer (the committed Mac/JavaSE baselines contain its black
- // placeholder). The execute() assertion above validates the real
- // DOM; use the normal harness capture for the surrounding form.
+ // placeholder), and the Linux port cannot either: its
+ // browserCapturePng is a documented stub pending the async WebKit
+ // snapshot bridge, so generatePeerImage always answers null and the
+ // peer never reaches the capture. The execute() assertion above
+ // validates the real DOM; use the normal harness capture for the
+ // surrounding form.
UITimer.timer(2000, false, form, readyRunnable);
} else {
// DOM readiness and even WebKit's first meaningful paint do not
@@ -210,6 +218,13 @@ private boolean containsRenderedBrowserContent(Image screen) {
|| (g > 120 && b > 160 && b > r + 30)) {
brightPixels++;
} else if (r < 48 && g < 48 && b < 48) {
+ // The fixture's #0e1116 backdrop. Requiring it as well as
+ // the text is what stops a blank frame from passing: a peer
+ // that never composited leaves the form's plain white
+ // background, which is bright everywhere and would satisfy
+ // the text test on its own -- which is exactly how the
+ // Linux port, whose peer capture is still a stub, recorded
+ // an empty rectangle as a rendered page.
darkPixels++;
}
if (brightPixels >= requiredBrightPixels && darkPixels >= requiredDarkPixels) {
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BytecodeTranslatorRegressionTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BytecodeTranslatorRegressionTest.java
index d4b75f64604..f291af07915 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BytecodeTranslatorRegressionTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BytecodeTranslatorRegressionTest.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.codenameone.examples.hellocodenameone.tests;
import com.codename1.ui.CN;
@@ -10,6 +32,15 @@
import java.util.ArrayList;
public class BytecodeTranslatorRegressionTest extends BaseTest {
+
+ /// Not safe for the runner's silent-timeout retry: it runs its regression cases on a worker thread,
+ /// and that worker outlives runTest(). A retry resets the shared
+ /// completion state, so a late done() from the first attempt's worker
+ /// would complete the second attempt and advance the suite early.
+ @Override
+ public boolean isRetrySafe() {
+ return false;
+ }
private interface Sketchable {
}
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java
index 0deb9eb3548..36ab771359f 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CameraApiTest.java
@@ -70,14 +70,22 @@ public boolean runTest() {
// JavaSE uses its synthetic CameraImpl. The JavaScript Playwright runner
// supplies Chromium's fake media device, which still exercises the real
// HTML5 getUserMedia/video/canvas/JPEG path. Native mobile ports need an
- // OS permission dialog, and Windows does not implement host webcam
- // capture yet, so those remain outside this cross-port headless test.
+ // OS permission dialog, Windows does not implement host webcam capture
+ // yet, and the native Linux port drives real V4L2 devices through
+ // GStreamer -- a hosted runner has no camera to enumerate, so the
+ // assertion chain below would only ever report the absent hardware.
+ // Those ports stay outside this cross-port headless test and are
+ // covered by the camera erratum on the port status page.
boolean isHeadlessCameraSupported = "HTML5".equals(platform)
|| (!"ios".equals(platform)
&& !"and".equals(platform)
- && !"win".equals(platform));
+ && !"win".equals(platform)
+ && !"linux".equals(platform));
if (!isHeadlessCameraSupported) {
- System.out.println("CN1SS:INFO:test=CameraApiTest status=SKIPPED reason=needs-runtime-permission-on-" + platform);
+ String reason = "win".equals(platform) ? "no-host-webcam-capture-on-win"
+ : ("linux".equals(platform) ? "no-camera-device-on-headless-runner"
+ : "needs-runtime-permission-on-" + platform);
+ System.out.println("CN1SS:INFO:test=CameraApiTest status=SKIPPED reason=" + reason);
done();
return true;
}
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java
index 28c344c7c04..f9a0a009219 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java
@@ -26,6 +26,7 @@
import com.codename1.testing.TestReporting;
import com.codename1.ui.CN;
import com.codename1.ui.Display;
+import com.codename1.ui.Font;
import com.codename1.ui.Form;
import com.codename1.util.StringUtil;
import com.codenameone.examples.hellocodenameone.NativeInterfaceLanguageValidator;
@@ -495,6 +496,23 @@ public void runSuite() {
runNextTest(0);
}
+ /// Which test wedged the suite is reported by the capture harness, not from
+ /// in here.
+ ///
+ /// This used to be an in-process watchdog thread. It could not do the job and
+ /// it caused a worse one. A test that blocks the event dispatch thread in a
+ /// tight compute loop also stops the collector, so the watchdog's own log
+ /// call -- which allocates, to build its message -- parks waiting for a
+ /// collection that cannot happen until the EDT yields. It reported nothing
+ /// across a 47 minute wedge for exactly that reason. Worse, merely asking to
+ /// allocate from a second thread during Base64NativePerformanceTest's
+ /// benchmark loops was enough to deadlock the pair, and the Linux suite --
+ /// which completes on master -- stopped dead on that test.
+ ///
+ /// The harness reads the suite's output from outside the process, so it can
+ /// name the last announced test whatever state the app is in, and cannot
+ /// perturb it. See lastStarted in CleanTargetLinuxIntegrationTest.
+
private void runNextTest(int index) {
int offset = prependedTest != null ? 1 : 0;
boolean includeJavaSeReferences = "SE".equals(
@@ -530,15 +548,24 @@ private void runNextTest(int index) {
CN.callSerially(() -> {
Cn1ssDeviceRunnerHelper.clearTransportFailure();
log("CN1SS:INFO:suite starting test=" + testName);
+ // Stage markers. When a suite stops dead the log ends on the
+ // "starting" line and says nothing about which call did not come
+ // back -- prepare(), runTest(), or the poll that follows. Naming
+ // each boundary costs one line per test and turns "stopped in X"
+ // into "stopped inside X's runTest", which is the difference
+ // between reading a stack and guessing at one.
try {
testClass.prepare();
+ log("CN1SS:INFO:stage=prepared test=" + testName);
testClass.runTest();
+ log("CN1SS:INFO:stage=ran test=" + testName);
} catch (Throwable t) {
log("CN1SS:ERR:suite test=" + testName + " failed=" + t);
t.printStackTrace();
logThrowable("runTest:" + testName, t);
testClass.fail(String.valueOf(t));
}
+ log("CN1SS:INFO:stage=awaiting test=" + testName);
awaitTestCompletion(index, testClass, testName, System.currentTimeMillis() + testTimeoutMs(testClass));
});
}
@@ -643,12 +670,32 @@ private void finalizeTest(int index, BaseTest testClass, String testName, boolea
/// - the test actually takes a screenshot (non-screenshot tests may have
/// side effects that aren't safe to repeat, and a missing tile is the
/// only failure mode this retry exists to prevent).
+ /// One retry for a test that timed out having neither failed nor started a
+ /// capture -- the show -> settle-timer -> DONE chain was swallowed.
+ ///
+ /// Deliberately NOT conditioned on shouldTakeScreenshot(). It used to be, on
+ /// the reasoning that the retry existed to recover a missing screenshot, but
+ /// the stall is in the show/EDT chain and hits any test: AccessibilityTest and
+ /// MutableImageClipReadbackTest capture nothing, so a transient stall that a
+ /// screenshot test shrugs off failed them outright, on a different test each
+ /// run. A test that is genuinely broken still fails -- it times out the second
+ /// time too, and the retry is one-shot per index.
+ ///
+ /// It IS conditioned on isRetrySafe(). Dropping the screenshot gate without
+ /// putting anything in its place made the retry reachable for tests whose
+ /// runTest() starts a worker and returns: resetForRetry() clears the shared
+ /// completion state, so a late done() from the first attempt's thread would
+ /// complete the second attempt, advance the suite before it had finished and
+ /// let that worker's side effects bleed into later tests. Screenshot-taking
+ /// was never the property that made a retry safe -- having no work in flight
+ /// is -- and VideoIODecodedFramesScreenshotTest takes a screenshot AND starts
+ /// a thread, so the old gate did not establish it either.
private boolean shouldRetryAfterSilentTimeout(int index, BaseTest testClass) {
return retriedTestIndex != index
&& !"HTML5".equals(Display.getInstance().getPlatformName())
&& !testClass.isFailed()
&& !testClass.isCaptureStarted()
- && testClass.shouldTakeScreenshot();
+ && testClass.isRetrySafe();
}
private boolean shouldRetryAfterTransportFailure(int index, BaseTest testClass) {
@@ -750,10 +797,19 @@ private static void logThrowable(String context, Throwable t) {
log("CN1SS:ERR:throwable context=" + context + " message=" + String.valueOf(t.getMessage()));
String stack = Display.getInstance().getStackTrace(Thread.currentThread(), t);
if (stack == null) {
- log("CN1SS:ERR:throwable context=" + context + " stack=null");
- return;
+ stack = "";
}
log("CN1SS:ERR:throwable context=" + context + " stackLength=" + stack.length());
+ logPlatformState(context);
+ if (stack.length() == 0) {
+ // The implementation's own capture comes back empty on some ports --
+ // ParparVM Windows reports stackLength=0 for every throwable -- which
+ // leaves a NullPointerException with no location at all, and CI is the
+ // only place these run. Throwable's own frames are worth asking for
+ // before giving up; on a port that fills them in this is the difference
+ // between naming the line and guessing at it.
+ logThrowableFrames(context, t);
+ }
for (String line : StringUtil.tokenize(stack, '\n')) {
if (line.length() > 200) {
line = line.substring(0, 200);
@@ -762,6 +818,72 @@ private static void logThrowable(String context, Throwable t) {
}
}
+ /// Platform state alongside every reported throwable.
+ ///
+ /// Some ports report an exception with no stack at all -- ParparVM Windows
+ /// answers empty for both Display.getStackTrace and Throwable.getStackTrace --
+ /// so a NullPointerException arrives as a bare type name and CI is the only
+ /// place it reproduces. The numbers below are the ones that turn such a
+ /// report into a lead: a zero font height or a zero display extent is how a
+ /// component ends up asking for a zero-sized image, whose graphics come back
+ /// null. Cheap, printed only on failure, and it costs nothing to leave in.
+ private static void logPlatformState(String context) {
+ StringBuilder sb = new StringBuilder("CN1SS:ERR:platform context=");
+ sb.append(context);
+ try {
+ Display d = Display.getInstance();
+ sb.append(" display=").append(d.getDisplayWidth()).append('x').append(d.getDisplayHeight());
+ sb.append(" density=").append(d.getDeviceDensity());
+ sb.append(" edt=").append(d.isEdt());
+ } catch (Throwable unavailable) {
+ sb.append(" display=unavailable");
+ }
+ try {
+ Font def = Font.getDefaultFont();
+ sb.append(" defaultFontHeight=").append(def == null ? "null-font" : String.valueOf(def.getHeight()));
+ } catch (Throwable unavailable) {
+ sb.append(" defaultFontHeight=unavailable");
+ }
+ try {
+ Font sys = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM);
+ sb.append(" systemFontHeight=").append(sys == null ? "null-font" : String.valueOf(sys.getHeight()));
+ } catch (Throwable unavailable) {
+ sb.append(" systemFontHeight=unavailable");
+ }
+ log(sb.toString());
+ }
+
+ private static void logThrowableFrames(String context, Throwable t) {
+ StackTraceElement[] frames;
+ try {
+ frames = t.getStackTrace();
+ } catch (Throwable unsupported) {
+ log("CN1SS:ERR:throwable context=" + context + " frames=unsupported");
+ return;
+ }
+ if (frames == null || frames.length == 0) {
+ log("CN1SS:ERR:throwable context=" + context + " frames=none");
+ // ParparVM's Throwable.getStackTrace() is a stub that always answers
+ // an empty array, but the VM does record frames: the native
+ // fillInStack walks threadStateData->callStack when the throwable is
+ // constructed and stores the rendered text, which printStackTrace is
+ // the only accessor for. Asking getStackTrace and stopping there is
+ // why a NullPointerException on Windows arrived with no location at
+ // all and took several CI rounds to place. Costs nothing on the JVM
+ // ports, where the frames above are non-empty and this never runs.
+ log("CN1SS:ERR:throwable context=" + context + " printing the VM's own stack:");
+ try {
+ t.printStackTrace();
+ } catch (Throwable unsupported) {
+ log("CN1SS:ERR:throwable context=" + context + " printStackTrace=unsupported");
+ }
+ return;
+ }
+ for (int i = 0; i < frames.length && i < 24; i++) {
+ log("CN1SS:ERR:throwable context=" + context + " frame=" + String.valueOf(frames[i]));
+ }
+ }
+
@Override
protected void startApplicationInstance() {
Cn1ssDeviceRunnerHelper.runOnEdtSync(() -> {
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MotionSensorDeviceTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MotionSensorDeviceTest.java
index b60053f03ea..f02ac6d1171 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MotionSensorDeviceTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MotionSensorDeviceTest.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.codenameone.examples.hellocodenameone.tests;
import com.codename1.sensors.MotionEvent;
@@ -25,6 +47,15 @@
*/
public class MotionSensorDeviceTest extends BaseTest {
+ /// Not safe for the runner's silent-timeout retry: it polls the sensors on a Display.startThread worker,
+ /// and that worker outlives runTest(). A retry resets the shared
+ /// completion state, so a late done() from the first attempt's worker
+ /// would complete the second attempt and advance the suite early.
+ @Override
+ public boolean isRetrySafe() {
+ return false;
+ }
+
@Override
public boolean shouldTakeScreenshot() {
return false;
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIODecodedFramesScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIODecodedFramesScreenshotTest.java
index ade79fdc5a7..f803a7788aa 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIODecodedFramesScreenshotTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIODecodedFramesScreenshotTest.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.codenameone.examples.hellocodenameone.tests;
import com.codename1.io.FileSystemStorage;
@@ -50,6 +72,15 @@
* {@link VideoIORoundTripTest} (frame order, brightness ramp, PCM verification).
*/
public class VideoIODecodedFramesScreenshotTest extends AbstractAnimationScreenshotTest {
+
+ /// Not safe for the runner's silent-timeout retry: it decodes frames on a worker thread,
+ /// and that worker outlives runTest(). A retry resets the shared
+ /// completion state, so a late done() from the first attempt's worker
+ /// would complete the second attempt and advance the suite early.
+ @Override
+ public boolean isRetrySafe() {
+ return false;
+ }
private static final int FRAMES = 6;
// Encode resolution: large enough that the rendered digit survives H.264.
private static final int VW = 192;
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java
index f4bede2c9d9..422c70daf3b 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VideoIORoundTripTest.java
@@ -58,6 +58,15 @@
* This is an assertion test, not a screenshot test, so it does not affect baselines.
*/
public class VideoIORoundTripTest extends BaseTest {
+
+ /// Not safe for the runner's silent-timeout retry: it encodes and decodes on a worker thread,
+ /// and that worker outlives runTest(). A retry resets the shared
+ /// completion state, so a late done() from the first attempt's worker
+ /// would complete the second attempt and advance the suite early.
+ @Override
+ public boolean isRetrySafe() {
+ return false;
+ }
private static final int W = 128;
private static final int H = 96;
private static final int FRAMES = 6;
@@ -157,6 +166,13 @@ private void runRoundTrip() {
+ "/cn1-videoio-roundtrip-" + System.currentTimeMillis() + (webm ? ".webm" : ".mp4");
// ---- ENCODE (encode-side unavailability is a SKIP, not a failure) ----
+ // Two phases, reported differently on purpose. Failing to obtain a
+ // writer is the documented "this runner exposes no encoder" case and
+ // is skipped. Failing after one was obtained means the encoder is
+ // there and broke, which is a regression -- reporting that as the same
+ // skip made an encoder failure render as a documented green cell,
+ // indistinguishable from a target that never had an encoder.
+ VideoWriter writer;
try {
VideoWriterBuilder builder = new VideoWriterBuilder()
.path(path).container(container)
@@ -165,7 +181,13 @@ private void runRoundTrip() {
if (withAudio) {
builder.hasAudio(true).audioCodec(audioCodec).sampleRate(SAMPLE_RATE).audioChannels(1);
}
- VideoWriter writer = io.createWriter(builder);
+ writer = io.createWriter(builder);
+ } catch (Throwable t) {
+ cleanup(path);
+ skip("encode-unavailable-on-" + Display.getInstance().getPlatformName());
+ return;
+ }
+ try {
int samplesPerFrame = SAMPLE_RATE / FRAMES;
for (int i = 0; i < FRAMES; i++) {
writer.writeFrame(makeCountingFrame(i), Math.round(i * 1000f / FPS));
@@ -174,9 +196,29 @@ private void runRoundTrip() {
}
}
writer.close();
+ writer = null;
} catch (Throwable t) {
+ // The writer is still open whenever the throw came from a write
+ // rather than from close(). Leaving it open leaks the native
+ // encoder into the rest of the suite, which shares this process;
+ // best-effort close, and ignore a secondary failure because we are
+ // already reporting the first one.
+ if (writer != null) {
+ try {
+ writer.close();
+ } catch (Throwable ignored) {
+ // reporting the original failure below
+ }
+ }
cleanup(path);
- skip("encode-unavailable-on-" + Display.getInstance().getPlatformName() + ":" + t.getMessage());
+ // Still a skip, with its own reason code. The Apple simulators
+ // obtain a writer and then fail to finalize the file, which is the
+ // same "no usable encoder in the headless job" condition as failing
+ // to obtain one -- it just surfaces later. Reporting it under a
+ // distinct code lets the errata document it for the targets where
+ // it is expected, while the same code from a port whose encoder is
+ // supposed to work stays undocumented and therefore not green.
+ skip("encode-write-failed-on-" + Display.getInstance().getPlatformName());
return;
}
diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt
index 239694cddea..951c2ab7b83 100644
--- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt
+++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/tests/KotlinUiTest.kt
@@ -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.codenameone.examples.hellocodenameone.tests
import com.codename1.components.Accordion
@@ -13,44 +35,117 @@ import com.codename1.ui.TextField
import com.codename1.ui.layouts.BoxLayout
class KotlinUiTest : BaseTest() {
+ // Breadcrumbs because the Windows port reports NullPointerException with no
+ // stack at all -- Display.getStackTrace and Throwable.getStackTrace both come
+ // back empty there, so CI gives a bare exception and no location. Cheap on
+ // every other port, and the difference between naming the failing
+ // construction and guessing at it. Remove once the Windows failure is fixed.
+ private fun step(name: String) {
+ System.out.println("CN1SS:INFO:kotlin-step=" + name)
+ }
+
override fun runTest(): Boolean {
+ step("form")
val kotlinForm = createForm("Kotlin", BoxLayout.y(), "kotlin")
- kotlinForm.addAll(
- Label("Kotlin UI Test Components"),
- Button("Kotlin Button"),
- BoxLayout.encloseX(Switch(), Switch().apply { setOn() }),
- TextField("", "Enter name"),
- Slider().apply {
- isEditable = true
- progress = 50
- }
- )
+ step("label")
+ val label = Label("Kotlin UI Test Components")
+ step("button")
+ val button = Button("Kotlin Button")
+ step("switch-off")
+ val switchOff = Switch()
+ step("switch-on-ctor")
+ val switchOn = Switch()
+ step("switch-on-seton")
+ switchOn.setOn()
+ step("switch-row")
+ val switchRow = BoxLayout.encloseX(switchOff, switchOn)
+ step("textfield")
+ val textField = TextField("", "Enter name")
+ step("slider")
+ val slider = Slider()
+ slider.isEditable = true
+ slider.progress = 50
+ step("addAll")
+ kotlinForm.addAll(label, button, switchRow, textField, slider)
+ step("added")
+ step("accordion-ctor")
val accordion = Accordion()
- accordion.addContent("Details", BoxLayout.encloseY(
- MultiButton("MultiButton Line 1").apply {
- setTextLine2("Additional detail line")
- },
- MultiButton("MultiButton Line 2").apply {
- setTextLine2("More detail for Kotlin UI")
- }
- ))
+ step("multibutton-1")
+ val mb1 = MultiButton("MultiButton Line 1")
+ mb1.setTextLine2("Additional detail line")
+ step("multibutton-2")
+ val mb2 = MultiButton("MultiButton Line 2")
+ mb2.setTextLine2("More detail for Kotlin UI")
+ step("accordion-details")
+ accordion.addContent("Details", BoxLayout.encloseY(mb1, mb2))
+ step("checkbox")
+ val check = CheckBox("Enable notifications")
+ step("prefs-switch")
+ val prefSwitch = Switch()
+ prefSwitch.setOn()
+ step("textarea")
+ val note = TextArea(3, 20)
+ step("textarea-hint")
+ note.hint = "Add a short note"
+ step("prefs-container")
val preferences = Container(BoxLayout.y())
- preferences.addAll(
- CheckBox("Enable notifications"),
- Switch().apply { setOn() },
- TextArea(3, 20).apply { hint = "Add a short note" }
- )
+ preferences.addAll(check, prefSwitch, note)
+ // A component that paints a drop shadow blurs an image and then draws
+ // ON TOP of the blur result -- Switch does exactly this for its ON thumb.
+ // A port whose blur hands back something undrawable therefore cannot
+ // render a Switch at all, and on Windows it did not: gaussianBlurImage
+ // returned an ARGB-backed image with no render target, getGraphics wrapped
+ // a null native peer, and the first call that read through it faulted. The
+ // fault surfaced as a bare NullPointerException with no message and no
+ // frames, because the port maps a null-address access violation to one.
+ // Nothing in the suite covered "draw on a blur result", so this is the
+ // check that would have caught it. Run on a throwaway Accordion that is
+ // never added to the form: an earlier version probed the rendered one and
+ // changed the kotlin golden on every other port.
+ val probe = Accordion()
+ step("probe-blur-drawable")
+ if (com.codename1.ui.Display.getInstance().isGaussianBlurSupported) {
+ val shadow = com.codename1.ui.ImageFactory.createImage(prefSwitch, 34, 34, 0)
+ val sg = shadow.graphics
+ sg.color = 0
+ sg.fillRoundRect(2, 2, 30, 30, 30, 30)
+ val blurred = com.codename1.ui.Display.getInstance().gaussianBlurImage(shadow, 5f)
+ ?: throw IllegalStateException("gaussianBlurImage returned null")
+ // The calls Switch makes on the blur result, in its order. concatenateAlpha
+ // reads the graphics state and is the first one to dereference the peer.
+ val bg = blurred.graphics
+ bg.concatenateAlpha(255)
+ bg.color = 0xffffff
+ bg.fillRoundRect(2, 2, 30, 30, 30, 30)
+ step("probe-blur-drawable-ok=" + blurred.width + "x" + blurred.height)
+ }
+ // An ON switch inside a container is what first exposed the above: the OFF
+ // thumb takes its shadow spread from switchThumbShadowSpreadInt (0 for the
+ // flat Material 3 thumb) and so never blurs, while the ON thumb hard-codes
+ // a spread of 2. Keep both shapes covered.
+ step("probe-switch-on-preferred")
+ val soSwitch = Switch()
+ soSwitch.setOn()
+ probe.addContent("ProbeSwitchOn", Container(BoxLayout.y()).apply { add(soSwitch) })
+ soSwitch.preferredSize
+ step("probe-switch-on-ok")
+
+ step("accordion-prefs")
accordion.addContent("Preferences", preferences)
+ step("accordion-summary")
accordion.addContent("Summary", BoxLayout.encloseY(
Label("Accordion showcases grouped UI"),
Button("Confirm Settings")
))
+ step("form-add")
kotlinForm.add(accordion)
+ step("form-show")
kotlinForm.show()
+ step("shown")
return true
}
}
\ No newline at end of file
diff --git a/scripts/hellocodenameone/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh
new file mode 100755
index 00000000000..9a8e53ff343
--- /dev/null
+++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh
@@ -0,0 +1,459 @@
+#!/usr/bin/env bash
+#
+# 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.
+
+set -euo pipefail
+
+# Publish the newest master report for every port in the compliance contract.
+#
+# port-status-publish.yml reacts to workflow_run events from the producing
+# workflows. Those events are not delivered reliably for every producer: the
+# Linux and Windows suites have never landed a single report that way, so the
+# public table served a checked-in fallback for them until it aged past the
+# staleness threshold and the whole column rendered as unknown. This sweep does
+# not depend on an event arriving. It reads the newest completed master run of
+# each producing workflow, takes the normalized report it uploaded, and
+# publishes it when it is newer than the copy on the data branch.
+#
+# It finishes by asserting that every port has a report that is inside the
+# contract's staleness window, so a producer that stops emitting reports fails
+# here instead of quietly rotting on the website.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
+MANIFEST="${REPO_ROOT}/docs/website/data/port_status.json"
+DATA_BRANCH="port-status-data"
+
+# port_status.py accept distinguishes "built against an older contract, wait for
+# the next run" from "the report is broken". Both keep the checked-in fallback,
+# but only the second is a defect worth shouting about, so the two get different
+# messages rather than one blanket "not usable".
+ACCEPT_CONTRACT_DRIFT=11
+ACCEPT_UNUSABLE=12
+
+# GNU coreutils spells it --decode, BSD (macOS) spells it -D. This script has a
+# BSD `date` fallback already, so it is meant to run in both places.
+decode_base64() {
+ base64 --decode 2>/dev/null || base64 -D
+}
+
+# Explains a non-zero `accept` status without pretending drift is corruption.
+describe_accept_status() {
+ case "$1" in
+ "${ACCEPT_CONTRACT_DRIFT}") echo "built against a different test contract; waiting for a run on the current one" ;;
+ "${ACCEPT_UNUSABLE}") echo "not usable by the website" ;;
+ *) echo "rejected by the publication gate (status $1)" ;;
+ esac
+}
+
+# Every downloaded report in $1 that names one of the whitespace-separated port
+# ids in $2. Used to tell a run that produced nothing for this workflow apart
+# from the browser-evidence sidecar from one whose producer really failed.
+owned_reports_in() {
+ local run_dir="$1"
+ local owned_ids="$2"
+ local report found
+ while IFS= read -r report; do
+ [ -n "${report}" ] || continue
+ found="$(jq -r '.port // empty' "${report}" 2>/dev/null || true)"
+ [ -n "${found}" ] || continue
+ case " $(printf '%s ' ${owned_ids}) " in
+ *" ${found} "*) printf '%s\n' "${found}" ;;
+ esac
+ done < <(port_reports_in "${run_dir}")
+}
+
+# The per-port reports a run uploaded. port-status-environment.json is the
+# browser-evidence sidecar rather than a port report -- it describes the
+# browsers the evidence was captured in and names no port -- so scanning it as
+# one recorded the newest run as having uploaded an unreadable report.
+port_reports_in() {
+ find "$1" -type f -name 'port-status-*.json' ! -name 'port-status-environment.json' | sort
+}
+
+for tool in gh jq python3; do
+ if ! command -v "${tool}" >/dev/null 2>&1; then
+ echo "backfill-port-status: ${tool} is required." >&2
+ exit 2
+ fi
+done
+
+: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
+
+tmp_dir="$(mktemp -d)"
+cleanup() {
+ rm -rf "${tmp_dir}"
+}
+trap cleanup EXIT
+
+published=0
+skipped=0
+# Ports whose newest report was rejected as malformed. Collected rather than
+# fatal on the spot so the older, usable report is still published first.
+unusable=()
+
+# Mirrors port_status.py's FUTURE_STAMP_TOLERANCE. The gate publishes a report
+# stamped slightly ahead of now, so the closing assertion has to accept the same
+# margin or it fails over reports this very run published.
+future_skew_seconds=3600
+
+# True when $1 is a strictly later instant than $2. Both are timezone-aware
+# ISO-8601, but not necessarily normalized to Z, so they are compared as
+# instants rather than as text.
+newer_instant() {
+ python3 - "$1" "$2" <<'INSTANT'
+import sys
+from datetime import datetime
+
+def parse(value):
+ try:
+ stamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ return stamp if stamp.tzinfo is not None else None
+
+candidate = parse(sys.argv[1])
+current = parse(sys.argv[2])
+# An unreadable candidate is never "newer"; an unreadable stored value is
+# replaced, since leaving it in place would strand the port on something the
+# page cannot render either.
+sys.exit(0 if candidate is not None and (current is None or candidate > current) else 1)
+INSTANT
+}
+
+# The contract's own freshness window bounds how far back a candidate run is
+# worth considering: a report older than this is stale by definition, so there
+# is nothing to be gained by looking past it.
+sweep_stale_days="$(jq -r '.stale_after_days' "${MANIFEST}")"
+
+# One producing workflow can own several ports (the iOS suite emits four), so
+# sweep per workflow and let the report itself name the port it belongs to.
+while IFS= read -r workflow; do
+ # Newest first, and a failed run counts: a suite that fails still uploads the
+ # normalized report, and a report that records real failures is the result
+ # the table is supposed to show.
+ #
+ # Every terminal conclusion except cancelled/skipped counts, not just success
+ # and failure. GitHub reports timed_out and startup_failure separately, and a
+ # producer that times out before normalization uploads no report at all --
+ # exactly the case the "newest run uploaded no port-status artifact" check
+ # exists to catch. Filtering those runs out here meant they never reached it,
+ # so an older still-fresh report covered the port and the sweep stayed green
+ # over a producer that emitted nothing. Cancelled stays excluded: that is
+ # someone superseding a run, not the producer failing.
+ #
+ # workflow_dispatch counts too. The producers declare dispatch and schedule
+ # rather than push, so a maintainer rerunning one on master to repair a port
+ # the scheduled run missed is exactly the recovery this sweep exists to pick
+ # up -- and filtering it out meant the manual fix could never reach the table.
+ # Every run still inside the staleness horizon is a candidate, rather than a
+ # fixed newest-five slice. A workflow whose matrix legs fail independently --
+ # the Linux producer especially, whose reports are not reliably published by
+ # workflow_run -- can accumulate several runs that each omit a different leg,
+ # and a five-run cap then hides a perfectly good report just behind them. The
+ # loop below stops as soon as every port the workflow owns is covered, so the
+ # wider net costs nothing when the newest run is complete.
+ horizon="$(date -u -d "${sweep_stale_days} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
+ || date -u -v-"${sweep_stale_days}"d +%Y-%m-%dT%H:%M:%SZ)"
+ # gh's --jq takes one expression and forwards no jq CLI options, so --arg has
+ # to go to a separate jq invocation rather than being smuggled in after --jq.
+ candidates="$(gh run list --workflow "${workflow}" --branch master --limit 100 \
+ --json databaseId,event,conclusion,updatedAt \
+ | jq -r --arg horizon "${horizon}" '[.[] | select((.event == "push" or .event == "schedule" or .event == "workflow_dispatch") and
+ (.conclusion != null and .conclusion != "cancelled" and .conclusion != "skipped") and
+ (.updatedAt >= $horizon))]
+ | sort_by(.updatedAt) | reverse | .[] | "\(.databaseId):\(.event)"')"
+ if [ -z "${candidates}" ]; then
+ echo "No completed master run for ${workflow}; nothing to publish." >&2
+ continue
+ fi
+
+ run_id=""
+ # The newest SCHEDULED (or push) run gets stricter treatment than the ones
+ # behind it: it must upload an artifact, and it must report every port its
+ # workflow owns. A workflow_dispatch is deliberately excluded from that role.
+ # Dispatches carry arbitrary inputs and routinely run a subset on purpose --
+ # scripts-ios.yml with watch_only skips the GL, Metal and tv legs, and
+ # scripts-javascript.yml with port_status_browser_evidence skips its screenshot
+ # job entirely -- so treating whichever run happens to be newest as the
+ # authoritative producer failed the sweep for ports nobody asked to run. Their
+ # reports are still merged (and, being newest, still win), which is the manual
+ # recovery this sweep exists to pick up; only the strictness is scoped to the
+ # runs that are supposed to cover everything.
+ newest_candidate=""
+ # Set once the strict candidate has been processed: coverage found at or
+ # before it counts as current, so a dispatch that repaired a port newer than
+ # the last scheduled run is not then reported as an omission of that run.
+ past_strict_candidate=0
+ download_dir="${tmp_dir}/${workflow}"
+ mkdir -p "${download_dir}"
+ # Merge across candidate runs rather than stopping at the first with any
+ # artifact: a failed matrix run can upload the report for one leg only, and
+ # the other ports that workflow owns would then never be considered.
+ owned="$(jq -r --arg workflow "${workflow}" '.ports[] | select(.workflow == $workflow) | .id' "${MANIFEST}")"
+ for candidate_entry in ${candidates}; do
+ candidate="${candidate_entry%%:*}"
+ candidate_event="${candidate_entry##*:}"
+ if [ -n "${newest_candidate}" ]; then
+ past_strict_candidate=1
+ fi
+ missing=0
+ for port in ${owned}; do
+ if [ ! -f "${download_dir}/covered-${port}" ]; then
+ missing=1
+ fi
+ done
+ if [ "${missing}" -eq 0 ]; then
+ break
+ fi
+ if ! gh run download "${candidate}" --pattern 'port-status-*' --dir "${download_dir}/run-${candidate}" >/dev/null 2>&1; then
+ # No report artifact at all. For the newest run that is a producer failure
+ # in its own right -- a job that died before normalization uploads nothing,
+ # so falling back to an older artifact covers every port, the closing
+ # freshness check passes, and the sweep goes green while the current run
+ # produced no evidence. Only the newest is reported: older candidates
+ # without artifacts are just how the merge walks back.
+ if [ -z "${newest_candidate}" ] && [ "${candidate_event}" != "workflow_dispatch" ]; then
+ newest_candidate="${candidate}"
+ unusable+=("${workflow}: newest run ${candidate} uploaded no port-status artifact")
+ fi
+ continue
+ fi
+ if [ -z "${newest_candidate}" ] && [ "${candidate_event}" != "workflow_dispatch" ]; then
+ newest_candidate="${candidate}"
+ fi
+ run_id="${candidate}"
+ while IFS= read -r downloaded; do
+ found="$(jq -r '.port // empty' "${downloaded}" 2>/dev/null || true)"
+ if [ -z "${found}" ]; then
+ # Invalid JSON, or an object with no "port": the artifact names nothing,
+ # so it cannot be matched to a port or gated. Skipping quietly let an
+ # older valid artifact cover the workflow and the sweep finish green
+ # while the newest producer output was malformed.
+ echo "Ignoring $(basename "${downloaded}") from run ${candidate}: it names no port." >&2
+ unusable+=("${workflow}: run ${candidate} uploaded $(basename "${downloaded}") with no readable port id")
+ continue
+ fi
+ if [ -f "${download_dir}/covered-${found}" ]; then
+ continue
+ fi
+ # Only ports this workflow is declared to produce. The port is read from
+ # the artifact, so a misconfigured matrix that stamped someone else's id
+ # on its report would otherwise be published straight over that port's
+ # entry -- Linux evidence replacing Android's genuine result, with both
+ # the gate and the freshness check satisfied.
+ # owned is newline-separated (one id per jq row); normalise to spaces
+ # so the space-delimited membership test below actually matches the ids
+ # in the middle of the list rather than only the first and last.
+ case " $(printf '%s ' ${owned}) " in
+ *" ${found} "*) ;;
+ *)
+ echo "Ignoring a report naming ${found}: ${workflow} does not produce that port." >&2
+ # Same standing as a malformed report, and for the same reason: the
+ # sweep can still find an older, correct artifact for the port this
+ # workflow really owns, and while that one stays fresh the closing
+ # check passes and the job goes green over a producer that is
+ # emitting misidentified data. Preserve the good report, then fail.
+ unusable+=("${workflow}: run ${candidate} uploaded a report naming ${found}, which it does not produce")
+ continue
+ ;;
+ esac
+ # Gate before marking the port covered, not after. A newest run that
+ # uploaded an unusable report would otherwise claim the port and stop
+ # the older candidates from being consulted, so the sweep would keep
+ # serving stale data -- or fail its closing freshness assertion --
+ # while a perfectly good report sat in the run behind it.
+ accept_status=0
+ python3 "${SCRIPT_DIR}/port_status.py" accept \
+ --port "${found}" --report "${downloaded}" >/dev/null 2>&1 || accept_status=$?
+ if [ "${accept_status}" -ne 0 ]; then
+ echo "Ignoring the ${found} report from run ${candidate}: $(describe_accept_status "${accept_status}")." >&2
+ if [ "${past_strict_candidate}" -eq 0 ] \
+ && [ "${accept_status}" -eq "${ACCEPT_CONTRACT_DRIFT}" ]; then
+ # The newest run DID report this port; its report is simply built
+ # against another revision of the contract, which is the one case that
+ # is meant to wait quietly for the next run. Without this marker the
+ # omission check below would see no newest-covered file, call the port
+ # omitted and fail the sweep -- turning the documented quiet fallback
+ # into a hard failure every time the merge behind it succeeded.
+ : > "${download_dir}/newest-drift-${found}"
+ fi
+ # A malformed report is a producer defect, and falling back to an older
+ # run hides it: the fallback is still inside the freshness window, so the
+ # closing assertion passes and the sweep goes green while the newest run
+ # is broken. Remember it and fail at the end -- after the older report has
+ # been preserved, so the table keeps showing something rather than
+ # nothing. Contract drift stays quiet, because waiting for a run on the
+ # current contract is the intended behaviour there, not a defect.
+ if [ "${accept_status}" -eq "${ACCEPT_UNUSABLE}" ] \
+ && [ ! -f "${download_dir}/covered-${found}" ]; then
+ unusable+=("${found}: run ${candidate} uploaded a report the website cannot use")
+ fi
+ continue
+ fi
+ cp "${downloaded}" "${download_dir}/port-status-${found}.json"
+ : > "${download_dir}/covered-${found}"
+ # At or before the strict candidate: a dispatch newer than the last
+ # scheduled run counts as current coverage, so a manual repair is not
+ # reported as that run having omitted the port.
+ if [ "${past_strict_candidate}" -eq 0 ]; then
+ : > "${download_dir}/newest-covered-${found}"
+ fi
+ # Remember which run this port's report actually came from. Reports are
+ # merged across candidates on purpose, so a single run_id would credit
+ # every port to whichever candidate happened to be examined last --
+ # misleading exactly when someone is chasing down a bad report.
+ printf '%s' "${candidate}" > "${download_dir}/source-run-${found}"
+ done < <(port_reports_in "${download_dir}/run-${candidate}")
+ done
+ if [ -z "${run_id}" ]; then
+ echo "No recent ${workflow} run has a port status artifact." >&2
+ continue
+ fi
+
+ # A multi-port producer (the iOS suite emits four, Linux two) can upload some
+ # of its ports and lose the rest when one leg dies before normalization. The
+ # merge then fills those from an older run and everything looks current, so
+ # the newest leg's failure is masked. Only reported when the newest run
+ # produced something: a newest run with no artifact at all is already recorded
+ # above, and saying both would be the same defect twice.
+ if [ -d "${download_dir}/run-${newest_candidate}" ]; then
+ for port in ${owned}; do
+ if [ -f "${download_dir}/covered-${port}" ] \
+ && [ ! -f "${download_dir}/newest-covered-${port}" ] \
+ && [ ! -f "${download_dir}/newest-drift-${port}" ]; then
+ unusable+=("${workflow}: newest run ${newest_candidate} did not report ${port}; an older run supplied it")
+ fi
+ done
+ fi
+
+ while IFS= read -r report; do
+ port="$(jq -r '.port // empty' "${report}")"
+ if [ -z "${port}" ]; then
+ echo "Ignoring ${report}: it names no port." >&2
+ continue
+ fi
+ source_run="${run_id}"
+ if [ -f "${download_dir}/source-run-${port}" ]; then
+ source_run="$(cat "${download_dir}/source-run-${port}")"
+ fi
+ # Publish only what the website will actually serve. A report built against
+ # an older contract passes the freshness check below but is rejected by the
+ # sync, which would leave the public column on its stale fallback while
+ # this sweep reported success.
+ accept_status=0
+ python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${report}" || accept_status=$?
+ if [ "${accept_status}" -ne 0 ]; then
+ echo "Not publishing the ${port} report from run ${source_run}: $(describe_accept_status "${accept_status}")." >&2
+ continue
+ fi
+ generated="$(jq -r '.generated_at // empty' "${report}")"
+ current=""
+ if gh api "repos/${GITHUB_REPOSITORY}/contents/ports/${port}.json?ref=${DATA_BRANCH}" \
+ --jq '.content' 2>/dev/null | decode_base64 > "${tmp_dir}/current.json" 2>/dev/null; then
+ current="$(jq -r '.generated_at // empty' "${tmp_dir}/current.json" 2>/dev/null || true)"
+ fi
+ # Compare instants rather than strings. The gate accepts any timezone-aware
+ # timestamp, and "2026-08-01T01:00:00+02:00" sorts after
+ # "2026-08-01T00:00:00Z" while being an hour older, so a lexical test can
+ # overwrite a newer report or refuse a genuinely newer one.
+ if [ -n "${current}" ] && ! newer_instant "${generated}" "${current}"; then
+ skipped=$((skipped + 1))
+ continue
+ fi
+ echo "Publishing ${port} from run ${source_run} of ${workflow} (${generated})."
+ PORT_STATUS_PUBLISH=1 "${SCRIPT_DIR}/publish_port_status.sh" "${report}"
+ published=$((published + 1))
+ done < <(find "${download_dir}" -maxdepth 1 -type f -name 'port-status-*.json' | sort)
+done < <(jq -r '[.ports[].workflow] | unique | .[]' "${MANIFEST}")
+
+echo "Port status sweep: published ${published} report(s), ${skipped} already current."
+
+# Assert the outcome rather than trusting it: a port whose newest published
+# report is outside the staleness window renders as unknown on the public
+# table, which is exactly the failure this sweep exists to prevent.
+stale_days="$(jq -r '.stale_after_days' "${MANIFEST}")"
+problems=()
+while IFS= read -r port; do
+ if ! gh api "repos/${GITHUB_REPOSITORY}/contents/ports/${port}.json?ref=${DATA_BRANCH}" \
+ --jq '.content' 2>/dev/null | decode_base64 > "${tmp_dir}/check.json" 2>/dev/null; then
+ problems+=("${port}: no published report")
+ continue
+ fi
+ # Freshness alone is not enough: a published report the website rejects
+ # leaves the column on its checked-in fallback, which is the state this
+ # sweep exists to detect.
+ accept_status=0
+ python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${tmp_dir}/check.json" >/dev/null || accept_status=$?
+ if [ "${accept_status}" -ne 0 ]; then
+ problems+=("${port}: published report is $(describe_accept_status "${accept_status}")")
+ continue
+ fi
+ generated="$(jq -r '.generated_at // empty' "${tmp_dir}/check.json" 2>/dev/null || true)"
+ # Compare elapsed seconds, not whole days: the page marks a report stale the
+ # moment its exact age passes the window, so flooring to days would keep this
+ # green for almost another day after the column had already gone stale.
+ age_seconds="$(python3 - "${generated}" <<'AGE'
+import sys
+from datetime import datetime, timezone
+
+raw = sys.argv[1]
+try:
+ stamp = datetime.fromisoformat(raw.replace("Z", "+00:00"))
+except ValueError:
+ print("unreadable")
+else:
+ print("unreadable" if stamp.tzinfo is None
+ else int((datetime.now(timezone.utc) - stamp).total_seconds()))
+AGE
+)"
+ stale_seconds=$((stale_days * 86400))
+ # The publication gate deliberately tolerates an hour of clock skew, so a
+ # report it accepted can legitimately carry a timestamp a little ahead of
+ # now. Calling that unreadable here would fail the nightly job over a report
+ # the same run just published, until wall time caught up. An unparseable or
+ # zone-less stamp prints "unreadable" from the helper above and is still a
+ # problem.
+ if [ "${age_seconds}" = "unreadable" ]; then
+ problems+=("${port}: unreadable generated_at ${generated:-}")
+ elif [ "${age_seconds}" -lt "-${future_skew_seconds}" ]; then
+ problems+=("${port}: generated_at ${generated} is $(( -age_seconds / 60 )) minutes in the future")
+ elif [ "${age_seconds}" -gt "${stale_seconds}" ]; then
+ problems+=("${port}: last report is $((age_seconds / 3600)) hours old (limit ${stale_days} days)")
+ fi
+done < <(jq -r '.ports[].id' "${MANIFEST}")
+
+if [ ${#problems[@]} -gt 0 ]; then
+ echo "Ports without a current compliance report:" >&2
+ printf ' %s\n' "${problems[@]}" >&2
+ echo "Fix the producing workflow; the public table cannot report a port it never hears from." >&2
+ exit 1
+fi
+
+if [ ${#unusable[@]} -gt 0 ]; then
+ echo "Ports whose newest report was unusable (an older one is still being served):" >&2
+ printf ' %s\n' "${unusable[@]}" >&2
+ echo "The table is current, but the producer is emitting reports the website cannot read." >&2
+ exit 1
+fi
+
+echo "Every port in the contract has a report inside the ${stale_days}-day window."
diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py
index e4e11980c26..02d8c4166ec 100755
--- a/scripts/hellocodenameone/conformance/port_status.py
+++ b/scripts/hellocodenameone/conformance/port_status.py
@@ -10,7 +10,7 @@
import re
import sys
from collections import Counter
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -25,6 +25,16 @@
)
COMMON_SOURCES = REPO_ROOT / "scripts/hellocodenameone/common/src/main"
STRICT_GATE_FAILED = 10
+# "accept" exit codes: the caller keeps the checked-in fallback for both, but
+# only an unusable report is a defect worth failing the website build over.
+ACCEPT_CONTRACT_DRIFT = 11
+ACCEPT_UNUSABLE = 12
+
+# Producers and this checker can disagree by a little without anything
+# being wrong -- runner clocks drift, and a report is stamped slightly
+# before it is published. An hour absorbs that; a skewed clock or a
+# mistyped --generated-at lands far outside it.
+FUTURE_STAMP_TOLERANCE = timedelta(hours=1)
START_RE = re.compile(r"suite starting test=([A-Za-z0-9_]+)")
FINISH_RE = re.compile(r"suite finished test=([A-Za-z0-9_]+)")
@@ -581,6 +591,252 @@ def strict_report_errors(report: dict) -> list[str]:
return errors
+def describe_workload_gap(seen: list[str], expected: list[str]) -> str:
+ """Name what is missing or extra, not what happens to be fine.
+
+ Printing the workloads that *are* covered leaves the reader to diff two
+ ten-item lists by eye to find the one that is not.
+ """
+ absent = sorted(set(expected) - set(seen))
+ unexpected = sorted(set(seen) - set(expected))
+ parts = []
+ if absent:
+ parts.append("missing " + ", ".join(absent))
+ if unexpected:
+ parts.append("unexpected " + ", ".join(unexpected))
+ return "; ".join(parts) if parts else "counts differ"
+
+
+def publishable_report_problems(
+ manifest: dict, port_id: str, report: dict
+) -> tuple[list[str], list[str]]:
+ """Decide whether a persisted report may replace the checked-in fallback.
+
+ Returns (drift, malformed). Drift means the report is well formed but was
+ produced against a different revision of the test contract, which happens
+ for every port between the commit that registers a test and that port's
+ next master run; the caller keeps the checked-in report and waits. Anything
+ in malformed is a defect in the report or in the producer and must be loud:
+ silently falling back for those is what lets a whole column of the public
+ table rot into "stale" while the port itself is healthy.
+ """
+ drift: list[str] = []
+ malformed: list[str] = []
+
+ if report.get("schema_version") != manifest.get("schema_version"):
+ malformed.append(
+ f"schema version {report.get('schema_version')!r} is not "
+ f"{manifest.get('schema_version')!r}"
+ )
+ if report.get("port") != port_id:
+ malformed.append(f"report identifies port {report.get('port')!r}")
+ generated_at = report.get("generated_at")
+ if not isinstance(generated_at, str) or not generated_at:
+ malformed.append("report has no generated_at timestamp")
+ else:
+ # Anything unparseable ("unknown") would sail through publication and
+ # then break both the freshness sweep and the page's own time
+ # rendering, so classify it as unusable here instead.
+ try:
+ stamp = datetime.fromisoformat(generated_at.replace("Z", "+00:00"))
+ except ValueError:
+ malformed.append(f"generated_at {generated_at!r} is not a timestamp")
+ else:
+ if stamp.tzinfo is None:
+ malformed.append(f"generated_at {generated_at!r} has no time zone")
+ elif stamp - datetime.now(timezone.utc) > FUTURE_STAMP_TOLERANCE:
+ # A clock skewed far ahead poisons the data branch rather than
+ # just looking odd: the page reads the report as permanently
+ # fresh, and the sweep's lexical "is this newer" comparison
+ # then refuses every later, correct timestamp. Nothing
+ # downstream can recover from that, so refuse it at the gate.
+ malformed.append(
+ f"generated_at {generated_at!r} is in the future"
+ )
+
+ mapped = test_to_feature(manifest)
+ tests = report.get("tests")
+ if not isinstance(tests, dict):
+ malformed.append("report has no test result map")
+ tests = {}
+ else:
+ missing = sorted(set(mapped) - set(tests))
+ unknown = sorted(set(tests) - set(mapped))
+ if missing:
+ drift.append("report predates tests: " + ", ".join(missing))
+ if unknown:
+ drift.append("report carries retired tests: " + ", ".join(unknown))
+
+ statuses = Counter()
+ for test, result in tests.items():
+ # isinstance before membership, not just membership. An unhashable
+ # status -- a producer writing a list or an object -- raises TypeError
+ # out of the `in` test, and main() catches only ContractError, so the
+ # gate died with a status the sweep does not read as unusable and fell
+ # back to an older report while finishing green.
+ if not isinstance(result, dict) or not isinstance(result.get("status"), str) \
+ or result["status"] not in {"pass", "fail", "skip", "not-run"}:
+ malformed.append(f"invalid result for {test}")
+ continue
+ # The reason list is optional, but when present the feature template
+ # calls len, range and hasPrefix on it. A producer emitting "reasons":
+ # true or an object would pass the status check here, reach the data
+ # branch, and then fail the Hugo build -- taking the whole site down
+ # rather than being classified as one unusable report.
+ reasons = result.get("reasons")
+ if reasons is not None and (
+ not isinstance(reasons, list)
+ or not all(isinstance(item, str) for item in reasons)
+ ):
+ malformed.append(f"{test} reasons is not an array of strings")
+ continue
+ statuses[result["status"]] += 1
+ expected_summary = {
+ key: statuses.get(key, 0) for key in ("pass", "fail", "skip", "not-run")
+ }
+ # Checked even under drift. The summary counts the results the report
+ # actually carries, so it stays self-consistent whether or not the report
+ # predates a test -- suppressing this whenever any drift was seen let a
+ # genuinely malformed report be filed as mere drift and fall back quietly,
+ # which is the outcome the loud/quiet split exists to avoid.
+ # Types before values. Python treats True as equal to 1, so a summary count
+ # serialized as a JSON boolean compared equal to a genuine count of one and
+ # sailed through the equality below -- Android's "skip": 1 becoming "skip":
+ # true is accepted, published, and rendered by Hugo as "true skipped".
+ summary = report.get("summary")
+ if not isinstance(summary, dict):
+ malformed.append("report has no summary")
+ elif any(
+ isinstance(summary.get(key), bool)
+ or not isinstance(summary.get(key), int)
+ or summary.get(key) < 0
+ for key in ("pass", "fail", "skip", "not-run")
+ ):
+ malformed.append("summary counts are not non-negative integers")
+ elif summary != expected_summary:
+ malformed.append("summary does not match the test results")
+
+ expected_benchmarks = manifest.get("performance_benchmarks", [])
+ performance = report.get("performance")
+ if not isinstance(performance, dict):
+ malformed.append("report has no performance section")
+ return drift, malformed
+ # CommonWorkloadBenchmarkTest runs late in Cn1ssDeviceRunner, so a suite that
+ # crashed or timed out never reaches it and its performance section is
+ # partial by construction. Rejecting the report for that would throw away the
+ # very evidence the page needs -- the fail / not-run counts -- and leave the
+ # table serving the last green run, which is the failure-masked-as-pass shape
+ # this gate exists to prevent. Completeness is therefore only required of a
+ # suite that actually finished; a partial section simply is not presentable
+ # as performance. Structural defects below stay loud either way, because
+ # those are producer bugs whatever the suite did.
+ # An actual boolean, not anything truthy. bool() accepted the string "false"
+ # and the integer 1 as a completed suite, and Hugo reads the same value as
+ # truthy in port-status-port-state.html -- so a report with no failures but a
+ # pile of not-run tests rendered a green "Suite completed" card.
+ suite_finished_raw = report.get("suite_finished")
+ if not isinstance(suite_finished_raw, bool):
+ malformed.append(
+ f"suite_finished is {type(suite_finished_raw).__name__}, not a boolean"
+ )
+ suite_finished_raw = False
+ suite_finished = suite_finished_raw
+
+ # Validated before anything joins or iterates it. A producer emitting
+ # "missing": true or a number raised TypeError out of the join below, and
+ # main() only catches ContractError -- so the gate crashed instead of
+ # answering ACCEPT_UNUSABLE, the sweep saw a status it does not treat as
+ # unusable, fell back to an older report and finished green.
+ declared_missing = performance.get("missing")
+ if declared_missing is None:
+ declared_missing = []
+ if not isinstance(declared_missing, list) or not all(
+ isinstance(item, str) for item in declared_missing
+ ):
+ malformed.append("performance missing list is not an array of workload names")
+ declared_missing = []
+
+ # Validated for every report, not only finished ones. port-status.html does
+ # `eq .status "complete"`, and Hugo aborts the whole site build with an
+ # incompatible-types error when that compares a map against a string -- so a
+ # malformed status on an UNFINISHED report used to skip validation entirely
+ # (the workload keys still accounted for) and take the build down.
+ perf_status = performance.get("status")
+ if perf_status not in ("complete", "partial"):
+ malformed.append(f"performance status is {perf_status!r}")
+ if suite_finished:
+ if perf_status != "complete":
+ malformed.append(f"performance run is {perf_status!r}")
+ if declared_missing:
+ malformed.append(
+ "performance workloads never reported: " + ", ".join(declared_missing)
+ )
+
+ benchmarks = performance.get("benchmarks")
+ skipped = performance.get("skipped")
+ if skipped is None:
+ # Absent is tolerated; a wrong type is not. `or {}` coerced a falsy
+ # non-dict -- "skipped": [] -- into {} and walked it straight past the
+ # isinstance check below.
+ skipped = {}
+ if not isinstance(benchmarks, dict) or not isinstance(skipped, dict):
+ malformed.append("performance results are not objects")
+ return drift, malformed
+
+ # A workload is measured or skipped, never both. Unioning the keys let a
+ # report claim both for the same workload and still look complete, which
+ # hides the producer bug that wrote it twice.
+ both = sorted(set(benchmarks) & set(skipped))
+ if both:
+ malformed.append(
+ "performance workloads both measured and skipped: " + ", ".join(both)
+ )
+
+ # A port may legitimately skip a workload (the iOS simulator skips the
+ # GC-footprint workloads); measured plus skipped has to cover the contract.
+ accounted = sorted(set(benchmarks) | set(skipped))
+ if suite_finished:
+ if accounted != sorted(expected_benchmarks):
+ malformed.append(
+ "performance workloads do not match the contract: "
+ + describe_workload_gap(accounted, expected_benchmarks)
+ )
+ else:
+ # A crashed suite is allowed to leave workloads unrun, but not to lose
+ # them silently: normalize computes `missing` as the contract minus what
+ # was measured or skipped, so measured + skipped + missing still has to
+ # name every workload. Dropping that check entirely would let a
+ # structurally broken section through unnoticed.
+ covered = sorted(set(accounted) | set(declared_missing))
+ if covered != sorted(expected_benchmarks):
+ malformed.append(
+ "performance workloads unaccounted for: "
+ + describe_workload_gap(covered, expected_benchmarks)
+ )
+ # A report that names workloads it never ran cannot also call the run
+ # complete. normalize never writes that pair, but nothing downstream
+ # re-derives the status: port-status.html renders the timings of any
+ # benchmark whose status is "complete", so the partial set that a
+ # crashed suite did manage to measure would be presented as a finished
+ # measurement run. accounted | missing can still cover the contract, so
+ # the check above does not catch it.
+ if declared_missing and performance.get("status") != "partial":
+ malformed.append(
+ "performance run is "
+ f"{performance.get('status')!r} but declares missing workloads: "
+ + ", ".join(declared_missing)
+ )
+ for name, measurement in benchmarks.items():
+ duration = measurement.get("duration_ns") if isinstance(measurement, dict) else None
+ if isinstance(duration, bool) or not isinstance(duration, int) or duration < 0:
+ malformed.append(f"{name} has no measured duration")
+ for name, reason in skipped.items():
+ if not isinstance(reason, str) or not reason:
+ malformed.append(f"skipped workload {name} has no reason")
+
+ return drift, malformed
+
+
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -592,6 +848,13 @@ def build_parser() -> argparse.ArgumentParser:
subparsers.add_parser("validate", help="validate feature and screenshot coverage")
+ accept_parser = subparsers.add_parser(
+ "accept",
+ help="decide whether a persisted report may replace the checked-in fallback",
+ )
+ accept_parser.add_argument("--port", required=True)
+ accept_parser.add_argument("--report", required=True, type=Path)
+
normalize_parser = subparsers.add_parser("normalize", help="write a normalized port report")
normalize_parser.add_argument("--port", required=True)
normalize_parser.add_argument("--log", action="append", type=Path, default=[])
@@ -621,6 +884,20 @@ def main() -> int:
f"{counts['ports']} ports, {counts['goldens']} golden names."
)
return 0
+ if args.command == "accept":
+ drift, malformed = publishable_report_problems(
+ manifest, args.port, read_json(args.report)
+ )
+ for problem in malformed:
+ print(f"port-status: {args.port} report is unusable: {problem}", file=sys.stderr)
+ for problem in drift:
+ print(f"port-status: {args.port} {problem}", file=sys.stderr)
+ if malformed:
+ return ACCEPT_UNUSABLE
+ if drift:
+ return ACCEPT_CONTRACT_DRIFT
+ print(f"{args.port} report accepted.")
+ return 0
report = normalize(
manifest=manifest,
port_id=args.port,
diff --git a/scripts/hellocodenameone/conformance/publish_port_status.sh b/scripts/hellocodenameone/conformance/publish_port_status.sh
index a4f676acfaa..30e45ce6368 100755
--- a/scripts/hellocodenameone/conformance/publish_port_status.sh
+++ b/scripts/hellocodenameone/conformance/publish_port_status.sh
@@ -17,18 +17,44 @@ if [ "${PORT_STATUS_PUBLISH:-}" != "1" ] && { [ "${GITHUB_ACTIONS:-}" != "true"
fi
branch="port-status-data"
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
port="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["port"])' "$report")"
-performance_status="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("performance", {}).get("status", "missing"))' "$report")"
-if [ "$performance_status" != "complete" ]; then
- echo "Not publishing incomplete ${port} performance data (${performance_status}); preserving the last complete report."
- exit 0
-fi
-if ! command -v gh >/dev/null 2>&1; then
- echo "GitHub CLI is required to publish port status." >&2
- exit 2
+# One acceptance rule, shared with the backfill sweep, rather than a second
+# opinion here. This used to refuse anything whose performance run was not
+# "complete" -- but a suite that crashes never reaches the benchmark, so its
+# report is partial by construction and that rule dropped precisely the reports
+# carrying fail / not-run evidence, leaving the table on the last green one.
+# port_status.py decides; a partial performance section is simply not presented
+# as performance.
+accept_status=0
+python3 "${script_dir}/port_status.py" accept --port "${port}" --report "${report}" || accept_status=$?
+if [ "${accept_status}" -ne 0 ]; then
+ case "${accept_status}" in
+ 11) reason="built against a different test contract; waiting for a run on the current one" ;;
+ 12) reason="not usable by the website" ;;
+ *) reason="rejected by the publication gate (status ${accept_status})" ;;
+ esac
+ echo "Not publishing the ${port} report: ${reason}; preserving the last published one."
+ # Contract drift is the one case that waits quietly: a report built before a
+ # newly registered test is expected, and the next run resolves it. Anything
+ # else -- an unusable report, or a gate failure nobody anticipated -- is a
+ # producer defect, and exiting zero here made port-status-publish.yml go green
+ # and rebuild the site off the PREVIOUS report, so the defect stayed invisible
+ # until some later nightly sweep happened to notice it.
+ if [ "${accept_status}" -eq 11 ]; then
+ exit 0
+ fi
+ exit "${accept_status}"
fi
+for tool in gh jq python3; do
+ if ! command -v "${tool}" >/dev/null 2>&1; then
+ echo "${tool} is required to publish port status." >&2
+ exit 2
+ fi
+done
+
repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
target="ports/${port}.json"
@@ -42,8 +68,53 @@ if ! gh api "repos/${repo}/git/ref/heads/${branch}" >/dev/null 2>&1; then
fi
content="$(base64 < "$report" | tr -d '\n')"
+generated="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("generated_at", ""))' "$report")"
+
+# GNU spells it --decode, BSD -D.
+decode_base64() {
+ base64 --decode 2>/dev/null || base64 -D
+}
+
+# True when $1 is strictly later than $2; both ISO-8601 and timezone-aware, so
+# they are compared as instants rather than as text.
+newer_instant() {
+ python3 - "$1" "$2" <<'INSTANT'
+import sys
+from datetime import datetime
+
+def parse(value):
+ try:
+ stamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ return stamp if stamp.tzinfo is not None else None
+
+candidate = parse(sys.argv[1])
+current = parse(sys.argv[2])
+sys.exit(0 if candidate is not None and (current is None or candidate > current) else 1)
+INSTANT
+}
+
for attempt in 1 2 3; do
- existing_sha="$(gh api "repos/${repo}/contents/${target}?ref=${branch}" --jq .sha 2>/dev/null || true)"
+ # Read the sha AND the stored timestamp together, and re-read both on every
+ # retry. A scheduled producer's workflow_run publisher can land a newer report
+ # between the sweep deciding to publish and this PUT; retrying on the fresh sha
+ # alone would then overwrite it, and the sweep's own freshness check would still
+ # pass because the older report it wrote is inside the window. The sha is the
+ # compare-and-swap; this timestamp check is what makes the swap meaningful.
+ existing_json="$(gh api "repos/${repo}/contents/${target}?ref=${branch}" 2>/dev/null || true)"
+ existing_sha=""
+ existing_generated=""
+ if [ -n "${existing_json}" ]; then
+ existing_sha="$(printf '%s' "${existing_json}" | jq -r '.sha // empty' 2>/dev/null || true)"
+ existing_generated="$(printf '%s' "${existing_json}" | jq -r '.content // empty' 2>/dev/null \
+ | decode_base64 2>/dev/null | jq -r '.generated_at // empty' 2>/dev/null || true)"
+ fi
+ if [ -n "${existing_generated}" ] && [ -n "${generated}" ] \
+ && ! newer_instant "${generated}" "${existing_generated}"; then
+ echo "Not publishing ${target}: the branch already holds a report at ${existing_generated} (ours is ${generated})."
+ exit 0
+ fi
args=(
--method PUT
"repos/${repo}/contents/${target}"
diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py
index b1d0dd9a893..92f4d75da4a 100755
--- a/scripts/hellocodenameone/conformance/test_port_status.py
+++ b/scripts/hellocodenameone/conformance/test_port_status.py
@@ -3,6 +3,7 @@
import json
import tempfile
import unittest
+from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
@@ -251,6 +252,361 @@ def test_validate_rejects_inconsistent_stored_report_summary(self):
):
port_status.validate(manifest)
+ def publishable_report(self, port_id, **overrides):
+ mapped = port_status.test_to_feature(self.manifest)
+ tests = {
+ test: {"status": "pass", "feature": feature}
+ for test, feature in mapped.items()
+ }
+ report = {
+ "schema_version": self.manifest["schema_version"],
+ "port": port_id,
+ "generated_at": "2026-07-30T09:24:29Z",
+ "suite_finished": True,
+ "summary": {"pass": len(tests), "fail": 0, "skip": 0, "not-run": 0},
+ "tests": tests,
+ "performance": {
+ "status": "complete",
+ "benchmark_version": 1,
+ "missing": [],
+ "skipped": {},
+ "benchmarks": {
+ benchmark: {"duration_ns": 12000000, "checksum": "42"}
+ for benchmark in self.manifest["performance_benchmarks"]
+ },
+ },
+ }
+ report.update(overrides)
+ return report
+
+ def test_publishable_accepts_a_report_that_skips_workloads(self):
+ # The shape every iOS, tvOS, and watchOS run produces: the simulator
+ # skips the three GC-footprint workloads and measures the other seven.
+ report = self.publishable_report("ios-gl")
+ for benchmark in ("objectAllocation", "hashMapChurn", "stringBuilding"):
+ del report["performance"]["benchmarks"][benchmark]
+ report["performance"]["skipped"][benchmark] = "ios-simulator-gc-footprint"
+
+ self.assertEqual(([], []), port_status.publishable_report_problems(
+ self.manifest, "ios-gl", report
+ ))
+
+ def test_publishable_accepts_a_documented_test_skip(self):
+ report = self.publishable_report("android")
+ report["tests"]["CameraApiTest"]["status"] = "skip"
+ report["summary"]["pass"] -= 1
+ report["summary"]["skip"] += 1
+
+ self.assertEqual(([], []), port_status.publishable_report_problems(
+ self.manifest, "android", report
+ ))
+
+ def test_publishable_rejects_a_timestamp_from_the_future(self):
+ # A skewed producer clock poisons the data branch rather than merely
+ # looking odd: the page reads the report as permanently fresh, and the
+ # sweep's "is this newer" comparison then refuses every later correct
+ # timestamp. Nothing downstream can undo it, so it has to be refused
+ # here.
+ ahead = datetime.now(timezone.utc) + timedelta(days=400)
+ report = self.publishable_report(
+ "android", generated_at=ahead.strftime("%Y-%m-%dT%H:%M:%SZ")
+ )
+ drift, malformed = port_status.publishable_report_problems(
+ self.manifest, "android", report
+ )
+ self.assertEqual([], drift)
+ self.assertTrue(any("future" in problem for problem in malformed), malformed)
+
+ def test_publishable_allows_a_little_clock_skew(self):
+ # Runner clocks drift and a report is stamped a moment before it is
+ # published, so being marginally ahead is normal rather than a defect.
+ skewed = datetime.now(timezone.utc) + timedelta(minutes=5)
+ report = self.publishable_report(
+ "android", generated_at=skewed.strftime("%Y-%m-%dT%H:%M:%SZ")
+ )
+ self.assertEqual(([], []), port_status.publishable_report_problems(
+ self.manifest, "android", report
+ ))
+
+ def test_publishable_separates_contract_drift_from_a_broken_report(self):
+ report = self.publishable_report("android")
+ del report["tests"]["CameraApiTest"]
+ # The summary counts the results the report carries, so dropping a test
+ # means dropping its count too. Leaving it stale would now -- correctly
+ # -- be a malformed report rather than pure drift.
+ report["summary"]["pass"] -= 1
+ drift, malformed = port_status.publishable_report_problems(
+ self.manifest, "android", report
+ )
+ self.assertEqual([], malformed)
+ self.assertIn("CameraApiTest", drift[0])
+
+ def test_publishable_rejects_a_stale_summary_even_under_drift(self):
+ # Drift used to suppress the summary check entirely, so a genuinely
+ # broken report could be filed as drift and fall back quietly.
+ report = self.publishable_report("android")
+ del report["tests"]["CameraApiTest"]
+
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "android", report
+ )
+ self.assertTrue(any("summary" in item for item in malformed), malformed)
+
+ def test_publishable_rejects_a_workload_both_measured_and_skipped(self):
+ report = self.publishable_report("linux-x64")
+ report["performance"]["skipped"]["quicksort"] = "some-reason"
+
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(
+ any("both measured and skipped" in item for item in malformed), malformed
+ )
+
+ def test_workload_gap_message_names_the_offender(self):
+ # The message must point at the workload that is wrong, not list the
+ # nine that are fine.
+ message = port_status.describe_workload_gap(
+ ["a", "c"], ["a", "b"]
+ )
+ self.assertIn("missing b", message)
+ self.assertIn("unexpected c", message)
+
+ def test_publishable_rejects_unaccounted_and_unmeasured_workloads(self):
+ report = self.publishable_report("linux-x64")
+ del report["performance"]["benchmarks"]["quicksort"]
+ report["performance"]["benchmarks"]["recursion"]["duration_ns"] = None
+ drift, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertEqual([], drift)
+ self.assertEqual(2, len(malformed), malformed)
+ self.assertTrue(any("do not match the contract" in item for item in malformed))
+ self.assertTrue(any("recursion" in item for item in malformed))
+
+ def test_publishable_rejects_an_incomplete_or_mislabelled_run(self):
+ for mutate, expected in (
+ (lambda report: report["performance"].update({"status": "partial"}), "partial"),
+ (lambda report: report["performance"].update({"missing": ["quicksort"]}), "quicksort"),
+ (lambda report: report.update({"port": "android"}), "android"),
+ (lambda report: report["summary"].update({"pass": 3}), "summary"),
+ ):
+ with self.subTest(expected=expected):
+ report = self.publishable_report("watchos")
+ mutate(report)
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "watchos", report
+ )
+ self.assertTrue(any(expected in item for item in malformed), malformed)
+
+ def test_publishable_accepts_a_crashed_suite_with_partial_performance(self):
+ # A suite that dies before CommonWorkloadBenchmarkTest (which runs late)
+ # cannot produce a complete performance section. That report is exactly
+ # the one the page must publish -- refusing it leaves the table serving
+ # the previous green run, hiding the failure behind a stale pass.
+ report = self.publishable_report("linux-x64")
+ report["suite_finished"] = False
+ report["performance"].update({
+ "status": "partial",
+ "missing": sorted(self.manifest["performance_benchmarks"])[3:],
+ "benchmarks": {
+ benchmark: {"duration_ns": 12000000, "checksum": "42"}
+ for benchmark in sorted(self.manifest["performance_benchmarks"])[:3]
+ },
+ })
+ failed, not_run = "ClipboardRoundTripTest", "MutableImageReadbackTest"
+ report["tests"][failed]["status"] = "fail"
+ report["tests"][not_run]["status"] = "not-run"
+ report["summary"] = {
+ "pass": len(report["tests"]) - 2, "fail": 1, "skip": 0, "not-run": 1
+ }
+
+ self.assertEqual(([], []), port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ ))
+
+ def test_publishable_still_rejects_partial_performance_when_the_suite_finished(self):
+ # The concession above is scoped to a suite that did not finish. A run
+ # that claims completion may not quietly drop workloads.
+ report = self.publishable_report("linux-x64")
+ report["performance"]["status"] = "partial"
+ del report["performance"]["benchmarks"]["quicksort"]
+
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(any("partial" in item for item in malformed), malformed)
+ self.assertTrue(
+ any("do not match the contract" in item for item in malformed), malformed
+ )
+
+ def test_publishable_still_rejects_structural_defects_from_a_crashed_suite(self):
+ # Producer bugs stay loud whatever the suite did: an unmeasured workload
+ # and a reasonless skip are defects, not consequences of crashing.
+ report = self.publishable_report("linux-x64")
+ report["suite_finished"] = False
+ report["performance"]["benchmarks"]["recursion"]["duration_ns"] = None
+ del report["performance"]["benchmarks"]["quicksort"]
+ report["performance"]["skipped"]["quicksort"] = ""
+
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(any("recursion" in item for item in malformed), malformed)
+ self.assertTrue(any("quicksort" in item for item in malformed), malformed)
+
+ def test_publishable_rejects_a_crashed_suite_that_loses_a_workload(self):
+ # The crashed-suite concession is not a hole: normalize derives
+ # `missing` from the contract, so measured + skipped + missing must
+ # still name every workload even when the suite died.
+ report = self.publishable_report("linux-x64")
+ report["suite_finished"] = False
+ report["performance"]["status"] = "partial"
+ del report["performance"]["benchmarks"]["quicksort"]
+ report["performance"]["missing"] = []
+
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(any("unaccounted for" in item for item in malformed), malformed)
+
+ def test_publishable_rejects_a_wrongly_typed_skipped_section(self):
+ # "skipped": [] is malformed, not "no skips" -- `or {}` used to coerce
+ # it past the type check.
+ report = self.publishable_report("linux-x64")
+ report["performance"]["skipped"] = []
+
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(any("not objects" in item for item in malformed), malformed)
+
+ def test_publishable_rejects_a_malformed_missing_field(self):
+ # "missing": true used to raise TypeError out of the join, and main()
+ # catches only ContractError -- so the gate crashed with a status the
+ # sweep does not treat as unusable, and it fell back quietly.
+ for bad in (True, 7, "quicksort", ["quicksort", 3]):
+ with self.subTest(bad=bad):
+ report = self.publishable_report("linux-x64")
+ report["performance"]["missing"] = bad
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(
+ any("missing list" in item for item in malformed), malformed)
+
+ def test_publishable_rejects_a_complete_label_on_partial_benchmark_data(self):
+ # normalize writes "partial" whenever a workload went unrun, but nothing
+ # downstream re-derives it: port-status.html renders the timings of any
+ # benchmark whose status is "complete". A crashed suite that mislabelled
+ # itself would present the handful of workloads it managed to measure as
+ # a finished measurement run, and measured | missing still covers the
+ # contract so the accounting check above stays quiet.
+ report = self.publishable_report("linux-x64")
+ report["suite_finished"] = False
+ report["performance"]["status"] = "complete"
+ report["performance"]["missing"] = ["quicksort"]
+ del report["performance"]["benchmarks"]["quicksort"]
+
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(
+ any("declares missing workloads" in item for item in malformed), malformed
+ )
+
+ def test_publishable_rejects_a_malformed_reason_list(self):
+ # The reason list is optional, but the feature template calls len, range
+ # and hasPrefix on whatever is there. A wrongly typed one has to be
+ # caught as a single unusable report rather than reaching the data
+ # branch and failing the Hugo build for the whole site.
+ for bad in (True, 7, "flaky", {"why": "flaky"}, ["ok", 3]):
+ with self.subTest(bad=bad):
+ report = self.publishable_report("linux-x64")
+ report["tests"]["ClipboardRoundTripTest"]["reasons"] = bad
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(
+ any("reasons is not an array of strings" in item
+ for item in malformed), malformed)
+
+ def test_publishable_accepts_a_well_formed_reason_list(self):
+ report = self.publishable_report("linux-x64")
+ report["tests"]["ClipboardRoundTripTest"]["reasons"] = ["documented skip"]
+
+ self.assertEqual(([], []), port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ ))
+
+ def test_publishable_requires_a_boolean_suite_completion_marker(self):
+ # bool() accepted "false" and 1 as a finished suite, and Hugo reads the
+ # same value as truthy -- so a report with no failures and a pile of
+ # not-run tests rendered a green "Suite completed" card.
+ for bad in ("false", "true", 1, 0, [], {}):
+ with self.subTest(bad=bad):
+ report = self.publishable_report("linux-x64")
+ report["suite_finished"] = bad
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(
+ any("suite_finished" in item for item in malformed), malformed)
+
+ def test_publishable_rejects_an_unhashable_test_status(self):
+ # A list status raises TypeError out of the set-membership test, and
+ # main() catches only ContractError -- so the gate died with a status the
+ # sweep does not read as unusable and fell back quietly.
+ for bad in (["skip"], {"status": "skip"}, 3, None):
+ with self.subTest(bad=bad):
+ report = self.publishable_report("linux-x64")
+ report["tests"]["ClipboardRoundTripTest"]["status"] = bad
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(
+ any("invalid result" in item for item in malformed), malformed)
+
+ def test_publishable_rejects_boolean_summary_counts(self):
+ # Python considers True == 1, so a count of exactly one serialized as a
+ # JSON boolean compared equal to the calculated summary and published;
+ # Hugo then rendered "true skipped".
+ report = self.publishable_report("linux-x64")
+ report["tests"]["ClipboardRoundTripTest"]["status"] = "skip"
+ report["summary"] = {
+ "pass": len(report["tests"]) - 1, "fail": 0, "skip": True, "not-run": 0
+ }
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(
+ any("non-negative integers" in item for item in malformed), malformed)
+
+ def test_publishable_rejects_a_malformed_performance_status_when_unfinished(self):
+ # port-status.html does `eq .status "complete"`, and Hugo aborts the whole
+ # site build when that compares a map with a string. The unfinished branch
+ # used to skip status validation entirely.
+ report = self.publishable_report("linux-x64")
+ report["suite_finished"] = False
+ report["performance"]["status"] = {"state": "partial"}
+ _, malformed = port_status.publishable_report_problems(
+ self.manifest, "linux-x64", report
+ )
+ self.assertTrue(
+ any("performance status is" in item for item in malformed), malformed)
+
+ def test_publishable_matches_every_report_the_site_serves(self):
+ for port in self.manifest["ports"]:
+ report_path = port_status.REPO_ROOT / self.manifest["report_directory"] / (
+ port["id"] + ".json"
+ )
+ with self.subTest(port=port["id"]):
+ drift, malformed = port_status.publishable_report_problems(
+ self.manifest, port["id"], port_status.read_json(report_path)
+ )
+ self.assertEqual([], malformed)
+ self.assertEqual([], drift)
+
if __name__ == "__main__":
unittest.main()
diff --git a/scripts/linux/screenshots-arm/BrowserComponent.png b/scripts/linux/screenshots-arm/BrowserComponent.png
new file mode 100644
index 00000000000..ae2ed972338
Binary files /dev/null and b/scripts/linux/screenshots-arm/BrowserComponent.png differ
diff --git a/scripts/linux/screenshots/BrowserComponent.png b/scripts/linux/screenshots/BrowserComponent.png
new file mode 100644
index 00000000000..ae2ed972338
Binary files /dev/null and b/scripts/linux/screenshots/BrowserComponent.png differ
diff --git a/scripts/setup-workspace.sh b/scripts/setup-workspace.sh
index eedbdff6068..bef96488ae5 100755
--- a/scripts/setup-workspace.sh
+++ b/scripts/setup-workspace.sh
@@ -78,7 +78,13 @@ install_jdk() {
log "Using cached JDK archive $(basename "$archive")"
else
log "Downloading JDK from $url"
- curl -fL --retry 3 --retry-all-errors "$url" -o "$archive"
+ # --retry-delay 15: curl's default backoff is 1s, 2s, 4s, which is nothing
+ # against a rate limit. Every Android job provisions its own JDKs from the
+ # same GitHub release at the same moment, so the whole matrix can trip 429
+ # together and all of it gave up inside seven seconds. curl waits for the
+ # larger of this and any Retry-After the server sends, and --retry-max-time
+ # bounds the whole thing so a genuinely dead mirror still fails the job.
+ curl -fL --retry 6 --retry-delay 15 --retry-max-time 300 --retry-all-errors "$url" -o "$archive"
fi
local top
@@ -130,7 +136,7 @@ if [ -z "${MAVEN_HOME:-}" ] || ! [ -x "$MAVEN_HOME/bin/mvn" ]; then
log "Using cached Maven archive $(basename "$mvn_archive")"
else
log "Downloading Maven from $MAVEN_URL"
- curl -fL --retry 3 --retry-all-errors "$MAVEN_URL" -o "$mvn_archive"
+ curl -fL --retry 6 --retry-delay 15 --retry-max-time 300 --retry-all-errors "$MAVEN_URL" -o "$mvn_archive"
fi
mvn_top=$(tar -tzf "$mvn_archive" 2>/dev/null | head -1 | cut -d/ -f1 || true)
if [ -z "$mvn_top" ]; then
diff --git a/scripts/website/sync_port_status_reports.sh b/scripts/website/sync_port_status_reports.sh
index b4fc0fa9b7d..7debbc3c477 100755
--- a/scripts/website/sync_port_status_reports.sh
+++ b/scripts/website/sync_port_status_reports.sh
@@ -50,29 +50,51 @@ if ! git -C "${REPO_ROOT}" fetch --quiet --no-tags --depth=1 origin "${DATA_REF}
fi
synced=0
+unusable=0
+fallback=()
while IFS= read -r port; do
candidate="${tmp_dir}/${port}.json"
if ! git -C "${REPO_ROOT}" show "FETCH_HEAD:ports/${port}.json" > "${candidate}" 2>/dev/null; then
echo "No persisted ${port} report; keeping the checked-in report." >&2
+ fallback+=("${port} (never published)")
continue
fi
- if ! jq -e --arg port "${port}" --slurpfile contract "${MANIFEST}" '
- .schema_version == $contract[0].schema_version and
- .port == $port and
- ((.tests | keys | sort) == ([$contract[0].features[].tests[]] | sort)) and
- .performance.status == "complete" and
- ([.performance.benchmarks[].duration_ns | type] | all(. == "number")) and
- ([.performance.benchmarks[].duration_ns] | all(. >= 0)) and
- ((.performance.benchmarks | keys) == ($contract[0].performance_benchmarks | sort))
- ' "${candidate}" >/dev/null; then
- echo "Persisted ${port} report does not match the current contract; keeping the checked-in report." >&2
- continue
- fi
+ # One implementation of "may this report be published", shared with the
+ # normalizer's own unit tests. Duplicating it here as a jq expression is what
+ # silently rejected every iOS-family report: those runs legitimately skip
+ # three GC-footprint workloads, and the copy demanded a measurement for all
+ # ten, so the site quietly served the checked-in copy until it went stale.
+ set +e
+ python3 "${REPO_ROOT}/scripts/hellocodenameone/conformance/port_status.py" \
+ accept --port "${port}" --report "${candidate}"
+ accept_rc=$?
+ set -e
+ case "${accept_rc}" in
+ 0) ;;
+ 11)
+ echo "Persisted ${port} report predates the current test contract; keeping the checked-in report." >&2
+ fallback+=("${port} (waiting for a run on the current contract)")
+ continue
+ ;;
+ *)
+ echo "Persisted ${port} report is unusable; keeping the checked-in report." >&2
+ fallback+=("${port} (unusable report)")
+ unusable=$((unusable + 1))
+ continue
+ ;;
+ esac
cp "${candidate}" "${REPORT_DIR}/${port}.json"
synced=$((synced + 1))
done < <(jq -r '.ports[].id' "${MANIFEST}")
echo "Resolved ${synced} Port Status reports from ${DATA_REF}; remaining ports use checked-in reports."
+if [ ${#fallback[@]} -gt 0 ]; then
+ echo "Ports served from the checked-in fallback: ${fallback[*]}" >&2
+fi
+if [ "${unusable}" -gt 0 ]; then
+ echo "${unusable} persisted report(s) are unusable; fix the producing workflow." >&2
+ exit 1
+fi
environment_candidate="${tmp_dir}/environment.json"
environment_target="${REPO_ROOT}/docs/website/data/port_status_environment.json"
diff --git a/scripts/website/validate_port_status.mjs b/scripts/website/validate_port_status.mjs
index ffdaee6f18d..d4532868933 100644
--- a/scripts/website/validate_port_status.mjs
+++ b/scripts/website/validate_port_status.mjs
@@ -167,6 +167,49 @@ function validate() {
fail("the generated page does not contain exhaustive skipped-test errata");
}
+ // A cell may only read as a pass while carrying a skip when the errata name
+ // that exact test, so a green mark can never outrun its explanation.
+ //
+ // Whether any such cell exists at all depends on what the live reports
+ // skipped this round: a round in which every port ran everything is a good
+ // outcome, not a page defect, so requiring at least one would fail the
+ // website build for the best possible reason. What must hold instead is
+ // that the marker and the cell's own label agree. Stale cells are exempt
+ // because staleness drops the marker while keeping the label it replaced.
+ const notedCells = primaryCellTags.filter((cell) => /\bhas-documented-skip\b/.test(cell));
+ const labelledSkipCells = primaryCellTags.filter((cell) =>
+ !/\bis-stale\b/.test(attribute(cell, "class")) &&
+ /skipped by the CI environment/i.test(attribute(cell, "title")));
+ if (notedCells.length !== labelledSkipCells.length) {
+ fail(`documented-skip markers and cell labels disagree: ${notedCells.length} marked, `
+ + `${labelledSkipCells.length} labelled`);
+ }
+ for (const cell of notedCells) {
+ const skips = attribute(cell, "data-documented-skip").split(/\s+/).filter(Boolean);
+ if (!/\bis-pass\b/.test(attribute(cell, "class")) || skips.length === 0 ||
+ !skips.every((test) => errata.includes(test))) {
+ fail(`a cell claims a documented skip the errata do not cover: ${cell}`);
+ }
+ }
+ // Counted inside each documented-skip cell, not across the whole page. A
+ // page-wide tally also picks up the legend's own marker, so a cell that had
+ // lost its was masked by the legend and the check passed while the
+ // reader saw a green cell with nothing pointing at its explanation.
+ //
+ // The production build minifies, which drops the quotes around attribute
+ // values, so match the class without assuming them.
+ const notedCellBodies = Array.from(
+ page.matchAll(/]*\bdata-feature-cell\b)(?=[^>]*\bhas-documented-skip\b)[^>]*>([\s\S]*?)<\/td>/gi),
+ (match) => match[1]);
+ if (notedCellBodies.length !== notedCells.length) {
+ fail(`could not read the body of every documented-skip cell: ${notedCellBodies.length} of ${notedCells.length}`);
+ }
+ const unmarked = notedCellBodies.filter(
+ (body) => !/]*\bcn1-port-status__note\b/.test(body)).length;
+ if (unmarked > 0) {
+ fail(`${unmarked} documented-skip cell(s) carry no visible note marker`);
+ }
+
const manualRows = countMatches(page, /\bdata-manual-feature-row(?:=|\s|>)/g);
const manualCells = countMatches(page, /\bdata-manual-feature-cell(?:=|\s|>)/g);
if (manualRows < 20 || manualCells !== manualRows * portCards) {
diff --git a/scripts/windows/screenshots/SwitchTheme_dark.png b/scripts/windows/screenshots/SwitchTheme_dark.png
new file mode 100644
index 00000000000..bdd97c042d7
Binary files /dev/null and b/scripts/windows/screenshots/SwitchTheme_dark.png differ
diff --git a/scripts/windows/screenshots/SwitchTheme_light.png b/scripts/windows/screenshots/SwitchTheme_light.png
new file mode 100644
index 00000000000..d1eed41994c
Binary files /dev/null and b/scripts/windows/screenshots/SwitchTheme_light.png differ
diff --git a/scripts/windows/screenshots/kotlin.png b/scripts/windows/screenshots/kotlin.png
new file mode 100644
index 00000000000..9d4d5fb8604
Binary files /dev/null and b/scripts/windows/screenshots/kotlin.png differ
diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.c b/vm/ByteCodeTranslator/src/cn1_win_compat.c
index efcfd12fee2..1881d46826a 100644
--- a/vm/ByteCodeTranslator/src/cn1_win_compat.c
+++ b/vm/ByteCodeTranslator/src/cn1_win_compat.c
@@ -39,6 +39,11 @@
#include "cn1_win_compat.h"
+/* Longest single SleepConditionVariableSRW timeout, ~24.8 days. Comfortably
+ below INFINITE (0xFFFFFFFF), so a long wait is never mistaken for one that
+ must not expire. */
+#define CN1_WIN_MAX_WAIT_CHUNK_MS 0x7FFFFFFFu
+
/* The mirror structs in the header must match the real Win32 types exactly. */
_Static_assert(sizeof(cn1_srwlock_t) == sizeof(SRWLOCK), "SRWLOCK layout mismatch");
_Static_assert(sizeof(cn1_condvar_t) == sizeof(CONDITION_VARIABLE), "CONDITION_VARIABLE layout mismatch");
@@ -98,21 +103,38 @@ int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex) {
int pthread_cond_timedwait(pthread_cond_t* cond, pthread_mutex_t* mutex, const struct timespec* abstime) {
/* pthread passes an absolute CLOCK_REALTIME deadline; Win32 wants a
relative millisecond timeout, so convert against the current time. */
+ /* The deadline is 64-bit milliseconds and the Win32 timeout is a 32-bit
+ DWORD, so it cannot simply be narrowed. Object.wait(Long.MAX_VALUE) -- the
+ usual "park until notified" idiom -- produces a wait of ~292 million
+ years; truncating that to 32 bits yields an arbitrary short timeout, and a
+ value whose low word happens to be 0xFFFFFFFF becomes Win32's INFINITE
+ sentinel, so the wait either returns early or never expires at all. Wait
+ in bounded chunks instead and only report a timeout once the ABSOLUTE
+ deadline has genuinely passed. */
struct timeval now;
long long now_ms, abs_ms, wait_ms;
- gettimeofday(&now, NULL);
- now_ms = (long long)now.tv_sec * 1000 + now.tv_usec / 1000;
abs_ms = (long long)abstime->tv_sec * 1000 + abstime->tv_nsec / 1000000;
- wait_ms = abs_ms - now_ms;
- if (wait_ms < 0) {
- wait_ms = 0;
- }
- if (!SleepConditionVariableSRW((PCONDITION_VARIABLE)&cond->cond, (PSRWLOCK)&mutex->lock, (DWORD)wait_ms, 0)) {
- if (GetLastError() == ERROR_TIMEOUT) {
+ for (;;) {
+ DWORD chunk;
+ gettimeofday(&now, NULL);
+ now_ms = (long long)now.tv_sec * 1000 + now.tv_usec / 1000;
+ wait_ms = abs_ms - now_ms;
+ if (wait_ms <= 0) {
return ETIMEDOUT;
}
+ chunk = wait_ms > (long long)CN1_WIN_MAX_WAIT_CHUNK_MS
+ ? CN1_WIN_MAX_WAIT_CHUNK_MS : (DWORD)wait_ms;
+ if (SleepConditionVariableSRW((PCONDITION_VARIABLE)&cond->cond,
+ (PSRWLOCK)&mutex->lock, chunk, 0)) {
+ /* Signalled. A spurious wake reports success too, which is correct:
+ every caller re-tests its predicate. */
+ return 0;
+ }
+ if (GetLastError() != ERROR_TIMEOUT) {
+ return 0;
+ }
+ /* A chunk expired. Loop to find out whether the real deadline did. */
}
- return 0;
}
int pthread_cond_signal(pthread_cond_t* cond) {
@@ -288,4 +310,101 @@ int gettimeofday(struct timeval* tv, void* tz) {
return 0;
}
+/*
+ * IANA time zone offsets.
+ *
+ * The Microsoft C runtime only understands the "EST5EDT" form of TZ, not an
+ * IANA identifier, and its struct tm carries no GMT offset at all -- so the
+ * POSIX path the runtime uses elsewhere reports zero for every named zone.
+ * Windows ships ICU as icu.dll (Windows 10 1703 and later), whose calendar
+ * speaks IANA identifiers directly and knows the daylight rules for the
+ * instant being asked about.
+ *
+ * ICU is resolved at runtime rather than linked: the clean target builds
+ * against a minimal SDK layout that need not carry icu.lib or , and a
+ * host without ICU degrades to the caller's fallback instead of failing to
+ * load. The two enum values used here are fixed by ICU's stable C API --
+ * UCAL_GREGORIAN, and the ZONE_OFFSET / DST_OFFSET calendar fields.
+ */
+#define CN1_UCAL_GREGORIAN 1
+#define CN1_UCAL_ZONE_OFFSET 15
+#define CN1_UCAL_DST_OFFSET 16
+
+typedef void* CN1UCalendar;
+
+static CN1UCalendar (__cdecl *cn1_ucal_open)(const WCHAR*, int32_t, const char*, int32_t, int32_t*);
+static void (__cdecl *cn1_ucal_setMillis)(CN1UCalendar, double, int32_t*);
+static int32_t (__cdecl *cn1_ucal_get)(const CN1UCalendar, int32_t, int32_t*);
+static void (__cdecl *cn1_ucal_close)(CN1UCalendar);
+static int cn1IcuResolved;
+/* Resolution runs under a lock, and cn1IcuResolved is written only once the
+ * function pointers are in place. Publishing "in progress" first, as a plain
+ * flag test would, lets a second thread asking for a zone at startup see a
+ * nonzero value, conclude ICU is unavailable and fall through to the CRT --
+ * which cannot read IANA identifiers, so that one query intermittently
+ * answers UTC. */
+static SRWLOCK cn1IcuLock = SRWLOCK_INIT;
+
+static int cn1IcuAvailable(void) {
+ int resolved;
+ AcquireSRWLockExclusive(&cn1IcuLock);
+ if (cn1IcuResolved == 0) {
+ HMODULE icu = LoadLibraryA("icu.dll");
+ int ok = 0;
+ if (icu != NULL) {
+ cn1_ucal_open = (CN1UCalendar (__cdecl *)(const WCHAR*, int32_t, const char*, int32_t, int32_t*))
+ GetProcAddress(icu, "ucal_open");
+ cn1_ucal_setMillis = (void (__cdecl *)(CN1UCalendar, double, int32_t*))
+ GetProcAddress(icu, "ucal_setMillis");
+ cn1_ucal_get = (int32_t (__cdecl *)(const CN1UCalendar, int32_t, int32_t*))
+ GetProcAddress(icu, "ucal_get");
+ cn1_ucal_close = (void (__cdecl *)(CN1UCalendar)) GetProcAddress(icu, "ucal_close");
+ ok = cn1_ucal_open != 0 && cn1_ucal_setMillis != 0
+ && cn1_ucal_get != 0 && cn1_ucal_close != 0;
+ }
+ cn1IcuResolved = ok ? 1 : -1;
+ }
+ resolved = cn1IcuResolved;
+ ReleaseSRWLockExclusive(&cn1IcuLock);
+ return resolved > 0;
+}
+
+int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut,
+ int* dstOut, int* rawOut) {
+ WCHAR zone[128];
+ CN1UCalendar cal;
+ int32_t status = 0;
+ int32_t zoneOffset, dstOffset;
+ if (zoneId == 0 || zoneId[0] == 0 || !cn1IcuAvailable()) {
+ return 0;
+ }
+ if (MultiByteToWideChar(CP_UTF8, 0, zoneId, -1, zone,
+ (int) (sizeof(zone) / sizeof(zone[0]))) == 0) {
+ return 0;
+ }
+ cal = cn1_ucal_open(zone, -1, "en_US", CN1_UCAL_GREGORIAN, &status);
+ if (status > 0 || cal == 0) {
+ return 0;
+ }
+ cn1_ucal_setMillis(cal, (double) millis, &status);
+ zoneOffset = cn1_ucal_get(cal, CN1_UCAL_ZONE_OFFSET, &status);
+ dstOffset = cn1_ucal_get(cal, CN1_UCAL_DST_OFFSET, &status);
+ cn1_ucal_close(cal);
+ if (status > 0) {
+ return 0;
+ }
+ if (offsetOut != 0) {
+ *offsetOut = (int) (zoneOffset + dstOffset);
+ }
+ if (dstOut != 0) {
+ *dstOut = dstOffset != 0;
+ }
+ if (rawOut != 0) {
+ /* UCAL_ZONE_OFFSET is the standard-time offset on its own; the daylight
+ * adjustment is the separate UCAL_DST_OFFSET field. */
+ *rawOut = (int) zoneOffset;
+ }
+ return 1;
+}
+
#endif /* _WIN32 */
diff --git a/vm/ByteCodeTranslator/src/cn1_win_compat.h b/vm/ByteCodeTranslator/src/cn1_win_compat.h
index 599e02f5446..b44ad66f600 100644
--- a/vm/ByteCodeTranslator/src/cn1_win_compat.h
+++ b/vm/ByteCodeTranslator/src/cn1_win_compat.h
@@ -143,6 +143,20 @@ int gettimeofday(struct timeval* tv, void* tz);
system clock must not stretch or cut the remaining sleep. */
long long cn1_monotonic_micros(void);
+/* --- IANA time zone offsets ---
+ Answers the total offset (zone plus daylight) in milliseconds for an IANA
+ zone identifier at an instant, writing the offset to offsetOut and whether
+ daylight time is in effect to dstOut. Returns non-zero on success, and zero
+ when the platform cannot answer -- the caller then keeps whatever the C
+ runtime reported. rawOut, when non-null, receives the zone's standard-time
+ offset at that instant with any daylight adjustment excluded -- ICU tracks
+ the two separately, so the base offset in force now needs no guessing from
+ seasonal samples. Any output pointer may be null.
+ Lives in cn1_win_compat.c because resolving it needs
+ , which this header keeps out of translated units. */
+int cn1_win_zone_offset_millis(const char* zoneId, long long millis, int* offsetOut,
+ int* dstOut, int* rawOut);
+
/* --- environment / time.h POSIX helpers absent from MSVC ---
Thin static-inline wrappers over the MSVC equivalents; used by the date /
timezone runtime in nativeMethods. */
@@ -155,8 +169,33 @@ static __inline int unsetenv(const char* name) {
return _putenv_s(name, "");
}
+/* Civil fields to a UTC epoch second, by arithmetic rather than through the
+ * CRT. _mkgmtime is documented to support only dates from 1970-01-01 and
+ * returns -1 for anything earlier, and the timezone runtime multiplies this
+ * result by 1000 without checking it -- so EVERY pre-epoch lookup collapsed to
+ * -1 and was then evaluated at 1969-12-31T23:59:59Z. A 1900 Europe/Paris
+ * request came back with 1969's rules. Howard Hinnant's days_from_civil is
+ * exact for the whole proleptic Gregorian range and has no such limit.
+ * Deliberately arithmetic-only: this header is included by translation units
+ * that may not pull in . */
static __inline time_t timegm(struct tm* tm) {
- return _mkgmtime(tm);
+ long long year = (long long) tm->tm_year + 1900;
+ long long month = (long long) tm->tm_mon + 1;
+ long long day = (long long) tm->tm_mday;
+ long long era, yoe, doy, doe, days;
+
+ /* March-based years, so a leap day lands at the end of the cycle. */
+ year -= (month <= 2) ? 1 : 0;
+ era = (year >= 0 ? year : year - 399) / 400;
+ yoe = year - era * 400; /* [0, 399] */
+ doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1;
+ doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; /* [0, 146096] */
+ days = era * 146097 + doe - 719468; /* since epoch */
+
+ return (time_t) (days * 86400LL
+ + (long long) tm->tm_hour * 3600LL
+ + (long long) tm->tm_min * 60LL
+ + (long long) tm->tm_sec);
}
static __inline struct tm* localtime_r(const time_t* timep, struct tm* result) {
diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java
index 22ebf24476b..629dfd97fd0 100644
--- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java
+++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java
@@ -1146,7 +1146,26 @@ public String generateCCode(List allClasses) {
clInitMethod = clsName + "_" + m.getMethodName() + "__";
}
if(m.isMain()) {
- b.append("\nint main(int argc, char *argv[]) {\n initConstantPool();\n");
+ b.append("\nint main(int argc, char *argv[]) {\n");
+ // Line-buffer stdout/stderr. C streams block-buffer when they are
+ // not a tty, so everything an app logs into a pipe -- which is
+ // how CI captures it -- arrives in 4KB chunks. A run that is
+ // killed mid-flight then shows a log ending thousands of lines
+ // behind where the process actually was, and every diagnosis
+ // made from that tail names the wrong place. Three separate
+ // "the suite hangs in X" readings of the Linux job came from
+ // exactly this. Costs a flush per line; buys logs that mean
+ // what they say.
+ // _IONBF, not _IOLBF: the MSVC CRT rejects a line-buffered
+ // request with a NULL buffer and size 0 -- it demands a size of
+ // at least 2 -- and answers the invalid parameter by fail-fasting
+ // the process (0xC0000409), so every Windows clean-target binary
+ // died on its first instruction. _IONBF ignores the size argument
+ // and is valid on every CRT, and unbuffered is what the
+ // diagnostics actually want.
+ b.append(" setvbuf(stdout, NULL, _IONBF, 0);\n");
+ b.append(" setvbuf(stderr, NULL, _IONBF, 0);\n");
+ b.append(" initConstantPool();\n");
// With the nursery, the main thread allocates and must cooperate with
// the concurrent GC's stop-the-world pause (so the GC never scans its
// nursery while a minor collection runs). Lightweight threads are the
diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java
index d133e96c35d..bdd68811d6e 100644
--- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java
+++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java
@@ -911,7 +911,7 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app
// dbghelp: lets the last-resort unhandled-exception handler symbolize its
// own native backtrace in-process (SymFromAddr against the /Zi .pdb), so a
// native crash logs Java/C function names instead of bare RVAs.
- writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 winmm runtimeobject dbghelp)\n");
+ writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt winmm runtimeobject dbghelp)\n");
// BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp),
// gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a
// Microsoft.Web.WebView2 build/native folder we link the static
@@ -1041,7 +1041,10 @@ private static void writeLinuxLinkSet(Writer writer) throws IOException {
writer.append("pkg_check_modules(CN1DEPS REQUIRED\n");
writer.append(" gtk+-3.0 cairo pango pangocairo gdk-pixbuf-2.0 glib-2.0 gobject-2.0 gio-2.0\n");
writer.append(" fontconfig freetype2\n");
- writer.append(" libcurl)\n");
+ // libcrypto backs the port's crypto bridge (cn1_linux_crypto.c). It is
+ // already present wherever libcurl is: the OpenSSL-flavoured curl links
+ // it, and the -dev package pulls in the headers.
+ writer.append(" libcurl libcrypto)\n");
writer.append("pkg_check_modules(CN1GL REQUIRED epoxy egl glesv2)\n");
// Optional feature libs (browser/media/secure-storage/notifications/
// location). The port dlopen()s these lazily at first use (see
diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js
index d76800d183b..6b7491ff82c 100644
--- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js
+++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js
@@ -4652,7 +4652,14 @@
hostBridge.register('__cn1_wait_for_ui_settle__', function(request) {
var payload = request || {};
var reason = payload.reason == null ? 'unknown' : String(payload.reason);
- var maxFrames = Math.max(1, Math.min(96, (payload.maxFrames | 0) || 14));
+ // Honour the caller's budget rather than quietly halving it. The
+ // graphics tests that render into an offscreen image and composite it
+ // ask for 120 frames precisely because they are the slowest to settle,
+ // and the old ceiling of 96 silently returned whatever had been drawn
+ // so far -- which is how DrawImage shipped a capture with its two
+ // offscreen-image cells still half-painted. The bound stays, well above
+ // any current request, so a bad value cannot spin forever.
+ var maxFrames = Math.max(1, Math.min(240, (payload.maxFrames | 0) || 14));
var stableFrames = Math.max(1, Math.min(6, (payload.stableFrames | 0) || 2));
var quietFramesRequired = Math.max(1, Math.min(12, (payload.quietFrames | 0) || stableFrames));
var previousSignature = String(global.__cn1LastScreenshotSignature || '');
@@ -4664,6 +4671,7 @@
var seenRenderSeq = startRenderSeq;
var renderAdvanced = false;
var quietFrames = 0;
+ var settleExhausted = false;
function chooseBetter(a, b) {
if (!a) {
return b;
@@ -4717,6 +4725,12 @@
}
}
if (index + 1 >= maxFrames) {
+ // Out of budget without ever meeting the quiet + stable condition.
+ // The capture still proceeds with the best frame seen, but say so:
+ // an exhausted settle is the difference between "the UI was ready"
+ // and "we stopped waiting", and only one of those explains a
+ // half-drawn screenshot afterwards.
+ settleExhausted = true;
return best;
}
return runFrame(index + 1);
@@ -4742,6 +4756,7 @@
diag('SCREENSHOT_START', 'settleRenderEndSeq', seenRenderSeq | 0);
diag('SCREENSHOT_START', 'settleRenderAdvanced', renderAdvanced ? 1 : 0);
diag('SCREENSHOT_START', 'settleQuietObserved', quietFrames | 0);
+ diag('SCREENSHOT_START', 'settleExhausted', settleExhausted ? 1 : 0);
return {
changedFromPrevious: changed ? 1 : 0,
canvasSignature: meta.canvasSignature || 'none',
@@ -4751,7 +4766,8 @@
canvasPick: meta.canvasPick | 0,
renderStartSeq: startRenderSeq | 0,
renderEndSeq: seenRenderSeq | 0,
- renderAdvanced: renderAdvanced ? 1 : 0
+ renderAdvanced: renderAdvanced ? 1 : 0,
+ settleExhausted: settleExhausted ? 1 : 0
};
});
});
diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m
index 467664e5ee1..3dd845aadd9 100644
--- a/vm/ByteCodeTranslator/src/nativeMethods.m
+++ b/vm/ByteCodeTranslator/src/nativeMethods.m
@@ -2031,12 +2031,32 @@ JAVA_VOID java_lang_Object_wait___long_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJEC
struct timeval tv;
gettimeofday(&tv, NULL);
struct timespec ts;
- ts.tv_sec = tv.tv_sec + (long)(timeout / 1000);
- ts.tv_nsec = tv.tv_usec * 1000 + (timeout % 1000) * 1000000 + nanos;
- if ( ts.tv_nsec > 1000000000 ){
- ts.tv_nsec -= 1000000000;
- ts.tv_sec++;
- }
+ /* Built in 64-bit throughout. `timeout` is a JAVA_LONG and time_t is
+ 64-bit everywhere we run, but `long` is only 32 bits on Windows
+ (LLP64) -- so casting the seconds through it truncated
+ Long.MAX_VALUE / 1000 (9223372036854775) to -1511828489, putting the
+ deadline about 48 years in the PAST. wait(Long.MAX_VALUE), the
+ ordinary park-until-notified idiom, therefore returned immediately
+ instead of blocking until notified. LP64 platforms were unaffected,
+ which is why this only ever showed up on Windows.
+
+ The addend is capped so the deadline cannot overflow time_t on any
+ platform; ~34,000 years is indistinguishable from never for a wait,
+ and the condition-variable wrapper chunks it down to timeouts the
+ host API can express. */
+ JAVA_LONG addSeconds = timeout / 1000;
+ if (addSeconds > ((JAVA_LONG)1 << 40)) {
+ addSeconds = ((JAVA_LONG)1 << 40);
+ }
+ /* Normalized in 64-bit as well: tv_usec * 1000 plus the sub-second part
+ of the timeout plus nanos can exceed 2e9, which no longer fits a
+ 32-bit tv_nsec, and a single subtract-one-second could not normalize
+ it anyway. */
+ JAVA_LONG nsec = (JAVA_LONG)tv.tv_usec * 1000
+ + (timeout % 1000) * 1000000
+ + (JAVA_LONG)nanos;
+ ts.tv_sec = (time_t)((JAVA_LONG)tv.tv_sec + addSeconds + nsec / 1000000000);
+ ts.tv_nsec = (long)(nsec % 1000000000);
pthread_cond_timedwait(&data->__codenameOneCondition, &data->__codenameOneMutex, &ts);
}
@@ -2648,6 +2668,46 @@ static void cn1_with_timezone(const char* zoneId, void (*func)(void*), void* ctx
pthread_mutex_unlock(&cn1_timezone_mutex);
}
+/*
+ * Windows named-zone support.
+ *
+ * The POSIX path below sets TZ and reads tm_gmtoff back. Neither half works
+ * here: the Microsoft C runtime only understands the "EST5EDT" form of TZ, not
+ * an IANA identifier, and its struct tm carries no GMT offset at all -- so
+ * every named zone resolved to an offset of zero and, for instance,
+ * America/New_York reported UTC. Windows ships ICU (icu.dll, Windows 10 1703
+ * and later), whose calendar speaks IANA identifiers directly and knows the
+ * daylight rules for the instant being asked about.
+ *
+ * cn1WinZoneOffsetMillis answers the total offset (zone + daylight) at an
+ * instant, or reports failure so the caller can fall back.
+ */
+#ifdef _WIN32
+/* Total offset (zone plus daylight) for a zone at an instant, 0 when the
+ * platform cannot answer -- the caller then keeps the C runtime's reply.
+ * The lookup itself lives in cn1_win_compat.c, the one translation unit that
+ * may include ; keeping it out of here is what lets the clean
+ * target compile this file against a minimal SDK layout. */
+static int cn1WinZoneOffsetMillis(const char* zoneId, long long millis, int* offsetOut,
+ int* dstOut, int* rawOut) {
+ return cn1_win_zone_offset_millis(zoneId, millis, offsetOut, dstOut, rawOut);
+}
+
+/* Milliseconds since the epoch for a set of UTC calendar fields. */
+static long long cn1WinUtcMillis(int year, int month, int day, int millisOfDay) {
+ struct tm utc;
+ memset(&utc, 0, sizeof(utc));
+ utc.tm_year = year - 1900;
+ utc.tm_mon = month - 1;
+ utc.tm_mday = day;
+ utc.tm_hour = millisOfDay / 3600000;
+ utc.tm_min = (millisOfDay / 60000) % 60;
+ utc.tm_sec = (millisOfDay / 1000) % 60;
+ utc.tm_isdst = 0;
+ return (long long) timegm(&utc) * 1000LL;
+}
+#endif
+
typedef struct {
int year;
int month;
@@ -2754,6 +2814,21 @@ JAVA_OBJECT java_util_TimeZone_getTimezoneId___R_java_lang_String(CODENAME_ONE_T
JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_INT year, JAVA_INT month, JAVA_INT day, JAVA_INT timeOfDayMillis) {
const char* buffer = stringToUTF8(threadStateData, name);
cn1_timezone_offset_ctx ctx;
+#ifdef _WIN32
+ {
+ /* The fields are UTC, matching the POSIX path below (timegm) and every
+ * other port. Reading them as local standard time instead -- which is
+ * what java.util.TimeZone.getOffset's signature suggests -- moves the
+ * instant by the raw offset and jumps DST transitions: TimeApiTest
+ * resolves 2020-03-08T01:30 EST as 02:30 EDT. The contract this native
+ * actually has is the one its callers and that test rely on. */
+ int offset = 0;
+ if (cn1WinZoneOffsetMillis(buffer, cn1WinUtcMillis(year, month, day, timeOfDayMillis),
+ &offset, 0, 0)) {
+ return offset;
+ }
+ }
+#endif
ctx.year = year;
ctx.month = month;
ctx.day = day;
@@ -2766,6 +2841,25 @@ JAVA_INT java_util_TimeZone_getTimezoneOffset___java_lang_String_int_int_int_int
JAVA_INT java_util_TimeZone_getTimezoneRawOffset___java_lang_String_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name) {
const char* buffer = stringToUTF8(threadStateData, name);
cn1_timezone_raw_ctx ctx;
+#ifdef _WIN32
+ {
+ /* The raw offset is the standard-time one in force right now. ICU keeps
+ * the base offset and the daylight adjustment in separate calendar
+ * fields, so asking at the current instant answers it directly.
+ *
+ * This used to sample both solstices of the current year and prefer
+ * July's when neither was in daylight saving, which is wrong whenever
+ * the base offset changes mid-year: with the clock in February 2024,
+ * Asia/Almaty was still UTC+6 but July's reading is the UTC+5 rule that
+ * had not taken effect yet, and a change landing after July stayed
+ * invisible for the rest of the year. */
+ int rawOffset = 0;
+ long long nowMillis = (long long) time(NULL) * 1000LL;
+ if (cn1WinZoneOffsetMillis(buffer, nowMillis, 0, 0, &rawOffset)) {
+ return rawOffset;
+ }
+ }
+#endif
ctx.januaryOffset = 0;
ctx.januaryIsDst = 0;
ctx.julyOffset = 0;
@@ -2783,6 +2877,14 @@ JAVA_INT java_util_TimeZone_getTimezoneRawOffset___java_lang_String_R_int(CODENA
JAVA_BOOLEAN java_util_TimeZone_isTimezoneDST___java_lang_String_long_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT name, JAVA_LONG millis) {
const char* buffer = stringToUTF8(threadStateData, name);
cn1_timezone_dst_ctx ctx;
+#ifdef _WIN32
+ {
+ int dst = 0;
+ if (cn1WinZoneOffsetMillis(buffer, (long long) millis, 0, &dst, 0)) {
+ return dst ? JAVA_TRUE : JAVA_FALSE;
+ }
+ }
+#endif
ctx.millis = millis;
ctx.result = JAVA_FALSE;
cn1_with_timezone(buffer, cn1_compute_timezone_dst, &ctx);
diff --git a/vm/JavaAPI/src/java/lang/Character.java b/vm/JavaAPI/src/java/lang/Character.java
index d51b2b7719f..dd2d4596513 100644
--- a/vm/JavaAPI/src/java/lang/Character.java
+++ b/vm/JavaAPI/src/java/lang/Character.java
@@ -363,10 +363,15 @@ public static boolean isJavaIdentifierStart(int codePoint) {
case CURRENCY_SYMBOL:
return true;
default:
- return isIdentifierIgnorable(codePoint);
+ // No ignorable fallback here, unlike isJavaIdentifierPart. An
+ // identifier-ignorable character is allowed inside a name but
+ // never as its first character, and with getType now answering
+ // CONTROL for the ASCII controls, the old fallback let
+ // U+0000 through U+0008 start an identifier.
+ return false;
}
}
-
+
public static boolean isJavaIdentifierPart(char ch) {
return isJavaIdentifierPart((int) ch);
}
@@ -457,6 +462,22 @@ public static boolean isLowerCase(int codePoint) {
if (codePoint < 128) {
return false;
}
+ // Answer over the rest of the range this class documents as supported.
+ // Returning false for every code point above 127 contradicted both that
+ // documentation and getType, which now classifies Latin-1 properly --
+ // isLowerCase would have said false for a character getType calls a
+ // LOWERCASE_LETTER.
+ //
+ // The two ordinal indicators are lowercase without being in category
+ // Ll: Unicode gives them the derived Other_Lowercase property, and the
+ // JDK agrees, so a plain category test would disagree with it on
+ // exactly these two code points.
+ if (codePoint == 0x00AA || codePoint == 0x00BA) {
+ return true;
+ }
+ if (codePoint < LATIN1_TYPES.length) {
+ return LATIN1_TYPES[codePoint] == LOWERCASE_LETTER;
+ }
//return isLowerCaseImpl(codePoint);
return false;
}
@@ -484,10 +505,15 @@ public static boolean isUpperCase(int codePoint) {
if ('A' <= codePoint && codePoint <= 'Z') {
return true;
}
- /*if (codePoint < 128) {
+ if (codePoint < 128) {
return false;
}
- return isUpperCaseImpl(codePoint);*/
+ // See isLowerCase: answer over the documented Latin-1 range rather than
+ // reporting false for every assigned character above ASCII.
+ if (codePoint < LATIN1_TYPES.length) {
+ return LATIN1_TYPES[codePoint] == UPPERCASE_LETTER;
+ }
+ /*return isUpperCaseImpl(codePoint);*/
return false;
}
@@ -1306,30 +1332,122 @@ public static boolean isSurrogate(char ch) {
return isHighSurrogate(ch) || isLowSurrogate(ch);
}
- private static UnicodeHelper.Range[] classMapping;
- private static UnicodeHelper.Range[] getClasses() {
- throw new UnsupportedOperationException("UnicodeHelper.getClasses() not supported");
- }
-
+ /**
+ * General category of every ISO Latin-1 code point, indexed by code point.
+ *
+ * This table used to be absent altogether and getType threw
+ * UnsupportedOperationException, which meant isLetter, isLetterOrDigit,
+ * isJavaIdentifierStart, isJavaIdentifierPart and isIdentifierIgnorable
+ * threw for every input on the ports that use this runtime.
+ *
+ * It covers Latin-1 rather than just ASCII because that is the range this
+ * class documents as supported (see the class comment and isLowerCase /
+ * isUpperCase / toLowerCase). Stopping at 127 answered UNASSIGNED for
+ * assigned characters, so isLetter('e' with an acute) and
+ * isJavaIdentifierStart came back false and a currency sign such as the
+ * pound got the wrong category.
+ *
+ * The 0x80-0xFF rows are not hand-written: they were generated from
+ * java.lang.Character.getType on a reference JDK and are verified against
+ * it -- CharacterLatin1TypeTest walks all 256 code points and compares.
+ */
+ private static final byte[] LATIN1_TYPES = {
+ /* 00-0f */ CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL,
+ CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL,
+ /* 10-1f */ CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL,
+ CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL,
+ /* ' '!"# */ SPACE_SEPARATOR, OTHER_PUNCTUATION, OTHER_PUNCTUATION, OTHER_PUNCTUATION,
+ /* $%&' */ CURRENCY_SYMBOL, OTHER_PUNCTUATION, OTHER_PUNCTUATION, OTHER_PUNCTUATION,
+ /* ()*+ */ START_PUNCTUATION, END_PUNCTUATION, OTHER_PUNCTUATION, MATH_SYMBOL,
+ /* ,-./ */ OTHER_PUNCTUATION, DASH_PUNCTUATION, OTHER_PUNCTUATION, OTHER_PUNCTUATION,
+ /* 0-7 */ DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER,
+ DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER,
+ /* 89:; */ DECIMAL_DIGIT_NUMBER, DECIMAL_DIGIT_NUMBER, OTHER_PUNCTUATION, OTHER_PUNCTUATION,
+ /* <=>? */ MATH_SYMBOL, MATH_SYMBOL, MATH_SYMBOL, OTHER_PUNCTUATION,
+ /* @A-G */ OTHER_PUNCTUATION, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ /* H-O */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ /* P-W */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ /* XYZ[ */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, START_PUNCTUATION,
+ /* \]^_ */ OTHER_PUNCTUATION, END_PUNCTUATION, MODIFIER_SYMBOL, CONNECTOR_PUNCTUATION,
+ /* `a-g */ MODIFIER_SYMBOL, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ /* h-o */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ /* p-w */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ /* xyz{ */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, START_PUNCTUATION,
+ /* |}~del */ MATH_SYMBOL, END_PUNCTUATION, MATH_SYMBOL, CONTROL,
+ /* 0x80-0x83 */ CONTROL, CONTROL, CONTROL, CONTROL,
+ /* 0x84-0x87 */ CONTROL, CONTROL, CONTROL, CONTROL,
+ /* 0x88-0x8B */ CONTROL, CONTROL, CONTROL, CONTROL,
+ /* 0x8C-0x8F */ CONTROL, CONTROL, CONTROL, CONTROL,
+ /* 0x90-0x93 */ CONTROL, CONTROL, CONTROL, CONTROL,
+ /* 0x94-0x97 */ CONTROL, CONTROL, CONTROL, CONTROL,
+ /* 0x98-0x9B */ CONTROL, CONTROL, CONTROL, CONTROL,
+ /* 0x9C-0x9F */ CONTROL, CONTROL, CONTROL, CONTROL,
+ /* 0xA0-0xA3 */ SPACE_SEPARATOR, OTHER_PUNCTUATION, CURRENCY_SYMBOL, CURRENCY_SYMBOL,
+ /* 0xA4-0xA7 */ CURRENCY_SYMBOL, CURRENCY_SYMBOL, OTHER_SYMBOL, OTHER_PUNCTUATION,
+ /* 0xA8-0xAB */ MODIFIER_SYMBOL, OTHER_SYMBOL, OTHER_LETTER, INITIAL_QUOTE_PUNCTUATION,
+ /* 0xAC-0xAF */ MATH_SYMBOL, FORMAT, OTHER_SYMBOL, MODIFIER_SYMBOL,
+ /* 0xB0-0xB3 */ OTHER_SYMBOL, MATH_SYMBOL, OTHER_NUMBER, OTHER_NUMBER,
+ /* 0xB4-0xB7 */ MODIFIER_SYMBOL, LOWERCASE_LETTER, OTHER_PUNCTUATION, OTHER_PUNCTUATION,
+ /* 0xB8-0xBB */ MODIFIER_SYMBOL, OTHER_NUMBER, OTHER_LETTER, FINAL_QUOTE_PUNCTUATION,
+ /* 0xBC-0xBF */ OTHER_NUMBER, OTHER_NUMBER, OTHER_NUMBER, OTHER_PUNCTUATION,
+ /* 0xC0-0xC3 */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ /* 0xC4-0xC7 */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ /* 0xC8-0xCB */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ /* 0xCC-0xCF */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ /* 0xD0-0xD3 */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ /* 0xD4-0xD7 */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, MATH_SYMBOL,
+ /* 0xD8-0xDB */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER,
+ /* 0xDC-0xDF */ UPPERCASE_LETTER, UPPERCASE_LETTER, UPPERCASE_LETTER, LOWERCASE_LETTER,
+ /* 0xE0-0xE3 */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ /* 0xE4-0xE7 */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ /* 0xE8-0xEB */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ /* 0xEC-0xEF */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ /* 0xF0-0xF3 */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ /* 0xF4-0xF7 */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, MATH_SYMBOL,
+ /* 0xF8-0xFB */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER,
+ /* 0xFC-0xFF */ LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER, LOWERCASE_LETTER
+ };
+
public static int getType(int codePoint) {
if (isBmpCodePoint(codePoint) && isSurrogate((char) codePoint)) {
return SURROGATE;
}
- UnicodeHelper.Range[] classes = getClasses();
- int l = 0;
- int u = classes.length - 1;
- while (l <= u) {
- int i = (l + u) / 2;
- UnicodeHelper.Range range = classes[i];
- if (codePoint >= range.end) {
- l = i + 1;
- } else if (codePoint < range.start) {
- u = i - 1;
- } else {
- return range.data[codePoint - range.start];
- }
+ if (codePoint >= 0 && codePoint < LATIN1_TYPES.length) {
+ return LATIN1_TYPES[codePoint];
+ }
+ // Above ASCII this runtime carries no category table, so answer from
+ // the primitives it does implement instead of failing the call. The
+ // code points isWhitespace() knows about individually are named here:
+ // they are not all separators, and the two that are come from
+ // different categories.
+ if (codePoint == 0x2028) {
+ return LINE_SEPARATOR;
+ }
+ if (codePoint == 0x2029) {
+ return PARAGRAPH_SEPARATOR;
+ }
+ if (codePoint == 0x180E) {
+ return FORMAT;
+ }
+ if (isLowerCase(codePoint)) {
+ return LOWERCASE_LETTER;
+ }
+ if (isUpperCase(codePoint)) {
+ return UPPERCASE_LETTER;
+ }
+ if (isDigit(codePoint)) {
+ return DECIMAL_DIGIT_NUMBER;
+ }
+ if (isWhitespace(codePoint)) {
+ return SPACE_SEPARATOR;
}
- return 0;
+ return UNASSIGNED;
}
/**
diff --git a/vm/JavaAPI/src/java/time/DateTimeSupport.java b/vm/JavaAPI/src/java/time/DateTimeSupport.java
index 29a36927ee8..c904655a29b 100644
--- a/vm/JavaAPI/src/java/time/DateTimeSupport.java
+++ b/vm/JavaAPI/src/java/time/DateTimeSupport.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 java.time;
import com.codename1.impl.time.TimeZoneSupport;
@@ -182,13 +204,40 @@ public static LocalDateTime localDateTimeFromInstant(Instant instant, ZoneId zon
}
public static ZoneOffset offsetFromInstant(Instant instant, ZoneId zone) {
+ if (zone instanceof ZoneOffset) {
+ // A fixed offset already is the answer; round-tripping it through
+ // the host time zone database can only lose or invert it.
+ return (ZoneOffset) zone;
+ }
TimeZone tz = TimeZoneSupport.toTimeZone(zone);
- Calendar cal = newCalendar(tz);
- cal.setTime(new Date(instant.toEpochMilli()));
- LocalDate localDate = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH));
- LocalTime localTime = LocalTime.of(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND));
- long localEpochSecond = localDate.toEpochDay() * SECONDS_PER_DAY + localTime.toSecondOfDay();
- return ZoneOffset.ofTotalSeconds((int) (localEpochSecond - instant.getEpochSecond()));
+ // Ask the zone for its offset at this instant rather than reading the
+ // fields back out of a Calendar. Calendar reconstructs the local time
+ // from a raw offset plus a fixed one-hour daylight guess, which loses
+ // the saving on the desktop ports (Europe/Berlin in June came back as
+ // UTC), while TimeZone.getOffset consults the platform's own rules.
+ // getOffset takes LOCAL STANDARD time fields, which is what its javadoc
+ // says and what every other caller passes. This used to hand it UTC
+ // fields, which happened to work only because the natives read them that
+ // way; now that TimeZone converts properly, an instant has to be turned
+ // into local standard time first -- add the raw offset -- or it would be
+ // shifted by that offset twice.
+ long epochMilli = instant.toEpochMilli();
+ long localStandard = epochMilli + tz.getRawOffset();
+ long epochDay = floorDiv(localStandard, MILLIS_PER_DAY);
+ int millisOfDay = (int) floorMod(localStandard, MILLIS_PER_DAY);
+ LocalDate utcDate = LocalDate.ofEpochDay(epochDay);
+ // Calendar.SUNDAY is 1 and epoch day 0 was a Thursday.
+ int dayOfWeek = (int) floorMod(epochDay + 4, 7) + 1;
+ // getOffset takes an era plus a year *of that era*, not a proleptic ISO
+ // year. Passing AD with a non-positive year described a date that does
+ // not exist, so instants before 1 CE resolved against the wrong year.
+ // ISO 0 is 1 BC, ISO -1 is 2 BC, hence 1 - isoYear.
+ int isoYear = utcDate.getYear();
+ int era = isoYear > 0 ? 1 /* GregorianCalendar.AD */ : 0 /* GregorianCalendar.BC */;
+ int yearOfEra = isoYear > 0 ? isoYear : 1 - isoYear;
+ int offsetMillis = tz.getOffset(era, yearOfEra,
+ utcDate.getMonthValue() - 1, utcDate.getDayOfMonth(), dayOfWeek, millisOfDay);
+ return ZoneOffset.ofTotalSeconds(offsetMillis / 1000);
}
public static SimpleDateFormat newFormat(String pattern, ZoneId zone, Locale locale) {
diff --git a/vm/JavaAPI/src/java/util/TimeZone.java b/vm/JavaAPI/src/java/util/TimeZone.java
index 2b67656bce2..846bbe275ca 100644
--- a/vm/JavaAPI/src/java/util/TimeZone.java
+++ b/vm/JavaAPI/src/java/util/TimeZone.java
@@ -63,6 +63,67 @@ public static java.lang.String[] getAvailableIDs(){
}
}
+ /// Resolves the offset for calendar fields expressed in local *standard* time,
+ /// which is what java.util.TimeZone.getOffset(era, year, month, day, dayOfWeek,
+ /// millis) documents and what every caller here passes: GregorianCalendar
+ /// decomposes a local time, DateUtil and both SimpleDateFormats read the fields
+ /// off a Calendar in the zone.
+ ///
+ /// The natives answer a different question -- "what is the offset at the instant
+ /// these UTC fields denote" -- and feeding local fields to them straight through
+ /// mixed the two frames. America/New_York at 2020-03-08 02:30 standard time is
+ /// UTC-04:00, but read as 02:30 UTC it lands the previous evening and answers
+ /// UTC-05:00.
+ ///
+ /// Converting here rather than in each port's native keeps the natives' actual
+ /// contract intact and fixes every port at once: the instant the fields denote is
+ /// (fields read as UTC) minus the raw offset, and the natives are then asked
+ /// about that instant in the UTC fields they expect.
+ static int offsetForLocalStandardFields(String id, int rawOffset, int era, int year,
+ int month, int day, int timeOfDayMillis) {
+ int isoYear = era > 0 ? year : 1 - year;
+ long fieldsAsUtc = daysFromCivil(isoYear, month + 1, day) * 86400000L + timeOfDayMillis;
+ long instant = fieldsAsUtc - rawOffset;
+ long epochDay = floorDiv(instant, 86400000L);
+ int millisOfDay = (int) (instant - epochDay * 86400000L);
+ int[] civil = civilFromDays(epochDay);
+ return getTimezoneOffset(id, civil[0], civil[1], civil[2], millisOfDay);
+ }
+
+ private static long floorDiv(long value, long divisor) {
+ long q = value / divisor;
+ if ((value % divisor != 0) && ((value < 0) != (divisor < 0))) {
+ q--;
+ }
+ return q;
+ }
+
+ /// Days since 1970-01-01 for a proleptic Gregorian date (Howard Hinnant's
+ /// civil-from-days inverse). Integer only, so it is exact for every year the
+ /// callers can produce.
+ private static long daysFromCivil(int y, int m, int d) {
+ int adjusted = y - (m <= 2 ? 1 : 0);
+ long era = (adjusted >= 0 ? adjusted : adjusted - 399) / 400;
+ int yoe = (int) (adjusted - era * 400);
+ int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
+ int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ return era * 146097L + doe - 719468L;
+ }
+
+ /// Inverse of daysFromCivil: { year, month 1-12, day }.
+ private static int[] civilFromDays(long z) {
+ long shifted = z + 719468L;
+ long era = (shifted >= 0 ? shifted : shifted - 146096) / 146097;
+ long doe = shifted - era * 146097;
+ long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
+ long y = yoe + era * 400;
+ long doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ long mp = (5 * doy + 2) / 153;
+ long d = doy - (153 * mp + 2) / 5 + 1;
+ long m = mp + (mp < 10 ? 3 : -9);
+ return new int[] { (int) (y + (m <= 2 ? 1 : 0)), (int) m, (int) d };
+ }
+
private static native String getTimezoneId();
private static native int getTimezoneOffset(String name, int year, int month, int day, int timeOfDayMillis);
private static native int getTimezoneRawOffset(String name);
@@ -107,7 +168,8 @@ public static java.util.TimeZone getDefault(){
defaultTimeZone = new TimeZone() {
@Override
public int getOffset(int era, int year, int month, int day, int dayOfWeek, int timeOfDayMillis) {
- return getTimezoneOffset(tzone, year, month + 1, day, timeOfDayMillis);
+ return offsetForLocalStandardFields(tzone, getTimezoneRawOffset(tzone),
+ era, year, month, day, timeOfDayMillis);
}
@Override
@@ -165,15 +227,39 @@ public java.lang.String getID(){
* Gets the TimeZone for the given ID.
*/
public static java.util.TimeZone getTimeZone(final java.lang.String ID){
- if(ID != null && ID.equalsIgnoreCase("gmt")) {
+ if (ID == null) {
+ // Fail here rather than three statements down, where the first
+ // unguarded equalsIgnoreCase used to throw from the middle of the
+ // method. NullPointerException is the right answer rather than GMT:
+ // the JDK throws for a null ID and reserves GMT for an ID it merely
+ // cannot parse, and the JavaSE and Android ports reach that JDK
+ // behaviour directly -- so answering GMT here would put this port
+ // out of step with them.
+ throw new NullPointerException("ID");
+ }
+ if(ID.equalsIgnoreCase("gmt")) {
+ return GMT;
+ }
+ TimeZone custom = customTimeZone(ID);
+ if (custom != null) {
+ return custom;
+ }
+ if (isOffsetIdAttempt(ID)) {
+ // A malformed offset ID resolves to GMT, as it does on every other
+ // port. Handing it to the platform instead was actively harmful:
+ // the POSIX tz syntax the natives speak reads the sign the other way
+ // round, so "UTC+5" came back as five hours *west* -- a ten-hour
+ // error against JavaSE for the same string.
return GMT;
- } else if (ID.equalsIgnoreCase(getTimezoneId())) {
+ }
+ if (ID.equalsIgnoreCase(getTimezoneId())) {
return getDefault();
} else {
TimeZone out = new TimeZone() {
@Override
public int getOffset(int era, int year, int month, int day, int dayOfWeek, int timeOfDayMillis) {
- return getTimezoneOffset(ID, year, month + 1, day, timeOfDayMillis);
+ return offsetForLocalStandardFields(ID, getTimezoneRawOffset(ID),
+ era, year, month, day, timeOfDayMillis);
}
@Override
@@ -204,6 +290,160 @@ public int hashCode() {
}
}
+ /**
+ * Resolves a custom fixed-offset ID -- {@code GMT+2}, {@code GMT-05:00},
+ * {@code UTC+01:30} -- to a zone with that raw offset and no daylight
+ * saving, exactly as {@code java.util.TimeZone} documents them.
+ *
+ * These must never reach the host time zone database. A POSIX {@code TZ}
+ * value inverts the sign of its offset, so handing {@code "GMT-05:00"} to
+ * {@code tzset()} produced UTC+5 -- java.time converts a ZoneOffset to
+ * exactly this form, so every OffsetDateTime formatted through a pattern
+ * came out shifted by twice its offset. Windows is worse: its C runtime
+ * cannot parse the form at all.
+ *
+ * @return the fixed-offset zone, or null when {@code ID} is not a custom ID
+ */
+ private static TimeZone customTimeZone(String ID) {
+ if (ID == null) {
+ return null;
+ }
+ int index;
+ if (ID.regionMatches(true, 0, "GMT", 0, 3)) {
+ index = 3;
+ } else if (ID.regionMatches(true, 0, "UTC", 0, 3)) {
+ index = 3;
+ } else if (ID.regionMatches(true, 0, "UT", 0, 2)) {
+ index = 2;
+ } else {
+ return null;
+ }
+ if (index >= ID.length()) {
+ // Exact uppercase "UTC" is the only bare spelling that names a zone of
+ // its own. "UT", "utc", "ut" and the rest take the unknown-ID fallback
+ // on JavaSE and Android -- verified identical on JDK 17 and 25 -- so
+ // keeping the caller's spelling here made getID() and equals() differ
+ // across ports while the offset agreed.
+ if ("UTC".equals(ID)) {
+ return new SimpleTimeZone(0, ID);
+ }
+ return GMT;
+ }
+ char sign = ID.charAt(index);
+ if (sign == 'Z' && index + 1 == ID.length()) {
+ // "GMTZ" / "UTCZ" / "UTZ" name no zone. Manufacturing a SimpleTimeZone
+ // that keeps the spelling made getID() and equals() disagree with
+ // JavaSE and Android, which take the unknown-ID fallback and answer a
+ // zone whose ID is "GMT" -- the raw offset matched, so the difference
+ // only surfaced in a serialized configuration or an ID comparison.
+ return GMT;
+ }
+ if (sign != '+' && sign != '-') {
+ return null;
+ }
+ // Only "GMT" takes an offset suffix. java.util.TimeZone defines the
+ // custom-ID syntax on GMT alone, so JavaSE and Android answer plain GMT
+ // for "UTC+5" / "UT+5"; accepting them here gave the same ID a different
+ // offset depending on the port.
+ if (index != 3 || !ID.startsWith("GMT")) {
+ return null;
+ }
+ String digits = ID.substring(index + 1);
+ String hourPart;
+ String minutePart;
+ int colon = digits.indexOf(':');
+ if (colon < 0) {
+ // Colon-less forms are h, hh, hmm and hhmm only. The five- and
+ // six-digit forms this used to accept ("GMT+013000") are not custom
+ // IDs at all -- the JDK falls back to GMT for them.
+ int length = digits.length();
+ if (length == 1 || length == 2) {
+ hourPart = digits;
+ minutePart = "0";
+ } else if (length == 3 || length == 4) {
+ hourPart = digits.substring(0, length - 2);
+ minutePart = digits.substring(length - 2);
+ } else {
+ return null;
+ }
+ } else {
+ String rest = digits.substring(colon + 1);
+ hourPart = digits.substring(0, colon);
+ // No seconds field. java.util.TimeZone documents the custom syntax
+ // as GMT Sign Hours [: Minutes] and nothing more, and JDKs disagree
+ // in practice -- "GMT+01:30:00" is GMT on 17 and GMT+01:30 on 25 --
+ // so honouring it would make this port track whichever JDK the
+ // JavaSE side happened to run. The documented form is the contract.
+ if (rest.indexOf(':') >= 0) {
+ return null;
+ }
+ minutePart = rest;
+ // Hours may be one or two digits, minutes must be exactly two --
+ // "GMT+1:2" is not a custom ID, and treating it as UTC+01:02 put
+ // this port an hour and two minutes away from every other one.
+ if (hourPart.length() < 1 || hourPart.length() > 2 || minutePart.length() != 2) {
+ return null;
+ }
+ }
+ if (!isDigits(hourPart) || !isDigits(minutePart)) {
+ return null;
+ }
+ int hours;
+ int minutes;
+ try {
+ hours = Integer.parseInt(hourPart);
+ minutes = Integer.parseInt(minutePart);
+ } catch (NumberFormatException notCustom) {
+ return null;
+ }
+ if (hours > 23 || minutes > 59) {
+ return null;
+ }
+ int offset = (hours * 60 + minutes) * 60 * 1000;
+ // Normalized like the JDK's, so the same custom ID reports the same
+ // getID() on every port rather than echoing whichever spelling was used.
+ String canonical = "GMT" + sign
+ + (hours < 10 ? "0" : "") + hours + ":"
+ + (minutes < 10 ? "0" : "") + minutes;
+ return new SimpleTimeZone(sign == '-' ? -offset : offset, canonical);
+ }
+
+ /**
+ * True when the ID reads as an attempt at a GMT/UT/UTC offset ID, well
+ * formed or not. Those never name a zone in the platform database, so a
+ * malformed one is GMT rather than something for the natives to guess at.
+ */
+ private static boolean isOffsetIdAttempt(String ID) {
+ int index;
+ if (ID.regionMatches(true, 0, "GMT", 0, 3) || ID.regionMatches(true, 0, "UTC", 0, 3)) {
+ index = 3;
+ } else if (ID.regionMatches(true, 0, "UT", 0, 2)) {
+ index = 2;
+ } else {
+ return false;
+ }
+ if (index >= ID.length()) {
+ return false;
+ }
+ char sign = ID.charAt(index);
+ return sign == '+' || sign == '-';
+ }
+
+ /** True when every character is an ASCII digit and there is at least one. */
+ private static boolean isDigits(String value) {
+ if (value.length() == 0) {
+ return false;
+ }
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if (c < '0' || c > '9') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+
/**
* Queries if this time zone uses Daylight Savings Time.
*/
diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CharacterLatin1TypeTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CharacterLatin1TypeTest.java
new file mode 100644
index 00000000000..4bdc4f59422
--- /dev/null
+++ b/vm/tests/src/test/java/com/codename1/tools/translator/CharacterLatin1TypeTest.java
@@ -0,0 +1,207 @@
+/*
+ * 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.tools.translator;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Pins vm/JavaAPI's Character against the JDK over the ISO Latin-1 range.
+ *
+ * getType used to throw UnsupportedOperationException, so isLetter,
+ * isLetterOrDigit, isJavaIdentifierStart, isJavaIdentifierPart and
+ * isIdentifierIgnorable threw for every input. Giving it a category table fixed
+ * that, but an ASCII-only table then answered UNASSIGNED for assigned Latin-1
+ * characters -- isLetter('e' with an acute) came back false. The table now
+ * covers Latin-1, which is the range this class documents as supported, and
+ * this test is what keeps it honest: a hand-edited entry shows up as a
+ * mismatch against the JDK rather than as a subtly wrong answer in the field.
+ *
+ * The host JDK is the oracle. java.lang classes cannot be replaced from the
+ * classpath, so the JavaSE leg really does run the JDK's Character while the
+ * ParparVM leg runs ours.
+ */
+class CharacterLatin1TypeTest {
+
+ @Test
+ void latin1CharacterPropertiesMatchTheJdk() throws Exception {
+ Parser.cleanup();
+
+ Path sourceDir = Files.createTempDirectory("character-latin1-sources");
+ Path classesDir = Files.createTempDirectory("character-latin1-classes");
+ Path javaApiDir = Files.createTempDirectory("character-latin1-japi");
+
+ Path source = sourceDir.resolve("CharacterLatin1App.java");
+ Files.write(source, loadAppSource().getBytes(StandardCharsets.UTF_8));
+
+ CompilerHelper.CompilerConfig config = selectCompiler();
+ if (config == null) {
+ fail("No compatible compiler available for the Latin-1 Character test");
+ }
+
+ assertTrue(CompilerHelper.isJavaApiCompatible(config),
+ "JDK " + config.jdkVersion + " must target matching bytecode level for JavaAPI");
+
+ CompilerHelper.compileJavaAPI(javaApiDir, config);
+
+ List compileArgs = new ArrayList<>();
+ compileArgs.add("-source");
+ compileArgs.add(config.targetVersion);
+ compileArgs.add("-target");
+ compileArgs.add(config.targetVersion);
+ if (CompilerHelper.useClasspath(config)) {
+ compileArgs.add("-classpath");
+ compileArgs.add(javaApiDir.toString());
+ } else {
+ compileArgs.add("-bootclasspath");
+ compileArgs.add(javaApiDir.toString());
+ compileArgs.add("-Xlint:-options");
+ }
+ compileArgs.add("-d");
+ compileArgs.add(classesDir.toString());
+ compileArgs.add(source.toString());
+
+ int compileResult = CompilerHelper.compile(config.jdkHome, compileArgs);
+ assertEquals(0, compileResult,
+ "CharacterLatin1App should compile. " + CompilerHelper.getLastErrorLog());
+
+ String jdkResult = extractResultLine(runJavaMain(config, classesDir));
+ assertTrue(jdkResult.startsWith("RESULT="),
+ "The JDK leg should produce a RESULT line");
+
+ CompilerHelper.copyDirectory(javaApiDir, classesDir);
+
+ Path outputDir = Files.createTempDirectory("character-latin1-output");
+ CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "CharacterLatin1App");
+
+ Path distDir = outputDir.resolve("dist");
+ Path cmakeLists = distDir.resolve("CMakeLists.txt");
+ assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project");
+
+ CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "CharacterLatin1App-src");
+
+ Path buildDir = distDir.resolve("build");
+ Files.createDirectories(buildDir);
+
+ CleanTargetIntegrationTest.runCommand(Arrays.asList(
+ "cmake",
+ "-S", distDir.toString(),
+ "-B", buildDir.toString(),
+ "-DCMAKE_C_COMPILER=clang",
+ "-DCMAKE_OBJC_COMPILER=clang"
+ ), distDir);
+
+ CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir);
+
+ Path executable = buildDir.resolve("CharacterLatin1App");
+ assertTrue(Files.exists(executable), "ParparVM build should produce a runnable executable");
+
+ String vmResult = extractResultLine(
+ CleanTargetIntegrationTest.runCommand(Arrays.asList(executable.toString()), buildDir));
+
+ assertEquals(jdkResult, vmResult, firstDifference(jdkResult, vmResult));
+ }
+
+ /** Names the offending code point instead of dumping two 2KB strings. */
+ private String firstDifference(String expected, String actual) {
+ String[] want = expected.substring("RESULT=".length()).split(";");
+ String[] got = actual.substring(actual.indexOf('=') + 1).split(";");
+ for (int c = 0; c < Math.min(want.length, got.length); c++) {
+ if (!want[c].equals(got[c])) {
+ return "Character properties differ at code point " + c
+ + " (0x" + Integer.toHexString(c) + "): JDK " + want[c]
+ + ", ParparVM " + got[c]
+ + ". Fields are getType, isLetter, isLowerCase, isUpperCase,"
+ + " isDigit, isLetterOrDigit, isJavaIdentifierStart,"
+ + " isJavaIdentifierPart.";
+ }
+ }
+ return "ParparVM Character should answer as the JDK does across Latin-1";
+ }
+
+ private String loadAppSource() throws Exception {
+ java.io.InputStream in = CharacterLatin1TypeTest.class.getResourceAsStream(
+ "/com/codename1/tools/translator/CharacterLatin1App.java");
+ assertNotNull(in, "CharacterLatin1App.java test resource should exist");
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+ return reader.lines().collect(Collectors.joining("\n")) + "\n";
+ }
+ }
+
+ private String runJavaMain(CompilerHelper.CompilerConfig config, Path classesDir) throws Exception {
+ String javaExe = config.jdkHome.resolve("bin").resolve("java").toString();
+ if (System.getProperty("os.name").toLowerCase().contains("win")) {
+ javaExe += ".exe";
+ }
+
+ ProcessBuilder pb = new ProcessBuilder(javaExe, "-cp", classesDir.toString(), "CharacterLatin1App");
+ pb.redirectErrorStream(true);
+
+ Process process = pb.start();
+ String output;
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
+ output = reader.lines().collect(Collectors.joining("\n"));
+ }
+
+ int exitCode = process.waitFor();
+ assertEquals(0, exitCode, "JVM run should exit cleanly. Output: " + output);
+ return output;
+ }
+
+ private String extractResultLine(String output) {
+ for (String line : output.split("\\R")) {
+ if (line.startsWith("RESULT=")) {
+ return line.trim();
+ }
+ }
+ return "";
+ }
+
+ private CompilerHelper.CompilerConfig selectCompiler() {
+ String[] preferredTargets = {"11", "17", "21", "25", "1.8"};
+ for (String target : preferredTargets) {
+ List configs = CompilerHelper.getAvailableCompilers(target);
+ for (CompilerHelper.CompilerConfig config : configs) {
+ if (CompilerHelper.isJavaApiCompatible(config)) {
+ return config;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java
index 8f1a5eb56af..71f5da32920 100644
--- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java
+++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java
@@ -1225,6 +1225,10 @@ public void run() {
final java.util.concurrent.atomic.AtomicInteger finishedTests = new java.util.concurrent.atomic.AtomicInteger(0);
final java.util.concurrent.atomic.AtomicReference lastLine =
new java.util.concurrent.atomic.AtomicReference("");
+ // Set by the suite watchdog when a test blocks the event dispatch
+ // thread; the run cannot progress past that point, so stop waiting.
+ final java.util.concurrent.atomic.AtomicReference wedged =
+ new java.util.concurrent.atomic.AtomicReference();
// The real shared benchmark emits "CN1SS:STAT:: " lines
// (Base64NativePerformanceTest: base64 native/CN1/SIMD + image
// createMask/applyMask/modifyAlpha/PNG/JPEG, plus the SIMD kernel tally),
@@ -1255,6 +1259,7 @@ public void run() {
performanceFinished.set(true);
}
}
+ if (line.contains("CN1SS:SUITE:WEDGED")) { wedged.set(line); }
int suite = line.indexOf("CN1SS:");
if (suite >= 0) { suiteLog.add(line.substring(suite)); }
if (line.contains("CN1SS:") || line.contains("suite ")) { lastLine.set(line); }
@@ -1286,6 +1291,10 @@ public void run() {
long lastChange = System.currentTimeMillis();
while (System.currentTimeMillis() < deadline) {
if (finished.get()) { break; }
+ if (wedged.get() != null) {
+ System.out.println("CN1SS:HARNESS: " + wedged.get());
+ break;
+ }
pngs = countPngFiles(outDir);
if (pngs != lastPngs) { lastPngs = pngs; lastChange = System.currentTimeMillis(); }
if (!requireSuite && pngs >= minPngs && (System.currentTimeMillis() - lastChange) >= stableMs
@@ -1293,13 +1302,13 @@ public void run() {
Thread.sleep(3000);
}
pngs = countPngFiles(outDir);
- assertTrue(finished.get() || (!requireSuite && pngs >= minPngs
- && (!requirePerformance || performanceFinished.get())),
- "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")"
- + " finishedTests=" + finishedTests.get() + " suiteFinished=" + finished.get()
- + " performanceFinished=" + performanceFinished.get()
- + " lastLine=" + lastLine.get() + "\n" + serverLog);
-
+ // Persist the capture BEFORE asserting on it. These assertions fire
+ // exactly when the suite came up short, which is the run whose evidence
+ // is most wanted -- and throwing first meant the PNGs and app-output.log
+ // never reached CN1_SHOT_OUTPUT_DIR, so the `if: always()` upload had
+ // nothing to upload, the reporting job had nothing to download, and no
+ // normalized port status was produced. The website then kept serving the
+ // previous, green report for a run that failed.
String outEnv = System.getenv("CN1_SHOT_OUTPUT_DIR");
if (outEnv != null) {
Path dest = Paths.get(outEnv);
@@ -1332,6 +1341,17 @@ public void run() {
System.out.println("CN1_SUITE_LOG=" + suiteLog.size() + " lines"
+ " (performance=" + performanceLog.size() + ")");
}
+
+ assertTrue(wedged.get() == null,
+ "the suite stopped because a test blocked the event dispatch thread: "
+ + wedged.get());
+ assertTrue(finished.get() || (!requireSuite && pngs >= minPngs
+ && (!requirePerformance || performanceFinished.get())),
+ "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")"
+ + " finishedTests=" + finishedTests.get() + " suiteFinished=" + finished.get()
+ + " performanceFinished=" + performanceFinished.get()
+ + " lastLine=" + lastLine.get() + "\n" + serverLog);
+
System.out.println("CN1_HELLO_SUITE_PNGS=" + pngs);
} finally {
if (app != null) { app.destroyForcibly(); }
diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java
index 83959fbd326..5689af3ccc9 100644
--- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java
+++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java
@@ -373,6 +373,23 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception {
appPb.redirectErrorStream(true);
app = appPb.start();
final AtomicBoolean finished = new AtomicBoolean(false);
+ // Set by the suite watchdog when a test blocks the event dispatch thread;
+ // the run cannot progress past that point, so stop instead of waiting.
+ final java.util.concurrent.atomic.AtomicReference wedged = new java.util.concurrent.atomic.AtomicReference<>();
+ // The last test the suite announced. The in-app watchdog names a wedging
+ // test itself, but it cannot always get the message out: a test that blocks
+ // the event dispatch thread in a tight loop also blocks the collector, so
+ // the watchdog's own log call can be stuck waiting to allocate. Reading the
+ // announcement from outside the process always works, and "stopped in X" is
+ // the difference between a diagnosable failure and a silent one.
+ final java.util.concurrent.atomic.AtomicReference lastStarted = new java.util.concurrent.atomic.AtomicReference<>();
+ // When the app last said anything at all. A stall is only diagnosable
+ // from the state it stalls IN: by the time the 40-minute cap expires
+ // the picture has settled and every thread looks idle. Sampling while
+ // it is stuck is what distinguishes "waiting for a callback that never
+ // came" from "still working".
+ final java.util.concurrent.atomic.AtomicLong lastOutputAt =
+ new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis());
final Process appF = app;
Thread areader = new Thread(() -> {
// Tee the app's merged stdout/stderr to CN1_APP_LOG_TEE when
@@ -395,6 +412,13 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception {
while ((line = r.readLine()) != null) {
if (tee != null) { tee.println(line); }
if (line.contains("CN1SS:SUITE:FINISHED")) { finished.set(true); }
+ if (line.contains("CN1SS:SUITE:WEDGED")) { wedged.set(line); }
+ lastOutputAt.set(System.currentTimeMillis());
+ int startedAt = line.indexOf("CN1SS:INFO:suite starting test=");
+ if (startedAt >= 0) {
+ lastStarted.set(line.substring(
+ startedAt + "CN1SS:INFO:suite starting test=".length()).trim());
+ }
}
} catch (IOException ignore) {
}
@@ -417,10 +441,23 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception {
// 40-minute hard cap either way.
long stableMs = 300_000L;
long deadline = System.currentTimeMillis() + 40L * 60 * 1000;
+ // Screenshot stabilization is a weak completion signal: DesktopMode,
+ // the VideoIO grid, the VR scene and the 360 panorama all capture
+ // AFTER the non-rendering API tail, so a slow tail trips the window
+ // while real screenshot tests are still queued -- the suite is then
+ // force-killed and every trailing test is reported as never run.
+ // CN1_REQUIRE_SUITE (as on the Windows arm64 pipeline) demands the
+ // suite's own completion marker instead.
+ boolean requireSuite = Boolean.parseBoolean(System.getenv("CN1_REQUIRE_SUITE"));
int pngs = 0, lastPngs = -1;
+ int stallSamples = 0;
long lastChange = System.currentTimeMillis();
while (System.currentTimeMillis() < deadline) {
if (finished.get()) { break; }
+ if (wedged.get() != null) {
+ System.out.println("CN1SS:HARNESS: " + wedged.get());
+ break;
+ }
if (!app.isAlive()) {
// The suite intermittently DIES mid-run with no output (the
// tee cuts mid-line): surface the exit status -- 128+N means
@@ -432,13 +469,43 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception {
}
pngs = CleanTargetIntegrationTest.countPngFiles(outDir);
if (pngs != lastPngs) { lastPngs = pngs; lastChange = System.currentTimeMillis(); }
- if (pngs >= minPngs && (System.currentTimeMillis() - lastChange) >= stableMs) { break; }
+ long silentMs = System.currentTimeMillis() - lastOutputAt.get();
+ if (silentMs >= STALL_SAMPLE_AFTER_MS && stallSamples < MAX_STALL_SAMPLES) {
+ stallSamples++;
+ System.out.println("CN1SS:HARNESS: no output for " + (silentMs / 1000)
+ + "s after " + lastStarted.get() + "; sampling thread stacks ("
+ + stallSamples + "/" + MAX_STALL_SAMPLES + ")");
+ dumpLiveThreadStacks();
+ // Re-arm so the next sample needs another quiet stretch rather
+ // than firing on every poll.
+ lastOutputAt.set(System.currentTimeMillis());
+ }
+ if (!requireSuite && pngs >= minPngs
+ && (System.currentTimeMillis() - lastChange) >= stableMs) { break; }
Thread.sleep(3000);
}
pngs = CleanTargetIntegrationTest.countPngFiles(outDir);
- assertTrue(finished.get() || pngs >= minPngs,
- "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")\n" + serverLog);
-
+ if (!finished.get() && app.isAlive()) {
+ // The suite is still running and has stopped saying anything. The
+ // post-mortem in the workflow only fires on a core file, so a HANG --
+ // as opposed to the crash that comment describes -- has so far produced
+ // no evidence at all. Attach to the live process and take every thread's
+ // stack before killing it; that is the difference between knowing which
+ // call is stuck and guessing at it.
+ dumpLiveThreadStacks();
+ }
+ if (!finished.get()) {
+ String stoppedIn = lastStarted.get();
+ System.out.println("CN1SS:HARNESS: suite never emitted CN1SS:SUITE:FINISHED; pngs=" + pngs
+ + "; stopped in " + (stoppedIn == null ? "" : stoppedIn)
+ + " -- that test and every one after it is reported as never run.");
+ }
+ // Copy the capture out BEFORE asserting on it, for the same reason as
+ // the Windows harness: these assertions fire precisely when the suite
+ // came up short, and throwing first left whatever screenshots it did
+ // manage stranded in the temporary directory. The partial capture is
+ // what normalization reads to report which tests ran and which never
+ // did, so losing it is how a failed run ends up with no report at all.
String outEnv = System.getenv("CN1_SHOT_OUTPUT_DIR");
if (outEnv != null) {
Path dest = Paths.get(outEnv);
@@ -451,6 +518,16 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception {
}
}
}
+
+ assertTrue(wedged.get() == null,
+ "the suite stopped because a test blocked the event dispatch thread: "
+ + wedged.get());
+ assertTrue(finished.get() || (!requireSuite && pngs >= minPngs),
+ "hello suite capture incomplete: pngs=" + pngs + " (need " + minPngs + ")"
+ + " suiteFinished=" + finished.get()
+ + " stoppedIn=" + (lastStarted.get() == null ? "" : lastStarted.get())
+ + "\n" + serverLog);
+
System.out.println("CN1_HELLO_SUITE_PNGS=" + pngs);
} finally {
if (app != null) { app.destroyForcibly(); }
@@ -539,4 +616,75 @@ static void spliceWindowedDemoLauncher(Path launcherC) throws IOException {
s = s.substring(0, start) + body + s.substring(end);
Files.write(launcherC, s.getBytes(StandardCharsets.UTF_8));
}
+ /// How long the suite may say nothing before it is worth photographing, and
+ /// how many photographs to take. Two minutes is far longer than the gap
+ /// between any two tests in a healthy run, and a handful of samples spread
+ /// across the stall shows whether it is stuck or merely crawling.
+ private static final long STALL_SAMPLE_AFTER_MS = 120_000L;
+ private static final int MAX_STALL_SAMPLES = 6;
+
+ private static int runGdbAttach(java.io.File out, String pid, boolean viaSudo) throws Exception {
+ java.util.List cmd = new java.util.ArrayList<>();
+ if (viaSudo) {
+ cmd.add("sudo");
+ cmd.add("-n");
+ }
+ cmd.add("gdb");
+ cmd.add("-p");
+ cmd.add(pid);
+ cmd.add("-batch");
+ cmd.add("-ex");
+ cmd.add("set pagination off");
+ cmd.add("-ex");
+ cmd.add("thread apply all bt");
+ Process gdb = new ProcessBuilder(cmd)
+ .redirectErrorStream(true)
+ .redirectOutput(ProcessBuilder.Redirect.appendTo(out))
+ .start();
+ return gdb.waitFor();
+ }
+
+ /// Dumps every thread's native stack from the still-running suite process.
+ ///
+ /// Best effort by design: gdb may be absent and ptrace may be restricted, and
+ /// neither should turn a diagnostic into a second failure. Output goes next to
+ /// the app log so it is uploaded with the screenshot artifact.
+ private static void dumpLiveThreadStacks() {
+ try {
+ String teePath = System.getenv("CN1_APP_LOG_TEE");
+ if (teePath == null) {
+ return;
+ }
+ Process pgrep = new ProcessBuilder("pgrep", "-f", "LinuxHelloMain")
+ .redirectErrorStream(true).start();
+ String pid;
+ try (BufferedReader r = new BufferedReader(
+ new InputStreamReader(pgrep.getInputStream(), StandardCharsets.UTF_8))) {
+ pid = r.readLine();
+ }
+ pgrep.waitFor();
+ if (pid == null || pid.trim().isEmpty()) {
+ return;
+ }
+ java.io.File out = new java.io.File(
+ new java.io.File(teePath).getParentFile(), "hang-stacks.txt");
+ try (java.io.PrintWriter header = new java.io.PrintWriter(
+ new java.io.FileWriter(out, true), true)) {
+ header.println("===== sample at " + new java.util.Date()
+ + " (pid " + pid.trim() + ") =====");
+ }
+ // Plain gdb first; if yama still refuses the attach, retry through sudo,
+ // which the runner allows passwordless. Either way a refusal must not
+ // become a second failure.
+ int rc = runGdbAttach(out, pid.trim(), false);
+ if (rc != 0) {
+ runGdbAttach(out, pid.trim(), true);
+ }
+ System.out.println("CN1SS:HARNESS: wrote live thread stacks for pid " + pid.trim()
+ + " to " + out);
+ } catch (Exception ignore) {
+ // A missing gdb or a denied ptrace must not mask the real failure.
+ }
+ }
+
}
diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CustomTimeZoneIdTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CustomTimeZoneIdTest.java
new file mode 100644
index 00000000000..02d87030314
--- /dev/null
+++ b/vm/tests/src/test/java/com/codename1/tools/translator/CustomTimeZoneIdTest.java
@@ -0,0 +1,197 @@
+/*
+ * 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.tools.translator;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Pins vm/JavaAPI's custom GMT-offset id parsing against the JDK.
+ *
+ * java.util.TimeZone defines the custom-id syntax on GMT alone and in fixed
+ * field widths. This parser was looser -- it accepted "UTC+5", "GMT+1:2" and
+ * six-digit "GMT+013000" -- so the same id produced one offset here and a plain
+ * GMT elsewhere. Since the JavaSE and Android ports reach the JDK's parser
+ * directly, that is a cross-port split for identical application code, which is
+ * exactly what this suite exists to catch.
+ */
+class CustomTimeZoneIdTest {
+
+ @Test
+ void customTimeZoneIdsResolveAsTheJdkDoes() throws Exception {
+ Parser.cleanup();
+
+ Path sourceDir = Files.createTempDirectory("custom-timezone-sources");
+ Path classesDir = Files.createTempDirectory("custom-timezone-classes");
+ Path javaApiDir = Files.createTempDirectory("custom-timezone-japi");
+
+ Path source = sourceDir.resolve("CustomTimeZoneApp.java");
+ Files.write(source, loadAppSource().getBytes(StandardCharsets.UTF_8));
+
+ CompilerHelper.CompilerConfig config = selectCompiler();
+ if (config == null) {
+ fail("No compatible compiler available for the Latin-1 Character test");
+ }
+
+ assertTrue(CompilerHelper.isJavaApiCompatible(config),
+ "JDK " + config.jdkVersion + " must target matching bytecode level for JavaAPI");
+
+ CompilerHelper.compileJavaAPI(javaApiDir, config);
+
+ List compileArgs = new ArrayList<>();
+ compileArgs.add("-source");
+ compileArgs.add(config.targetVersion);
+ compileArgs.add("-target");
+ compileArgs.add(config.targetVersion);
+ if (CompilerHelper.useClasspath(config)) {
+ compileArgs.add("-classpath");
+ compileArgs.add(javaApiDir.toString());
+ } else {
+ compileArgs.add("-bootclasspath");
+ compileArgs.add(javaApiDir.toString());
+ compileArgs.add("-Xlint:-options");
+ }
+ compileArgs.add("-d");
+ compileArgs.add(classesDir.toString());
+ compileArgs.add(source.toString());
+
+ int compileResult = CompilerHelper.compile(config.jdkHome, compileArgs);
+ assertEquals(0, compileResult,
+ "CustomTimeZoneApp should compile. " + CompilerHelper.getLastErrorLog());
+
+ String jdkResult = extractResultLine(runJavaMain(config, classesDir));
+ assertTrue(jdkResult.startsWith("RESULT="),
+ "The JDK leg should produce a RESULT line");
+
+ CompilerHelper.copyDirectory(javaApiDir, classesDir);
+
+ Path outputDir = Files.createTempDirectory("custom-timezone-output");
+ CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "CustomTimeZoneApp");
+
+ Path distDir = outputDir.resolve("dist");
+ Path cmakeLists = distDir.resolve("CMakeLists.txt");
+ assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project");
+
+ CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "CustomTimeZoneApp-src");
+
+ Path buildDir = distDir.resolve("build");
+ Files.createDirectories(buildDir);
+
+ CleanTargetIntegrationTest.runCommand(Arrays.asList(
+ "cmake",
+ "-S", distDir.toString(),
+ "-B", buildDir.toString(),
+ "-DCMAKE_C_COMPILER=clang",
+ "-DCMAKE_OBJC_COMPILER=clang"
+ ), distDir);
+
+ CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir);
+
+ Path executable = buildDir.resolve("CustomTimeZoneApp");
+ assertTrue(Files.exists(executable), "ParparVM build should produce a runnable executable");
+
+ String vmResult = extractResultLine(
+ CleanTargetIntegrationTest.runCommand(Arrays.asList(executable.toString()), buildDir));
+
+ assertEquals(jdkResult, vmResult, firstDifference(jdkResult, vmResult));
+ }
+
+ /** Names the offending id rather than dumping two long strings. */
+ private String firstDifference(String expected, String actual) {
+ String[] want = expected.substring("RESULT=".length()).split(";");
+ String[] got = actual.substring(actual.indexOf('=') + 1).split(";");
+ for (int i = 0; i < Math.min(want.length, got.length); i++) {
+ if (!want[i].equals(got[i])) {
+ return "Custom time zone offsets differ: JDK " + want[i]
+ + ", ParparVM " + got[i] + " (offsets in milliseconds)";
+ }
+ }
+ return "ParparVM should resolve custom GMT offset ids as the JDK does";
+ }
+
+ private String loadAppSource() throws Exception {
+ java.io.InputStream in = CustomTimeZoneIdTest.class.getResourceAsStream(
+ "/com/codename1/tools/translator/CustomTimeZoneApp.java");
+ assertNotNull(in, "CustomTimeZoneApp.java test resource should exist");
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+ return reader.lines().collect(Collectors.joining("\n")) + "\n";
+ }
+ }
+
+ private String runJavaMain(CompilerHelper.CompilerConfig config, Path classesDir) throws Exception {
+ String javaExe = config.jdkHome.resolve("bin").resolve("java").toString();
+ if (System.getProperty("os.name").toLowerCase().contains("win")) {
+ javaExe += ".exe";
+ }
+
+ ProcessBuilder pb = new ProcessBuilder(javaExe, "-cp", classesDir.toString(), "CustomTimeZoneApp");
+ pb.redirectErrorStream(true);
+
+ Process process = pb.start();
+ String output;
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
+ output = reader.lines().collect(Collectors.joining("\n"));
+ }
+
+ int exitCode = process.waitFor();
+ assertEquals(0, exitCode, "JVM run should exit cleanly. Output: " + output);
+ return output;
+ }
+
+ private String extractResultLine(String output) {
+ for (String line : output.split("\\R")) {
+ if (line.startsWith("RESULT=")) {
+ return line.trim();
+ }
+ }
+ return "";
+ }
+
+ private CompilerHelper.CompilerConfig selectCompiler() {
+ String[] preferredTargets = {"11", "17", "21", "25", "1.8"};
+ for (String target : preferredTargets) {
+ List configs = CompilerHelper.getAvailableCompilers(target);
+ for (CompilerHelper.CompilerConfig config : configs) {
+ if (CompilerHelper.isJavaApiCompatible(config)) {
+ return config;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/TimeZoneOffsetFrameTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/TimeZoneOffsetFrameTest.java
new file mode 100644
index 00000000000..67985d46710
--- /dev/null
+++ b/vm/tests/src/test/java/com/codename1/tools/translator/TimeZoneOffsetFrameTest.java
@@ -0,0 +1,197 @@
+/*
+ * 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.tools.translator;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Pins the reference frame of TimeZone.getOffset against the JDK.
+ *
+ * The six-argument getOffset takes LOCAL STANDARD time fields. The ports'
+ * natives answer a different question -- the offset at the instant a set of UTC
+ * fields denotes -- and the two were being used interchangeably, so
+ * America/New_York at 2020-03-08 02:30 standard time answered UTC-05:00 where
+ * JavaSE and Android answer UTC-04:00. This walks both frames across that
+ * transition, in three zones, and requires the JDK's answers exactly.
+ */
+class TimeZoneOffsetFrameTest {
+
+ @Test
+ void offsetFramesMatchTheJdkAcrossATransition() throws Exception {
+ Parser.cleanup();
+
+ Path sourceDir = Files.createTempDirectory("timezone-frame-sources");
+ Path classesDir = Files.createTempDirectory("timezone-frame-classes");
+ Path javaApiDir = Files.createTempDirectory("timezone-frame-japi");
+
+ Path source = sourceDir.resolve("TimeZoneOffsetFrameApp.java");
+ Files.write(source, loadAppSource().getBytes(StandardCharsets.UTF_8));
+
+ CompilerHelper.CompilerConfig config = selectCompiler();
+ if (config == null) {
+ fail("No compatible compiler available for the Latin-1 Character test");
+ }
+
+ assertTrue(CompilerHelper.isJavaApiCompatible(config),
+ "JDK " + config.jdkVersion + " must target matching bytecode level for JavaAPI");
+
+ CompilerHelper.compileJavaAPI(javaApiDir, config);
+
+ List compileArgs = new ArrayList<>();
+ compileArgs.add("-source");
+ compileArgs.add(config.targetVersion);
+ compileArgs.add("-target");
+ compileArgs.add(config.targetVersion);
+ if (CompilerHelper.useClasspath(config)) {
+ compileArgs.add("-classpath");
+ compileArgs.add(javaApiDir.toString());
+ } else {
+ compileArgs.add("-bootclasspath");
+ compileArgs.add(javaApiDir.toString());
+ compileArgs.add("-Xlint:-options");
+ }
+ compileArgs.add("-d");
+ compileArgs.add(classesDir.toString());
+ compileArgs.add(source.toString());
+
+ int compileResult = CompilerHelper.compile(config.jdkHome, compileArgs);
+ assertEquals(0, compileResult,
+ "TimeZoneOffsetFrameApp should compile. " + CompilerHelper.getLastErrorLog());
+
+ String jdkResult = extractResultLine(runJavaMain(config, classesDir));
+ assertTrue(jdkResult.startsWith("RESULT="),
+ "The JDK leg should produce a RESULT line");
+
+ CompilerHelper.copyDirectory(javaApiDir, classesDir);
+
+ Path outputDir = Files.createTempDirectory("timezone-frame-output");
+ CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "TimeZoneOffsetFrameApp");
+
+ Path distDir = outputDir.resolve("dist");
+ Path cmakeLists = distDir.resolve("CMakeLists.txt");
+ assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project");
+
+ CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "TimeZoneOffsetFrameApp-src");
+
+ Path buildDir = distDir.resolve("build");
+ Files.createDirectories(buildDir);
+
+ CleanTargetIntegrationTest.runCommand(Arrays.asList(
+ "cmake",
+ "-S", distDir.toString(),
+ "-B", buildDir.toString(),
+ "-DCMAKE_C_COMPILER=clang",
+ "-DCMAKE_OBJC_COMPILER=clang"
+ ), distDir);
+
+ CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir);
+
+ Path executable = buildDir.resolve("TimeZoneOffsetFrameApp");
+ assertTrue(Files.exists(executable), "ParparVM build should produce a runnable executable");
+
+ String vmResult = extractResultLine(
+ CleanTargetIntegrationTest.runCommand(Arrays.asList(executable.toString()), buildDir));
+
+ assertEquals(jdkResult, vmResult, firstDifference(jdkResult, vmResult));
+ }
+
+ /** Names the offending id rather than dumping two long strings. */
+ private String firstDifference(String expected, String actual) {
+ String[] want = expected.substring("RESULT=".length()).split(";");
+ String[] got = actual.substring(actual.indexOf('=') + 1).split(";");
+ for (int i = 0; i < Math.min(want.length, got.length); i++) {
+ if (!want[i].equals(got[i])) {
+ return "Time zone offset frames differ: JDK " + want[i]
+ + ", ParparVM " + got[i] + " (offsets in milliseconds)";
+ }
+ }
+ return "ParparVM should resolve offsets in the same frame as the JDK";
+ }
+
+ private String loadAppSource() throws Exception {
+ java.io.InputStream in = TimeZoneOffsetFrameTest.class.getResourceAsStream(
+ "/com/codename1/tools/translator/TimeZoneOffsetFrameApp.java");
+ assertNotNull(in, "TimeZoneOffsetFrameApp.java test resource should exist");
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+ return reader.lines().collect(Collectors.joining("\n")) + "\n";
+ }
+ }
+
+ private String runJavaMain(CompilerHelper.CompilerConfig config, Path classesDir) throws Exception {
+ String javaExe = config.jdkHome.resolve("bin").resolve("java").toString();
+ if (System.getProperty("os.name").toLowerCase().contains("win")) {
+ javaExe += ".exe";
+ }
+
+ ProcessBuilder pb = new ProcessBuilder(javaExe, "-cp", classesDir.toString(), "TimeZoneOffsetFrameApp");
+ pb.redirectErrorStream(true);
+
+ Process process = pb.start();
+ String output;
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
+ output = reader.lines().collect(Collectors.joining("\n"));
+ }
+
+ int exitCode = process.waitFor();
+ assertEquals(0, exitCode, "JVM run should exit cleanly. Output: " + output);
+ return output;
+ }
+
+ private String extractResultLine(String output) {
+ for (String line : output.split("\\R")) {
+ if (line.startsWith("RESULT=")) {
+ return line.trim();
+ }
+ }
+ return "";
+ }
+
+ private CompilerHelper.CompilerConfig selectCompiler() {
+ String[] preferredTargets = {"11", "17", "21", "25", "1.8"};
+ for (String target : preferredTargets) {
+ List configs = CompilerHelper.getAvailableCompilers(target);
+ for (CompilerHelper.CompilerConfig config : configs) {
+ if (CompilerHelper.isJavaApiCompatible(config)) {
+ return config;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/CharacterLatin1App.java b/vm/tests/src/test/resources/com/codename1/tools/translator/CharacterLatin1App.java
new file mode 100644
index 00000000000..8a2751dcfca
--- /dev/null
+++ b/vm/tests/src/test/resources/com/codename1/tools/translator/CharacterLatin1App.java
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+/**
+ * Prints Character's answers for every ISO Latin-1 code point.
+ *
+ * Run twice by CharacterLatin1TypeTest -- once on the host JDK, once through
+ * ParparVM against vm/JavaAPI's Character -- and the two RESULT lines must be
+ * identical. Latin-1 is the range this class documents as supported, and the
+ * category table backing getType was generated from a JDK, so the JDK is the
+ * right oracle for it.
+ */
+public class CharacterLatin1App {
+
+ public static void main(String[] args) {
+ StringBuilder sb = new StringBuilder("RESULT=");
+ for (int c = 0; c < 256; c++) {
+ sb.append(Character.getType(c));
+ sb.append(flag(Character.isLetter(c)));
+ sb.append(flag(Character.isLowerCase(c)));
+ sb.append(flag(Character.isUpperCase(c)));
+ sb.append(flag(Character.isDigit(c)));
+ sb.append(flag(Character.isLetterOrDigit(c)));
+ sb.append(flag(Character.isJavaIdentifierStart(c)));
+ sb.append(flag(Character.isJavaIdentifierPart(c)));
+ sb.append(';');
+ }
+ System.out.println(sb.toString());
+ }
+
+ private static char flag(boolean value) {
+ return value ? 'T' : 'F';
+ }
+}
diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java
new file mode 100644
index 00000000000..4607196419c
--- /dev/null
+++ b/vm/tests/src/test/resources/com/codename1/tools/translator/CustomTimeZoneApp.java
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ */
+/**
+ * Prints the id and raw offset java.util.TimeZone resolves for a set of custom
+ * GMT-offset ids, including malformed ones.
+ *
+ * CustomTimeZoneIdTest runs this on the host JDK and through ParparVM and
+ * requires identical output. Only offset ids appear here -- a named zone would
+ * depend on the platform tz database rather than on the custom-id parser.
+ */
+public class CustomTimeZoneApp {
+
+ private static final String[] IDS = {
+ "GMT", "GMT+0", "GMT-0", "GMT+5", "GMT-8", "GMT+10", "GMT+23",
+ "GMT+000", "GMT+123", "GMT+0130", "GMT+2359",
+ "GMT+01:02", "GMT+5:00", "GMT+23:59", "GMT-08:00", "GMT+00:00",
+ // "GMT+01:30:00" is deliberately absent: JDK 17 answers GMT and JDK 25
+ // answers GMT+01:30 for it, so it cannot serve as a stable oracle. The
+ // documented syntax has no seconds field and this port rejects it.
+ // Each of these is malformed and must not become an offset.
+ "GMT+1:2", "GMT+05:0", "GMT+013000", "GMT+12345",
+ // "GMT+00000" is deliberately absent. Five digits are undefined by the
+ // documented syntax and the JDKs disagree: 11 and 17 answer GMT+00:00,
+ // 21 and 25 answer GMT. It cannot be a stable oracle, so this port
+ // follows the documented form and rejects it.
+ "GMT+24", "GMT+1:60", "GMT+23:60", "GMT+01:30:99", "GMT+01:30xyz",
+ "GMT+1x", "GMT+", "UTC+5", "UT+5",
+ // Z-suffixed pseudo ids name no zone; the JDK takes the unknown-id
+ // fallback, so the id has to be GMT and not the spelling asked for.
+ "GMTZ", "UTCZ", "UTZ",
+ // Bare prefixes: only exact uppercase UTC names a zone of its own; the
+ // rest take the unknown-id fallback and answer GMT.
+ "UTC", "UT", "utc", "ut", "gmt", "Utc",
+ // An OFFSET suffix is accepted only after exact uppercase "GMT". The
+ // JDK's parseCustomTimeZone tests `id.indexOf("GMT") != 0`, which is
+ // case-sensitive, and the zone-name lookup ahead of it is case-sensitive
+ // too -- so every spelling below answers plain GMT with a zero offset,
+ // NOT the offset it appears to name. Reviewed as an inconsistency
+ // (the port tests the prefix case-insensitively when deciding whether an
+ // id is an offset attempt at all, and case-sensitively when deciding
+ // whether to honour it), so it is pinned here against the real JDK
+ // rather than left to argument.
+ "gmt+05:00", "Gmt+05:00", "gMt+05:00", "gmt+5", "gmt-08:00",
+ "utc+05:00", "ut+5"
+ };
+
+ public static void main(String[] args) {
+ StringBuilder sb = new StringBuilder("RESULT=");
+ for (int i = 0; i < IDS.length; i++) {
+ java.util.TimeZone tz = java.util.TimeZone.getTimeZone(IDS[i]);
+ sb.append(IDS[i]).append('=').append(tz.getRawOffset())
+ .append('/').append(tz.getID()).append(';');
+ }
+ System.out.println(sb.toString());
+ }
+}
diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/TimeZoneOffsetFrameApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/TimeZoneOffsetFrameApp.java
new file mode 100644
index 00000000000..63ecce2c25e
--- /dev/null
+++ b/vm/tests/src/test/resources/com/codename1/tools/translator/TimeZoneOffsetFrameApp.java
@@ -0,0 +1,60 @@
+/*
+ * 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.
+ */
+/**
+ * Offsets around the 2020 US spring-forward transition, from both directions.
+ *
+ * TimeZoneOffsetFrameTest runs this on the host JDK and through ParparVM and
+ * requires identical output. The two halves are the two frames that were being
+ * confused: getOffset takes local STANDARD time fields, while an Instant has to
+ * be converted into that frame before it can be asked about.
+ */
+import java.util.Calendar;
+import java.util.TimeZone;
+
+public class TimeZoneOffsetFrameApp {
+
+ public static void main(String[] args) {
+ StringBuilder sb = new StringBuilder("RESULT=");
+ String[] zones = { "America/New_York", "Europe/Berlin", "Asia/Tokyo" };
+ for (int z = 0; z < zones.length; z++) {
+ TimeZone tz = TimeZone.getTimeZone(zones[z]);
+ // Local-standard fields straight across the US transition.
+ for (int hour = 0; hour < 6; hour++) {
+ sb.append(zones[z]).append('@').append(hour).append('=')
+ .append(tz.getOffset(1, 2020, 2, 8, Calendar.SUNDAY, hour * 3600000))
+ .append(';');
+ }
+ // The same day approached as instants, one per hour of UTC.
+ for (int hour = 0; hour < 12; hour++) {
+ long instant = 1583625600000L + hour * 3600000L; // 2020-03-08T00:00Z
+ java.util.Date d = new java.util.Date(instant);
+ Calendar cal = Calendar.getInstance(tz);
+ cal.setTime(d);
+ sb.append(zones[z]).append('#').append(hour).append('=')
+ .append(cal.get(Calendar.HOUR_OF_DAY)).append(':')
+ .append(cal.get(Calendar.MINUTE)).append(';');
+ }
+ }
+ System.out.println(sb.toString());
+ }
+}
|