diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 5a14f0bbd57..abc047fd5c0 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 diff --git a/.github/workflows/port-status-nightly.yml b/.github/workflows/port-status-nightly.yml index 3d22b52cb5d..f377bede1b3 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,8 +99,10 @@ jobs: if-no-files-found: error publish-browser-evidence: + # 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. if: always() && needs.build-javascript-app.result == 'success' - needs: [build-javascript-app, browser-lifecycle] + needs: [build-javascript-app, browser-lifecycle, publish-latest-port-reports] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 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/CodenameOne/src/com/codename1/impl/health/EdtResult.java b/CodenameOne/src/com/codename1/impl/health/EdtResult.java index 780a1d2c79e..29800dc6370 100644 --- a/CodenameOne/src/com/codename1/impl/health/EdtResult.java +++ b/CodenameOne/src/com/codename1/impl/health/EdtResult.java @@ -23,6 +23,9 @@ package com.codename1.impl.health; import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; +import com.codename1.util.EasyThread; +import com.codename1.util.SuccessCallback; /// The resource every public health operation hands back: one outcome, /// delivered on the EDT. @@ -65,6 +68,73 @@ public void error(Throwable t) { Display.getInstance().callSerially(new Deliver(this, null, t)); } + /// Completing on the EDT is only half of the guarantee. `AsyncResource` + /// runs a callback registered against an already-finished resource + /// immediately, on whichever thread registered it, so the outcome landing + /// on the EDT does not mean the callback does. + /// + /// That is the ordinary case for the operations that resolve before they + /// return -- the facade's `openHealthSettings` and `openProviderSetup` + /// complete inside the call -- where the delivery thread came down to + /// whether the EDT had drained the hop above before the caller got as far + /// as `onResult`. The same call arrived on the EDT or off it from one run + /// to the next. + /// + /// Wrapping every callback closes that half. A callback already reached on + /// the EDT sees `isEdt()` and runs inline, so this costs a branch and + /// never an extra queued runnable. Both arities funnel through the + /// `EasyThread` overloads, so overriding these two covers `ready`, + /// `except` and `onResult` alike. + /// + /// A caller who names an `EasyThread` is asking for delivery there + /// specifically, which is the point of that overload, so those are left + /// alone -- the default is the EDT, not an override of an explicit choice. + @Override + public AsyncResource ready(SuccessCallback callback, EasyThread t) { + return super.ready(t == null ? new OnEdt(callback) : callback, t); + } + + @Override + public AsyncResource except(SuccessCallback callback, EasyThread t) { + return super.except(t == null ? new OnEdt(callback) : callback, t); + } + + /// Named rather than anonymous so the hop carries no synthetic reference + /// to anything enclosing (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static final class OnEdt implements SuccessCallback { + + private final SuccessCallback delegate; + + OnEdt(SuccessCallback delegate) { + this.delegate = delegate; + } + + @Override + public void onSucess(V value) { + if (Display.getInstance().isEdt()) { + delegate.onSucess(value); + return; + } + Display.getInstance().callSerially(new Invoke(delegate, value)); + } + } + + private static final class Invoke implements Runnable { + + private final SuccessCallback delegate; + private final V value; + + Invoke(SuccessCallback delegate, V value) { + this.delegate = delegate; + this.value = value; + } + + @Override + public void run() { + delegate.onSucess(value); + } + } + /// Named rather than anonymous so the hop carries no synthetic reference /// to anything enclosing (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). private static final class Deliver implements Runnable { diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java index 5e715ae6fea..e393f60522f 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -118,8 +118,25 @@ public void open() throws IOException { } active = this; } + // open() runs on the server's reader thread, so a stop() from another thread can + // already have closed this transport, or close it while we are binding. Two + // things have to be true afterwards whichever order those land in: the + // process-wide registration must not stay claimed by a transport that never + // listens (every later open() would then be refused with "already open on port + // N" for the life of the process), and no listener may be left bound with + // nobody holding a reference to stop it -- an orphan keeps the port and its + // connection callbacks still consult `active`, so it could hand a connection to + // a later transport. + // + // Publishing `listening` under the same lock close() uses is what makes that + // decidable. The bind itself stays outside the lock -- Socket.listenLoopback is + // a platform call and holding a lock the connection callback also takes across + // it invites a deadlock -- so the check is "did close() win?" immediately after, + // and the loser cleans up. Either close() sees a published listener and stops + // it, or we see closed and stop it ourselves. + Socket.StopListening bound; try { - listening = Socket.listenLoopback(port, Connection.class); + bound = Socket.listenLoopback(port, Connection.class); } catch (RuntimeException ex) { // Two things go wrong if this escapes. The process-wide registration would stay // pointing at a transport that never started listening, so every later open() @@ -132,6 +149,21 @@ public void open() throws IOException { failure.initCause(ex); throw failure; } + boolean closedWhileBinding; + synchronized (lock) { + closedWhileBinding = closed; + if (!closedWhileBinding) { + listening = bound; + } + } + if (closedWhileBinding) { + // No null check on `bound`: the only way past the try above is with a + // listener in hand, and SpotBugs flags the redundant test. Stop before + // releasing the slot, for the reason close() gives. + bound.stop(); + clearActiveIfOurs(); + throw new IOException("This MCP socket transport was closed before it began listening"); + } } /// Releases the process-wide registration, but only when it is still this transport's. @@ -149,10 +181,21 @@ void attach(InputStream is, OutputStream os) { InputStream previousIn; // NOPMD closed below, deliberately outside the lock OutputStream previousOut; // NOPMD closed below, outside the lock synchronized (lock) { - previousIn = in; - previousOut = out; - in = is; - out = os; + if (closed) { + // A listener retired by close() can still have a connection in + // flight. Adopting it would serve a client that dialled a port + // this transport no longer owns, so hand the streams back to be + // closed rather than wiring them to a dead session. + previousIn = is; + previousOut = os; + is = null; + os = null; + } else { + previousIn = in; + previousOut = out; + in = is; + out = os; + } lock.notifyAll(); } // Dropping the previous client means closing its streams, not just forgetting @@ -369,11 +412,17 @@ public void close() { out = null; lock.notifyAll(); } - // Only if it is still ours: a transport opened after this one keeps its slot. - clearActiveIfOurs(); + // Stop the listener BEFORE releasing the registration, not after. A + // connection this listener already accepted resolves `active` inside its + // callback, so releasing first leaves a window where a replacement + // transport has taken the slot and the retired listener hands it a client + // that dialled the old port. Stopping first means any in-flight callback + // still finds this transport, and attach() below refuses it because we + // are closed. if (l != null) { l.stop(); } + clearActiveIfOurs(); // Closing the output as well as the input: forgetting the field is not enough, // because a writer that already captured it would go on writing to a session that // has ended, and the socket would stay open until the connection callback unwound. 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 63702181cb5..cf667b6f536 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -13939,13 +13939,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()); @@ -13958,7 +13995,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 7353168fe4a..92dc66fce31 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -19768,13 +19768,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()); @@ -19787,7 +19824,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..b245b70375c --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_crypto.c @@ -0,0 +1,589 @@ +/* + * 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) { + /* Tolerate a bare PKCS#1/SEC1 key as well; some callers keep those. */ + cursor = der; + key = d2i_AutoPrivateKey(0, &cursor, (long) length); + } + if (key == 0) { + 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 && + EVP_DigestVerify(ctx, signature, (size_t) signatureLength, data, (size_t) dataLength) == 1) { + result = JAVA_TRUE; + } 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..2afadd4168d 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,38 @@ 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) { + snprintf(cn1LastIoError, sizeof(cn1LastIoError), "%s", strerror(errno)); + (void) path; +} + +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 +272,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 +299,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 +338,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 +375,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..b1f2ea6b855 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,17 @@ 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 component is rendered from a +/// cached image: the native side captures the view to PNG bytes after each +/// navigation, which `generatePeerImage()` turns into the peer image that +/// `PeerComponent.paint()` draws, so it appears in the offscreen screenshot +/// where the live WebKit widget would not. The peer polls the native event +/// queue to fire `onLoad` and to route the JS return-value bridge into the /// BrowserComponent's navigation callbacks. +/// +/// (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 +141,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 431d1775b57..ff7d4384d78 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; @@ -2363,8 +2364,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); } @@ -2373,23 +2374,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. @@ -2407,8 +2431,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; @@ -2584,13 +2611,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); } @@ -2619,6 +2652,13 @@ 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. */ @Override public String getAppHomePath() { @@ -2629,7 +2669,12 @@ public String getAppHomePath() { if (!dir.endsWith("/")) { dir += "/"; } - return dir; + return "file://" + dir; + } + + @Override + public String toNativePath(String path) { + return stripFileUrl(path); } @Override @@ -2692,6 +2737,137 @@ 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. + 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 && (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); + 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); + 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; + } + boolean wantsEc = algorithm.toUpperCase().indexOf("ECDSA") >= 0; + boolean keyIsEc = keyAlgorithm.toUpperCase().startsWith("EC"); + 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..1619fa99bd4 --- /dev/null +++ b/Ports/WindowsPort/nativeSources/cn1_windows_crypto.c @@ -0,0 +1,1242 @@ +/* + * 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 + +#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; + } + modulusBytes = bits / 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 / 8; + } + 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); + } + } + /* A rejected signature is a normal answer here, not a fault. */ + if (usable && BCryptVerifySignature(key, isEc ? NULL : &padding, digest, + (ULONG) digestLength, (PUCHAR) toVerify, + toVerifyLength, + isEc ? 0 : BCRYPT_PAD_PKCS1) == STATUS_SUCCESS) { + result = JAVA_TRUE; + } + } + 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_io.c b/Ports/WindowsPort/nativeSources/cn1_windows_io.c index 442889295fa..96b000d487c 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_io.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_io.c @@ -82,6 +82,20 @@ 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; + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_lastIoError___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + char buffer[256]; + _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); @@ -93,6 +107,7 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenRead___java_lang_Stri OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); free(path); if (h == INVALID_HANDLE_VALUE) { + cn1WinLastIoError = GetLastError(); return 0; } return (JAVA_LONG)(intptr_t)h; @@ -119,6 +134,7 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_fileOpenWrite___java_lang_Str } free(path); if (h == INVALID_HANDLE_VALUE) { + cn1WinLastIoError = GetLastError(); return 0; } return (JAVA_LONG)(intptr_t)h; diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 41d720777da..d2e0e8743f9 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; @@ -2377,8 +2378,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); } @@ -2387,19 +2388,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 @@ -2598,13 +2621,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); } @@ -2623,6 +2652,39 @@ 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. + */ + @Override + public String getAppHomePath() { + String dir = WindowsNative.storageDir(); + if (dir == null || dir.length() == 0) { + dir = "."; + } + if (!dir.endsWith("\\") && !dir.endsWith("/")) { + dir += 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. + return "file://" + dir.replace('\\', '/'); + } + + @Override + public String toNativePath(String path) { + return stripFileUrl(path); + } + @Override public String[] listFiles(String directory) throws IOException { return WindowsNative.fileList(stripFileUrl(directory)); @@ -2683,6 +2745,137 @@ 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. + 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 && (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); + 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); + 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; + } + boolean wantsEc = algorithm.toUpperCase().indexOf("ECDSA") >= 0; + boolean keyIsEc = keyAlgorithm.toUpperCase().startsWith("EC"); + 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 797d7259761..03a2be0bbf4 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" #if TARGET_OS_WATCH #import "CN1CGGraphics.h" @@ -10017,8 +10018,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]; @@ -11137,12 +11148,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); @@ -11152,11 +11182,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_reports/android.json b/docs/website/data/port_status_reports/android.json index fb044632f8f..e0662657dc1 100644 --- a/docs/website/data/port_status_reports/android.json +++ b/docs/website/data/port_status_reports/android.json @@ -1,14 +1,64 @@ { - "commit": "b51436a94d6451eae6b2673e1ff48eb88003c142", - "generated_at": "2026-07-16T15:09:30Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:16:00Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 257653628 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 35267214 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 327725521 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 203997199 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 141909697 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 4377145955 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 152307139 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 172021740 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 763524967 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 295697914 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "android", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29508571507", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977096", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 164, + "not-run": 0, + "pass": 169, "skip": 1 }, "tests": { @@ -72,6 +122,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +221,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +393,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +409,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +517,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -675,76 +741,9 @@ "feature": "video-round-trip", "status": "pass" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 270971236 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 33987228 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 306089084 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 239946517 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 174849906 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 5031760674 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 145574439 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 151462539 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 743204825 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 307991556 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/ios-gl.json b/docs/website/data/port_status_reports/ios-gl.json index 56dceeb7689..894d5f63a56 100644 --- a/docs/website/data/port_status_reports/ios-gl.json +++ b/docs/website/data/port_status_reports/ios-gl.json @@ -1,14 +1,56 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T12:54:52Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T09:24:29Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 334687000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 21787000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 122655000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 74123000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 322920000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 134806000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 175512000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "ios-gl", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889339", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 260829000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 20455000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 39588000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 106287000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 65812000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 288774000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 218533000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 126690000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 164612000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 47528000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/ios-metal.json b/docs/website/data/port_status_reports/ios-metal.json index 41e6d8bb21c..0af514c1187 100644 --- a/docs/website/data/port_status_reports/ios-metal.json +++ b/docs/website/data/port_status_reports/ios-metal.json @@ -1,14 +1,56 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T13:06:47Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T10:12:12Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 346696000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 24678000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 119904000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 73599000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 388630000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 126606000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 178679000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "ios-metal", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889339", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 440011000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 26479000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 47714000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 135175000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 76514000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 410277000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 367596000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 129907000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 246493000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 86101000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/javascript.json b/docs/website/data/port_status_reports/javascript.json index a31881d039b..b0152b54e94 100644 --- a/docs/website/data/port_status_reports/javascript.json +++ b/docs/website/data/port_status_reports/javascript.json @@ -1,15 +1,65 @@ { - "commit": "4a3f5807e0850131483b20b45d3802c9c42f7fd7", - "generated_at": "2026-07-16T14:48:44Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:30:48Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 1486700000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 851400000 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 14321000000 + }, + "intArithmetic": { + "checksum": "1313580095284", + "duration_ns": 705300000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 3920500000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 586000000 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 2741500000 + }, + "quicksort": { + "checksum": "786886890168670967", + "duration_ns": 576800000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 1123899999 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 1223900000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "javascript", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29505603266", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977438", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 164, - "skip": 1 + "not-run": 0, + "pass": 170, + "skip": 0 }, "tests": { "ARApiTest": { @@ -72,16 +122,17 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "reasons": [ - "needs-runtime-permission-on-HTML5" - ], - "status": "skip" + "status": "pass" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -167,11 +218,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +390,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +406,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +514,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -675,76 +738,9 @@ "feature": "video-round-trip", "status": "pass" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 1454299999 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 956000000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 15788000000 - }, - "intArithmetic": { - "checksum": "1313580095284", - "duration_ns": 721800001 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 3997400000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 300100000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 3617099999 - }, - "quicksort": { - "checksum": "786886890168670967", - "duration_ns": 577900001 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 1071299999 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 1333500000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/linux-arm64.json b/docs/website/data/port_status_reports/linux-arm64.json index e2858afe2d2..d0b356331c1 100644 --- a/docs/website/data/port_status_reports/linux-arm64.json +++ b/docs/website/data/port_status_reports/linux-arm64.json @@ -1,14 +1,64 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T12:38:03Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:22:38Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 170452739 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 26587685 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 35381384 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 62814185 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 39201326 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 270735594 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 4175763773 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 94506846 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 132546281 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 44943794 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "linux-arm64", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496888305", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977091", "schema_version": 1, "suite_finished": false, "summary": { - "fail": 0, - "not-run": 5, - "pass": 165, + "fail": 7, + "not-run": 2, + "pass": 161, "skip": 0 }, "tests": { @@ -46,7 +96,10 @@ }, "AudioMixerApiTest": { "feature": "audio-media-playback", - "status": "pass" + "reasons": [ + "failed: AudioMixer API test failed: java.lang.IllegalStateException: mixed WAV file was not created" + ], + "status": "fail" }, "BackgroundThreadUiAccessTest": { "feature": "threading", @@ -62,7 +115,10 @@ }, "BrowserComponentScreenshotTest": { "feature": "embedded-web-content", - "status": "pass" + "reasons": [ + "failed due to timeout waiting for DONE stage=show-completed" + ], + "status": "fail" }, "ButtonThemeScreenshotTest": { "feature": "native-theme-controls", @@ -72,13 +128,20 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "status": "pass" + "reasons": [ + "failed: Camera.getCameras() returned no cameras" + ], + "status": "fail" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -164,11 +227,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -190,7 +253,10 @@ }, "CryptoApiTest": { "feature": "secure-storage-crypto", - "status": "pass" + "reasons": [ + "failed: Crypto API test failed: com.codename1.security.CryptoException: Crypto operation secureRandomBytes is not supported on this platform. If you are running in a fresh CodenameOneImplementation subclass, override the matching method." + ], + "status": "fail" }, "CssFilterBlurScreenshotTest": { "feature": "theme-palette-css", @@ -266,7 +332,10 @@ }, "FileSystemStorageOpenInputStreamMissingTest": { "feature": "filesystem-storage", - "status": "pass" + "reasons": [ + "failed: openInputStream returned a stream (com.codename1.impl.linux.LinuxInputStream@FF4236CFD0F0) for a missing path /home/runner/.local/share/codenameone/this-file-must-not-exist-1502-1785438836814.bin instead of throwing. Platform=linux" + ], + "status": "fail" }, "FillArc": { "feature": "graphics-primitives", @@ -336,6 +405,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -348,6 +421,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -452,6 +529,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -558,7 +639,10 @@ }, "SurfacesPublishTest": { "feature": "surfaces", - "status": "pass" + "reasons": [ + "failed: Surfaces publish contract failed: java.lang.UnsupportedOperationException: UnicodeHelper.getClasses() not supported" + ], + "status": "fail" }, "SurfacesRasterizerScreenshotTest": { "feature": "surfaces", @@ -610,7 +694,10 @@ }, "TimeApiTest": { "feature": "java-standard-apis", - "status": "pass" + "reasons": [ + "failed: Time API test failed: java.lang.RuntimeException: Expected [2020-03-08T01:30:00-05:00[America/New_York]], Actual [2020-03-08T11:30:00-05:00[America/New_York]]" + ], + "status": "fail" }, "ToastBarTopPositionScreenshotTest": { "feature": "toast-notifications", @@ -666,82 +753,15 @@ }, "VideoIODecodedFramesScreenshotTest": { "feature": "video-decoding", - "status": "pass" + "status": "not-run" }, "VideoIORoundTripTest": { "feature": "video-round-trip", - "status": "pass" - }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", "status": "not-run" }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 167647092 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 26067574 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 29352127 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 62775877 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 39325844 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 273404082 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 4638409365 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 91416037 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 137627018 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 36440861 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/linux-x64.json b/docs/website/data/port_status_reports/linux-x64.json index 91949b47702..765104c021c 100644 --- a/docs/website/data/port_status_reports/linux-x64.json +++ b/docs/website/data/port_status_reports/linux-x64.json @@ -1,14 +1,64 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T12:37:58Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:22:35Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 264299278 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 30422427 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 47879836 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 79764388 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 60672355 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 264162804 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 3044145335 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 136000546 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 181468088 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 37993300 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "linux-x64", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496888305", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977091", "schema_version": 1, "suite_finished": false, "summary": { - "fail": 0, - "not-run": 5, - "pass": 165, + "fail": 7, + "not-run": 2, + "pass": 161, "skip": 0 }, "tests": { @@ -46,7 +96,10 @@ }, "AudioMixerApiTest": { "feature": "audio-media-playback", - "status": "pass" + "reasons": [ + "failed: AudioMixer API test failed: java.lang.IllegalStateException: mixed WAV file was not created" + ], + "status": "fail" }, "BackgroundThreadUiAccessTest": { "feature": "threading", @@ -62,7 +115,10 @@ }, "BrowserComponentScreenshotTest": { "feature": "embedded-web-content", - "status": "pass" + "reasons": [ + "failed due to timeout waiting for DONE stage=show-completed" + ], + "status": "fail" }, "ButtonThemeScreenshotTest": { "feature": "native-theme-controls", @@ -72,13 +128,20 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "status": "pass" + "reasons": [ + "failed: Camera.getCameras() returned no cameras" + ], + "status": "fail" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -164,11 +227,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -190,7 +253,10 @@ }, "CryptoApiTest": { "feature": "secure-storage-crypto", - "status": "pass" + "reasons": [ + "failed: Crypto API test failed: com.codename1.security.CryptoException: Crypto operation secureRandomBytes is not supported on this platform. If you are running in a fresh CodenameOneImplementation subclass, override the matching method." + ], + "status": "fail" }, "CssFilterBlurScreenshotTest": { "feature": "theme-palette-css", @@ -266,7 +332,10 @@ }, "FileSystemStorageOpenInputStreamMissingTest": { "feature": "filesystem-storage", - "status": "pass" + "reasons": [ + "failed: openInputStream returned a stream (com.codename1.impl.linux.LinuxInputStream@7FF238EAE6B0) for a missing path /home/runner/.local/share/codenameone/this-file-must-not-exist-1502-1785438759604.bin instead of throwing. Platform=linux" + ], + "status": "fail" }, "FillArc": { "feature": "graphics-primitives", @@ -336,6 +405,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -348,6 +421,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -452,6 +529,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -558,7 +639,10 @@ }, "SurfacesPublishTest": { "feature": "surfaces", - "status": "pass" + "reasons": [ + "failed: Surfaces publish contract failed: java.lang.UnsupportedOperationException: UnicodeHelper.getClasses() not supported" + ], + "status": "fail" }, "SurfacesRasterizerScreenshotTest": { "feature": "surfaces", @@ -610,7 +694,10 @@ }, "TimeApiTest": { "feature": "java-standard-apis", - "status": "pass" + "reasons": [ + "failed: Time API test failed: java.lang.RuntimeException: Expected [2020-03-08T01:30:00-05:00[America/New_York]], Actual [2020-03-08T11:30:00-05:00[America/New_York]]" + ], + "status": "fail" }, "ToastBarTopPositionScreenshotTest": { "feature": "toast-notifications", @@ -666,82 +753,15 @@ }, "VideoIODecodedFramesScreenshotTest": { "feature": "video-decoding", - "status": "pass" + "status": "not-run" }, "VideoIORoundTripTest": { "feature": "video-round-trip", - "status": "pass" - }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", "status": "not-run" }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 253856480 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 28233661 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 27566288 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 82153005 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 53960096 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 184573752 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 5754321719 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 148385092 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 183272160 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 33385972 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/mac-native.json b/docs/website/data/port_status_reports/mac-native.json index d7792f7aa4b..3c9527cf45b 100644 --- a/docs/website/data/port_status_reports/mac-native.json +++ b/docs/website/data/port_status_reports/mac-native.json @@ -1,14 +1,64 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T13:13:15Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T20:36:03Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 249161000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 20743000 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 58460000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 106862000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 67221000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 281716000 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 261799000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 126419000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 171015000 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 60775000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "mac-native", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889418", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977464", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +122,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +221,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +393,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +409,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +517,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +744,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 284063000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 20505000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 40363000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 107821000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 65412000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 286132000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 328540000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 111802000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 167855000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 24303000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/tvos.json b/docs/website/data/port_status_reports/tvos.json index d9c49c0ec83..6eafc87c9ea 100644 --- a/docs/website/data/port_status_reports/tvos.json +++ b/docs/website/data/port_status_reports/tvos.json @@ -1,14 +1,56 @@ { - "commit": "205dcc675896c1c094db47d9b0a2622adf7ec3bf", - "generated_at": "2026-07-16T13:10:57Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T10:23:02Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 323661000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 148797000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 168944000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 102844000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 400868000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 324035000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 680592000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "tvos", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29496889339", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,76 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 558066000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 209285000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 183568000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 181322000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 109270000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 727584000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 266922000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 331650000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 718026000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 154040000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } diff --git a/docs/website/data/port_status_reports/watchos.json b/docs/website/data/port_status_reports/watchos.json index b2bdbfad26b..e95b838515d 100644 --- a/docs/website/data/port_status_reports/watchos.json +++ b/docs/website/data/port_status_reports/watchos.json @@ -1,14 +1,56 @@ { - "commit": "dec3d172f6fe327798cc083f2bab03a98cf9a8ac", - "generated_at": "2026-07-15T01:51:30Z", + "commit": "9c7affa41291e328b08a21d823737f581f1b7d3e", + "generated_at": "2026-07-30T09:36:29Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 426870000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 193328000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 200087000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 114148000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 488586000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 319774000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 710894000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": { + "hashMapChurn": "ios-simulator-gc-footprint", + "objectAllocation": "ios-simulator-gc-footprint", + "stringBuilding": "ios-simulator-gc-footprint" + }, + "status": "complete", + "suite_checksum": 0 + }, "port": "watchos", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29380730117", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30516173164", "schema_version": 1, "suite_finished": true, "summary": { "fail": 0, - "not-run": 5, - "pass": 163, + "not-run": 0, + "pass": 168, "skip": 2 }, "tests": { @@ -72,6 +114,10 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "pass" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" @@ -167,11 +213,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -339,6 +385,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -351,6 +401,10 @@ "feature": "application-bootstrap", "status": "pass" }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", + "status": "pass" + }, "LargeStrokeDirtyClipTest": { "feature": "graphics-clipping", "status": "pass" @@ -455,6 +509,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -678,27 +736,9 @@ ], "status": "skip" }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", - "status": "not-run" - }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success" + } } diff --git a/docs/website/data/port_status_reports/windows-x64.json b/docs/website/data/port_status_reports/windows-x64.json index 1530b2cc8e0..af3bceabef5 100644 --- a/docs/website/data/port_status_reports/windows-x64.json +++ b/docs/website/data/port_status_reports/windows-x64.json @@ -1,15 +1,65 @@ { - "commit": "d17e18eb583d0fa7f433e3ad3e293264475b49ad", - "generated_at": "2026-07-16T17:21:16Z", + "commit": "587813ab1f221046e99996b2c1d1dfcb58f4ac94", + "generated_at": "2026-07-30T19:12:27Z", + "performance": { + "benchmark_version": 1, + "benchmarks": { + "arrayRandom": { + "checksum": "-2288487891715278", + "duration_ns": 206041000 + }, + "arraySequential": { + "checksum": "0", + "duration_ns": 21366000 + }, + "hashMapChurn": { + "checksum": "49941", + "duration_ns": 49883000 + }, + "intArithmetic": { + "checksum": "1307491170054", + "duration_ns": 69671000 + }, + "longArithmetic": { + "checksum": "6887886960473257608", + "duration_ns": 53309000 + }, + "mathTranscendental": { + "checksum": "4729652805076374709", + "duration_ns": 137248000 + }, + "objectAllocation": { + "checksum": "2999790376128", + "duration_ns": 2387343000 + }, + "quicksort": { + "checksum": "809667393311589960", + "duration_ns": 121121000 + }, + "recursion": { + "checksum": "33385282", + "duration_ns": 166653000 + }, + "stringBuilding": { + "checksum": "-609121604069", + "duration_ns": 45653000 + } + }, + "method": "minimum of five measured runs after three in-process warm-ups", + "missing": [], + "skipped": {}, + "status": "complete", + "suite_checksum": 0 + }, "port": "windows-x64", - "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/29515915384", + "run_url": "https://github.com/codenameone/CodenameOne/actions/runs/30571977214", "schema_version": 1, "suite_finished": false, "summary": { - "fail": 0, - "not-run": 5, - "pass": 165, - "skip": 0 + "fail": 7, + "not-run": 2, + "pass": 160, + "skip": 1 }, "tests": { "ARApiTest": { @@ -46,7 +96,10 @@ }, "AudioMixerApiTest": { "feature": "audio-media-playback", - "status": "pass" + "reasons": [ + "failed: AudioMixer API test failed: java.lang.IllegalStateException: mixed WAV file was not created" + ], + "status": "fail" }, "BackgroundThreadUiAccessTest": { "feature": "threading", @@ -72,13 +125,20 @@ "feature": "bytecode-runtime", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "CallDetectionAPITest": { "feature": "device-security-signals", "status": "pass" }, "CameraApiTest": { "feature": "camera-access", - "status": "pass" + "reasons": [ + "needs-runtime-permission-on-win" + ], + "status": "skip" }, "CenteredDialogTitleScreenshotTest": { "feature": "dialogs-and-labels", @@ -164,11 +224,11 @@ "feature": "graphics-clipping", "status": "pass" }, - "CodeEditorScreenshotTest": { - "feature": "pure-text-editing", + "ClipboardRoundTripTest": { + "feature": "clipboard", "status": "pass" }, - "PureEditorScreenshotTest": { + "CodeEditorScreenshotTest": { "feature": "pure-text-editing", "status": "pass" }, @@ -190,7 +250,10 @@ }, "CryptoApiTest": { "feature": "secure-storage-crypto", - "status": "pass" + "reasons": [ + "failed: Crypto API test failed: com.codename1.security.CryptoException: Crypto operation secureRandomBytes is not supported on this platform. If you are running in a fresh CodenameOneImplementation subclass, override the matching method." + ], + "status": "fail" }, "CssFilterBlurScreenshotTest": { "feature": "theme-palette-css", @@ -266,7 +329,10 @@ }, "FileSystemStorageOpenInputStreamMissingTest": { "feature": "filesystem-storage", - "status": "pass" + "reasons": [ + "failed: openInputStream returned a stream (com.codename1.impl.windows.WindowsInputStream@27714760B90) for a missing path C:\\null\\this-file-must-not-exist-1502-1785438531532.bin instead of throwing. Platform=win" + ], + "status": "fail" }, "FillArc": { "feature": "graphics-primitives", @@ -336,6 +402,10 @@ "feature": "text-input", "status": "pass" }, + "InferenceOnDeviceApiTest": { + "feature": "on-device-inference", + "status": "pass" + }, "InscribedTriangleGrid": { "feature": "graphics-shapes-strokes", "status": "pass" @@ -346,6 +416,14 @@ }, "KotlinUiTest": { "feature": "application-bootstrap", + "reasons": [ + "failed=java.lang.NullPointerException", + "failed: java.lang.NullPointerException" + ], + "status": "fail" + }, + "LanguageOnDeviceApiTest": { + "feature": "on-device-language", "status": "pass" }, "LargeStrokeDirtyClipTest": { @@ -452,6 +530,10 @@ "feature": "scrolling-and-pull", "status": "pass" }, + "PureEditorScreenshotTest": { + "feature": "pure-text-editing", + "status": "pass" + }, "RealOsmVectorScreenshotTest": { "feature": "vector-maps", "status": "pass" @@ -558,7 +640,10 @@ }, "SurfacesPublishTest": { "feature": "surfaces", - "status": "pass" + "reasons": [ + "failed: Surfaces publish contract failed: java.lang.UnsupportedOperationException: UnicodeHelper.getClasses() not supported" + ], + "status": "fail" }, "SurfacesRasterizerScreenshotTest": { "feature": "surfaces", @@ -578,7 +663,11 @@ }, "SwitchThemeScreenshotTest": { "feature": "native-theme-controls", - "status": "pass" + "reasons": [ + "failed=java.lang.NullPointerException", + "failed due to timeout waiting for DONE stage=created" + ], + "status": "fail" }, "TabsAnimatedIndicatorScreenshotTest": { "feature": "tabs-animation", @@ -610,7 +699,10 @@ }, "TimeApiTest": { "feature": "java-standard-apis", - "status": "pass" + "reasons": [ + "failed: Time API test failed: java.lang.RuntimeException: Expected [2020-03-08T01:30:00-05:00[America/New_York]], Actual [2020-03-08T06:30:00+01:00[America/New_York]]" + ], + "status": "fail" }, "ToastBarTopPositionScreenshotTest": { "feature": "toast-notifications", @@ -670,78 +762,11 @@ }, "VideoIORoundTripTest": { "feature": "video-round-trip", - "status": "pass" - }, - "CalendarApiTest": { - "feature": "calendar-integration", - "status": "not-run" - }, - "ClipboardRoundTripTest": { - "feature": "clipboard", - "status": "not-run" - }, - "InferenceOnDeviceApiTest": { - "feature": "on-device-inference", - "status": "not-run" - }, - "LanguageOnDeviceApiTest": { - "feature": "on-device-language", "status": "not-run" }, "VisionOnDeviceApiTest": { "feature": "on-device-vision", - "status": "not-run" + "status": "pass" } - }, - "bootstrap_source": "successful-master-workflow", - "workflow_conclusion": "success", - "performance": { - "benchmark_version": 1, - "benchmarks": { - "arrayRandom": { - "checksum": "-2288487891715278", - "duration_ns": 290140000 - }, - "arraySequential": { - "checksum": "0", - "duration_ns": 24989000 - }, - "hashMapChurn": { - "checksum": "49941", - "duration_ns": 61276000 - }, - "intArithmetic": { - "checksum": "1307491170054", - "duration_ns": 78850000 - }, - "longArithmetic": { - "checksum": "6887886960473257608", - "duration_ns": 60657000 - }, - "mathTranscendental": { - "checksum": "4729652805076374709", - "duration_ns": 175326000 - }, - "objectAllocation": { - "checksum": "2999790376128", - "duration_ns": 4421996000 - }, - "quicksort": { - "checksum": "809667393311589960", - "duration_ns": 135399000 - }, - "recursion": { - "checksum": "33385282", - "duration_ns": 242775000 - }, - "stringBuilding": { - "checksum": "-609121604069", - "duration_ns": 32223000 - } - }, - "method": "minimum of five measured runs after three in-process warm-ups", - "missing": [], - "status": "complete", - "suite_checksum": 0 } } 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..3c736ae1642 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 }} 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 -}} - + + {{- if $documentedSkips }}{{ end }} {{ $label }} 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/HealthEdtDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java index c51fc2e35d6..4762ad779bd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthEdtDeliveryTest.java @@ -310,6 +310,56 @@ private static String enclosingMethod(String[] lines, int at) { return ""; } + /** + * A listener attached after the outcome has already landed still arrives on + * the EDT. + * + *

{@code AsyncResource} runs a callback registered against a finished + * resource immediately, on whichever thread registered it, so completing on + * the EDT is only half the guarantee. The facade operations resolve inside + * the call, which made {@link #aFacadeActionDeliversOnTheEdt()} a race: it + * passed when the caller reached {@code onResult} before the EDT drained + * the completion, and failed when it did not.

+ * + *

Waiting for {@code isDone()} before listening makes the late case the + * only case, so this fails every time against that bug rather than + * occasionally.

+ */ + @Test + void aListenerAttachedAfterCompletionStillArrivesOnTheEdt() { + final Landing landing = new Landing(); + CN.invokeAndBlock(new Runnable() { + public void run() { + assertFalse(CN.isEdt(), "the operation must start off the EDT"); + AsyncResource settings = + Health.getInstance().openHealthSettings(); + long deadline = System.currentTimeMillis() + 10_000L; + while (!settings.isDone() + && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(5L); + } catch (InterruptedException ex) { + return; + } + } + assertTrue(settings.isDone(), "the outcome must have landed first"); + settings.onResult(landing); + deadline = System.currentTimeMillis() + 10_000L; + while (!landing.arrived.get() + && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(5L); + } catch (InterruptedException ex) { + return; + } + } + } + }); + assertTrue(landing.arrived.get(), "the callback must arrive"); + assertTrue(landing.onEdt.get(), + "a late listener must still be called on the EDT"); + } + @Test void aFacadeActionDeliversOnTheEdt() { final Landing landing = new Landing(); 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/mcp/MCPLoopbackTransportOpenTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java index c9985e747b4..bba072ef257 100644 --- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java @@ -55,6 +55,29 @@ void closeTransports() { } } + /// A transport closed before its reader thread reaches open() must not take the + /// process-wide registration with it. + /// + /// MCPServer.start hands the transport to a reader thread and returns; stop() closes + /// it from the caller's thread. When stop() wins that race, close() cannot release the + /// registration because open() has not claimed it yet -- and open() then claimed it + /// for a transport that never listens, stranding the slot for the rest of the process. + /// Every later open() was refused with "already open on port N", which is how an + /// unrelated MCP test started failing in CI depending on class order. + @Test + void aTransportClosedBeforeItListensReleasesTheRegistration() throws Exception { + implementation.setServerSocketAvailable(true); + + MCPLoopbackSocketTransport early = new MCPLoopbackSocketTransport(47883); + early.close(); + // The reader thread's open(), arriving after the close. + assertThrows(IOException.class, () -> early.open()); + + // The slot has to be free, or nothing can open a transport again. + second = new MCPLoopbackSocketTransport(47884); + second.open(); + } + @Test void aFailedListenNeitherStrandsTheRegistrationNorEscapesAsRuntime() throws Exception { // The transport checks loopback support and then uses it. Report support to the 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/build-android-app.sh b/scripts/build-android-app.sh index 3dda2eb77d6..2e95564ee49 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -192,7 +192,11 @@ export JAVA_HOME="${JDK_HOME:-$JAVA17_HOME}" yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "build-tools;36.0.0" >/dev/null 2>&1 || ba_log "Warning: unable to install Android SDK 36 components" yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true fi - ./gradlew --no-daemon assembleDebug + # --stacktrace: packageDebug has failed intermittently on CI reporting only + # "A failure occurred while executing PackageAndroidArtifact$IncrementalSplitterRunnable" + # with no cause, which is not enough to fix anything. The flag costs nothing on + # a successful build and prints the actual exception when it does happen. + ./gradlew --no-daemon --stacktrace assembleDebug ) export JAVA_HOME="$ORIGINAL_JAVA_HOME" 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/BrowserComponentScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/BrowserComponentScreenshotTest.java index 23918b1b5ae..e7dba2a264b 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 @@ -179,7 +187,9 @@ private boolean containsRenderedBrowserContent(Image screen) { screen.toRGB(visualBand, 0, 0, left, top, bandWidth, bandHeight); int[] rgb = visualBand.getRGB(); int requiredBrightPixels = Math.max(32, bandWidth / 20); + int requiredDarkPixels = Math.max(32, bandWidth / 20); int brightPixels = 0; + int darkPixels = 0; for (int y = 0; y < bandHeight; y++) { int rowOffset = y * bandWidth; for (int x = 0; x < bandWidth; x++) { @@ -191,9 +201,19 @@ private boolean containsRenderedBrowserContent(Image screen) { // background. The black uncomposited peer contains neither. if ((r > 160 && g > 160 && b > 160) || (g > 120 && b > 160 && b > r + 30)) { - if (++brightPixels >= requiredBrightPixels) { - return true; - } + brightPixels++; + } else if (r < 80 && g < 80 && b < 80) { + // 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) { + return true; } } } 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 3b31c5bb407..74696b897a2 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 @@ -494,6 +494,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( @@ -529,15 +546,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)); }); } 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..3eca87011c2 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 @@ -157,6 +157,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 +172,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)); @@ -176,7 +189,14 @@ private void runRoundTrip() { writer.close(); } catch (Throwable t) { 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/conformance/backfill_port_status.sh b/scripts/hellocodenameone/conformance/backfill_port_status.sh new file mode 100755 index 00000000000..7eeae0ce138 --- /dev/null +++ b/scripts/hellocodenameone/conformance/backfill_port_status.sh @@ -0,0 +1,281 @@ +#!/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" + +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 + +# 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. + # + # 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 == "success" or .conclusion == "failure") and + (.updatedAt >= $horizon))] + | sort_by(.updatedAt) | reverse | .[].databaseId')" + if [ -z "${candidates}" ]; then + echo "No completed master run for ${workflow}; nothing to publish." >&2 + continue + fi + + run_id="" + 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 in ${candidates}; do + 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 + run_id="${candidate}" + while IFS= read -r downloaded; do + found="$(jq -r '.port // empty' "${downloaded}" 2>/dev/null || true)" + if [ -z "${found}" ] || [ -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. + case " ${owned} " in + *" ${found} "*) ;; + *) + echo "Ignoring a report naming ${found}: ${workflow} does not produce that port." >&2 + 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. + if ! python3 "${SCRIPT_DIR}/port_status.py" accept \ + --port "${found}" --report "${downloaded}" >/dev/null 2>&1; then + echo "Ignoring the ${found} report from run ${candidate}: not usable by the website." >&2 + continue + fi + cp "${downloaded}" "${download_dir}/port-status-${found}.json" + : > "${download_dir}/covered-${found}" + done < <(find "${download_dir}/run-${candidate}" -type f -name 'port-status-*.json' | sort) + fi + done + if [ -z "${run_id}" ]; then + echo "No recent ${workflow} run has a port status artifact." >&2 + continue + 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 + # 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. + if ! python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${report}"; then + echo "Not publishing the ${port} report from run ${run_id}: it is not usable by the website." >&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 | base64 --decode > "${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 ${run_id} 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 | base64 --decode > "${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. + if ! python3 "${SCRIPT_DIR}/port_status.py" accept --port "${port}" --report "${tmp_dir}/check.json" >/dev/null; then + problems+=("${port}: published report is not usable by the website") + 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. Unparseable stays + # -1 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 + +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..16c1d7392c5 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,129 @@ def strict_report_errors(report: dict) -> list[str]: return errors +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(): + if not isinstance(result, dict) or result.get("status") not in { + "pass", "fail", "skip", "not-run" + }: + malformed.append(f"invalid result for {test}") + continue + statuses[result["status"]] += 1 + expected_summary = { + key: statuses.get(key, 0) for key in ("pass", "fail", "skip", "not-run") + } + if report.get("summary") != expected_summary and not drift: + 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. + suite_finished = bool(report.get("suite_finished")) + if suite_finished: + if performance.get("status") != "complete": + malformed.append(f"performance run is {performance.get('status')!r}") + if performance.get("missing"): + malformed.append( + "performance workloads never reported: " + + ", ".join(performance["missing"]) + ) + + benchmarks = performance.get("benchmarks") + skipped = performance.get("skipped") or {} + if not isinstance(benchmarks, dict) or not isinstance(skipped, dict): + malformed.append("performance results are not objects") + return drift, malformed + + # 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 and accounted != sorted(expected_benchmarks): + malformed.append( + "performance workloads do not match the contract: " + + ", ".join(accounted) + ) + 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 +725,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 +761,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/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index b5817f12521..99ad73746f3 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,186 @@ 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"] + drift, malformed = port_status.publishable_report_problems( + self.manifest, "android", report + ) + self.assertEqual([], malformed) + self.assertIn("CameraApiTest", drift[0]) + + 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_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/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..41a4cd68877 100644 --- a/scripts/website/validate_port_status.mjs +++ b/scripts/website/validate_port_status.mjs @@ -167,6 +167,36 @@ 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}`); + } + } + // The production build minifies, which drops the quotes around attribute + // values, so match the class without assuming them. + if (countMatches(page, /]*\bcn1-port-status__note\b/g) < notedCells.length) { + fail("documented-skip cells must carry a 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/vm/ByteCodeTranslator/src/cn1_win_compat.c b/vm/ByteCodeTranslator/src/cn1_win_compat.c index efcfd12fee2..eefef097452 100644 --- a/vm/ByteCodeTranslator/src/cn1_win_compat.c +++ b/vm/ByteCodeTranslator/src/cn1_win_compat.c @@ -288,4 +288,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..8f77f92da75 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. */ 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 8ab3d4e313e..002e2063454 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -4622,7 +4622,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 || ''); @@ -4634,6 +4641,7 @@ var seenRenderSeq = startRenderSeq; var renderAdvanced = false; var quietFrames = 0; + var settleExhausted = false; function chooseBetter(a, b) { if (!a) { return b; @@ -4687,6 +4695,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); @@ -4712,6 +4726,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', @@ -4721,7 +4736,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 6b40f077b39..62d667bf7cc 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -2603,6 +2603,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; @@ -2709,6 +2749,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; @@ -2721,6 +2776,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; @@ -2738,6 +2812,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..21571ce31f5 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); } @@ -1306,30 +1311,82 @@ 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 ASCII code point, indexed by code point. + * + * The rest of this class is deliberately ASCII-only (isDigit, isLowerCase + * and isUpperCase all answer false above 127), so the category table is + * too. It 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, rather than + * answering for the ASCII text they are almost always asked about. + */ + private static final byte[] ASCII_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 + }; + 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 < ASCII_TYPES.length) { + return ASCII_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..aef3a8541cb 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,28 @@ 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. + // The fields below are UTC, which is the reference frame every port's + // getOffset native resolves against. + long epochMilli = instant.toEpochMilli(); + long epochDay = floorDiv(epochMilli, MILLIS_PER_DAY); + int millisOfDay = (int) floorMod(epochMilli, 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; + int offsetMillis = tz.getOffset(1 /* GregorianCalendar.AD */, utcDate.getYear(), + 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..39e6e048c8b 100644 --- a/vm/JavaAPI/src/java/util/TimeZone.java +++ b/vm/JavaAPI/src/java/util/TimeZone.java @@ -167,7 +167,12 @@ public java.lang.String getID(){ public static java.util.TimeZone getTimeZone(final java.lang.String ID){ if(ID != null && ID.equalsIgnoreCase("gmt")) { return GMT; - } else if (ID.equalsIgnoreCase(getTimezoneId())) { + } + TimeZone custom = customTimeZone(ID); + if (custom != null) { + return custom; + } + if (ID.equalsIgnoreCase(getTimezoneId())) { return getDefault(); } else { TimeZone out = new TimeZone() { @@ -204,6 +209,83 @@ 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()) { + return new SimpleTimeZone(0, ID); + } + char sign = ID.charAt(index); + if (sign == 'Z' && index + 1 == ID.length()) { + return new SimpleTimeZone(0, ID); + } + if (sign != '+' && sign != '-') { + return null; + } + String digits = ID.substring(index + 1); + int colon = digits.indexOf(':'); + String hourPart = colon < 0 ? digits : digits.substring(0, colon); + String rest = colon < 0 ? "" : digits.substring(colon + 1); + String minutePart = "0"; + String secondPart = "0"; + if (colon < 0) { + // The colon-less forms are h, hh, hmm, hhmm and hhmmss: the last two + // digits are always the minutes once there are more than two, so a + // one-digit hour ("GMT+012" is UTC+00:12) splits the same way. + int length = digits.length(); + if (length == 3 || length == 4 || length == 6) { + int hourDigits = length == 6 ? 2 : length - 2; + hourPart = digits.substring(0, hourDigits); + minutePart = digits.substring(hourDigits, hourDigits + 2); + secondPart = length == 6 ? digits.substring(4, 6) : "0"; + } + } else { + int secondColon = rest.indexOf(':'); + minutePart = secondColon < 0 ? rest : rest.substring(0, secondColon); + secondPart = secondColon < 0 ? "0" : rest.substring(secondColon + 1); + } + int hours; + int minutes; + int seconds; + try { + hours = Integer.parseInt(hourPart); + minutes = Integer.parseInt(minutePart); + seconds = Integer.parseInt(secondPart); + } catch (NumberFormatException notCustom) { + return null; + } + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59) { + return null; + } + int offset = ((hours * 60 + minutes) * 60 + seconds) * 1000; + return new SimpleTimeZone(sign == '-' ? -offset : offset, ID); + } + /** * Queries if this time zone uses Daylight Savings Time. */ 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..60c049e3812 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,6 +1302,9 @@ public void run() { Thread.sleep(3000); } pngs = countPngFiles(outDir); + 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 + ")" 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..83f4a49bb8d 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,12 +469,45 @@ 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."); + } + 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); String outEnv = System.getenv("CN1_SHOT_OUTPUT_DIR"); if (outEnv != null) { @@ -539,4 +609,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. + } + } + }