diff --git a/.github/workflows/device-runtime-store.yml b/.github/workflows/device-runtime-store.yml new file mode 100644 index 00000000000..c1ff5d974d2 --- /dev/null +++ b/.github/workflows/device-runtime-store.yml @@ -0,0 +1,341 @@ +# Weekly build of the device runtime for testers. +# +# Uploads to Google Play internal testing and to TestFlight. It does not promote +# to production and does not submit for App Store review -- see +# scripts/cn1-device-runtime/store/README.md for why that is deliberate. +# +# Without credentials the job reports what is missing and stops. It never +# publishes half of a release. Two mechanisms hold that together: +# +# 1. The two build jobs run independently on their own runners, but publishing +# is gated on both having reached success (or been skipped because that +# platform is not configured), so a failing iOS archive cannot leave Google +# Play with a lone Android upload for the week. +# 2. Publishing is ordered rather than parallel. iOS uploads first, because +# TestFlight has no draft-then-release split -- an upload there is a +# release the moment Apple's processing finishes, and there is no undo. +# Android's Play upload runs only if the iOS half succeeded, so a failing +# iOS upload never leaves Google Play with a lone Android release for the +# week. The residual risk is the mirror image (iOS uploaded, Android +# upload then fails), which is a follow-up the human can fix while the +# Android side sits still, rather than a half-published state. +name: Device runtime store build + +on: + schedule: + # Monday morning, so a failure has a working week in front of it. + - cron: '0 6 * * 1' + workflow_dispatch: + inputs: + dry_run: + description: 'Build and check credentials without uploading' + type: boolean + default: false + +concurrency: + group: device-runtime-store + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-latest + outputs: + android: ${{ steps.check.outputs.android }} + ios: ${{ steps.check.outputs.ios }} + steps: + - id: check + name: Which stores are configured + env: + PLAY_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + IOS_CERT: ${{ secrets.IOS_DIST_CERT_P12 }} + IOS_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} + IOS_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} + ASC_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }} + ASC_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }} + run: | + # Every secret the job will actually consume, not just the two that + # name the store. A partial configuration used to pass here, spend + # half an hour building, and then fail in the signing step -- which is + # the opposite of what a preflight is for. + android=true + ios=true + missing="" + for name in PLAY_JSON KEYSTORE KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do + if [ -z "${!name}" ]; then android=false; missing="$missing android:$name"; fi + done + for name in IOS_CERT IOS_CERT_PASSWORD IOS_PROFILE ASC_KEY ASC_KEY_ID ASC_ISSUER_ID; do + if [ -z "${!name}" ]; then ios=false; missing="$missing ios:$name"; fi + done + if [ -n "$missing" ]; then + echo "missing secrets:$missing" + fi + echo "android=$android" >> "$GITHUB_OUTPUT" + echo "ios=$ios" >> "$GITHUB_OUTPUT" + echo "### Device runtime store build" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Store | Configured |" >> "$GITHUB_STEP_SUMMARY" + echo "|---|---|" >> "$GITHUB_STEP_SUMMARY" + echo "| Google Play | $android |" >> "$GITHUB_STEP_SUMMARY" + echo "| App Store | $ios |" >> "$GITHUB_STEP_SUMMARY" + if [ -n "$missing" ]; then + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Secrets that are absent:\`$missing\`" >> "$GITHUB_STEP_SUMMARY" + fi + if [ "$android" = false ] && [ "$ios" = false ]; then + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "No publishing credentials are present, so nothing was uploaded." >> "$GITHUB_STEP_SUMMARY" + echo "The secrets each store needs are listed in" >> "$GITHUB_STEP_SUMMARY" + echo "\`scripts/cn1-device-runtime/store/README.md\`." >> "$GITHUB_STEP_SUMMARY" + fi + + build-android: + needs: preflight + if: needs.preflight.outputs.android == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: JDK 8 for the framework, JDK 17 for the Android port + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + 8 + 17 + + - name: Build the framework and the Android port + env: + # setup-java makes the LAST version listed the default, so JAVA_HOME + # is 17 here despite the step name. The framework build wants 8 -- the + # newer JDK trips JaCoCo -- and the Android port wants 17, so each + # command names its own. + JAVA8: ${{ env.JAVA_HOME_8_X64 }} + run: | + export JAVA_HOME="$JAVA8" + export PATH="$JAVA_HOME/bin:$PATH" + cd maven + mvn -B -q -DskipTests install -pl core,parparvm -am + mvn -B -q -DskipTests -Pcompile-android install -pl android + + - name: Verify the shim generator + # The app build generates the shims; this asserts the properties that + # build takes on faith. It fails if a shim will not compile, if a + # load-bearing shim is missing, or if generating twice differs -- all of + # which are release blockers. + run: scripts/generate-interp-shims.sh + + - name: Build the app bundle + env: + JAVA17_HOME: ${{ env.JAVA_HOME_17_X64 }} + run: | + # A store rejects an upload that reuses a version code, and the app's + # version is a fixed 1.0 -- so every scheduled run after the first + # would be refused. The run number is monotonic and unique per repo, + # which is exactly what a build number has to be. It has to travel as + # codename1.arg.*: that is the prefix CN1BuildMojo copies into the + # BuildRequest, and android.versionCode is what the Gradle builder + # reads from it. + cd scripts/cn1-device-runtime + mvn -B -q package -DskipTests \ + -Dcodename1.platform=android \ + -Dcodename1.buildTarget=android-source \ + -Dcodename1.arg.android.versionCode=${{ github.run_number }} \ + -Dopen=false + + - name: Sign and assemble + env: + KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + set -euo pipefail + gradle_dir="$(find scripts/cn1-device-runtime/android/target -maxdepth 1 \ + -name '*-android-source' -type d | head -1)" + [ -n "$gradle_dir" ] || { echo "no gradle project was generated" >&2; exit 1; } + echo "$KEYSTORE_B64" | base64 -d > "$gradle_dir/upload.keystore" + cd "$gradle_dir" + ./gradlew --no-daemon bundleRelease \ + -Pandroid.injected.signing.store.file=upload.keystore \ + -Pandroid.injected.signing.store.password="$KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$KEY_PASSWORD" + + - name: Stage the signed AAB + # Uploaded here rather than published from this job: publishing waits + # until the iOS build has also succeeded (or is skipped because that + # platform is not configured), so a broken iOS archive cannot leave + # Google Play with a lone Android upload for the week. + uses: actions/upload-artifact@v4 + with: + name: android-aab + path: scripts/cn1-device-runtime/android/target/*-android-source/app/build/outputs/bundle/release/*.aab + if-no-files-found: error + retention-days: 7 + + build-ios: + needs: preflight + if: needs.preflight.outputs.ios == 'true' + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: JDK 8 for the framework, JDK 17 for the translator + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + 8 + 17 + + - name: Build the framework, translator and iOS port + env: + JAVA8: ${{ env.JAVA_HOME_8_X64 }} + run: | + # As above: the last JDK listed to setup-java is the default, and the + # framework build wants 8. + export JAVA_HOME="$JAVA8" + export PATH="$JAVA_HOME/bin:$PATH" + cd maven + mvn -B -q -DskipTests install -pl core,parparvm,ios -am + + - name: Verify the shim generator + run: scripts/generate-interp-shims.sh + + - name: Translate to Xcode + env: + JAVA17_HOME: ${{ env.JAVA_HOME_17_X64 }} + run: | + cd scripts/cn1-device-runtime + # As on Android: TestFlight refuses a build number it has seen, and + # the app's version stays 1.0 between releases. ios.bundleVersion is + # the argument IPhoneBuilder reads for CFBundleVersion, and it has to + # be dotted numerics, so the run number becomes the third component. + mvn -B -q package -DskipTests \ + -Dcodename1.platform=ios \ + -Dcodename1.buildTarget=ios-source \ + -Dcodename1.arg.ios.interpHost=true \ + -Dcodename1.arg.ios.bundleVersion=1.0.${{ github.run_number }} \ + -Dopen=false + + - name: Import signing material + env: + CERT_P12: ${{ secrets.IOS_DIST_CERT_P12 }} + CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} + PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} + run: | + set -euo pipefail + keychain=build.keychain + security create-keychain -p actions "$keychain" + security default-keychain -s "$keychain" + security unlock-keychain -p actions "$keychain" + echo "$CERT_P12" | base64 -d > cert.p12 + security import cert.p12 -k "$keychain" -P "$CERT_PASSWORD" \ + -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k actions "$keychain" + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + echo "$PROFILE" | base64 -d > \ + ~/Library/MobileDevice/Provisioning\ Profiles/devruntime.mobileprovision + + - name: Archive and export + run: | + set -euo pipefail + src="$(find scripts/cn1-device-runtime/ios/target -maxdepth 1 \ + -name '*-ios-source' -type d | head -1)" + [ -n "$src" ] || { echo "no Xcode project was generated" >&2; exit 1; } + # Resolved before the cd: the export options live at a fixed place in + # the repo, and the generated project's depth is not ours to predict. + export_options="$PWD/scripts/cn1-device-runtime/store/ExportOptions.plist" + cd "$src" + xcodebuild -workspace CN1DeviceRuntime.xcworkspace \ + -scheme CN1DeviceRuntime -configuration Release \ + -archivePath build/CN1DeviceRuntime.xcarchive archive + xcodebuild -exportArchive \ + -archivePath build/CN1DeviceRuntime.xcarchive \ + -exportPath build/ipa \ + -exportOptionsPlist "$export_options" + + - name: Stage the signed IPA + # Same reason as the Android stage step: publishing is deferred to a + # separate job that also gates on the other platform's outcome, so + # neither store can go live alone when the other build failed. + uses: actions/upload-artifact@v4 + with: + name: ios-ipa + path: scripts/cn1-device-runtime/ios/target/*-ios-source/build/ipa/*.ipa + if-no-files-found: error + retention-days: 7 + + publish-android: + # Gated on both build jobs, and additionally on publish-ios having + # already succeeded (or been skipped because iOS is not configured). + # GitHub applies an implicit `success()` when the `if:` contains no + # status-check function, and that would skip this job whenever any + # `needs` was skipped -- including the iOS build when only Android is + # configured. `always()` is the documented way to opt out of that + # implicit gate so the explicit result comparisons below actually run: + # Android must have succeeded, iOS must have succeeded or been skipped, + # a failing iOS build still blocks Android publish, and a failing + # publish-ios keeps Play untouched so we never ship a lone Android + # release for the week. + needs: [preflight, build-android, build-ios, publish-ios] + if: >- + always() + && !inputs.dry_run + && needs.preflight.outputs.android == 'true' + && needs.build-android.result == 'success' + && (needs.build-ios.result == 'success' || needs.build-ios.result == 'skipped') + && (needs.publish-ios.result == 'success' || needs.publish-ios.result == 'skipped') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: android-aab + path: android-aab + + - name: Upload to internal testing + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: com.codenameone.devruntime + releaseFiles: android-aab/*.aab + track: internal + status: completed + whatsNewDirectory: scripts/cn1-device-runtime/fastlane/metadata/android/en-US/changelogs + + publish-ios: + # See publish-android for the gating rationale (including why the + # explicit `always()` is required for the result comparisons below to + # be evaluated when the Android build is skipped). + needs: [preflight, build-android, build-ios] + if: >- + always() + && !inputs.dry_run + && needs.preflight.outputs.ios == 'true' + && needs.build-ios.result == 'success' + && (needs.build-android.result == 'success' || needs.build-android.result == 'skipped') + runs-on: macos-14 + steps: + - uses: actions/download-artifact@v4 + with: + name: ios-ipa + path: ios-ipa + + - name: Upload to TestFlight + env: + ASC_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }} + ASC_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }} + ASC_PRIVATE_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }} + run: | + set -euo pipefail + mkdir -p ~/private_keys + echo "$ASC_PRIVATE_KEY" > ~/private_keys/AuthKey_$ASC_KEY_ID.p8 + xcrun altool --upload-app -f ios-ipa/*.ipa -t ios \ + --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" diff --git a/.gitignore b/.gitignore index 7665845fb72..c5c44f545fd 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,9 @@ scripts/fidelity-app/common/src/main/resources/*ThemeDev.res # build time (common/pom.xml copy-native-themes); never commit the duplicate. scripts/fidelity-app/common/src/main/resources/iOSModernTheme.res scripts/fidelity-app/common/src/main/resources/AndroidMaterialTheme.res + +# Local Maven repository used for isolated local builds (see .m2-local) +.m2-local/ + +# Generated by the device runtime build (see common/pom.xml). +scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/gen/ diff --git a/CLAUDE.md b/CLAUDE.md index 3ec43bd914a..5e4ab9664be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,6 +213,60 @@ removing one can make a previously-used private method dead. Findings land in each module's `target/spotbugsXml.xml`. +### Device runtime (on-device interpreter) + +`scripts/hellocodenameone/` is a device runtime: an app that runs Codename One +programs pushed from a desktop, interpreted rather than compiled. See +`docs/developer-guide/Device-Runtime.asciidoc`. Verify on both platforms -- +`scripts/run-device-runtime-android.sh ` (minutes) and +`scripts/run-device-runtime-ios.sh ` (~30 min). + +- **A pushed `main` runs on the EDT**, like an app's `start()`. `callSerially` + is legal there, `callSeriallyAndWait` is not. +- **The EDT budget is per entry into the interpreter, not per session.** Every + framework callback is a fresh entry. Measuring from the start of the run makes + the budget expire once and stay expired, killing every later callback -- i.e. + every button press -- with "ran without yielding". +- **Lambdas and method references are desugared** by `InterpLambdaDesugar` when + the bundle is written; neither target can spin a class at run time. String + concatenation needs `-XDstringConcat=inline` (cn1-push.sh passes it). +- **A linker must dispatch on the receiver's class, not the call site's owner.** + `list.add(x)` compiles to a call naming `java.util.List`; resolving from there + finds `AbstractList.add`, whose body throws `UnsupportedOperationException`. + Android got this free from reflection; iOS resolves the receiver's class id + and walks up from there. +- **`synchronized` uses the real host monitor**, so it interoperates with the + framework and `wait`/`notify` work. A synchronized method wraps the call (on + the peer where there is one); a synchronized block runs its region nested + inside a real `synchronized`, and `monitorexit` returns to the enclosing + level, which is what releases it. +- **A pushed tree's non-`.java` files become resources**, published to + `CodenameOneImplementation` -- not `Display`, which `Resources.openLayered` + never passes through. +- **`java.lang.Enum` has no shim and needs none** -- the interpreter answers + name/ordinal/valueOf itself, since Java forbids naming Enum as a superclass. +- **The device dials out; the desktop listens.** A listening socket inside the + iOS simulator is unreachable from the host. Android needs `adb reverse`, not + `adb forward`. Both runtimes dial the same host port, so a running emulator + app will answer a push meant for the simulator -- the iOS script force-stops + it for exactly this reason. +- **Push a source tree, not a file**: `scripts/cn1-push.sh src/main/java 18234`. + The entry point is discovered -- a `main`, else a `Lifecycle` subclass, which + is what a real app has. `scripts/devruntime-probes/` holds the battery of + programs that found the defects worth knowing about; run it after touching + the interpreter, the linkers or the shims. +- **The shims are generated by the app build** into + `common/target/generated-sources/shims`, never committed. After changing + `GenerateInterpShims`, run `scripts/generate-interp-shims.sh`: it fails if a + shim will not compile (never prune -- that once ate `Interp_ui_Form`), if a + load-bearing shim is missing, or if generating twice differs. +- **The generator reads the device's `java.*` from the `codenameone-java-runtime` + jar with ASM, not by reflecting over the JDK**, and runs under `JAVA17_HOME`. + The two disagree about which methods exist, which are `final`, which + interfaces a class implements, and which constructors exist. Note `javap` + resolves `java.*` from the platform even with `-cp`, so inspect the extracted + `.class` file directly or you will be reading the JDK's copy. + ### Never rely on ClassCastException **ParparVM's `CHECKCAST` is unchecked.** `BC_CHECKCAST` expands to nothing and the diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index f640731c6e2..ec38928a923 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -4547,12 +4547,59 @@ private int scanBackFirst(char[] chars, int ixStart, int ixEnd) { /// /// input stream for the resource or null if not found public InputStream getResourceAsStream(Class cls, String resource) { + InputStream local = localResource(resource); + if (local != null) { + return local; + } if (cls != null) { return cls.getResourceAsStream(resource); } return CodenameOneImplementation.class.getResourceAsStream(resource); } + /// Resources supplied at run time rather than compiled into the app. + /// + /// The device runtime pushes a program's own `theme.res`, CSS and images + /// here. They have to be visible from this layer rather than from + /// `Display`, because the calls that matter never pass through `Display`: + /// `Resources.openLayered("/theme")` and `UIManager.initFirstTheme` resolve + /// inside the framework, which asks the implementation directly. + /// + /// Empty in an ordinary app, and one emptiness check on a path that + /// already touches the file system. Allocated eagerly rather than lazily: + /// a push arrives on a socket thread while the event thread may be reading, + /// and lazily creating a shared static under that is how entries go missing. + private static final Hashtable localResources = new Hashtable(); + + /// Publishes a resource under the path an application would load it by, + /// e.g. `/theme.res`. A null value removes it. + public static void setLocalResource(String path, byte[] data) { + if (data == null) { + localResources.remove(path); + } else { + localResources.put(path, data); + } + } + + /// Drops every published resource, so a newly pushed program does not + /// inherit the previous one's theme. + public static void clearLocalResources() { + localResources.clear(); + } + + /// A published resource as a stream, or null. Platform implementations call + /// this before falling back to the classpath. + protected static InputStream localResource(String resource) { + if (resource == null || localResources.isEmpty()) { + return null; + } + byte[] data = (byte[]) localResources.get(resource); + if (data == null && !resource.startsWith("/")) { + data = (byte[]) localResources.get("/" + resource); + } + return data == null ? null : new java.io.ByteArrayInputStream(data); + } + /// Animations should return true to allow the native image animation to update /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpBacked.java b/CodenameOne/src/com/codename1/impl/interp/InterpBacked.java new file mode 100644 index 00000000000..366862c5606 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpBacked.java @@ -0,0 +1,37 @@ +/* + * 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.interp; + +/// Implemented by a generated shim: a framework subclass standing in for an +/// interpreted class. +/// +/// Lets the runtime recover the interpreted object from the host-visible peer, +/// which is what makes the round trip work -- interpreted code hands its peer +/// to the framework, the framework hands the peer back to a listener, and the +/// runtime has to get from there to the interpreted instance again. +/// +/// @author Shai Almog +public interface InterpBacked { + /// The interpreted object this peer stands for. + InterpObject getInterpObject(); +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpBundle.java b/CodenameOne/src/com/codename1/impl/interp/InterpBundle.java new file mode 100644 index 00000000000..7a889c9aa5b --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpBundle.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.interp; + +import java.util.Hashtable; + +/// A program pushed to the device runtime: the interpreted classes, the symbols +/// they reference in the host app, and the source the user wrote. +/// +/// The bundle is produced on the developer's machine (see the translator's +/// `InterpBundleWriter`) and consumed by [InterpRuntime]. Nothing here parses a +/// class file: the constant pool is already resolved into flat tables and jump +/// targets are already instruction indices, so the device only walks arrays. +/// +/// The IR deliberately keeps the JVM's own opcodes rather than inventing a new +/// instruction set. Every semantic question -- what `dup2` does to a category-2 +/// value, when `athrow` unwinds, how `invokespecial` differs from +/// `invokevirtual` -- then has one authoritative answer instead of a +/// reinterpretation that has to be rediscovered by testing. What the IR removes +/// is only the parts a device should not pay for: constant-pool lookups, symbol +/// resolution and label arithmetic. +/// +/// @author Shai Almog +public final class InterpBundle { + /// Magic word at the head of every bundle: 'C','N','1','I'. + public static final int MAGIC = 0x434E3149; + + /// Bundle format version. The runtime refuses anything it does not know, + /// because a bundle is pushed from a machine whose SDK moves independently + /// of the installed app. + /// + /// Bumped to 4 when LDC_DOUBLE constants switched from a `Double.toString` + /// encoding to raw long bits: an older reader would call + /// `Double.parseDouble` on the new encoding and fail on a value like + /// "4607182418800017408" (the bits of 1.0). + public static final int VERSION = 4; + + /// Extern kinds -- what a reference into the host app names. + public static final int EXTERN_CLASS = 0; + public static final int EXTERN_METHOD = 1; + public static final int EXTERN_FIELD = 2; + + String[] strings; + + /// Parallel arrays over the extern table. For a method or field, owner/name/ + /// desc are string-pool indices; for a class only owner is meaningful. + int[] externKind; + int[] externOwner; + int[] externName; + int[] externDesc; + + /// Resolved lazily by the linker, so a program that never touches a symbol + /// never pays to resolve it -- and never fails because of it. + Object[] externResolved; + boolean[] externResolveAttempted; + + InterpClass[] classes; + + /// Interpreted class name -> InterpClass, in JVM internal form (a/b/C). + Hashtable classesByName = new Hashtable(); + + /// Source file name -> source text. Mandatory: the runtime refuses a bundle + /// whose interpreted classes are not all covered, because the App Store's + /// educational-code allowance is conditional on the user being able to see + /// and edit what runs (guideline 2.5.2). + Hashtable sources = new Hashtable(); + + /// The program's own resources -- theme.res, CSS, images -- keyed by the + /// path an application loads them by. Handed to the implementation layer + /// when the bundle is loaded, so `Resources.openLayered("/theme")` finds + /// the pushed program's theme rather than the runtime host's. + Hashtable resources = new Hashtable(); + + String mainClass; + + InterpBundle() { + } + + /// The string pool entry at the given index. + public String string(int index) { + return strings[index]; + } + + /// The interpreted class with this JVM internal name, or null if the class + /// is not part of this bundle (i.e. it belongs to the host app). + public InterpClass findClass(String internalName) { + return (InterpClass) classesByName.get(internalName); + } + + /// The interpreted classes in this bundle. + public InterpClass[] getClasses() { + return classes; + } + + /// The class whose main method the runtime should enter, or null when the + /// bundle is a library rather than a program. + public String getMainClass() { + return mainClass; + } + + /// The source text for a file name, or null. Used by the on-device viewer. + public String getSource(String fileName) { + return (String) sources.get(fileName); + } + + /// The names of every source file carried by this bundle. + public java.util.Enumeration getSourceFileNames() { + return sources.keys(); + } + + /// The resources this bundle carries, keyed by load path. + public Hashtable getResources() { + return resources; + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpBundleReader.java b/CodenameOne/src/com/codename1/impl/interp/InterpBundleReader.java new file mode 100644 index 00000000000..bd65d1e7f8c --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpBundleReader.java @@ -0,0 +1,313 @@ +/* + * 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.interp; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Vector; + +/// Reads a `.cn1ip` bundle. +/// +/// Deliberately dull: every structure is a length followed by that many fixed +/// records, so the reader is a loop over `readInt` with no lookahead and no +/// allocation beyond the arrays it is filling. It has to run on ParparVM, where +/// `java.io` is a subset -- `DataInputStream` over a byte stream is available +/// and is all this uses. +/// +/// @author Shai Almog +public final class InterpBundleReader { + private InterpBundleReader() { + } + + /// Reads a bundle and links its interpreted classes to each other. Host + /// symbols stay unresolved until first use. + /// + /// #### Throws + /// + /// - `IOException`: if the stream is truncated, not a bundle, or a version + /// this runtime does not know + public static InterpBundle read(InputStream rawIn) throws IOException { + DataInputStream in = new DataInputStream(rawIn); + InterpBundle b = new InterpBundle(); + + int magic = in.readInt(); + if (magic != InterpBundle.MAGIC) { + throw new IOException("not a Codename One interpreter bundle"); + } + int version = in.readInt(); + if (version != InterpBundle.VERSION) { + throw new IOException("bundle format version " + version + + " but this runtime speaks " + InterpBundle.VERSION + + " -- rebuild the bundle against the installed app"); + } + String main = in.readUTF(); + b.mainClass = main.length() == 0 ? null : main; + + int stringCount = in.readInt(); + b.strings = new String[stringCount]; + for (int i = 0; i < stringCount; i++) { + b.strings[i] = in.readUTF(); + } + + int externCount = in.readInt(); + b.externKind = new int[externCount]; + b.externOwner = new int[externCount]; + b.externName = new int[externCount]; + b.externDesc = new int[externCount]; + b.externResolved = new Object[externCount]; + b.externResolveAttempted = new boolean[externCount]; + for (int i = 0; i < externCount; i++) { + b.externKind[i] = in.readInt(); + b.externOwner[i] = in.readInt(); + b.externName[i] = in.readInt(); + b.externDesc[i] = in.readInt(); + } + + int classCount = in.readInt(); + b.classes = new InterpClass[classCount]; + // Two passes: create every class first so a forward reference between + // interpreted classes resolves without ordering constraints. + String[][] pendingSupers = new String[classCount][]; + for (int i = 0; i < classCount; i++) { + pendingSupers[i] = readClass(in, b, i); + } + for (int i = 0; i < classCount; i++) { + InterpClass c = b.classes[i]; + String superName = pendingSupers[i][0]; + if (superName != null) { + c.superInterp = b.findClass(superName); + if (c.superInterp == null) { + throw new IOException("bundle names interpreted superclass " + + superName + " but does not contain it"); + } + } + int ifaceCount = pendingSupers[i].length - 1; + c.interpInterfaces = new InterpClass[ifaceCount]; + for (int j = 0; j < ifaceCount; j++) { + c.interpInterfaces[j] = b.findClass(pendingSupers[i][j + 1]); + if (c.interpInterfaces[j] == null) { + throw new IOException("bundle names interpreted interface " + + pendingSupers[i][j + 1] + " but does not contain it"); + } + } + } + // Field bases depend on the superclass chain, so they can only be + // computed once every link above exists. + for (int i = 0; i < classCount; i++) { + assignFieldBase(b.classes[i]); + } + + int sourceCount = in.readInt(); + for (int i = 0; i < sourceCount; i++) { + String fileName = in.readUTF(); + int len = in.readInt(); + byte[] utf8 = new byte[len]; + in.readFully(utf8); + b.sources.put(fileName, new String(utf8, "UTF-8")); + } + + // The version is an exact match by the check above, so the section is + // always present -- a bundle from an older desktop was already refused. + int resourceCount = in.readInt(); + for (int i = 0; i < resourceCount; i++) { + String path = in.readUTF(); + int len = in.readInt(); + byte[] data = new byte[len]; + in.readFully(data); + b.resources.put(path, data); + } + + requireSourcesFor(b); + return b; + } + + private static void assignFieldBase(InterpClass c) { + if (c.superInterp == null) { + c.fieldBase = 0; + return; + } + assignFieldBase(c.superInterp); + c.fieldBase = c.superInterp.fieldBase + c.superInterp.fieldNames.length; + } + + /// The runtime will not execute code whose source the user cannot read. + /// + /// This is the condition Apple attaches to running downloaded code at all + /// (App Store Review Guideline 2.5.2: an app that downloads code for + /// teaching or testing "must make the source code provided by the app + /// completely viewable and editable by the user"). Enforcing it here rather + /// than trusting the tool chain means a bundle built by any other route + /// still cannot bypass it. + private static void requireSourcesFor(InterpBundle b) throws IOException { + for (int i = 0; i < b.classes.length; i++) { + InterpClass c = b.classes[i]; + if (c.sourceFile == null || c.sourceFile.length() == 0) { + throw new IOException("class " + c.name + + " was compiled without source information; the device runtime " + + "only executes code whose source it can show"); + } + // Keyed by package, not by file name: two classes named Util in + // different packages both declare "Util.java", and a bare-name map + // keeps only one of them. + if (b.sources.get(sourceKey(c)) == null) { + throw new IOException("bundle is missing the source file " + + sourceKey(c) + " for class " + c.name + + "; the device runtime only executes code whose source it can show"); + } + } + } + + /// The bundle key for a class's source: its package, then the file name the + /// SourceFile attribute records. Mirrors InterpBundleWriter.sourceKey. + static String sourceKey(InterpClass c) { + int slash = c.name.lastIndexOf('/'); + if (slash < 0) { + return c.sourceFile; + } + return c.name.substring(0, slash + 1) + c.sourceFile; + } + + private static String[] readClass(DataInputStream in, InterpBundle b, int index) + throws IOException { + String name = b.strings[in.readInt()]; + InterpClass c = new InterpClass(name); + c.accessFlags = in.readInt(); + String src = in.readUTF(); + c.sourceFile = src.length() == 0 ? null : src; + // What javac's InnerClasses attribute called this class. The flag says + // whether there was an entry at all: without one the class is top-level + // and its simple name is the last segment of its binary name, `$` and + // all; with one but no name it is anonymous, and its simple name is + // genuinely empty. + boolean recorded = in.readBoolean(); + String simple = in.readUTF(); + c.simpleName = recorded ? simple : null; + + boolean superInterpreted = in.readBoolean(); + int superRef = in.readInt(); + String superInterpName = null; + if (superInterpreted) { + superInterpName = b.strings[superRef]; + } else { + c.superExtern = superRef; + } + + int interpIfaceCount = in.readInt(); + String[] interpIfaceNames = new String[interpIfaceCount]; + for (int i = 0; i < interpIfaceCount; i++) { + interpIfaceNames[i] = b.strings[in.readInt()]; + } + int hostIfaceCount = in.readInt(); + c.hostInterfaces = new int[hostIfaceCount]; + for (int i = 0; i < hostIfaceCount; i++) { + c.hostInterfaces[i] = in.readInt(); + } + + int instanceFieldCount = in.readInt(); + c.fieldNames = new String[instanceFieldCount]; + c.fieldDescs = new String[instanceFieldCount]; + c.fieldAccess = new int[instanceFieldCount]; + for (int i = 0; i < instanceFieldCount; i++) { + c.fieldNames[i] = b.strings[in.readInt()]; + c.fieldDescs[i] = b.strings[in.readInt()]; + // Access flags: only `ACC_VOLATILE` matters at run time -- the + // interpreter wraps a volatile get/put in a `synchronized (io)` + // so happens-before ordering matches Java's contract. + c.fieldAccess[i] = in.readInt(); + } + int staticFieldCount = in.readInt(); + for (int i = 0; i < staticFieldCount; i++) { + String fname = b.strings[in.readInt()]; + String fdesc = b.strings[in.readInt()]; + // Static access flags are read past for format compatibility -- + // the writer serialises them alongside the field row -- but not + // consulted: static storage goes through `Hashtable.get`/`put`, + // whose synchronised bodies establish happens-before, so volatile + // needs no extra wrapping the way instance fields do. + in.readInt(); + c.setStaticValue(fname, InterpValues.defaultValue(fdesc)); + } + + int methodCount = in.readInt(); + c.methods = new InterpMethod[methodCount]; + for (int i = 0; i < methodCount; i++) { + c.methods[i] = readMethod(in, b, c); + } + + b.classes[index] = c; + b.classesByName.put(name, c); + + String[] result = new String[interpIfaceCount + 1]; + result[0] = superInterpName; + for (int i = 0; i < interpIfaceCount; i++) { + result[i + 1] = interpIfaceNames[i]; + } + return result; + } + + private static InterpMethod readMethod(DataInputStream in, InterpBundle b, InterpClass owner) + throws IOException { + InterpMethod m = new InterpMethod(owner); + m.name = b.strings[in.readInt()]; + m.desc = b.strings[in.readInt()]; + m.accessFlags = in.readInt(); + m.maxStack = in.readInt(); + m.maxLocals = in.readInt(); + + int offsetCount = in.readInt(); + m.instructionOffsets = new int[offsetCount]; + for (int i = 0; i < offsetCount; i++) { + m.instructionOffsets[i] = in.readInt(); + } + int codeLen = in.readInt(); + m.code = new int[codeLen]; + for (int i = 0; i < codeLen; i++) { + m.code[i] = in.readInt(); + } + int excCount = in.readInt(); + m.exceptionTable = new int[excCount * 4]; + for (int i = 0; i < m.exceptionTable.length; i++) { + m.exceptionTable[i] = in.readInt(); + } + int lineCount = in.readInt(); + m.lineTable = new int[lineCount * 2]; + for (int i = 0; i < m.lineTable.length; i++) { + m.lineTable[i] = in.readInt(); + } + + m.argKinds = InterpValues.argumentKinds(m.desc); + m.returnKind = InterpValues.returnKind(m.desc); + return m; + } + + + /// Reads every {@code .cn1ip} entry name in a bundle, for diagnostics. + static Vector classNames(InterpBundle b) { + Vector v = new Vector(); + for (int i = 0; i < b.classes.length; i++) { + v.addElement(b.classes[i].name); + } + return v; + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpCancelled.java b/CodenameOne/src/com/codename1/impl/interp/InterpCancelled.java new file mode 100644 index 00000000000..1ca347f45da --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpCancelled.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.interp; + +/// Raised when the runtime stops a pushed program: the Stop button was used, or +/// the program held the event thread past its budget. +/// +/// It extends `Error` rather than `Exception` on purpose. Pushed code routinely +/// contains `catch (Exception e)` around a loop, and a cancellation that user +/// code can swallow is not a cancellation -- the app would stay wedged with the +/// Stop button apparently doing nothing. +/// +/// @author Shai Almog +public final class InterpCancelled extends Error { //NOPMD DoNotExtendJavaLangError - see above: Exception would be swallowed + InterpCancelled(String message) { + super(message); + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpClass.java b/CodenameOne/src/com/codename1/impl/interp/InterpClass.java new file mode 100644 index 00000000000..a06f3f95fe8 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpClass.java @@ -0,0 +1,478 @@ +/* + * 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.interp; + +import java.util.Hashtable; +import java.util.Vector; + +/// One interpreted class. +/// +/// A class in a pushed bundle may extend another interpreted class or a class +/// that lives in the host app; the two cases are deliberately different. +/// Extending an interpreted class is bookkeeping -- method resolution walks +/// [#superInterp]. Extending a host class means an object the AOT framework has +/// to accept as an instance of that class, which the host cannot manufacture +/// from a name; that is what [InterpObjectFactory] exists for. +/// +/// @author Shai Almog +public final class InterpClass { + String name; + int accessFlags; + + /// Superclass when it is itself interpreted; null when the superclass lives + /// in the host app (or when this is java/lang/Object's stand-in). + InterpClass superInterp; + + /// Extern index of the superclass when it lives in the host app, else -1. + /// Read by [#collectHostSupertypes]. + int superExtern = -1; + + /// Extern indices of implemented interfaces that live in the host app. + int[] hostInterfaces = new int[0]; + + /// Interfaces that are themselves interpreted. + InterpClass[] interpInterfaces = new InterpClass[0]; + + InterpMethod[] methods = new InterpMethod[0]; + + /// Instance field names declared here, in declaration order. Field storage + /// is a flat Object[] per instance rather than typed slots: an interpreted + /// class's fields are never read by AOT code, so nothing constrains their + /// layout, and one representation avoids a per-field type switch on every + /// getfield. + String[] fieldNames = new String[0]; + String[] fieldDescs = new String[0]; + /// Field access flags, one entry per {@code fieldNames} slot. Only + /// {@link #ACC_VOLATILE} is consulted at run time -- to give a `volatile` + /// field's read and write a happens-before barrier -- but the full flags + /// word is stored so a future consumer can honour other modifiers without + /// another bundle-format bump. + int[] fieldAccess = new int[0]; + + /// JVM `ACC_VOLATILE` bit, from the class-file spec. Mirrored here so + /// this file has no dependency on ASM: the reader stores raw access + /// words the writer copied out of ASM and the runtime tests against + /// this constant only. + public static final int ACC_VOLATILE = 0x0040; + + /// Whether an instance-field slot (in this class's own declared range) + /// is `volatile`. + boolean isInstanceFieldVolatile(int slotInThisClass) { + return slotInThisClass >= 0 && slotInThisClass < fieldAccess.length + && (fieldAccess[slotInThisClass] & ACC_VOLATILE) != 0; + } + + /// Stands in for a null static value. + /// + /// Static storage is a `Hashtable`, which is what the CLDC-era subset the + /// core is written against provides -- and which rejects null values. A + /// reference-typed static starts at null, so it needs a stand-in rather + /// than an absent entry: absence is how "this class does not declare that + /// field" is expressed, and conflating the two would send a lookup up the + /// superclass chain to a shadowed field. + static final Object NULL_STATIC = new Object(); + + /// Static field storage, by field name. Access through [#staticValue] and + /// [#setStaticValue] rather than directly, so the null stand-in stays an + /// implementation detail. + Hashtable statics = new Hashtable(); + + /// The value of a static field declared by this class. + Object staticValue(String fieldName) { + Object v = statics.get(fieldName); + return v == NULL_STATIC ? null : v; + } + + /// Sets a static field declared by this class. + void setStaticValue(String fieldName, Object value) { + statics.put(fieldName, value == null ? NULL_STATIC : value); + } + + /// Whether this class declares the named static field. + boolean declaresStatic(String fieldName) { + return statics.containsKey(fieldName); + } + + /// Resolved (name+desc -> InterpMethod) including inherited interpreted + /// methods. Built once, on first use. + private Hashtable vtable; + + /// Offset of this class's own fields within an instance's flat field array; + /// inherited interpreted fields come first. + int fieldBase; + + /// Not yet initialized. + static final int INIT_NONE = 0; + + /// Some thread is running the class initializer right now. + static final int INIT_RUNNING = 1; + + /// The class initializer completed, or the class has none. + static final int INIT_DONE = 2; + + /// The class initializer threw. The class is permanently unusable. + static final int INIT_FAILED = 3; + + /// Where this class is in the four-state initialization sequence. + /// + /// A boolean cannot express it. "Running" has to be distinguishable from + /// "done" or a second thread reads the static fields of a class whose + /// `` is halfway through, and "failed" has to be distinguishable + /// from both or a class whose initializer threw is treated ever after as + /// though it had succeeded. Guarded by this object's monitor. + int initState; + + /// The thread running ``, so its own re-entry is allowed through. + /// + /// `` reaching back into its own class is legal and common -- a + /// static field read from a static method called by the initializer -- so + /// the initializing thread must not block on itself. Guarded with + /// {@link #initState}. + Thread initThread; + + String sourceFile; + + /// What javac's InnerClasses attribute called this class, or null when it + /// recorded none -- a top-level class, or an anonymous one. + String simpleName; + + InterpClass(String name) { + this.name = name; + } + + /// The JVM internal name (a/b/C), or a descriptor for an array type. + public String getName() { + return name; + } + + /// The element type when this token stands for an array type, else null. + /// + /// `Entry[].class` needs a token of its own: sharing the leaf's would make + /// `Entry[].class == Entry.class` true, `getName()` answer `Entry`, and + /// `isInstance` test the elements rather than the array. + InterpClass arrayComponent; + + /// The token for an array of this type, created once and reused so + /// `Entry[].class == Entry[].class` holds. + synchronized InterpClass arrayType() { + if (arrayToken == null) { + InterpClass t = new InterpClass("[" + descriptorOf(this)); + t.arrayComponent = this; + arrayToken = t; + } + return arrayToken; + } + + private InterpClass arrayToken; + + /// Whether this token stands for an array type. + public boolean isArray() { + return arrayComponent != null; + } + + private static String descriptorOf(InterpClass c) { + return c.isArray() ? c.name : "L" + c.name + ";"; + } + + /// Whether this interpreted type is an interface. An interface has no + /// instances of its own, so the object factory never has to produce a peer + /// for one -- only for the classes that implement it. + public boolean isInterface() { + return (accessFlags & 0x0200) != 0; + } + + /// The source file this class was compiled from, for stack traces and the + /// on-device source viewer. + public String getSourceFile() { + return sourceFile; + } + + /// Total number of instance fields including inherited interpreted ones. + int totalFieldCount() { + return fieldBase + fieldNames.length; + } + + /// Finds a method declared directly on this class. + InterpMethod declaredMethod(String methodName, String desc) { + for (InterpMethod m : methods) { + if (m.name.equals(methodName) && m.desc.equals(desc)) { + return m; + } + } + return null; + } + + /// Resolves a method against this class and its interpreted supertypes. + /// Returns null when nothing in the interpreted hierarchy declares it -- + /// which means the call has to reach the host app instead. + /// + /// Public because an [InterpObjectFactory] lives outside this package -- it + /// is inherently platform-specific -- and a proxy handler has to ask + /// whether the interpreted class actually provides the method it was just + /// handed. + public InterpMethod resolve(String methodName, String desc) { + if (vtable == null) { + buildVtable(); + } + return (InterpMethod) vtable.get(methodName + desc); + } + + /// The interfaces ordered so a supertype is copied before its subtype. + /// + /// Topological order: supertypes before subtypes, so + /// {@code copyDeclaredMethods} lets a subinterface's override land on top + /// of the superinterface's default at the same key. On a three-level + /// chain `C -> B -> A` (with B overriding A.m), the picker takes A first, + /// then B, then C -- so B.m ends up on top and calls through C reach the + /// more-specific method the JVM would pick. + /// + /// A selection sort rather than an insertion sort: "extends" is a partial + /// order, so an unrelated interface sitting between two related ones is + /// not a barrier and an insertion sort that stops at the first non-swap + /// leaves `C implements B, X, A` (with `B extends A`) in exactly the + /// wrong order. + private static InterpClass[] sortBySpecificity(InterpClass[] ifaces) { + InterpClass[] remaining = new InterpClass[ifaces.length]; + System.arraycopy(ifaces, 0, remaining, 0, ifaces.length); + InterpClass[] out = new InterpClass[ifaces.length]; + for (int written = 0; written < out.length; written++) { + int pick = -1; + for (int i = 0; i < remaining.length; i++) { + if (remaining[i] == null) { + continue; + } + boolean hasUnpickedSupertype = false; + for (int j = 0; j < remaining.length; j++) { + if (j != i && remaining[j] != null + && extendsInterface(remaining[i], remaining[j])) { + // remaining[j] is a proper superinterface of + // remaining[i] that has not been picked yet, so i's + // ancestor has to come first -- topological order. + hasUnpickedSupertype = true; + break; + } + } + if (!hasUnpickedSupertype) { + pick = i; + break; + } + } + if (pick < 0) { + break; + } + out[written] = remaining[pick]; + remaining[pick] = null; + } + return out; + } + + /// Whether `sub` reaches `parent` through its interpreted superinterfaces. + private static boolean extendsInterface(InterpClass sub, InterpClass parent) { + for (InterpClass up : sub.interpInterfaces) { + if (up == parent || (up != null && extendsInterface(up, parent))) { //NOPMD CompareObjectsWithEquals - one class object, not an equal one + return true; + } + } + return false; + } + + /// The package of this class's internal name -- everything up to the last + /// '/', or empty for the default package. Used to decide whether a + /// package-private method of a supertype is inherited: JLS 8.4.6 says one + /// is visible only to classes in the same package. + String packageName() { + int slash = name.lastIndexOf('/'); + return slash < 0 ? "" : name.substring(0, slash); + } + + private void buildVtable() { + Hashtable t = new Hashtable(); + // Every interface transitively reachable through this class or any + // interpreted superclass. The subinterface pass on the sorted set + // deposits directly-declared methods so an override lands on top of + // its superinterface's default at the same key -- and *only* the + // directly-declared methods, so an unrelated interface `K extends I` + // that inherits `I.m` does not reintroduce `I.m` on top of a + // sibling `J extends I` that overrode it. Copying each interface's + // whole vtable did that clobber, because the vtable already merged + // in every inherited default and cannot distinguish declared from + // inherited by the time it is read. + Vector allIfaces = collectAllInterfaces(); + InterpClass[] ifaceArr = new InterpClass[allIfaces.size()]; + for (int i = 0; i < ifaceArr.length; i++) { + ifaceArr[i] = (InterpClass) allIfaces.elementAt(i); + } + for (InterpClass iface : sortBySpecificity(ifaceArr)) { + copyDeclaredMethods(t, iface); + } + // Superclass class-declared entries next: JLS gives every class method + // precedence over an interface default at the same key, so overwriting + // interface entries here is right. Interface-owned entries in the + // superclass's vtable are skipped because the pass above already + // covered every interface in the chain -- copying them would just + // reintroduce the same clobber. The receiver package gates + // package-private inheritance: a superclass's default-access method + // is only visible to a subclass in the same package (JLS 8.4.6), and + // installing it as callable on a cross-package subclass would silently + // execute a method a real JVM refuses with IllegalAccessError. + if (superInterp != null) { + copyInto(t, superInterp, packageName()); + } + for (InterpMethod m : methods) { + if (!m.isStatic()) { + t.put(m.name + m.desc, m); + } + } + vtable = t; + } + + /// Every interpreted interface transitively reachable through this class + /// or any of its interpreted superclasses, deduplicated, preserving + /// encounter order so the specificity sort has something to work with. + /// Walks each interface's own superinterfaces so an interface reached only + /// through a subinterface still contributes its declared defaults. + private Vector collectAllInterfaces() { + Vector out = new Vector(); + for (InterpClass c = this; c != null; c = c.superInterp) { + for (int i = 0; i < c.interpInterfaces.length; i++) { + addInterfaceRecursive(c.interpInterfaces[i], out); + } + } + return out; + } + + private static void addInterfaceRecursive(InterpClass iface, Vector out) { + if (iface == null || out.contains(iface)) { //NOPMD CompareObjectsWithEquals - class-object identity, not equal-by-value + return; + } + out.addElement(iface); + for (int i = 0; i < iface.interpInterfaces.length; i++) { + addInterfaceRecursive(iface.interpInterfaces[i], out); + } + } + + /// Copies non-static, non-private, non-abstract methods that {@code source} + /// declares directly into {@code target}. Only used for interfaces: an + /// interface's `methods` list gives the default methods it defines + /// itself, cleanly separated from anything inherited from a + /// superinterface -- unlike {@code source.vtable}, which merges them. + /// This is what lets the interface pass in {@link #buildVtable} keep a + /// sibling's more-specific override intact when an unrelated interface + /// with the same superinterface is also in the pool. + private static void copyDeclaredMethods(Hashtable target, InterpClass source) { + for (InterpMethod m : source.methods) { + if (m.isStatic() || m.isPrivate() || m.isAbstract()) { + continue; + } + target.put(m.name + m.desc, m); + } + } + + /// Copies non-private entries from {@code source.vtable} into {@code target}. + /// {@code interfaceOwnedToo} controls whether entries whose owner is an + /// interface are copied. Called with {@code true} for the interface pass and + /// {@code false} for the superclass pass -- the latter avoids reintroducing + /// an interface default the superclass merely inherited, which the interface + /// pass has already selected across the whole class chain. + /// Copies non-private class methods from `source.vtable` into `target`. + /// Called for the superclass pass only -- the interface pass runs + /// {@link #copyDeclaredMethods} instead, so an inherited default cannot + /// mask a sibling interface's override. + /// + /// Interface entries in `source.vtable` are skipped because the interface + /// pass has already selected the maximally specific one across the whole + /// class chain. Package-private entries are skipped when the receiver + /// class is in a different package: JLS 8.4.6 makes such a method + /// invisible across packages, and installing it would silently execute + /// a method the JVM refuses with IllegalAccessError. + private static void copyInto(Hashtable target, InterpClass source, + String receiverPackage) { + if (source.vtable == null) { + source.buildVtable(); + } + java.util.Enumeration e = source.vtable.keys(); + while (e.hasMoreElements()) { + Object k = e.nextElement(); + InterpMethod m = (InterpMethod) source.vtable.get(k); + if (m.isPrivate()) { + continue; + } + if (m.owner.isInterface()) { + continue; + } + if (isPackagePrivate(m) + && !receiverPackage.equals(m.owner.packageName())) { + continue; + } + target.put(k, m); + } + } + + private static boolean isPackagePrivate(InterpMethod m) { + return !m.isPublic() && !m.isProtected() && !m.isPrivate(); + } + + /// Whether this interpreted class is, transitively, a subtype of the named + /// interpreted type. Host supertypes are not considered here -- the linker + /// answers those, since only it knows the host's type graph. + boolean isSubclassOfInterp(String otherName) { + InterpClass c = this; + while (c != null) { + if (c.name.equals(otherName)) { + return true; + } + for (int i = 0; i < c.interpInterfaces.length; i++) { + if (c.interpInterfaces[i].isSubclassOfInterp(otherName)) { + return true; + } + } + c = c.superInterp; + } + return false; + } + + /// Every host type this class must be assignable to: its nearest host + /// superclass plus every host interface in the hierarchy. Used to decide + /// what the object factory has to produce. + void collectHostSupertypes(Vector externIndices) { + InterpClass c = this; + while (c != null) { + if (c.superExtern >= 0) { + Integer boxed = Integer.valueOf(c.superExtern); + if (!externIndices.contains(boxed)) { + externIndices.addElement(boxed); + } + } + for (int i = 0; i < c.hostInterfaces.length; i++) { + Integer boxed = Integer.valueOf(c.hostInterfaces[i]); + if (!externIndices.contains(boxed)) { + externIndices.addElement(boxed); + } + } + for (int i = 0; i < c.interpInterfaces.length; i++) { + c.interpInterfaces[i].collectHostSupertypes(externIndices); + } + c = c.superInterp; + } + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpFrame.java b/CodenameOne/src/com/codename1/impl/interp/InterpFrame.java new file mode 100644 index 00000000000..1107bad87c7 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpFrame.java @@ -0,0 +1,155 @@ +/* + * 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.interp; + +/// One interpreted call frame: locals, operand stack, and where execution is. +/// +/// Primitives live in `prim` (a `long`, with float/double held as raw bits) and +/// references in `refs`; a slot uses one or the other, never both. That avoids +/// boxing every arithmetic result, which for a stack machine is the difference +/// between an allocation per operation and none. +/// +/// Category-2 values occupy two slots exactly as the JVM specifies. Modelling +/// them as one slot would be simpler until `dup2`, `pop2` and `dup2_x1` -- whose +/// meaning is defined in terms of slots, not values -- quietly did the wrong +/// thing for `long` and `double`. +/// +/// @author Shai Almog +final class InterpFrame { + final InterpMethod method; + final long[] prim; + final Object[] refs; + final long[] stackPrim; + final Object[] stackRefs; + + int sp; + + /// Index of the instruction being executed, for stack traces and for + /// matching the exception table. + int insn; + + /// Where to carry on after a `monitorexit` handed control back to the + /// enclosing level. See the monitor handling in InterpRuntime. + int resumeInsn; + + InterpFrame(InterpMethod method) { + this.method = method; + int locals = Math.max(method.maxLocals, 1); + this.prim = new long[locals]; + this.refs = new Object[locals]; + int stack = Math.max(method.maxStack, 1) + 2; + this.stackPrim = new long[stack]; + this.stackRefs = new Object[stack]; + } + + void pushInt(int v) { + stackRefs[sp] = null; + stackPrim[sp++] = v; + } + + void pushLong(long v) { + stackRefs[sp] = null; + stackPrim[sp++] = v; + stackRefs[sp] = null; + stackPrim[sp++] = 0; + } + + void pushFloat(float v) { + // Raw bits, not canonicalising: `Float.floatToIntBits` collapses every + // NaN pattern into 0x7fc00000, so a value the program built with + // `Float.intBitsToFloat(0x7fc00001)` would round-trip through the + // stack as the canonical NaN. Programs that inspect NaN payloads (rare + // but legal) would then observe a different bit pattern here than the + // JVM does elsewhere. + pushInt(Float.floatToRawIntBits(v)); + } + + void pushDouble(double v) { + // Raw bits for the same reason as pushFloat. + pushLong(Double.doubleToRawLongBits(v)); + } + + void pushRef(Object v) { + stackPrim[sp] = 0; + stackRefs[sp++] = v; + } + + int popInt() { + return (int) stackPrim[--sp]; + } + + long popLong() { + sp -= 2; + return stackPrim[sp]; + } + + float popFloat() { + return Float.intBitsToFloat(popInt()); + } + + double popDouble() { + return Double.longBitsToDouble(popLong()); + } + + Object popRef() { + return stackRefs[--sp]; + } + + /// Pushes a value of the given kind from its raw representation. Sub-int + /// kinds are pushed as ints, which is how the JVM stores them. + void pushKind(int kind, long raw, Object ref) { + switch (kind) { + case InterpOpcodes.RET_VOID: + break; + case InterpOpcodes.RET_LONG: + case InterpOpcodes.RET_DOUBLE: + pushLong(raw); + break; + case InterpOpcodes.RET_OBJECT: + pushRef(ref); + break; + default: + pushInt((int) raw); + break; + } + } + + void setLocalInt(int index, int v) { + prim[index] = v; + refs[index] = null; + } + + void setLocalLong(int index, long v) { + prim[index] = v; + refs[index] = null; + if (index + 1 < prim.length) { + prim[index + 1] = 0; + refs[index + 1] = null; + } + } + + void setLocalRef(int index, Object v) { + prim[index] = 0; + refs[index] = v; + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpHostInterceptor.java b/CodenameOne/src/com/codename1/impl/interp/InterpHostInterceptor.java new file mode 100644 index 00000000000..9d047f1d486 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpHostInterceptor.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.interp; + +/// Answers a host static call in place of the linker. +/// +/// The device runtime uses this to stand in for subsystems it cannot honestly +/// provide but a developer still needs to exercise: a purchase flow, a social +/// login. Those are reached through static factories -- `Purchase +/// .getInAppPurchase()`, `FacebookConnect.getInstance()` -- so intercepting the +/// factory is enough to hand pushed code a mock, and every later call lands on +/// the mock by ordinary dispatch. +/// +/// It is deliberately narrow. Only static calls are offered, only the +/// interpreter consults it, and the host application is untouched: a mock +/// installed here changes what a *pushed program* sees and nothing else. +/// +/// @author Shai Almog +public interface InterpHostInterceptor { + /// Returned when the call should go to the linker as usual. + Object NOT_INTERCEPTED = new Object(); + + /// Answers a static call, or [#NOT_INTERCEPTED] to decline it. + /// + /// #### Parameters + /// + /// - `owner`: JVM internal name of the class the call site named + /// - `name`: method name + /// - `descriptor`: JVM method descriptor + /// - `args`: the arguments, already converted for host code + Object interceptStatic(String owner, String name, String descriptor, Object[] args) + throws Throwable; +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpLinker.java b/CodenameOne/src/com/codename1/impl/interp/InterpLinker.java new file mode 100644 index 00000000000..64f882ab0ba --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpLinker.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.interp; + +/// How interpreted code reaches the app it was pushed into. +/// +/// Every platform answers this differently, and the difference is the whole +/// reason the interface exists: +/// +/// - **Android and the JavaSE simulator** have real reflection, so the backend +/// is `java.lang.reflect` and there is nothing to generate. +/// - **iOS / ParparVM** has none -- `Method.invoke` does not exist and +/// `struct clazz` carries no name-to-method table. What it does have, under +/// the interp-host build, is a per-method invoke thunk registered by method +/// id plus a symbol table mapping JVM name and descriptor to that id. The +/// iOS backend binds through those. +/// +/// The interpreter above this interface never learns which one it is talking +/// to. +/// +/// @author Shai Almog +public interface InterpLinker { + /// Resolves a class by JVM internal name (a/b/C), or null if the host does + /// not have it. Returning null rather than throwing matters: a pushed + /// program may legitimately reference a class the installed app was built + /// without, and the interpreter turns that into a diagnosable error at the + /// point of use rather than at load. + Object findClass(String internalName); + + /// Runs a host class's static initializer, if the platform has a way to ask + /// for that and has not run it already. + /// + /// [#findClass(String)] deliberately does not: resolution happens for all + /// sorts of reasons -- a cast, an `instanceof`, a symbol lookup -- and + /// initializing on every one of them would run initializers Java does not. + /// Initializing an interpreted class, on the other hand, has to initialize + /// its host superclass first, or the parent's static state is built after + /// the child's rather than before it. + void initializeClass(String internalName) throws Throwable; + + /// Initializes the default-bearing interfaces at or above a host + /// interface, in the order JLS 12.4.1 requires. + /// + /// The whole walk belongs to the platform, not to the interpreter: the + /// bundle records only the interfaces a class declares directly, and + /// whether any of them declares a default method is a fact about the app. + /// A platform that cannot tell does nothing, which leaves each interface to + /// initialize on its own first use -- the behaviour before any of this, and + /// wrong only in ordering. + void initializeDefaultBearingInterfaces(String internalName) throws Throwable; + + /// Constructs a host object. + Object construct(Object hostClass, String descriptor, Object[] args) throws Throwable; + + /// Invokes a host instance method virtually -- dispatch follows the + /// receiver's real type, not the named owner. + Object invokeVirtual(Object target, String owner, String name, String descriptor, + Object[] args) throws Throwable; + + /// Invokes a host method without virtual dispatch, for `invokespecial` + /// (a `super.` call or a private method). + Object invokeSpecial(Object target, String owner, String name, String descriptor, + Object[] args) throws Throwable; + + /// Whether the host has this instance method, without calling it. + /// + /// Asked before a `super.` call: a generated shim provides `super_paint` + /// only for methods it could override, and a *final* host method has no + /// such bridge -- yet `super.play()` on a final method is ordinary Java. + /// Knowing beforehand is what lets the interpreter call the method itself + /// in that case rather than fail on a bridge that was never meant to exist. + /// A platform that cannot tell may answer false, which is the behaviour + /// before the question was asked. + boolean hasMethod(String owner, String name, String descriptor); + + /// Invokes a host static method. + Object invokeStatic(String owner, String name, String descriptor, Object[] args) + throws Throwable; + + /// Reads a host static field. + Object getStatic(String owner, String name, String descriptor) throws Throwable; + + /// Writes a host static field. + void setStatic(String owner, String name, String descriptor, Object value) throws Throwable; + + /// Reads a host instance field. + Object getField(Object target, String owner, String name, String descriptor) throws Throwable; + + /// Writes a host instance field. + void setField(Object target, String owner, String name, String descriptor, Object value) + throws Throwable; + + /// Whether a value is an instance of a host type. Used by `instanceof` and + /// `checkcast`, and by exception-table matching. + boolean isInstance(Object hostClass, Object value); + + /// Allocates an array of a host component type. + Object newArray(String componentDescriptor, int length) throws Throwable; + + /// Allocates a multi-dimensional array. + Object newMultiArray(String arrayDescriptor, int[] dimensions) throws Throwable; + + /// An empty array of the same runtime type and length as `source`, or null + /// when the platform cannot say what that type is. + /// + /// `clone()` on a `String[]` has to produce a `String[]`. The interpreter + /// represents its own reference arrays as `Object[]`, but an array that came + /// from the host carries a real component type, and a copy that lost it + /// fails the moment it is passed back. + Object cloneArray(Object source) throws Throwable; + + /// The `java.lang.Class` object for a host class, for `ldc` of a class + /// literal. + Object classObject(Object hostClass); +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpMethod.java b/CodenameOne/src/com/codename1/impl/interp/InterpMethod.java new file mode 100644 index 00000000000..e9e4e03f469 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpMethod.java @@ -0,0 +1,144 @@ +/* + * 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.interp; + +/// One interpreted method: its signature, its frame sizes, and its code. +/// +/// `code` is a flat int array of (opcode, operand...) with a fixed operand +/// count per opcode, and `pcOfInstruction` maps instruction index to code +/// offset. Jump operands are already instruction indices, so branching is an +/// array index rather than a search for a label. +/// +/// @author Shai Almog +public final class InterpMethod { + final InterpClass owner; + + String name = ""; + String desc = ""; + int accessFlags; + + int maxStack; + int maxLocals; + + /// Flat instruction stream. Layout is (opcode, operands...) with the count + /// fixed per opcode -- except the two switch opcodes, which carry their own + /// length as the first operand. + int[] code = new int[0]; + + /// Instruction index -> offset into `code`. Jump operands are instruction + /// indices, which this turns into a code offset. + int[] instructionOffsets = new int[0]; + + /// Exception table, four ints per entry: startInsn, endInsn (exclusive), + /// handlerInsn, typeExtern (-1 for `finally` / catch-all). + int[] exceptionTable = new int[0]; + + /// Line numbers, two ints per entry: instruction index, source line. + int[] lineTable = new int[0]; + + /// Argument kinds, one RET_* per declared parameter, used to pop a call's + /// arguments in the right widths. + int[] argKinds = new int[0]; + + /// The kind of the declared return type. + /// + /// Needed because `ireturn` carries boolean, byte, char, short and int + /// alike -- the JVM keeps them all in an int-sized slot -- so the value on + /// the stack cannot say which one it is. Only the descriptor knows, and the + /// caller unboxes by the descriptor. + int returnKind = InterpOpcodes.RET_INT; + + InterpMethod(InterpClass owner) { + this.owner = owner; + } + + /// True for a static method. + public boolean isStatic() { + return (accessFlags & 0x0008) != 0; + } + + /// A synchronized method holds its receiver's monitor -- or its class's, + /// when static -- for the whole call. There is no `monitorenter` in the + /// body; the flag is the only record of it. + public boolean isSynchronized() { + return (accessFlags & 0x0020) != 0; + } + + /// Whether this method is private, which is what makes it not virtual. + /// + /// javac emits `invokevirtual` for a private method from JDK 11 onwards + /// (nestmates replaced the synthetic access bridges), so the opcode alone + /// no longer says whether dispatch follows the receiver. + public boolean isPrivate() { + return (accessFlags & 0x0002) != 0; + } + + /// Whether this method is public. + public boolean isPublic() { + return (accessFlags & 0x0001) != 0; + } + + /// Whether this method is protected. + public boolean isProtected() { + return (accessFlags & 0x0004) != 0; + } + + /// True for an abstract method -- one with no code. + public boolean isAbstract() { + return (accessFlags & 0x0400) != 0; + } + + /// The declaring class. + public InterpClass getOwner() { + return owner; + } + + /// The method name; `` for a constructor. + public String getName() { + return name; + } + + /// The JVM descriptor. + public String getDescriptor() { + return desc; + } + + /// The source line for an instruction index, or -1. Used to build a stack + /// trace that names real lines in the user's file. + public int lineFor(int instructionIndex) { + int line = -1; + for (int i = 0; i < lineTable.length; i += 2) { + if (lineTable[i] <= instructionIndex) { + line = lineTable[i + 1]; + } else { + break; + } + } + return line; + } + + @Override + public String toString() { + return owner.name + "." + name + desc; + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpObject.java b/CodenameOne/src/com/codename1/impl/interp/InterpObject.java new file mode 100644 index 00000000000..39c0122edbc --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpObject.java @@ -0,0 +1,210 @@ +/* + * 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.interp; + +/// An instance of an interpreted class. +/// +/// Fields are a flat `Object[]`, indexed by the declaring class's +/// [InterpClass#fieldBase] plus the field's position. Nothing in the host reads +/// these, so there is no layout to match and no reason to keep typed slots. +/// +/// When the interpreted class extends a host class, `hostPeer` holds the object +/// the host actually sees -- produced by [InterpObjectFactory]. The two are +/// distinct because the host's object has the host's layout and the host's +/// vtable, while interpreted state has to live somewhere the host does not know +/// about. +/// +/// @author Shai Almog +public final class InterpObject { + final InterpClass type; + final Object[] fields; + + /// The host-visible object when this interpreted class extends or + /// implements something from the host app; null for a class whose whole + /// hierarchy is interpreted (other than java.lang.Object). + Object hostPeer; + + /// JVM internal name of the peer's own class. + /// + /// Recorded when the factory builds the peer rather than read back from + /// `getClass()`: ParparVM derives `Class.getName()` from the mangled C + /// symbol, so a class whose simple name contains an underscore -- which + /// every generated shim's does, `Interp_Form` -- comes back as + /// `Interp/Form` and matches no symbol at all. + String hostPeerOwner; + + /// True when the peer exists only because the interpreted class + /// implements a host interface -- the interpreted class's superclass is + /// still `java.lang.Object`. Object's own default methods + /// (`toString`/`hashCode`/`equals`) then belong to the interpreter, not + /// to the shim: the shim's `Object.toString()` would name the shim class + /// (`Interp_Runnable@...`) instead of the pushed class the program + /// wrote. + boolean hostPeerFromInterfacesOnly; + + /// Name and position of an interpreted enum constant, or null and -1. + /// + /// An enum constant has no peer. `java.lang.Enum` cannot be subclassed from + /// Java source, so no shim for it can exist, and it needs none: everything + /// `Enum` does is two final fields and the handful of methods that read + /// them, which the interpreter implements directly. What the host sees of + /// an interpreted enum is whatever interfaces it declares. + String enumName; + int enumOrdinal = -1; + + /// The runtime that created this object, so that host code converting it + /// to a string reaches the interpreted `toString`. + InterpRuntime runtime; + + InterpObject(InterpClass type) { + this.type = type; + this.fields = new Object[type.totalFieldCount()]; + initFields(type); + } + + private void initFields(InterpClass c) { + if (c == null) { + return; + } + initFields(c.superInterp); + for (int i = 0; i < c.fieldNames.length; i++) { + fields[c.fieldBase + i] = InterpValues.defaultValue(c.fieldDescs[i]); + } + } + + /// The interpreted class of this object. + public InterpClass getType() { + return type; + } + + /// The host-visible peer, or null. + public Object getHostPeer() { + return hostPeer; + } + + int indexOf(InterpClass declaring, String fieldName) { + for (int i = 0; i < declaring.fieldNames.length; i++) { + if (declaring.fieldNames[i].equals(fieldName)) { + return declaring.fieldBase + i; + } + } + // Walk up: javac names the declaring class of an inherited field as the + // static type at the access site, which may be a subclass. + InterpClass c = declaring.superInterp; + while (c != null) { + for (int i = 0; i < c.fieldNames.length; i++) { + if (c.fieldNames[i].equals(fieldName)) { + return c.fieldBase + i; + } + } + c = c.superInterp; + } + return -1; + } + + /// The interpreted class's own `toString`, when it has one. + /// + /// An interpreted object with no host peer is handed to the framework as + /// itself, so anything that converts it to a string -- `StringBuilder`, + /// `System.out.println`, a `Label` -- lands here rather than on any + /// interpreted method. Without this an enum constant prints as + /// `Color@interp` instead of `RED`, and so does every class that defines a + /// perfectly good `toString`. + /// + /// An object that *does* have a peer never reaches this: the framework sees + /// the peer, whose generated override routes to the interpreter already. + @Override + public String toString() { + // The override first, the constant name second. An enum is allowed to + // define toString -- `RED` printing as `red` is the ordinary reason to + // write one -- and answering the name here made the override apply to + // interpreted callers and not to host ones, so the same constant + // printed two different ways depending on who asked. + if (runtime != null) { + Object r = runtime.dispatch(this, "toString", "()Ljava/lang/String;", + new Object[0]); + if (!isMiss(r)) { + return (String) r; + } + } + if (enumName != null) { + // What java.lang.Enum.toString does, for a constant that did not + // override it. + return enumName; + } + // Object.toString's own shape: `getName() + "@" + hex(hashCode())`. + // A fixed `@interp` suffix folded every peerless instance of the + // same class to one string, hid the difference between two objects + // in logs and labels, and returned a non-Java value from what looks + // like a plain `toString()` call. + return type.name.replace('/', '.') + "@" + + Integer.toHexString(System.identityHashCode(this)); + } + + /// Delegates to an interpreted `equals`, for the same reason `toString` + /// does. + /// + /// A peerless object reaches host code as itself, and host code puts it in + /// a `HashMap`. Leaving equality as identity there does not merely lose a + /// nicety: two keys the program considers equal hash differently and every + /// lookup misses, quietly. + @Override + public boolean equals(Object other) { + if (runtime != null) { + Object r = runtime.dispatch(this, "equals", "(Ljava/lang/Object;)Z", + new Object[] {other}); + if (!isMiss(r)) { + return ((Boolean) r).booleanValue(); + } + } + return other == this; //NOPMD CompareObjectsWithEquals - Object.equals is identity + } + + /// Delegates to an interpreted `hashCode`. Overriding `equals` without this + /// is the classic way to break every hash-based collection, and here the + /// collection belongs to the host. + @Override + public int hashCode() { + if (runtime != null) { + Object r = runtime.dispatch(this, "hashCode", "()I", new Object[0]); + if (!isMiss(r)) { + return ((Integer) r).intValue(); + } + } + return System.identityHashCode(this); + } + + /// Whether the interpreter answered with a sentinel rather than a value. + /// + /// Two of them, and both mean "use the default behaviour here": + /// NOT_OVERRIDDEN because the pushed class does not declare the method, + /// DETACHED because the program that did has been stopped. Host code can + /// still hold a peerless object -- a key in a collection, something waiting + /// to be logged -- and casting the sentinel would turn printing it into a + /// ClassCastException. + private static boolean isMiss(Object answer) { + // Sentinels, not equal objects: identity is the whole point of them. + return answer == InterpRuntime.NOT_OVERRIDDEN //NOPMD CompareObjectsWithEquals - a sentinel + || answer == InterpRuntime.DETACHED; //NOPMD CompareObjectsWithEquals - a sentinel + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpObjectFactory.java b/CodenameOne/src/com/codename1/impl/interp/InterpObjectFactory.java new file mode 100644 index 00000000000..72e94bdefb8 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpObjectFactory.java @@ -0,0 +1,98 @@ +/* + * 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.interp; + +/// Produces the host-visible object for an interpreted class that extends or +/// implements something from the host app. +/// +/// This is the hard half of the device runtime, and the half that differs most +/// between platforms. An interpreted `class MyForm extends Form` has to be an +/// object the framework accepts as a `Form` and whose overrides the framework +/// calls -- but the framework was compiled before the class existed, and +/// neither platform lets you define a class at run time: +/// +/// - **iOS / ParparVM**: no `defineClass`, and iOS forbids writing executable +/// memory. What ParparVM does have is a heap-allocated, slot-indexed vtable +/// per class, so a subclass is built by copying the parent's `struct clazz`, +/// copying its vtable, and repointing the overridden slots at a trampoline +/// into the interpreter. Nothing is generated and nothing is written to +/// executable pages. +/// - **Android / ART**: no patchable vtable, and Play forbids loading dex at +/// run time, so the guard has to be compiled ahead of time -- a generated +/// subclass per extensible framework class, each overridable method either +/// delegating to the interpreter or calling `super`. Interfaces are easier: +/// `java.lang.reflect.Proxy` covers them with no generation at all. +/// +/// Both satisfy this interface, so the interpreter never learns which is in +/// play. +/// +/// @author Shai Almog +public interface InterpObjectFactory { + /// Creates the host-visible peer for an interpreted object. + /// + /// #### Parameters + /// + /// - `object`: the interpreted instance the peer stands for + /// - `hostSuperclassName`: JVM internal name of the nearest host + /// superclass, or null if the class only implements host interfaces + /// - `hostInterfaceNames`: JVM internal names of the host interfaces the + /// class implements + /// - `superConstructorDescriptor`: descriptor of the host superclass + /// constructor to run, or null for the no-arg one + /// - `superConstructorArgs`: arguments for that constructor + /// + /// #### Returns + /// + /// an object the host will accept as an instance of `hostSuperclassName` + /// and of every entry in `hostInterfaceNames` + /// + /// Supertypes arrive as internal names rather than as whatever the linker + /// uses to represent a class, because the two platforms disagree about what + /// a class even is: a `java.lang.Class` on Android, a numeric class id on + /// iOS where `Class` carries no member information. A name is the one + /// handle both can act on, and it is what the generated shim registry is + /// keyed by. + Object createPeer(InterpObject object, + String hostSuperclassName, + String[] hostInterfaceNames, + String superConstructorDescriptor, + Object[] superConstructorArgs) throws Throwable; + + /// The JVM internal name of a peer's own class. + /// + /// The factory knows this; `peer.getClass().getName()` does not, at least + /// not everywhere. ParparVM derives `Class.getName()` from the mangled C + /// symbol, where package separators and underscores are the same character, + /// so `Interp_Form` comes back as `Interp/Form` and resolves against + /// nothing. Asking the factory instead removes the guesswork. + String peerClassName(Object peer); + + /// Whether this factory can produce a peer for the given host supertype, + /// named as a JVM internal name or null for none. + /// + /// A platform answers false when the type is outside what it can extend -- + /// on Android, a class with no generated shim; on iOS, a final class. The + /// runtime turns that into an error naming the type, which is far easier to + /// act on than a peer that exists but is never dispatched to. + boolean canExtend(String hostSuperclassName); +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpOpcodes.java b/CodenameOne/src/com/codename1/impl/interp/InterpOpcodes.java new file mode 100644 index 00000000000..caff8603674 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpOpcodes.java @@ -0,0 +1,255 @@ +/* + * 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.interp; + +/// The instruction encoding shared by the desktop bundle writer and the device +/// interpreter. +/// +/// Opcode numbers are the JVM's own. Only the operands differ: a constant-pool +/// index becomes an index into the bundle's extern or string table, and a +/// branch offset becomes an instruction index. Keeping the opcodes means the +/// interpreter's semantics can be checked against the JVM directly, which is +/// what the differential conformance tests do. +/// +/// Two encodings are synthesised because the JVM's are not fixed-width: +/// [#OP_TABLESWITCH] and [#OP_LOOKUPSWITCH] store their own operand count +/// first. Everything else has a constant operand count given by +/// [#operandCount]. +/// +/// @author Shai Almog +public final class InterpOpcodes { + private InterpOpcodes() { + } + + // Return / value kinds. Used for descriptors, call argument widths and + // array element widths. + public static final int RET_VOID = 0; + public static final int RET_INT = 1; + public static final int RET_LONG = 2; + public static final int RET_FLOAT = 3; + public static final int RET_DOUBLE = 4; + public static final int RET_OBJECT = 5; + public static final int RET_BOOLEAN = 6; + public static final int RET_BYTE = 7; + public static final int RET_CHAR = 8; + public static final int RET_SHORT = 9; + + /// True when a value of this kind occupies two stack slots. + public static boolean isCategory2(int kind) { + return kind == RET_LONG || kind == RET_DOUBLE; + } + + // Opcodes are the JVM's; listed here only where the interpreter refers to + // them by name. + public static final int NOP = 0; + public static final int ACONST_NULL = 1; + public static final int ICONST_M1 = 2; + public static final int ICONST_0 = 3; + public static final int ICONST_5 = 8; + public static final int LCONST_0 = 9; + public static final int LCONST_1 = 10; + public static final int FCONST_0 = 11; + public static final int FCONST_2 = 13; + public static final int DCONST_0 = 14; + public static final int DCONST_1 = 15; + public static final int BIPUSH = 16; + public static final int SIPUSH = 17; + public static final int LDC = 18; + public static final int ILOAD = 21; + public static final int LLOAD = 22; + public static final int FLOAD = 23; + public static final int DLOAD = 24; + public static final int ALOAD = 25; + public static final int IALOAD = 46; + public static final int LALOAD = 47; + public static final int FALOAD = 48; + public static final int DALOAD = 49; + public static final int AALOAD = 50; + public static final int BALOAD = 51; + public static final int CALOAD = 52; + public static final int SALOAD = 53; + public static final int ISTORE = 54; + public static final int LSTORE = 55; + public static final int FSTORE = 56; + public static final int DSTORE = 57; + public static final int ASTORE = 58; + public static final int IASTORE = 79; + public static final int LASTORE = 80; + public static final int FASTORE = 81; + public static final int DASTORE = 82; + public static final int AASTORE = 83; + public static final int BASTORE = 84; + public static final int CASTORE = 85; + public static final int SASTORE = 86; + public static final int POP = 87; + public static final int POP2 = 88; + public static final int DUP = 89; + public static final int DUP_X1 = 90; + public static final int DUP_X2 = 91; + public static final int DUP2 = 92; + public static final int DUP2_X1 = 93; + public static final int DUP2_X2 = 94; + public static final int SWAP = 95; + public static final int IADD = 96; + public static final int LADD = 97; + public static final int FADD = 98; + public static final int DADD = 99; + public static final int ISUB = 100; + public static final int LSUB = 101; + public static final int FSUB = 102; + public static final int DSUB = 103; + public static final int IMUL = 104; + public static final int LMUL = 105; + public static final int FMUL = 106; + public static final int DMUL = 107; + public static final int IDIV = 108; + public static final int LDIV = 109; + public static final int FDIV = 110; + public static final int DDIV = 111; + public static final int IREM = 112; + public static final int LREM = 113; + public static final int FREM = 114; + public static final int DREM = 115; + public static final int INEG = 116; + public static final int LNEG = 117; + public static final int FNEG = 118; + public static final int DNEG = 119; + public static final int ISHL = 120; + public static final int LSHL = 121; + public static final int ISHR = 122; + public static final int LSHR = 123; + public static final int IUSHR = 124; + public static final int LUSHR = 125; + public static final int IAND = 126; + public static final int LAND = 127; + public static final int IOR = 128; + public static final int LOR = 129; + public static final int IXOR = 130; + public static final int LXOR = 131; + public static final int IINC = 132; + public static final int I2L = 133; + public static final int I2F = 134; + public static final int I2D = 135; + public static final int L2I = 136; + public static final int L2F = 137; + public static final int L2D = 138; + public static final int F2I = 139; + public static final int F2L = 140; + public static final int F2D = 141; + public static final int D2I = 142; + public static final int D2L = 143; + public static final int D2F = 144; + public static final int I2B = 145; + public static final int I2C = 146; + public static final int I2S = 147; + public static final int LCMP = 148; + public static final int FCMPL = 149; + public static final int FCMPG = 150; + public static final int DCMPL = 151; + public static final int DCMPG = 152; + public static final int IFEQ = 153; + public static final int IFNE = 154; + public static final int IFLT = 155; + public static final int IFGE = 156; + public static final int IFGT = 157; + public static final int IFLE = 158; + public static final int IF_ICMPEQ = 159; + public static final int IF_ICMPNE = 160; + public static final int IF_ICMPLT = 161; + public static final int IF_ICMPGE = 162; + public static final int IF_ICMPGT = 163; + public static final int IF_ICMPLE = 164; + public static final int IF_ACMPEQ = 165; + public static final int IF_ACMPNE = 166; + public static final int GOTO = 167; + public static final int OP_TABLESWITCH = 170; + public static final int OP_LOOKUPSWITCH = 171; + public static final int IRETURN = 172; + public static final int LRETURN = 173; + public static final int FRETURN = 174; + public static final int DRETURN = 175; + public static final int ARETURN = 176; + public static final int RETURN = 177; + public static final int GETSTATIC = 178; + public static final int PUTSTATIC = 179; + public static final int GETFIELD = 180; + public static final int PUTFIELD = 181; + public static final int INVOKEVIRTUAL = 182; + public static final int INVOKESPECIAL = 183; + public static final int INVOKESTATIC = 184; + public static final int INVOKEINTERFACE = 185; + public static final int NEW = 187; + public static final int NEWARRAY = 188; + public static final int ANEWARRAY = 189; + public static final int ARRAYLENGTH = 190; + public static final int ATHROW = 191; + public static final int CHECKCAST = 192; + public static final int INSTANCEOF = 193; + public static final int MONITORENTER = 194; + public static final int MONITOREXIT = 195; + public static final int MULTIANEWARRAY = 197; + public static final int IFNULL = 198; + public static final int IFNONNULL = 199; + + /// A constant loaded by LDC, tagged so the interpreter knows the width and + /// whether the operand indexes the string pool or is an immediate. + public static final int LDC_INT = 0; + public static final int LDC_LONG = 1; + public static final int LDC_FLOAT = 2; + public static final int LDC_DOUBLE = 3; + public static final int LDC_STRING = 4; + public static final int LDC_CLASS = 5; + + /// Number of operand ints that follow the given opcode. + /// + /// The two switch opcodes are variable length and are not covered here; + /// they store their operand count as their first operand. Everything else + /// is fixed so the writer and the interpreter agree without a table lookup + /// per instruction. + public static int operandCount(int opcode) { + switch (opcode) { + case BIPUSH: + case SIPUSH: + case ILOAD: case LLOAD: case FLOAD: case DLOAD: case ALOAD: + case ISTORE: case LSTORE: case FSTORE: case DSTORE: case ASTORE: + case IFEQ: case IFNE: case IFLT: case IFGE: case IFGT: case IFLE: + case IF_ICMPEQ: case IF_ICMPNE: case IF_ICMPLT: + case IF_ICMPGE: case IF_ICMPGT: case IF_ICMPLE: + case IF_ACMPEQ: case IF_ACMPNE: + case GOTO: + case NEW: case ANEWARRAY: case CHECKCAST: case INSTANCEOF: + case NEWARRAY: + case GETSTATIC: case PUTSTATIC: case GETFIELD: case PUTFIELD: + case INVOKEVIRTUAL: case INVOKESPECIAL: + case INVOKESTATIC: case INVOKEINTERFACE: + case IFNULL: case IFNONNULL: + return 1; + case LDC: // tag, value/index + case IINC: // local, increment + case MULTIANEWARRAY: // extern, dimensions + return 2; + default: + return 0; + } + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpPairingSecret.java b/CodenameOne/src/com/codename1/impl/interp/InterpPairingSecret.java new file mode 100644 index 00000000000..5cdc2150233 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpPairingSecret.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.interp; + +import com.codename1.security.Hmac; + +/// The shared secret a paired computer proves it holds, on every connection. +/// +/// #### What this replaces, and why +/// +/// The first version of pairing sent a peer id and let a matching id authorise +/// every later push. That is a bearer token in plaintext on a LAN: anyone who +/// watched one push, or who guessed the id, could push a program of their own +/// to somebody's phone -- and a program is arbitrary code. Worse, the id never +/// changed, so a single captured frame worked forever. +/// +/// What crosses the wire now is never enough to reuse. Pairing derives a +/// 256-bit secret on both ends **without transmitting it**: the only secret +/// input is the code the IDE prints and a human types into the device, and the +/// two ends combine it with the peer id and the device id, both of which are +/// public. Every connection afterwards is a fresh challenge from the device and +/// an HMAC over it, so a captured response authenticates exactly one +/// connection, and a push additionally MACs the bundle so the bytes that run +/// are the bytes that were authorised. +/// +/// #### The residual weakness, stated plainly +/// +/// The code is six digits, so an attacker who records a *pairing* exchange can +/// grind 10^6 candidates offline. [#ITERATIONS] iterations is what makes that +/// cost real rather than instant, and it is the reason the derivation is +/// deliberately slow. It is not a PAKE; a passive observer of the pairing +/// handshake is still the attacker this does not defeat. Observing any number +/// of *pushes*, which is the exposure that actually persists, tells them +/// nothing. +/// +/// It lives in core rather than in the runtime app because it has two callers +/// that share no code: the device, and the desktop push tool, which mirrors it +/// against the JDK's own HMAC. One authoritative definition is the difference +/// between "pairing broke" and "pairing broke and a test said which end +/// changed". +/// +/// @author Shai Almog +public final class InterpPairingSecret { + /// Iterations of the derivation. + /// + /// Chosen so a phone spends well under a second on it once, at pairing, + /// while an attacker grinding the six-digit code pays that cost a million + /// times over. Changing it invalidates every existing pairing, which is + /// tolerable (pair again) but not free. + public static final int ITERATIONS = 20000; + + private InterpPairingSecret() { + } + + /// Derives the shared secret from the typed code and the two public ids. + /// + /// Both ends compute this independently; it is never transmitted. The peer + /// id and device id are bound in so a code seen on one device cannot pair a + /// different one, and so two computers pairing with the same device do not + /// end up holding the same key. + public static byte[] derive(String code, String peerId, String deviceId) { + byte[] key = utf8(code == null ? "" : code.trim()); + byte[] block = Hmac.sha256(key, + utf8("cn1-device-runtime|" + peerId + "|" + deviceId)); + for (int i = 1; i < ITERATIONS; i++) { + block = Hmac.sha256(key, block); + } + return block; + } + + /// The answer to a challenge: hex HMAC-SHA256 of the challenge under the + /// secret. Used for the pairing handshake, where there is no payload yet. + public static String respond(byte[] secret, String challenge) { + return hex(Hmac.sha256(secret, utf8(challenge))); + } + + /// The answer to a challenge over a bundle. + /// + /// The payload is covered as well as the challenge, so an attacker who can + /// modify the stream cannot swap in a different program behind a valid + /// response -- the response would no longer verify against what arrived. + public static String respond(byte[] secret, String challenge, byte[] payload) { + Hmac mac = Hmac.create(com.codename1.security.Hash.SHA256, secret); + mac.update(utf8(challenge)); + mac.update(payload); + return hex(mac.doFinal()); + } + + /// Compares two hex responses without leaking where they first differ. + public static boolean matches(String a, String b) { + if (a == null || b == null) { + return false; + } + return Hmac.constantTimeEquals(utf8(a), utf8(b)); + } + + /// A fresh challenge: 32 random bytes as hex. The device issues one per + /// connection, which is what makes a captured response worthless. + public static String challenge() { + return hex(com.codename1.security.SecureRandom.bytes(32)); + } + + /// Lowercase hex, since the values travel as UTF strings on the wire and + /// live in a properties file on the desktop. + public static String hex(byte[] data) { + char[] out = new char[data.length * 2]; + for (int i = 0; i < data.length; i++) { + int b = data[i] & 0xff; + out[i * 2] = hexDigit(b >> 4); + out[i * 2 + 1] = hexDigit(b & 0xf); + } + return new String(out); + } + + /// The inverse of [#hex(byte[])], for a secret read back from storage. + public static byte[] unhex(String s) { + byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) ((digit(s.charAt(i * 2)) << 4) | digit(s.charAt(i * 2 + 1))); + } + return out; + } + + private static char hexDigit(int nibble) { + return (char) (nibble < 10 ? '0' + nibble : 'a' + nibble - 10); + } + + private static int digit(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + throw new IllegalArgumentException("not hex: " + c); + } + + private static byte[] utf8(String s) { + // getBytes("UTF-8") throws a checked exception on the device's API and + // every caller here is passing hex or a typed code; encoding cannot + // fail, so the checked exception would only add noise. + try { + return s.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + //NOPMD PreserveStackTrace - the device's IllegalStateException has no cause constructor + throw new IllegalStateException("UTF-8 is always available: " + e); //NOPMD PreserveStackTrace + } + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpPlatform.java b/CodenameOne/src/com/codename1/impl/interp/InterpPlatform.java new file mode 100644 index 00000000000..c109cf7c58f --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpPlatform.java @@ -0,0 +1,62 @@ +/* + * 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.interp; + +/// Where a port publishes the linker that binds interpreted code to the app. +/// +/// The linker is the one part of the device runtime that cannot be shared: +/// reflection on Android and in the simulator, the translator's invoke thunks +/// on iOS. The object factory is not here because it is per-program rather than +/// per-platform -- it holds the runtime that a given pushed bundle is running +/// under. +/// +/// A registry rather than a lookup by name, because the obvious alternative +/// (`Class.forName` on a per-platform class) is exactly what ParparVM cannot +/// do. The port registers itself while it is initialising, which is code the +/// translator has already proven reachable. +/// +/// A build with no device runtime registers nothing, so the whole feature +/// reduces to one null field. +/// +/// @author Shai Almog +public final class InterpPlatform { + private static InterpLinker linker; + + private InterpPlatform() { + } + + /// Registers this platform's linker. Called by the port during startup. + public static void register(InterpLinker platformLinker) { + linker = platformLinker; + } + + /// The registered linker, or null when this build has no device runtime. + public static InterpLinker getLinker() { + return linker; + } + + /// Whether this build can run pushed code. + public static boolean isAvailable() { + return linker != null; + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpRuntime.java b/CodenameOne/src/com/codename1/impl/interp/InterpRuntime.java new file mode 100644 index 00000000000..f1a5161b936 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpRuntime.java @@ -0,0 +1,3965 @@ +/* + * 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.interp; + +import java.util.Vector; + +/// Executes a pushed bundle. +/// +/// ### Frames +/// +/// One interpreted frame is one real Java frame: [#execute] recurses. The +/// alternative -- heap-allocated frames driven by a trampoline -- would be +/// tidier and would break `Display.invokeAndBlock`, which on ParparVM runs a +/// nested event loop on the caller's native stack. Every blocking Codename One +/// idiom (`Dialog.show()`, a synchronous `NetworkManager` call) is built on +/// that, so interpreted code has to be able to sit in the middle of it. The +/// cost is that interpreted depth is bounded by the real stack, which +/// [#maxDepth] caps well short of it. +/// +/// ### Fuel +/// +/// Every back edge and method entry decrements a counter. At zero the +/// interpreter checks whether it has been asked to stop and whether it has +/// outstayed its budget on the event thread. This is what makes a runaway +/// pushed program recoverable instead of a hung app -- the existing BeanShell +/// playground has no such check, and `while(true){}` there wedges the EDT +/// permanently. Accounting deliberately pauses while inside a host call, or a +/// legitimate `invokeAndBlock` waiting on the network would look like a runaway +/// loop. +/// +/// @author Shai Almog +public final class InterpRuntime { + private final InterpBundle bundle; + private final InterpLinker linker; + private final InterpObjectFactory factory; + + private int maxDepth = 512; + private int fuelPerCheck = 20000; + private long edtBudgetMs = 2000; + + private volatile boolean cancelRequested; //NOPMD AvoidUsingVolatile - written from another thread on purpose + + /// The last exception interpreted code threw, and the interpreted frames it + /// was thrown from. + /// + /// On the runtime rather than on the thread state, because the thread that + /// reports a failure is not the thread that ran the program -- a pushed + /// main runs on the event thread and the socket thread is what answers the + /// push. The pair is only ever read through an identity check, so the worst + /// a race can do is decline to produce a stack. + /// One record, published in one write: the throwable, the frames it came + /// from and the host call it happened in. Three separate fields could be + /// read half-updated -- another thread's frames beside this thread's + /// throwable -- which is a confidently wrong stack rather than a missing + /// one. + private static final class Failure { + private final Object thrown; + /// The object interpreted code will see on the operand stack when a + /// catch runs -- `getThrown()` for a wrapped InterpObject, and the + /// throwable itself when there is no wrapper. Kept alongside + /// [#thrown] so a rethrow of either identity is recognised: the + /// wrapper is what escapes to host code, but the original is what + /// interpreted code pops off the stack and passes to ATHROW. + private final Object original; + private final String[] stack; + private final String hostCall; + + Failure(Object thrown, Object original, String[] stack, String hostCall) { + this.thrown = thrown; + this.original = original; + this.stack = stack; + this.hostCall = hostCall; + } + } + + private volatile Failure lastFailure; //NOPMD AvoidUsingVolatile - written from another thread on purpose + + /// Execution state that belongs to one thread, not to the runtime. + /// + /// The runtime is genuinely entered from several threads at once: the + /// thread running a pushed `main`, and the event thread every time the + /// framework calls an interpreted `paint` or listener through a generated + /// shim. Holding depth, fuel and the call stack on the runtime meant those + /// threads corrupted each other's -- the depth cap tripping on the wrong + /// thread, a stack trace naming another thread's frames. + private static final class ThreadState { + int depth; + int fuel; + int hostCallDepth; + long runStartMs; + final Vector callStack = new Vector(); + } + + private final ThreadLocal threadState = new ThreadLocal(); + + private ThreadState state() { + ThreadState s = (ThreadState) threadState.get(); + if (s == null) { + s = new ThreadState(); + s.fuel = fuelPerCheck; + // Each thread's budget starts when it first enters the interpreter. + // A shared start time would make a callback arriving an hour into + // the session look like a program that had run for an hour. + s.runStartMs = System.currentTimeMillis(); + threadState.set(s); + } + return s; + } + + public InterpRuntime(InterpBundle bundle, InterpLinker linker, InterpObjectFactory factory) { + this.bundle = bundle; + this.linker = linker; + this.factory = factory; + } + + /// Maximum interpreted call depth. Exceeding it raises an interpreted + /// `StackOverflowError` rather than letting the real stack overflow, which + /// on a device is a process death with no diagnosis. + public void setMaxDepth(int maxDepth) { + this.maxDepth = maxDepth; + } + + /// Wall-clock budget for a single run on the event thread, in + /// milliseconds. Zero disables the check. + public void setEdtBudgetMs(long edtBudgetMs) { + this.edtBudgetMs = edtBudgetMs; + } + + /// Asks the running program to stop at the next checkpoint. Safe to call + /// from another thread -- this is what the Stop button uses. + /// True once the program has been stopped and must not run again. + private volatile boolean detached; //NOPMD AvoidUsingVolatile - set from the UI thread, read on every callback + + /// Ends this runtime for good: cancels what is running and refuses every + /// later callback. + /// + /// Stop cannot be only a cancellation. A normal Lifecycle program is not + /// running when the user presses it -- its start() returned after showing a + /// Form -- and what remains is listeners the framework still holds. + public void detach() { + detached = true; + requestCancel(); + } + + /// Whether this runtime has been stopped. + public boolean isDetached() { + return detached; + } + + /// Stands in for host subsystems this runtime only mocks. See + /// [InterpHostInterceptor]. + private InterpHostInterceptor hostInterceptor; + + /// Installs the interceptor consulted before a host static call. + public void setHostInterceptor(InterpHostInterceptor interceptor) { + this.hostInterceptor = interceptor; + } + + public void requestCancel() { + cancelRequested = true; + } + + public InterpBundle getBundle() { + return bundle; + } + + /// Runs the bundle's main class. + public Object runMain(String[] args) throws Throwable { + String main = bundle.getMainClass(); + if (main == null) { + throw new IllegalStateException("bundle declares no main class"); + } + InterpClass c = bundle.findClass(main); + if (c == null) { + throw new IllegalStateException("main class " + main + " is not in the bundle"); + } + InterpMethod m = c.declaredMethod("main", "([Ljava/lang/String;)V"); + cancelRequested = false; + ensureInitialized(c); + // Only a `public static void main(String[])` is an entry point, per + // the same rule the JVM applies. A Lifecycle subclass that happens to + // declare a private or instance helper of the same signature is not + // meant to be entered through it -- the packer's finder rejects one + // for exactly this reason, and the runtime has to match or a bundle + // whose main class was chosen via the Lifecycle fallback would still + // invoke the helper here with a null receiver. + if (m != null && m.isStatic() && m.isPublic()) { + return invokeInterpreted(m, null, new Object[]{args}); + } + // A real Codename One application has no main: its entry point is a + // Lifecycle subclass, and the platform calls init then start. Running + // one is the whole point of this runtime, so that shape is entered the + // way the platform would enter it. + if (extendsHost(c, "com/codename1/system/Lifecycle")) { + return runLifecycle(c); + } + throw new IllegalStateException(main + " has neither public static" + + " main(String[]) nor a Lifecycle to start"); + } + + /// The pushed Lifecycle, when the program has one. + private InterpObject lifecycle; + + /// Delivers `stop()` to the pushed Lifecycle, if it has one to receive. + /// + /// The platform calls stop before an application goes away, and a program + /// that acquired anything releases it there. Call it before [#detach], + /// which is what makes every later callback a no-op -- including this one. + /// + /// @return whether a stop() actually ran + public boolean stopLifecycle() throws Throwable { + InterpObject app = lifecycle; + if (app == null || detached) { + return false; + } + lifecycle = null; + // The interpreted override when there is one, the framework's own + // through the peer when there is not -- the same route init and start + // took, so a program that overrides nothing still behaves like the + // Lifecycle it is. + callLifecycle(app.getType(), app, "stop", "()V", new Object[0]); + return true; + } + + /// Whether this interpreted class has the named host class as a supertype. + /// + /// [InterpClass#isSubclassOfInterp] cannot answer it: as its name says, it + /// walks the interpreted chain, and a real application's superclass -- + /// Lifecycle -- lives in the app, not in the bundle. + private boolean extendsHost(InterpClass c, String hostName) { + Vector hostSupertypes = new Vector(); + c.collectHostSupertypes(hostSupertypes); + for (int i = 0; i < hostSupertypes.size(); i++) { + int ext = ((Integer) hostSupertypes.elementAt(i)).intValue(); + if (hostName.equals(externOwnerName(ext))) { + return true; + } + } + return false; + } + + /// Starts an interpreted Lifecycle the way the platform starts one. + /// + /// The object is constructed through the ordinary interpreted path, so it + /// gets its generated peer and the framework sees a real Lifecycle. `init` + /// receives null: the platform passes a native context that means nothing + /// to interpreted code, and Lifecycle's own init ignores it. + private Object runLifecycle(InterpClass c) throws Throwable { + InterpMethod ctor = c.declaredMethod("", "()V"); + if (ctor == null) { + throw new IllegalStateException(c.getName().replace('/', '.') + + " is a Lifecycle with no no-argument constructor"); + } + InterpObject app = new InterpObject(c); + app.runtime = this; + invokeInterpreted(ctor, app, new Object[0]); + + Object target = app.hostPeer != null ? app.hostPeer : app; + // Held before init, not after start: a Lifecycle that opened a media + // player, a socket or a sensor releases it in stop(), and init is + // already far enough in to have opened one. Recording it only once the + // program was running left an init that threw with nothing to release + // it, and detaching the runtime without calling stop leaves those + // running against the runtime's own screen and the next pushed program. + lifecycle = app; + callLifecycle(c, app, "init", "(Ljava/lang/Object;)V", new Object[]{null}); + callLifecycle(c, app, "start", "()V", new Object[0]); + return target; + } + + private void callLifecycle(InterpClass c, InterpObject app, String name, String desc, + Object[] args) throws Throwable { + InterpMethod m = c.resolve(name, desc); + if (m != null && !m.isAbstract()) { + invokeInterpreted(m, app, args); + return; + } + // Not overridden: Lifecycle's own implementation, reached through the + // peer, which is what the framework would have called. + if (app.hostPeer != null) { + hostCall(app.hostPeerOwner, "super_" + name, desc, app.hostPeer, args, false); + } + } + + /// Returned by [#dispatch] when the interpreted class does not override the + /// method, telling the generated shim to call `super` instead. + /// + /// A sentinel rather than null, because null is a perfectly good return + /// value for a method the interpreted class *does* override. + public static final Object NOT_OVERRIDDEN = new Object(); + + /// Returned by [#dispatch] when the program that owned the object has been + /// stopped. + /// + /// Distinct from [#NOT_OVERRIDDEN] because the two mean different things to + /// a shim that has nothing to defer to. A class shim answers both by + /// calling the framework's own implementation, but an interface shim over + /// an abstract method has none -- and it used to throw AbstractMethodError, + /// which turned an expected late callback (a timer, a network response, a + /// listener the framework still holds) into an event-thread failure long + /// after the user stopped the program. On this sentinel a generated method + /// quietly answers nothing instead. + public static final Object DETACHED = new Object(); + + /// Entry point for a generated shim: run the interpreted override of this + /// method if there is one, otherwise report that there is not. + /// + /// The shim is a framework subclass compiled into the app, so every one of + /// its overridable methods routes here. Methods the pushed class does not + /// override have to cost as little as possible -- they are on the framework's + /// own hot paths -- which is why the miss returns immediately rather than + /// raising anything. + public Object dispatch(InterpObject object, String name, String descriptor, Object[] args) { + if (detached) { + // The program was stopped. Its peers are still held by framework + // listeners and timers, and cancellation only stops code that is + // currently running -- so a short callback arriving now would + // execute happily against a program the user has ended. + return DETACHED; + } + if (object == null) { + return NOT_OVERRIDDEN; + } + InterpMethod m = object.getType().resolve(name, descriptor); + if (m == null || m.isAbstract()) { + // The pushed class did not override the method, but Enum defines + // some of them itself. `Collections.sort` on a list of interpreted + // enum constants casts to Comparable and calls compareTo -- and + // Enum.compareTo is not on the enum class's own method table. + // Route the Enum-inherited methods through `enumCall` here so + // host sorting works on peers that advertise Comparable. + if (object.enumOrdinal >= 0) { + Object early = enumCall(object, name, args); + if (early != NOT_ENUM_METHOD) { + return early; + } + } + return NOT_OVERRIDDEN; + } + try { + return invoke(m, object, args); + } catch (RuntimeException e) { + throw e; + } catch (Error e) { + throw e; + } catch (Throwable t) { + // The framework called us; it cannot be given a checked exception + // its own signature does not declare. + throw new InterpThrowable(t, snapshotStack()); + } + } + + /// Invokes an interpreted method from host code -- an overridden `paint`, + /// an `actionPerformed`, or a proxied interface method. + /// + /// A returned interpreted object is handed back as its host peer: the + /// caller is host code, which can do nothing with an [InterpObject]. Calls + /// that stay inside the interpreter use the internal path instead, where + /// the interpreted identity is the thing that matters. + public Object invoke(InterpMethod m, Object receiver, Object[] args) throws Throwable { + Object result = invokeInterpreted(m, receiver, args); + if (result instanceof InterpObject) { + InterpObject io = (InterpObject) result; + return io.hostPeer != null ? io.hostPeer : io; + } + if (result instanceof Object[]) { + // A returned array crosses like an argument does: its elements are + // exchanged for their peers, or host code casting one to the + // interface it implements gets a wrapper instead. Not converted + // back -- the array is the caller's now, and interpreted code + // reading an element converts on the way in. + toHostElements(result, new Vector()); + } + return result; + } + + /// Invokes an interpreted method without translating the result at the host + /// boundary. + private Object invokeInterpreted(InterpMethod m, Object receiver, Object[] args) + throws Throwable { + InterpFrame f = new InterpFrame(m); + int slot = 0; + if (!m.isStatic()) { + f.setLocalRef(slot++, receiver); + } + for (int i = 0; i < m.argKinds.length; i++) { + int kind = m.argKinds[i]; + Object a = args == null || i >= args.length ? null : args[i]; + if (kind == InterpOpcodes.RET_OBJECT) { + f.setLocalRef(slot++, fromHost(a)); + } else if (InterpOpcodes.isCategory2(kind)) { + f.setLocalLong(slot, InterpValues.unbox(kind, a)); + slot += 2; + } else { + f.setLocalInt(slot++, (int) InterpValues.unbox(kind, a)); + } + } + if (!m.isSynchronized()) { + return execute(f); + } + // A synchronized method locks for its whole duration, and the body + // contains no monitor instruction to hook -- ACC_SYNCHRONIZED is the + // only sign of it. The peer is preferred as the lock where there is + // one, because the peer is the object host code has and would lock. + Object lock; + if (m.isStatic()) { + lock = m.getOwner(); + } else if (receiver instanceof InterpObject + && ((InterpObject) receiver).hostPeer != null) { + lock = ((InterpObject) receiver).hostPeer; + } else { + lock = receiver; + } + if (lock == null) { + return execute(f); + } + synchronized (lock) { + return execute(f); + } + } + + // ---------------------------------------------------------------- engine + + private Object execute(InterpFrame f) throws Throwable { + ThreadState st = state(); + // A fresh entry into the interpreter, which is what the budget covers. + // + // "Fresh" is not "depth == 0". A host call can run a nested event loop + // -- Dialog.show, invokeAndBlock -- and dispatch a callback into the + // interpreter from inside it, on the same thread, while the outer + // frames are still on the stack. That callback is a new entry and needs + // its own clock; without one it inherits an already-spent budget, or + // (worse) is exempted from the check entirely because the outer host + // call is still counted. + // + // Measuring from the start of the run instead of per entry made every + // callback arriving more than edtBudgetMs after the program began -- + // which is every button press in a real application -- fail instantly + // with "ran without yielding", having done nothing. + boolean freshEntry = st.depth == 0 || st.hostCallDepth > 0; + int enclosingHostCalls = 0; + long enclosingRunStart = st.runStartMs; + int enclosingFuel = st.fuel; + if (freshEntry) { + st.runStartMs = System.currentTimeMillis(); + st.fuel = fuelPerCheck; + // The host calls below this entry are not this entry's business: + // leaving them counted would exempt every reentrant callback from + // the budget, which is exactly the wedge the budget exists to stop. + enclosingHostCalls = st.hostCallDepth; + st.hostCallDepth = 0; + } + // Entering a method is progress too. A back edge is the usual place to + // check, but code that recurses, catches the StackOverflowError this + // raises and recurses again never takes one -- and would hold the event + // thread with Stop having no effect, since nothing would look at the + // cancel flag. Charged against the same fuel counter, so the cost is a + // decrement per call and a real check once every fuelPerCheck of them. + st.fuel--; + if (st.fuel <= 0) { + checkpoint(st); + } + st.depth++; + if (st.depth > maxDepth) { + st.depth--; + // The fresh-entry bookkeeping above already reset fuel, clock and + // hostCallDepth; the finally that undoes it is only reached + // through the run() call below, so this early throw has to undo it + // itself. Otherwise the enclosing host call returns to a state + // where hostCallDepth was zeroed and never restored, and a later + // reentrant callback fails to recognise itself as fresh -- it + // inherits the outer entry's budget and its cancellation + // checkpoints stop firing. + if (freshEntry) { + st.hostCallDepth = enclosingHostCalls; + st.runStartMs = enclosingRunStart; + st.fuel = enclosingFuel; + } + throw new InterpThrowable(new StackOverflowError( + "interpreted call depth exceeded " + maxDepth), snapshotStack()); + } + st.callStack.addElement(f); + try { + return run(f); + } finally { + st.callStack.removeElementAt(st.callStack.size() - 1); + st.depth--; + if (freshEntry) { + st.hostCallDepth = enclosingHostCalls; + // And the clock it was measured against. The outer entry is + // still running -- its host call has not returned yet -- so + // leaving the callback's clock in place would hand the outer + // one a fresh budget every time a dialog dispatched an event, + // and the host-call exclusion would then be added to a + // timestamp that no longer belongs to anybody. + st.runStartMs = enclosingRunStart; + // The fuel counter with it. A loop whose body dispatches a + // listener would otherwise see a nearly full counter on every + // iteration and never reach a checkpoint -- so Stop would have + // nothing to act on, which is the thing the counter is for. + st.fuel = enclosingFuel; + } + } + } + + /// Returned by [#run] when a `monitorexit` released the monitor the + /// enclosing level is holding, so execution continues there -- outside the + /// Java `synchronized` block, which is what releases the real lock. + private static final Object MONITOR_RELEASED = new Object(); + + private Object run(InterpFrame f) throws Throwable { + return run(f, 0, false); + } + + private Object run(InterpFrame f, int startInsn, boolean insideMonitor) throws Throwable { + final InterpMethod m = f.method; + final int[] code = m.code; + int insn = startInsn; + + while (true) { + f.insn = insn; + int pc = m.instructionOffsets[insn]; + int op = code[pc]; + int next = insn + 1; + + try { + switch (op) { + case InterpOpcodes.NOP: + break; + case InterpOpcodes.ACONST_NULL: + f.pushRef(null); + break; + case 2: case 3: case 4: case 5: case 6: case 7: case 8: + f.pushInt(op - InterpOpcodes.ICONST_0); + break; + case InterpOpcodes.LCONST_0: + case InterpOpcodes.LCONST_1: + f.pushLong(op - InterpOpcodes.LCONST_0); + break; + case 11: case 12: case 13: + f.pushFloat(op - InterpOpcodes.FCONST_0); + break; + case InterpOpcodes.DCONST_0: + case InterpOpcodes.DCONST_1: + f.pushDouble(op - InterpOpcodes.DCONST_0); + break; + case InterpOpcodes.BIPUSH: + case InterpOpcodes.SIPUSH: + f.pushInt(code[pc + 1]); + break; + case InterpOpcodes.LDC: + ldc(f, code[pc + 1], code[pc + 2]); + break; + + case InterpOpcodes.ILOAD: + case InterpOpcodes.FLOAD: + f.pushInt((int) f.prim[code[pc + 1]]); + break; + case InterpOpcodes.LLOAD: + case InterpOpcodes.DLOAD: + f.pushLong(f.prim[code[pc + 1]]); + break; + case InterpOpcodes.ALOAD: + f.pushRef(f.refs[code[pc + 1]]); + break; + case InterpOpcodes.ISTORE: + case InterpOpcodes.FSTORE: + f.setLocalInt(code[pc + 1], f.popInt()); + break; + case InterpOpcodes.LSTORE: + case InterpOpcodes.DSTORE: + f.setLocalLong(code[pc + 1], f.popLong()); + break; + case InterpOpcodes.ASTORE: + f.setLocalRef(code[pc + 1], f.popRef()); + break; + + case InterpOpcodes.IALOAD: case InterpOpcodes.LALOAD: + case InterpOpcodes.FALOAD: case InterpOpcodes.DALOAD: + case InterpOpcodes.AALOAD: case InterpOpcodes.BALOAD: + case InterpOpcodes.CALOAD: case InterpOpcodes.SALOAD: + arrayLoad(f, op); + break; + case InterpOpcodes.IASTORE: case InterpOpcodes.LASTORE: + case InterpOpcodes.FASTORE: case InterpOpcodes.DASTORE: + case InterpOpcodes.AASTORE: case InterpOpcodes.BASTORE: + case InterpOpcodes.CASTORE: case InterpOpcodes.SASTORE: + arrayStore(f, op); + break; + + case InterpOpcodes.POP: + f.sp--; + break; + case InterpOpcodes.POP2: + f.sp -= 2; + break; + case InterpOpcodes.DUP: + dupSlots(f, 1, 0); + break; + case InterpOpcodes.DUP_X1: + dupSlots(f, 1, 1); + break; + case InterpOpcodes.DUP_X2: + dupSlots(f, 1, 2); + break; + case InterpOpcodes.DUP2: + dupSlots(f, 2, 0); + break; + case InterpOpcodes.DUP2_X1: + dupSlots(f, 2, 1); + break; + case InterpOpcodes.DUP2_X2: + dupSlots(f, 2, 2); + break; + case InterpOpcodes.SWAP: { + long p = f.stackPrim[f.sp - 1]; + Object r = f.stackRefs[f.sp - 1]; + f.stackPrim[f.sp - 1] = f.stackPrim[f.sp - 2]; + f.stackRefs[f.sp - 1] = f.stackRefs[f.sp - 2]; + f.stackPrim[f.sp - 2] = p; + f.stackRefs[f.sp - 2] = r; + break; + } + + case InterpOpcodes.IADD: f.pushInt(f.popInt() + f.popInt()); break; + case InterpOpcodes.LADD: f.pushLong(f.popLong() + f.popLong()); break; + case InterpOpcodes.FADD: f.pushFloat(f.popFloat() + f.popFloat()); break; + case InterpOpcodes.DADD: f.pushDouble(f.popDouble() + f.popDouble()); break; + case InterpOpcodes.ISUB: { + int b = f.popInt(); + f.pushInt(f.popInt() - b); + break; + } + case InterpOpcodes.LSUB: { + long b = f.popLong(); + f.pushLong(f.popLong() - b); + break; + } + case InterpOpcodes.FSUB: { + float b = f.popFloat(); + f.pushFloat(f.popFloat() - b); + break; + } + case InterpOpcodes.DSUB: { + double b = f.popDouble(); + f.pushDouble(f.popDouble() - b); + break; + } + case InterpOpcodes.IMUL: f.pushInt(f.popInt() * f.popInt()); break; + case InterpOpcodes.LMUL: f.pushLong(f.popLong() * f.popLong()); break; + case InterpOpcodes.FMUL: f.pushFloat(f.popFloat() * f.popFloat()); break; + case InterpOpcodes.DMUL: f.pushDouble(f.popDouble() * f.popDouble()); break; + case InterpOpcodes.IDIV: { + int b = f.popInt(); + if (b == 0) { + throw new InterpThrowable(new ArithmeticException("/ by zero"), snapshotStack()); + } + f.pushInt(f.popInt() / b); + break; + } + case InterpOpcodes.LDIV: { + long b = f.popLong(); + if (b == 0) { + throw new InterpThrowable(new ArithmeticException("/ by zero"), snapshotStack()); + } + f.pushLong(f.popLong() / b); + break; + } + case InterpOpcodes.FDIV: { + float b = f.popFloat(); + f.pushFloat(f.popFloat() / b); + break; + } + case InterpOpcodes.DDIV: { + double b = f.popDouble(); + f.pushDouble(f.popDouble() / b); + break; + } + case InterpOpcodes.IREM: { + int b = f.popInt(); + if (b == 0) { + throw new InterpThrowable(new ArithmeticException("/ by zero"), snapshotStack()); + } + f.pushInt(f.popInt() % b); + break; + } + case InterpOpcodes.LREM: { + long b = f.popLong(); + if (b == 0) { + throw new InterpThrowable(new ArithmeticException("/ by zero"), snapshotStack()); + } + f.pushLong(f.popLong() % b); + break; + } + case InterpOpcodes.FREM: { + float b = f.popFloat(); + f.pushFloat(f.popFloat() % b); + break; + } + case InterpOpcodes.DREM: { + double b = f.popDouble(); + f.pushDouble(f.popDouble() % b); + break; + } + case InterpOpcodes.INEG: f.pushInt(-f.popInt()); break; + case InterpOpcodes.LNEG: f.pushLong(-f.popLong()); break; + case InterpOpcodes.FNEG: f.pushFloat(-f.popFloat()); break; + case InterpOpcodes.DNEG: f.pushDouble(-f.popDouble()); break; + case InterpOpcodes.ISHL: { + int b = f.popInt(); + f.pushInt(f.popInt() << b); + break; + } + case InterpOpcodes.LSHL: { + int b = f.popInt(); + f.pushLong(f.popLong() << b); + break; + } + case InterpOpcodes.ISHR: { + int b = f.popInt(); + f.pushInt(f.popInt() >> b); + break; + } + case InterpOpcodes.LSHR: { + int b = f.popInt(); + f.pushLong(f.popLong() >> b); + break; + } + case InterpOpcodes.IUSHR: { + int b = f.popInt(); + f.pushInt(f.popInt() >>> b); + break; + } + case InterpOpcodes.LUSHR: { + int b = f.popInt(); + f.pushLong(f.popLong() >>> b); + break; + } + case InterpOpcodes.IAND: f.pushInt(f.popInt() & f.popInt()); break; + case InterpOpcodes.LAND: f.pushLong(f.popLong() & f.popLong()); break; + case InterpOpcodes.IOR: f.pushInt(f.popInt() | f.popInt()); break; + case InterpOpcodes.LOR: f.pushLong(f.popLong() | f.popLong()); break; + case InterpOpcodes.IXOR: f.pushInt(f.popInt() ^ f.popInt()); break; + case InterpOpcodes.LXOR: f.pushLong(f.popLong() ^ f.popLong()); break; + case InterpOpcodes.IINC: + f.prim[code[pc + 1]] = (int) f.prim[code[pc + 1]] + code[pc + 2]; + break; + + case InterpOpcodes.I2L: f.pushLong(f.popInt()); break; + case InterpOpcodes.I2F: f.pushFloat(f.popInt()); break; + case InterpOpcodes.I2D: f.pushDouble(f.popInt()); break; + case InterpOpcodes.L2I: f.pushInt((int) f.popLong()); break; + case InterpOpcodes.L2F: f.pushFloat(f.popLong()); break; + case InterpOpcodes.L2D: f.pushDouble(f.popLong()); break; + case InterpOpcodes.F2I: f.pushInt((int) f.popFloat()); break; + case InterpOpcodes.F2L: f.pushLong((long) f.popFloat()); break; + case InterpOpcodes.F2D: f.pushDouble(f.popFloat()); break; + case InterpOpcodes.D2I: f.pushInt((int) f.popDouble()); break; + case InterpOpcodes.D2L: f.pushLong((long) f.popDouble()); break; + case InterpOpcodes.D2F: f.pushFloat((float) f.popDouble()); break; + case InterpOpcodes.I2B: f.pushInt((byte) f.popInt()); break; + case InterpOpcodes.I2C: f.pushInt((char) f.popInt()); break; + case InterpOpcodes.I2S: f.pushInt((short) f.popInt()); break; + + case InterpOpcodes.LCMP: { + long b = f.popLong(); + long a = f.popLong(); + f.pushInt(a < b ? -1 : (a == b ? 0 : 1)); + break; + } + case InterpOpcodes.FCMPL: + case InterpOpcodes.FCMPG: { + float b = f.popFloat(); + float a = f.popFloat(); + // NaN makes both operands unordered; the L and G forms + // differ only in which way they resolve it. + if (Float.isNaN(a) || Float.isNaN(b)) { + f.pushInt(op == InterpOpcodes.FCMPG ? 1 : -1); + } else { + f.pushInt(a < b ? -1 : (a == b ? 0 : 1)); + } + break; + } + case InterpOpcodes.DCMPL: + case InterpOpcodes.DCMPG: { + double b = f.popDouble(); + double a = f.popDouble(); + if (Double.isNaN(a) || Double.isNaN(b)) { + f.pushInt(op == InterpOpcodes.DCMPG ? 1 : -1); + } else { + f.pushInt(a < b ? -1 : (a == b ? 0 : 1)); + } + break; + } + + case InterpOpcodes.IFEQ: + if (f.popInt() == 0) { + next = code[pc + 1]; + } + break; + case InterpOpcodes.IFNE: + if (f.popInt() != 0) { + next = code[pc + 1]; + } + break; + case InterpOpcodes.IFLT: + if (f.popInt() < 0) { + next = code[pc + 1]; + } + break; + case InterpOpcodes.IFGE: + if (f.popInt() >= 0) { + next = code[pc + 1]; + } + break; + case InterpOpcodes.IFGT: + if (f.popInt() > 0) { + next = code[pc + 1]; + } + break; + case InterpOpcodes.IFLE: + if (f.popInt() <= 0) { + next = code[pc + 1]; + } + break; + case InterpOpcodes.IF_ICMPEQ: { + int b = f.popInt(); + if (f.popInt() == b) { + next = code[pc + 1]; + } + break; + } + case InterpOpcodes.IF_ICMPNE: { + int b = f.popInt(); + if (f.popInt() != b) { + next = code[pc + 1]; + } + break; + } + case InterpOpcodes.IF_ICMPLT: { + int b = f.popInt(); + if (f.popInt() < b) { + next = code[pc + 1]; + } + break; + } + case InterpOpcodes.IF_ICMPGE: { + int b = f.popInt(); + if (f.popInt() >= b) { + next = code[pc + 1]; + } + break; + } + case InterpOpcodes.IF_ICMPGT: { + int b = f.popInt(); + if (f.popInt() > b) { + next = code[pc + 1]; + } + break; + } + case InterpOpcodes.IF_ICMPLE: { + int b = f.popInt(); + if (f.popInt() <= b) { + next = code[pc + 1]; + } + break; + } + case InterpOpcodes.IF_ACMPEQ: { + Object b = f.popRef(); + if (f.popRef() == b) { //NOPMD CompareObjectsWithEquals - IF_ACMP compares references, by definition + next = code[pc + 1]; + } + break; + } + case InterpOpcodes.IF_ACMPNE: { + Object b = f.popRef(); + if (f.popRef() != b) { //NOPMD CompareObjectsWithEquals - IF_ACMP compares references, by definition + next = code[pc + 1]; + } + break; + } + case InterpOpcodes.IFNULL: + if (f.popRef() == null) { + next = code[pc + 1]; + } + break; + case InterpOpcodes.IFNONNULL: + if (f.popRef() != null) { + next = code[pc + 1]; + } + break; + case InterpOpcodes.GOTO: next = code[pc + 1]; break; + + case InterpOpcodes.OP_TABLESWITCH: { + int min = code[pc + 2]; + int max = code[pc + 3]; + int dflt = code[pc + 4]; + int key = f.popInt(); + next = (key < min || key > max) ? dflt : code[pc + 5 + (key - min)]; + break; + } + case InterpOpcodes.OP_LOOKUPSWITCH: { + int dflt = code[pc + 2]; + int count = code[pc + 3]; + int key = f.popInt(); + next = dflt; + for (int i = 0; i < count; i++) { + if (code[pc + 4 + i * 2] == key) { + next = code[pc + 5 + i * 2]; + break; + } + } + break; + } + + case InterpOpcodes.IRETURN: + // Boxed by the declared return type, not as an int. + // `ireturn` is what boolean, byte, char, short and int + // all compile to, so the stack cannot say which it is; + // the caller unboxes by the descriptor, and an Integer + // where it expects a Boolean is a ClassCastException in + // the middle of an ordinary predicate. + return InterpValues.box(f.method.returnKind, f.popInt(), null); + case InterpOpcodes.LRETURN: return Long.valueOf(f.popLong()); + case InterpOpcodes.FRETURN: return Float.valueOf(f.popFloat()); + case InterpOpcodes.DRETURN: return Double.valueOf(f.popDouble()); + case InterpOpcodes.ARETURN: return f.popRef(); + case InterpOpcodes.RETURN: return null; + + case InterpOpcodes.GETSTATIC: getStatic(f, code[pc + 1]); break; + case InterpOpcodes.PUTSTATIC: putStatic(f, code[pc + 1]); break; + case InterpOpcodes.GETFIELD: getField(f, code[pc + 1]); break; + case InterpOpcodes.PUTFIELD: putField(f, code[pc + 1]); break; + + case InterpOpcodes.INVOKEVIRTUAL: + case InterpOpcodes.INVOKEINTERFACE: + case InterpOpcodes.INVOKESPECIAL: + case InterpOpcodes.INVOKESTATIC: + invokeSite(f, op, code[pc + 1]); + break; + + case InterpOpcodes.NEW: { + int ext = code[pc + 1]; + String name = externOwnerName(ext); + InterpClass ic = bundle.findClass(name); + if (ic != null) { + ensureInitialized(ic); + InterpObject created = new InterpObject(ic); + created.runtime = this; + f.pushRef(created); + } else { + // Uninitialised host object: the following + // invokespecial is what actually constructs + // it, so record the intent and let that site do it. + f.pushRef(new PendingHostNew(ext)); + } + break; + } + case InterpOpcodes.NEWARRAY: { + int count = f.popInt(); + checkNegativeSize(count); + f.pushRef(newPrimitiveArray(code[pc + 1], count)); + break; + } + case InterpOpcodes.ANEWARRAY: { + int count = f.popInt(); + checkNegativeSize(count); + String comp = externOwnerName(code[pc + 1]); + // An array of an interpreted type is an Object[]: the + // element type only exists in the interpreter. This has + // to look through the brackets -- `new Entry[1][]` names + // the component `[LEntry;`, and asking the host to load + // Entry is asking for a class only the bundle has. + // `new Class[n]` is Object[] for the same reason a + // pushed type is: a class literal for a pushed type is + // an InterpClass token, and storing that in a real + // host Class[] would raise ArrayStoreException. The + // arg-conversion path materialises a real Class[] + // when handing one to a host method that wants it. + f.pushRef(isInterpretedLeaf(comp) || isClassLeaf(comp) + ? new Object[count] + : linker.newArray(comp.startsWith("[") ? comp : "L" + comp + ";", count)); + break; + } + case InterpOpcodes.MULTIANEWARRAY: { + int dims = code[pc + 2]; + int[] sizes = new int[dims]; + for (int i = dims - 1; i >= 0; i--) { + sizes[i] = f.popInt(); + checkNegativeSize(sizes[i]); + } + String arrayType = externOwnerName(code[pc + 1]); + f.pushRef(isInterpretedLeaf(arrayType) || isClassLeaf(arrayType) + ? nestedObjectArray(sizes, 0) + : linker.newMultiArray(arrayType, sizes)); + break; + } + case InterpOpcodes.ARRAYLENGTH: { + Object a = f.popRef(); + if (a == null) { + throw new InterpThrowable(new NullPointerException("array is null"), + snapshotStack()); + } + f.pushInt(arrayLength(a)); + break; + } + + case InterpOpcodes.ATHROW: { + Object t = f.popRef(); + throw toThrowable(t); + } + case InterpOpcodes.CHECKCAST: { + Object v = f.stackRefs[f.sp - 1]; + if (v != null && !isInstanceOf(v, code[pc + 1])) { + throw new InterpThrowable(new ClassCastException( + "cannot cast to " + externOwnerName(code[pc + 1])), + snapshotStack()); + } + break; + } + case InterpOpcodes.INSTANCEOF: { + Object v = f.popRef(); + f.pushInt(v != null && isInstanceOf(v, code[pc + 1]) ? 1 : 0); + break; + } + case InterpOpcodes.MONITORENTER: { + // The real monitor of the real object. Interpreted + // frames run on real threads and everything they can + // lock is a real object, so `synchronized` means what + // it says -- including against host code locking the + // same object. + // + // Java has no explicit monitor-enter, only a block, so + // the guarded region is run nested inside one. The + // matching `monitorexit` returns MONITOR_RELEASED and + // execution carries on here, outside the block, which + // is what drops the lock. + Object lock = f.popRef(); + if (lock == null) { + throw new InterpThrowable( + new NullPointerException("monitorenter on null"), + snapshotStack()); + } + // The peer, when there is one: a synchronized method on + // an interpreted Form locks the peer (that is where the + // call runs), so a synchronized block locking the + // wrapper would be a second, unrelated monitor over + // state Java says the same one protects. + if (lock instanceof InterpObject + && ((InterpObject) lock).hostPeer != null) { + lock = ((InterpObject) lock).hostPeer; + } + Object nested; + synchronized (lock) { + nested = run(f, insn + 1, true); + } + if (nested != MONITOR_RELEASED) { + return nested; // the region returned from the method + } + insn = f.resumeInsn; + continue; + } + case InterpOpcodes.MONITOREXIT: { + f.popRef(); + if (insideMonitor) { + f.resumeInsn = insn + 1; + return MONITOR_RELEASED; + } + // Unbalanced. javac's synthetic handler releases the + // monitor again on the exception path, by which time + // the enclosing block already has, so the second one is + // a no-op rather than an error. + break; + } + + default: + throw new InterpThrowable(new UnsupportedOperationException( + "opcode " + op + " in " + m), snapshotStack()); + } + } catch (InterpThrowable it) { + // Cancellation (InterpCancelled) is now caught by any handler, + // including javac's compiler-generated cleanup for + // try-with-resources (which is a `catch (Throwable)` entry + // rather than a catch-all). The Stop button and EDT budget + // are still honoured: `cancelRequested` stays set, so the + // next checkpoint after the handler returns raises + // `InterpCancelled` again. A `catch (Throwable)` around a + // loop can therefore run its cleanup but cannot resume -- + // the back-edge checkpoint will re-fire cancellation on the + // next iteration. + Object thrown = it.getThrown(); + int handler = findHandler(m, insn, thrown, false); + if (handler < 0) { + throw it; + } + f.sp = 0; + f.pushRef(thrown); + insn = handler; + continue; + } catch (Throwable hostThrown) { + // Something the host raised while we were inside it. It is a + // real Java throwable, and interpreted `catch` clauses have to + // be able to see it. + int handler = findHandler(m, insn, hostThrown, false); + if (handler < 0) { + throw hostThrown; + } + f.sp = 0; + f.pushRef(hostThrown); + insn = handler; + continue; + } + + if (next <= insn) { + // Back edge: the only place a loop can spin, so the only place + // that needs a fuel check. The checkpoint is wrapped in its + // own try/catch that routes an InterpThrowable through + // `findHandler` -- otherwise a Stop or budget cancellation + // fired from a back edge would escape run() without ever + // reaching the source-level `finally`, skipping the very + // cleanup that had to run for the resource close. + ThreadState st = state(); + st.fuel--; + if (st.fuel <= 0) { + try { + checkpoint(st); + } catch (InterpThrowable it) { + Object thrown = it.getThrown(); + int handler = findHandler(m, insn, thrown, false); + if (handler < 0) { + throw it; + } + f.sp = 0; + f.pushRef(thrown); + insn = handler; + continue; + } + } + } + insn = next; + } + } + + // ------------------------------------------------------------ checkpoint + + private void checkpoint(ThreadState st) throws InterpThrowable { + st.fuel = fuelPerCheck; + if (cancelRequested) { + throw new InterpThrowable(new InterpCancelled("stopped by request"), + snapshotStack()); + } + // Only on the event thread. A worker thread computing for ten seconds + // blocks nothing and is a perfectly ordinary thing for a program to do; + // killing it would be the runtime inventing a rule Java does not have. + // Cancellation above applies to every thread, which is what the Stop + // button needs. + if (edtBudgetMs > 0 && st.hostCallDepth == 0 && st.runStartMs > 0 + && isEventThread()) { + long elapsed = System.currentTimeMillis() - st.runStartMs; + if (elapsed > edtBudgetMs) { + throw new InterpThrowable(new InterpCancelled( + "pushed program ran for " + elapsed + "ms without yielding"), + snapshotStack()); + } + } + } + + /// Whether a thrown value is an Error, interpreted or not. + /// + /// A pushed `class MyError extends Error` is an InterpObject, so a host + /// `instanceof Error` says no and the initializer's own error would be + /// replaced by ExceptionInInitializerError -- which JLS 12.4.2 says happens + /// only for a non-Error, and which would make `catch (MyError)` miss. + private boolean isError(Object thrown) { + if (thrown instanceof Error) { + return true; + } + if (thrown instanceof InterpObject) { + InterpObject io = (InterpObject) thrown; + if (io.hostPeer instanceof Error) { + return true; + } + try { + return isInstanceOf(io, "java/lang/Error"); + } catch (Throwable cannotTell) { + // The hierarchy could not be walked; treat it as not an Error, + // which wraps rather than loses it. + return false; + } + } + return false; + } + + /// What an interpreted failure actually carries: the thrown object when it + /// arrived in the interpreter's carrier, the throwable itself otherwise. + private static Object unwrapInterpreted(Throwable failure) { + if (failure instanceof InterpThrowable) { + return ((InterpThrowable) failure).getThrown(); + } + return failure; + } + + /// Whether the wall-clock budget applies on this thread. + /// + /// It is an *event thread* budget: a worker thread computing for ten + /// seconds blocks nothing, and killing it would be the runtime inventing a + /// rule Java does not have. Cancellation is separate and applies + /// everywhere, which is what the Stop button needs. + /// + /// With no Display -- the conformance harness, and anything embedding the + /// interpreter headless -- there is no event thread to protect and no way + /// to identify one, so the budget applies to whatever thread is running. + private boolean isEventThread() { + return !com.codename1.ui.Display.isInitialized() + || com.codename1.ui.Display.getInstance().isEdt(); + } + + // ------------------------------------------------------------- constants + + private void ldc(InterpFrame f, int tag, int operand) throws Throwable { + switch (tag) { + case InterpOpcodes.LDC_INT: + f.pushInt(operand); + break; + case InterpOpcodes.LDC_LONG: + f.pushLong(Long.parseLong(bundle.string(operand))); + break; + case InterpOpcodes.LDC_FLOAT: + f.pushFloat(Float.intBitsToFloat(operand)); + break; + case InterpOpcodes.LDC_DOUBLE: + // Raw long bits, matching the writer's format. Reading via + // `Double.parseDouble` collapsed every noncanonical NaN back + // to the canonical `0x7ff8000000000000L` -- because "NaN" is + // the only spelling `Double.toString` produces for any NaN -- + // and a program that read the LDC constant back with + // `doubleToRawLongBits` would see the wrong bits. + f.pushDouble(Double.longBitsToDouble(Long.parseLong(bundle.string(operand)))); + break; + case InterpOpcodes.LDC_STRING: + f.pushRef(bundle.string(operand)); + break; + case InterpOpcodes.LDC_CLASS: { + // `Color.class` where Color is in this bundle names something + // the host has never heard of, so the class object is the + // InterpClass itself. Every consumer that can receive one -- + // Enum.valueOf is the one javac generates -- checks for it. + String literal = externOwnerName(operand); + InterpClass local = bundle.findClass(literal); + if (local == null && isInterpretedLeaf(literal)) { + // `Entry[].class` for a bundle-only Entry. There is no host + // class for it and asking the linker to load `[LEntry;` + // fails before the program can so much as call getName(), + // so the interpreter makes a token of its own -- one per + // rank, so `Entry[].class == Entry[].class` holds and + // `Entry[].class != Entry.class` does too. + local = arrayTokenFor(literal); + } + f.pushRef(local != null + ? (Object) local + : linker.classObject(resolveExternClass(operand))); + break; + } + default: + throw new IllegalStateException("bad ldc tag " + tag); + } + } + + // ----------------------------------------------------------------- state + + /// Runs a class's initializer once, following JLS 12.4.2. + /// + /// The subtlety is that "has it been initialized" is four states, not two. + /// Marking the class done before running `` is what stops a cycle + /// -- a static initializer that reaches back into its own class -- from + /// recursing forever, but the same mark tells *another* thread that the + /// static fields are ready when they are not. The JVM separates the two + /// with a per-class lock and an owning thread: the initializing thread + /// passes straight through, everyone else waits. + /// + /// A failed initializer is the other half. Once `` throws, the + /// class is erroneous forever; leaving the "done" mark set would hand every + /// later reader a class whose statics were half-assigned, and the failure + /// would surface as a wrong value rather than as an error. + private void ensureInitialized(InterpClass c) throws Throwable { + synchronized (c) { + if (c.initState == InterpClass.INIT_DONE) { + return; + } + if (c.initState == InterpClass.INIT_FAILED) { + throw new NoClassDefFoundError("could not initialize " + + c.getName().replace('/', '.')); + } + if (c.initState == InterpClass.INIT_RUNNING) { + if (c.initThread == Thread.currentThread()) { //NOPMD CompareObjectsWithEquals - the initializing thread, not an equal one + // Recursive entry from the initializer itself: legal, and + // the one case where a partly-built class must be visible. + return; + } + // Uninterruptibly, as JVMS 5.5 requires: waiting for another + // thread's is not something the instruction that + // triggered it can report. An interrupt is remembered and + // reasserted, so the program still sees it at its next + // interruptible point rather than as a failure from a getstatic. + boolean interrupted = false; + while (c.initState == InterpClass.INIT_RUNNING) { + try { + c.wait(); + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + // Re-read the state the other thread left behind. + if (c.initState == InterpClass.INIT_FAILED) { + throw new NoClassDefFoundError("could not initialize " + + c.getName().replace('/', '.')); + } + return; + } + c.initState = InterpClass.INIT_RUNNING; + c.initThread = Thread.currentThread(); + } + boolean ok = false; + try { + if (c.superInterp != null) { + ensureInitialized(c.superInterp); + } else if (c.superExtern >= 0) { + // A host superclass has to be initialized first too. Resolution + // deliberately does not initialize, so without this the host + // parent's runs whenever its first peer is constructed + // -- after the interpreted subclass's, which reverses the order + // Java guarantees and with it any registration the parent does. + linker.initializeClass(externOwnerName(c.superExtern)); + } + // JLS 12.4.1: initializing a *class* initializes the + // superinterfaces that declare a default method, and only those. + // Reaching one through an interface that declares none does not + // initialize the intermediate interface -- so this walks the + // hierarchy but initializes only the interfaces that themselves + // declare a default method. Initializing an interface, on the other + // hand, initializes none of its superinterfaces at all, which is why + // this is skipped for one. + if (!c.isInterface()) { + initializeDefaultBearingInterfaces(c); + } + InterpMethod clinit = c.declaredMethod("", "()V"); + if (clinit != null) { + try { + invokeInterpreted(clinit, null, null); + } catch (Throwable failure) { + // JLS 12.4.2: a non-Error failure is wrapped, so + // `catch (ExceptionInInitializerError)` -- which is how Java + // code catches this -- actually catches it. An Error passes + // through unwrapped, as the spec says, and so does + // cancellation, which is not the program's failure at all. + Object thrown = unwrapInterpreted(failure); //NOPMD AvoidInstanceofChecksInCatchClause - the carrier has to be looked through + if (isError(thrown)) { + throw failure; + } + // The peer when the failure is a pushed exception class: + // an ExceptionInInitializerError whose getCause() is null + // says nothing about what actually went wrong, and the peer + // is the throwable host code was given. + ExceptionInInitializerError wrapped = new ExceptionInInitializerError( + InterpThrowable.hostThrowableOf(thrown)); + throw new InterpThrowable(wrapped, interpretedStackFor(failure)); + } + } + ok = true; + } finally { + synchronized (c) { + c.initState = ok ? InterpClass.INIT_DONE : InterpClass.INIT_FAILED; + c.initThread = null; + c.notifyAll(); + } + } + } + + /// Whether a type descriptor bottoms out in a class this bundle carries. + /// + /// The brackets have to be looked through, and so does the `L...;` wrapper, + /// because the same leaf reaches here spelled three ways: `Entry` from a + /// one-dimensional `anewarray`, `[LEntry;` from `new Entry[1][]`, and + /// `[[LEntry;` from a `multianewarray`. Only the leaf says whether the host + /// has ever heard of the type. + private boolean isInterpretedLeaf(String descriptor) { + return bundle.findClass(leafOf(descriptor)) != null; + } + + /// Whether the leaf of a component or array-type descriptor is + /// java.lang.Class. Accepts the bare name `java/lang/Class`, the + /// L-wrapped form `Ljava/lang/Class;`, and any level of bracketing: + /// ANEWARRAY names an outer `Class[][]` as `[Ljava/lang/Class;`, and the + /// inner `Class[]` allocation must round-trip to the same Object[] + /// representation or storing it into the outer array throws + /// ArrayStoreException. + private static boolean isClassLeaf(String descriptor) { + return "java/lang/Class".equals(leafOf(descriptor)); + } + + /// The token for an interpreted array type named by a descriptor. + private InterpClass arrayTokenFor(String descriptor) { + InterpClass t = bundle.findClass(leafOf(descriptor)); + if (t == null) { + return null; + } + for (int i = 0; i < descriptor.length() && descriptor.charAt(i) == '['; i++) { + t = t.arrayType(); + } + return t; + } + + /// The class name at the bottom of a descriptor: `[[LEntry;` is `Entry`. + private static String leafOf(String descriptor) { + String at = descriptor; + while (at.length() > 0 && at.charAt(0) == '[') { + at = at.substring(1); + } + if (at.length() > 2 && at.charAt(0) == 'L' && at.endsWith(";")) { + at = at.substring(1, at.length() - 1); + } + return at; + } + + /// The interpreter's representation of a multi-dimensional array of an + /// interpreted type: nested `Object[]`, allocated for every dimension the + /// bytecode gave a size for. + private static Object[] nestedObjectArray(int[] sizes, int depth) { + Object[] out = new Object[sizes[depth]]; + if (depth + 1 < sizes.length) { + for (int i = 0; i < out.length; i++) { + out[i] = nestedObjectArray(sizes, depth + 1); + } + } + return out; + } + + /// Whether an interpreted interface declares a default method, directly or + /// through a superinterface -- the condition JLS 12.4.1 attaches to + /// initializing an interface on behalf of an implementor. + private boolean declaresDefaultMethod(InterpClass iface) { + for (InterpMethod m : iface.methods) { + // Private too: an interface may declare a private helper with a + // body (JDK 9 onwards) and that is not a default method, so it must + // not pull the interface's initializer forward. + if (!m.isStatic() && !m.isAbstract() && !m.isPrivate() + && !"".equals(m.name)) { + return true; + } + } + return false; + } + + /// Initializes every superinterface that declares a default method itself. + /// + /// Walks through the ones that do not, rather than stopping at them: an + /// interface with no default method is not initialized on an implementor's + /// behalf, but an interface *above* it that has one still is. + private void initializeDefaultBearingInterfaces(InterpClass c) throws Throwable { + for (InterpClass iface : c.interpInterfaces) { + if (iface == null) { + continue; + } + // Above it first, always. `ensureInitialized` on an interface + // deliberately initializes none of its superinterfaces -- that is + // the rule for initializing an interface -- so the walk on the + // class's behalf has to reach the ancestors itself, in the order + // the JVM would. + initializeDefaultBearingInterfaces(iface); + if (declaresDefaultMethod(iface)) { + ensureInitialized(iface); + } + } + // Host interfaces too, and under the same rule -- but the whole walk + // belongs to the platform. The bundle records only the interfaces a + // class declares directly, so an interpreted class implementing a host + // Child that extends a default-bearing host Parent is a hierarchy only + // the app can see. + for (int i = 0; i < c.hostInterfaces.length; i++) { + linker.initializeDefaultBearingInterfaces(externOwnerName(c.hostInterfaces[i])); + } + } + + // ---------------------------------------------------------------- fields + + private void getStatic(InterpFrame f, int ext) throws Throwable { + String owner = externOwnerName(ext); + String name = bundle.string(bundle.externName[ext]); + String desc = bundle.string(bundle.externDesc[ext]); + InterpClass ic = bundle.findClass(owner); + if (ic != null) { + if (declaredByInterpreted(ic, name)) { + // The class that declares it, not the one the call site named. + // `Child.x` where Parent declares x initializes Parent and + // leaves Child alone. + InterpClass holder = findStaticHolder(ic, name); + ensureInitialized(holder); + pushBoxed(f, InterpValues.kindOf(desc), holder.staticValue(name)); + return; + } + // Inherited from a host supertype -- a superclass, or an + // interface whose constant is read through the implementing class. + // Reading it from the interpreted class would answer null for a + // field that class never declared. + String[] hostOwners = hostStaticOwners(ic); + for (int i = 0; i < hostOwners.length; i++) { + try { + pushBoxed(f, InterpValues.kindOf(desc), + linker.getStatic(hostOwners[i], name, desc)); + return; + } catch (Throwable notThere) { + // Only "this candidate does not have it" moves on. A field + // that exists and whose class initializer threw must be + // reported as itself, not masked by the next candidate's + // NoSuchFieldError. + if (!isAbsent(notThere) || i == hostOwners.length - 1) { + throw notThere; + } + } + } + pushBoxed(f, InterpValues.kindOf(desc), findStaticHolder(ic, name).staticValue(name)); + return; + } + pushBoxed(f, InterpValues.kindOf(desc), linker.getStatic(owner, name, desc)); + } + + private void putStatic(InterpFrame f, int ext) throws Throwable { + String owner = externOwnerName(ext); + String name = bundle.string(bundle.externName[ext]); + String desc = bundle.string(bundle.externDesc[ext]); + int kind = InterpValues.kindOf(desc); + Object value = popBoxed(f, kind); + InterpClass ic = bundle.findClass(owner); + if (ic != null) { + if (declaredByInterpreted(ic, name)) { + InterpClass holder = findStaticHolder(ic, name); + ensureInitialized(holder); + holder.setStaticValue(name, value); + return; + } + // As above: writing it here would create a private copy the host + // never sees, and leave the real field unchanged. An interface's + // fields are final, so only the superclass chain can be written -- + // but the same lookup is used so a wrong owner fails loudly rather + // than silently writing somewhere else. + String[] hostOwners = hostStaticOwners(ic); + for (int i = 0; i < hostOwners.length; i++) { + try { + linker.setStatic(hostOwners[i], name, desc, value); + return; + } catch (Throwable notThere) { + if (!isAbsent(notThere) || i == hostOwners.length - 1) { + throw notThere; + } + } + } + findStaticHolder(ic, name).setStaticValue(name, value); + return; + } + linker.setStatic(owner, name, desc, value); + } + + /// The slot of a field, resolved from the owner named in the field + /// reference rather than from the object's runtime type. + /// + /// Field access is not virtual. When `Base` declares `v` and `Mid extends + /// Base` shadows it with its own `v`, code compiled inside `Base` reads + /// `Base.v` even for a `Mid` instance -- javac records which one it meant. + /// Resolving from the runtime type instead would silently read the + /// subclass's field, which is the sort of difference that produces a wrong + /// number rather than an error. + /// Whether the named Object method is one that reports naming or identity + /// (`toString`, `hashCode`, `equals`) rather than one tied to the object's + /// monitor (`wait`, `notify`, `notifyAll`). The interface-only-peer route + /// above uses this to hand naming/identity to the interpreter -- so the + /// pushed class's own name shows through -- while leaving monitor + /// operations on the peer, which is what MONITORENTER already locked. + private static boolean isObjectNamingOrIdentity(String methodName) { + return "toString".equals(methodName) + || "hashCode".equals(methodName) + || "equals".equals(methodName); + } + + private int fieldIndex(InterpObject io, String owner, String name) { + InterpClass declaring = bundle.findClass(owner); + if (declaring == null) { + return -1; + } + return io.indexOf(declaring, name); + } + + /// Whether the named instance field is declared `volatile`. Walks up the + /// declared owner's chain to reach the class that actually declares the + /// field, matching `indexOf`. + private boolean isInstanceFieldVolatile(String owner, String name) { + InterpClass declaring = bundle.findClass(owner); + while (declaring != null) { + for (int i = 0; i < declaring.fieldNames.length; i++) { + if (declaring.fieldNames[i].equals(name)) { + return declaring.isInstanceFieldVolatile(i); + } + } + declaring = declaring.superInterp; + } + return false; + } + + // Static-field volatile access needs no explicit synchronisation from + // the interpreter: static storage goes through `Hashtable.get`/`put`, + // whose synchronised bodies establish happens-before between the writer + // and any reader that also enters the map. That gives static volatile + // the same memory-visibility guarantee the JVM does, without an extra + // wrapping monitor. + + /// The nearest host ancestor's internal name, or null when there is none. + /// + /// A static that no interpreted class in the chain declares belongs to the + /// host superclass: `MyForm.SOME_CONSTANT` compiles to a field reference + /// owned by MyForm, which the installed app has never heard of, so the + /// access has to be re-addressed to the class that does declare it. + private String hostOwnerOf(InterpClass c) { + InterpClass k = c; + while (k != null) { + if (k.superExtern >= 0) { + return externOwnerName(k.superExtern); + } + k = k.superInterp; + } + return null; + } + + /// Whether a failure means "this class does not have that member" rather + /// than "reading it went wrong". + /// + /// The difference decides whether another candidate owner may be tried. A + /// class initializer that threw is a real failure and belongs to the + /// caller; only absence is a reason to keep looking. + private static boolean isAbsent(Throwable t) { + // NoClassDefFoundError is deliberately not here. It is what a class + // whose initializer already failed throws on every later touch, and + // treating that as "this candidate does not have the field" would move + // on and report the next one's NoSuchFieldError -- hiding the failure + // this whole distinction exists to preserve. A class the app genuinely + // lacks arrives as ClassNotFoundException from the reflective linker + // and as NoSuchFieldError from the symbol-table one. + if (t instanceof NoSuchFieldError || t instanceof ClassNotFoundException) { + return true; + } + // NoSuchFieldException is a reflection type, and the device's java.lang + // does not have one -- naming it here would not compile for the device + // at all. The reflection-backed linker still throws it, so it is + // recognised by name. + String thrown = t.getClass().getName(); + return "java.lang.NoSuchFieldException".equals(thrown) + || "java.lang.NoSuchMethodException".equals(thrown); + } + + /// Where a static the interpreted hierarchy does not declare might live. + /// + /// The host superclass chain first, then the host interfaces -- a constant + /// on an implemented interface is read as `PushedClass.FIELD`, and the + /// superclass walk answers `java/lang/Object`, which does not have it. + /// Every candidate is tried in turn because only the linker can say which + /// one actually declares it. + private String[] hostStaticOwners(InterpClass c) { + Vector out = new Vector(); + String superOwner = hostOwnerOf(c); + if (superOwner != null) { + out.addElement(superOwner); + } + collectHostInterfaceOwners(c, out, new Vector()); + String[] owners = new String[out.size()]; + out.copyInto(owners); + return owners; + } + + /// Every host interface reachable from this class, however it is reached. + /// + /// Through the interpreted superclass chain and through interpreted + /// interfaces alike: `class C implements I` where `interface I extends + /// HostIface` reads `HostIface.VALUE` as `C.VALUE`, and a walk that only + /// followed superclasses never saw HostIface -- so a constant that plainly + /// exists was reported as NoSuchFieldError. The visited set is what makes + /// the diamond an interface hierarchy is allowed to be terminate; a depth + /// cap would answer wrongly on a legal hierarchy instead. + private void collectHostInterfaceOwners(InterpClass c, Vector out, Vector visited) { + InterpClass k = c; + while (k != null) { + if (visited.contains(k)) { + return; + } + visited.addElement(k); + for (int i = 0; i < k.hostInterfaces.length; i++) { + String iface = externOwnerName(k.hostInterfaces[i]); + if (!out.contains(iface)) { + out.addElement(iface); + } + } + for (int i = 0; i < k.interpInterfaces.length; i++) { + collectHostInterfaceOwners(k.interpInterfaces[i], out, visited); + } + k = k.superInterp; + } + } + + /// Whether any interpreted class in the chain declares this static. + private boolean declaredByInterpreted(InterpClass c, String name) throws Throwable { + InterpClass k = c; + while (k != null) { + if (k.declaresStatic(name) || findStaticInInterfaces(k, name) != null) { + return true; + } + k = k.superInterp; + } + return false; + } + + /// The class a host instance field really belongs to. + /// + /// The call site names the interpreted class -- javac records the type it + /// saw -- and the installed app has never heard of it, so the nearest host + /// ancestor is the name the linker can resolve. + private String hostFieldOwner(InterpObject io, String owner) { + if (bundle.findClass(owner) == null) { + return owner; + } + String hostOwner = hostOwnerOf(io.type); + return hostOwner == null ? owner : hostOwner; + } + + private InterpClass findStaticHolder(InterpClass c, String name) throws Throwable { + InterpClass k = c; + while (k != null) { + if (k.declaresStatic(name)) { + return k; + } + // Interfaces too, and before moving up: `B.Z` where B implements an + // interface declaring Z compiles to a field reference owned by B, + // and searching only the superclass chain answers with B, which + // declares no such field. That reads as the field's default value + // rather than as an error, which is the worst way to be wrong. + InterpClass fromInterface = findStaticInInterfaces(k, name); + if (fromInterface != null) { + return fromInterface; + } + k = k.superInterp; + } + return c; + } + + /// The interface that declares a static field, searching an interpreted + /// class's interfaces depth first. Initializes it on the way, since reading + /// an interface's field is exactly what initializes that interface. + private InterpClass findStaticInInterfaces(InterpClass c, String name) throws Throwable { + for (InterpClass iface : c.interpInterfaces) { + if (iface == null) { + continue; + } + if (iface.declaresStatic(name)) { + ensureInitialized(iface); + return iface; + } + InterpClass deeper = findStaticInInterfaces(iface, name); + if (deeper != null) { + return deeper; + } + } + return null; + } + + /// A static method declared by this class or inherited from an interpreted + /// superclass. + /// + /// The vtable cannot answer: it holds instance methods only, by design, and + /// javac records the *call site's* owner for a static call, so `B.m()` where + /// B inherits m from A arrives naming B. Falling through to the host linker + /// from there asks it for a class only the bundle has. + private static InterpMethod resolveStatic(InterpClass c, String name, String desc) { + InterpClass k = c; + while (k != null) { + InterpMethod m = k.declaredMethod(name, desc); + if (m != null && m.isStatic()) { + return m; + } + k = k.superInterp; + } + return null; + } + + private void getField(InterpFrame f, int ext) throws Throwable { + String owner = externOwnerName(ext); + String name = bundle.string(bundle.externName[ext]); + String desc = bundle.string(bundle.externDesc[ext]); + Object target = f.popRef(); + if (target == null) { + throw new InterpThrowable(new NullPointerException(owner + "." + name), snapshotStack()); + } + if (target instanceof InterpObject) { + InterpObject io = (InterpObject) target; + int idx = fieldIndex(io, owner, name); + if (idx >= 0) { + Object v; + if (isInstanceFieldVolatile(owner, name)) { + // `volatile` needs the read to happen-after every write + // this field's writer performed before publishing. A + // plain `io.fields[idx]` is a lock-free access with no + // barrier, so a `volatile boolean ready` coordinating a + // worker-thread handoff would let the reader observe + // `ready` while the writes it announced remained stale. + // Synchronising on the object gives the pair the same + // happens-before ordering the JVM does. + synchronized (io) { + v = io.fields[idx]; + } + } else { + v = io.fields[idx]; + } + pushBoxed(f, InterpValues.kindOf(desc), v); + return; + } + // Declared by a host superclass, so it lives on the peer -- and + // under that class's name, not the interpreted subclass javac + // recorded. A pushed Form subclass reading `focusScrolling` names + // itself as the owner, and no linker has ever heard of it. + pushBoxed(f, InterpValues.kindOf(desc), + linker.getField(io.hostPeer, hostFieldOwner(io, owner), name, desc)); + return; + } + pushBoxed(f, InterpValues.kindOf(desc), linker.getField(target, owner, name, desc)); + } + + private void putField(InterpFrame f, int ext) throws Throwable { + String owner = externOwnerName(ext); + String name = bundle.string(bundle.externName[ext]); + String desc = bundle.string(bundle.externDesc[ext]); + int kind = InterpValues.kindOf(desc); + Object value = popBoxed(f, kind); + Object target = f.popRef(); + if (target == null) { + throw new InterpThrowable(new NullPointerException(owner + "." + name), snapshotStack()); + } + if (target instanceof InterpObject) { + InterpObject io = (InterpObject) target; + int idx = fieldIndex(io, owner, name); + if (idx >= 0) { + if (isInstanceFieldVolatile(owner, name)) { + // Same rationale as getField above: the reader waits on + // the same monitor, and both entries share happens-before. + synchronized (io) { + io.fields[idx] = value; + } + } else { + io.fields[idx] = value; + } + return; + } + linker.setField(io.hostPeer, hostFieldOwner(io, owner), name, desc, value); + return; + } + linker.setField(target, owner, name, desc, value); + } + + // ----------------------------------------------------------------- calls + + private void invokeSite(InterpFrame f, int op, int ext) throws Throwable { + String owner = externOwnerName(ext); + String name = bundle.string(bundle.externName[ext]); + String desc = bundle.string(bundle.externDesc[ext]); + + int[] argKinds = InterpValues.argumentKinds(desc); + Object[] args = new Object[argKinds.length]; + for (int i = argKinds.length - 1; i >= 0; i--) { + args[i] = popBoxed(f, argKinds[i]); + } + + int returnKind = InterpValues.returnKind(desc); + + if (op == InterpOpcodes.INVOKESTATIC) { + InterpClass ic = bundle.findClass(owner); + if (ic != null) { + InterpMethod m = ic.declaredMethod(name, desc); + if (m == null) { + m = resolveStatic(ic, name, desc); + } + if (m == null) { + m = ic.resolve(name, desc); + } + // The declaring class, not the one the call site named. `B.m()` + // where only A declares m initializes A and leaves B alone, and + // an initializer with an observable effect makes the difference + // visible. + ensureInitialized(m != null && m.owner != null ? m.owner : ic); + if (m != null) { + pushBoxed(f, returnKind, invokeInterpreted(m, null, args)); + return; + } + // Nothing interpreted declares it, so it is inherited from the + // host superclass -- and the call site names the interpreted + // subclass, which the installed app has never heard of. + // Re-address it to the class that does declare it. + String hostOwner = hostOwnerOf(ic); + if (hostOwner != null) { + pushBoxed(f, returnKind, + hostCall(hostOwner, name, desc, null, args, false)); + return; + } + } + // javac compiles an enum's own valueOf(String) into a call to + // Enum.valueOf(Class,String), which reflects over the class's + // constants. There is no reflection here and the class is + // interpreted, so the lookup runs against the bundle instead. + if ("java/lang/Enum".equals(owner) && "valueOf".equals(name) + && args.length == 2 && args[0] instanceof InterpClass) { + pushBoxed(f, returnKind, enumValueOf((InterpClass) args[0], (String) args[1])); + return; + } + // A native interface declared by the pushed code itself. Its native + // half was never compiled into this app and never could be -- that + // is the one thing a device runtime cannot accept from the wire -- + // so it gets the answer the API is designed around: a stub that + // reports isSupported() false. The library's Java half runs + // normally, which is what makes an app that uses a cn1lib still + // compile and still run here. + if ("com/codename1/system/NativeLookup".equals(owner) && "create".equals(name) + && args.length == 1 && args[0] instanceof InterpClass) { + pushBoxed(f, returnKind, new NativeStub((InterpClass) args[0])); + return; + } + // `System.identityHashCode(value)` must return the same hash as + // `value.hashCode()` would when hashCode is not overridden. + // Which object that hashes depends on whether the pushed class + // has a host peer to inherit `Object.hashCode` from: + // + // * class-backed peer (`class Sub extends Form`): hashCode + // reaches the peer's inherited Object.hashCode and returns + // the peer's identity, so identityHashCode has to match it. + // * interface-only peer or peerless: hashCode is answered by + // `objectCall(io)` which hashes the InterpObject wrapper, + // so identityHashCode has to hash the wrapper too. + // + // `popBoxed` handed us the peer for a peered InterpObject, so + // `fromHost` picks the wrapper back up; the choice below then + // selects the identity that matches the hashCode path. + if ("java/lang/System".equals(owner) && "identityHashCode".equals(name) + && "(Ljava/lang/Object;)I".equals(desc) && args.length == 1) { + Object v = fromHost(args[0]); + if (v instanceof InterpObject) { + InterpObject io = (InterpObject) v; + Object target = io.hostPeer != null && !io.hostPeerFromInterfacesOnly + ? io.hostPeer : io; + pushBoxed(f, returnKind, Integer.valueOf(System.identityHashCode(target))); + return; + } + } + pushBoxed(f, returnKind, hostCall(owner, name, desc, null, args, false)); + return; + } + + Object target = f.popRef(); + if (target == null) { + throw new InterpThrowable(new NullPointerException(owner + "." + name), snapshotStack()); + } + + // `new X(...)` on a host class: NEW pushed a placeholder and this is + // the invokespecial that turns it into a real object. The placeholder + // may have been duplicated by DUP, so every copy has to be replaced. + if (target instanceof PendingHostNew) { + PendingHostNew pending = (PendingHostNew) target; + Object created = linker.construct(resolveExternClass(pending.externIndex), desc, args); + replaceOnStack(f, pending, created); + return; + } + + // `$VALUES.clone()`, which is how javac writes an enum's values(). An + // array's clone is not a method any linker can look up, so it is done + // here -- for arrays of every kind, not only for enums. + if ("clone".equals(name) && args.length == 0 && isArray(target)) { + pushBoxed(f, returnKind, copyArray(target)); + return; + } + + // The unimplemented half of a cn1lib. isSupported() is the question the + // API tells callers to ask, and it answers false; everything else + // answers the way an uninitialised field would, so a caller that + // ignores isSupported() gets zero or null rather than a crash. + if (target instanceof NativeStub) { + if ("isSupported".equals(name)) { + pushBoxed(f, returnKind, Boolean.FALSE); + } else { + pushBoxed(f, returnKind, InterpValues.defaultForKind(returnKind)); + } + return; + } + + // A class literal or getClass() for a type only the bundle has: the + // token on the stack is the InterpClass itself, because there is no host + // class object to hand back. The bytecode still calls java.lang.Class + // methods on it, and the linkers cannot -- a reflective one rejects the + // receiver and a native one has no clazz pointer for it -- so the small + // part of Class that means anything here is answered here. + if (target instanceof InterpClass) { + Object r = classCall((InterpClass) target, name, args); + if (r != NOT_CLASS_METHOD) { + pushBoxed(f, returnKind, r); + return; + } + throw new InterpThrowable(new UnsupportedOperationException( + "Class." + name + desc + " is not available for " + + ((InterpClass) target).getName().replace('/', '.') + + ", which exists only in this bundle"), snapshotStack()); + } + + if (target instanceof InterpObject) { + InterpObject io = (InterpObject) target; + + // `super(...)` reaching a host class is the moment the peer can be + // built: it is the first point at which the superclass constructor + // arguments are known, and it happens before any host code can + // observe the object. + if (op == InterpOpcodes.INVOKESPECIAL && "".equals(name) + && bundle.findClass(owner) == null) { + createPeer(io, owner, desc, args); + return; + } + + InterpMethod m; + if (op == InterpOpcodes.INVOKESPECIAL) { + // A super call, a private method or a constructor: resolve + // against the named owner, not the receiver's class, or an + // override would make `super.foo()` recurse forever. + InterpClass declaring = bundle.findClass(owner); + m = declaring == null ? null : declaring.declaredMethod(name, desc); + if (m == null && declaring != null) { + m = declaring.resolve(name, desc); + } + } else { + // A private method is not virtual, and the opcode no longer + // says so: from JDK 11 javac emits invokevirtual for one, + // nestmates having replaced the synthetic bridges. Resolving + // from the receiver would make `Base.value()` calling its own + // private `label()` land on a `label()` that Child happens to + // declare -- a different answer, silently. + m = resolveVirtual(io.type, owner, name, desc); + } + if (m != null && !m.isAbstract()) { + pushBoxed(f, returnKind, invokeInterpreted(m, io, args)); + return; + } + // Nothing interpreted implements it: it must be inherited from the + // host superclass, and the peer is what can run it. + // + // Resolution has to start at the peer's own class, not at `owner`. + // The call site names the interpreted class -- `new MyForm().show()` + // records MyForm -- and the host has never heard of that name. The + // peer is a generated subclass of the real framework class, so + // walking up from it finds the method exactly where it lives. + // getClass() first. The peer is a generated shim -- Interp_ui_Form + // -- and answering with its class would hand the program a name it + // has never heard of, break class identity, and pass that on to + // every API taking a Class. + if ("getClass".equals(name) && args.length == 0) { + pushBoxed(f, returnKind, io.type); + return; + } + // Enum's own methods first when the receiver is a constant: an + // interpreted enum that implements a host interface gets an + // Object-based peer, and that peer does not inherit java.lang.Enum, + // so name() and ordinal() would be looked up on something that has + // never heard of them. + if (io.enumOrdinal >= 0) { + Object early = enumCall(io, name, args); + if (early != NOT_ENUM_METHOD) { + pushBoxed(f, returnKind, early); + return; + } + } + // Object's naming/identity defaults on an interface-only peer: + // the shim's Object.toString/hashCode/equals prints the shim + // class's own name (`Interp_Runnable@...`), which is neither the + // pushed class's name nor consistent with `getClass()` above. + // Answered through `objectCall` here so the class's own name + // shows through and an interpreted override still wins via the + // `resolveVirtual` result higher up. + // + // Only those three -- `wait`/`notify`/`notifyAll` stay on the + // peer, because `MONITORENTER` already locked the peer (see + // `synchronized`) and running them on `io` instead acquires a + // different monitor and raises IllegalMonitorStateException. Any + // other Object method that reaches here is also left to the peer. + if (io.hostPeer != null && io.hostPeerFromInterfacesOnly + && "java/lang/Object".equals(owner) + && isObjectNamingOrIdentity(name)) { + Object early = objectCall(io, name, args, op == InterpOpcodes.INVOKESPECIAL); + if (early != NOT_OBJECT_METHOD) { + pushBoxed(f, returnKind, early); + return; + } + } + if (io.hostPeer != null) { + // The peer's own class name, recorded when the factory built it + // rather than read back from getClass(). ParparVM derives + // Class.getName() from the mangled C symbol, so a class whose + // simple name contains an underscore -- which every generated + // shim's does, Interp_Form -- comes back as "Interp/Form" and + // resolves against nothing. + String peerOwner = io.hostPeerOwner; + if (op == InterpOpcodes.INVOKESPECIAL) { + // `super.paint(g)` in interpreted code. Calling `paint` on + // the peer would land on the shim's override, which asks the + // interpreter for paint again -- unbounded recursion, once + // per frame, on the event thread. The shim exists precisely + // to provide `super_paint` as the way out; that bridge is + // the only thing that can reach the framework implementation + // from here. + // + // Unless there is no bridge, which is not an error: a shim + // overrides only what it can, so a *final* host method has + // none -- and `super.play()` on a final method is ordinary + // Java. With nothing overriding it, calling the method + // itself is what the super call means, and cannot recurse. + if (linker.hasMethod(peerOwner, "super_" + name, desc)) { + pushBoxed(f, returnKind, hostCall(peerOwner, "super_" + name, desc, + io.hostPeer, args, false)); + } else { + pushBoxed(f, returnKind, + hostCall(owner, name, desc, io.hostPeer, args, true)); + } + return; + } + pushBoxed(f, returnKind, + hostCall(peerOwner, name, desc, io.hostPeer, args, false)); + return; + } + // An enum constant that did not override the method: what is left + // is java.lang.Enum's own behaviour, which is small enough to + // answer here and has no peer to answer it. + if (io.enumOrdinal >= 0) { + Object r = enumCall(io, name, args); + if (r != NOT_ENUM_METHOD) { + pushBoxed(f, returnKind, r); + return; + } + } + // java.lang.Object's own methods, for an object with no peer to + // inherit them from. getClass() is not an exotic case: javac emits + // a `receiver.getClass()` null check in front of every bound + // method reference, so `self::method` needs it. + Object r = objectCall(io, name, args, op == InterpOpcodes.INVOKESPECIAL); + if (r != NOT_OBJECT_METHOD) { + pushBoxed(f, returnKind, r); + return; + } + // IncompatibleClassChangeError rather than the AbstractMethodError + // this really is: CLDC11's AbstractMethodError keeps its + // constructors package-private, so the framework cannot throw one + // with a message, and a message naming the method is worth more + // here than the exactly right type. + throw new InterpThrowable(new IncompatibleClassChangeError( + owner + "." + name + desc + " is not implemented"), snapshotStack()); + } + + pushBoxed(f, returnKind, + hostCall(owner, name, desc, target, args, op == InterpOpcodes.INVOKESPECIAL)); + } + + /// Builds the host-visible peer for an interpreted object, at the point its + /// constructor chains into the host superclass. + /// + /// `java.lang.Object` is not treated as a host superclass: every class has + /// it as an ancestor and no dispatch depends on it, so a class whose only + /// host supertype is Object needs no peer at all unless it also implements + /// host interfaces. + private void createPeer(InterpObject io, String superOwner, String superDesc, Object[] superArgs) + throws Throwable { + Vector hostSupertypes = new Vector(); + io.type.collectHostSupertypes(hostSupertypes); + + // An enum constant is not given a peer. `java.lang.Enum` has no shim + // and can have none -- Java forbids naming it as a superclass, so there + // is no source a generator could emit -- and it needs none: `Enum` is + // a name and an ordinal plus the methods that read them, all of which + // the interpreter answers itself. The constant is still free to + // implement host interfaces, and those are collected below as usual. + if ("java/lang/Enum".equals(superOwner)) { + io.enumName = superArgs.length > 0 ? (String) superArgs[0] : null; + io.enumOrdinal = superArgs.length > 1 && superArgs[1] instanceof Number + ? ((Number) superArgs[1]).intValue() : -1; + superOwner = "java/lang/Object"; + } + + String hostSuperclassName = null; + Vector interfaces = new Vector(); + if (!"java/lang/Object".equals(superOwner)) { + if (linker.findClass(superOwner) == null) { + throw new InterpThrowable(new NoClassDefFoundError( + superOwner + " is not present in the installed app"), snapshotStack()); + } + hostSuperclassName = superOwner; + } + for (int i = 0; i < hostSupertypes.size(); i++) { + int ext = ((Integer) hostSupertypes.elementAt(i)).intValue(); + String n = externOwnerName(ext); + // Enum is skipped for the same reason it is skipped as a + // superclass: it has no shim and needs none. It reaches here as + // well as there because the walk collects the whole supertype set, + // not just the immediate one. + if (n.equals(superOwner) || "java/lang/Object".equals(n) + || "java/lang/Enum".equals(n)) { + continue; + } + if (linker.findClass(n) != null) { + interfaces.addElement(n); + } + } + // Interfaces Enum implements are not in the pushed class's own + // interface list, but they still belong on the peer -- an enum + // sorted through Collections.sort casts to Comparable, and Enum + // supplies that. `enumCall` answers `compareTo`, so the shim only + // needs to advertise the interface; the interpreter routes the + // call back. Comparable and Serializable are the two Enum brings + // in on every JDK. + if (io.enumOrdinal >= 0) { + addIfPresent(interfaces, "java/lang/Comparable"); + addIfPresent(interfaces, "java/io/Serializable"); + } + + if (hostSuperclassName == null && interfaces.isEmpty()) { + return; // nothing in the host needs to see this object + } + if (!factory.canExtend(hostSuperclassName)) { + throw new InterpThrowable(new UnsupportedOperationException( + io.type.getName().replace('/', '.') + " extends " + superOwner.replace('/', '.') + + ", which this platform's object factory cannot subclass"), snapshotStack()); + } + String[] ifaceArray = new String[interfaces.size()]; + interfaces.copyInto(ifaceArray); + io.hostPeer = factory.createPeer(io, hostSuperclassName, ifaceArray, superDesc, superArgs); + io.hostPeerOwner = factory.peerClassName(io.hostPeer); + // Interface-only peer: no host superclass in the chain, so Object's + // own default methods still belong to the interpreter. The dispatch + // path below routes toString/hashCode/equals to `objectCall` in that + // case rather than calling the shim, which inherits Object.toString + // from the shim class and prints `Interp_Runnable@...` instead of + // the pushed class's own name. + io.hostPeerFromInterfacesOnly = hostSuperclassName == null; + } + + private Object hostCall(String owner, String name, String desc, Object target, + Object[] args, boolean special) throws Throwable { + // Fuel accounting stops for the duration: a host call may legitimately + // block for a long time (invokeAndBlock waiting on the network) and + // that must not read as a runaway loop. + ThreadState st = state(); + // A class token for a bundle-only type is an InterpClass, and a host + // method declaring java.lang.Class cannot be handed one. The documented + // resource idiom -- `getResourceAsStream(getClass(), "/theme.res")` -- + // hits this on every pushed program, so the token is exchanged for the + // class of its nearest host ancestor: the same class loader, and a real + // Class. + // + // Only where the parameter actually says Class. Substituting into an + // Object parameter loses the token itself: `list.add(Pushed.class)` + // would store Object.class, and reading it back would not equal the + // literal the program still holds. A host that took it as Object never + // needed a Class in the first place -- it is storing a reference. + if (target != null && "java/lang/Class".equals(owner) + && "isAssignableFrom".equals(name) && args.length == 1 + && args[0] instanceof InterpClass) { + // `Runnable.class.isAssignableFrom(Task.class)` where Task is a + // pushed class implementing Runnable. Substituting the token below + // would hand the host Object.class and get false for a relationship + // the bundle records; the answer is whether any host supertype the + // pushed class actually has is assignable to the receiver. + return assignableFromInterp(target, (InterpClass) args[0]) + ? Boolean.TRUE : Boolean.FALSE; + } + if ("getResourceAsStream".equals(name) && args.length == 2 + && args[0] instanceof InterpClass && args[1] instanceof String) { + // Java resolves a relative resource name against the *caller's* + // package -- `getResourceAsStream(MyApp.class, "data.json")` reads + // /com/example/data.json -- and the bundle stores it under exactly + // that path. The token is about to become a host class, taking the + // package with it, so the name is qualified here while it is still + // known. + String path = (String) args[1]; + if (path.length() > 0 && path.charAt(0) != '/') { + String caller = ((InterpClass) args[0]).getName(); + int slash = caller.lastIndexOf('/'); + args[1] = slash < 0 ? "/" + path : "/" + caller.substring(0, slash + 1) + path; + } + } + String[] params = paramDescriptors(desc); + // Pairs of (src, dst) for Class[] materialisations: kept so the finally + // block can copy any host mutations back to the interpreter-owned + // Object[], preserving Java's array-by-reference semantics for + // `Class...` arguments the host method reorders, clears or replaces. + Vector classArrayPairs = null; + for (int i = 0; i < args.length && i < params.length; i++) { + if (args[i] instanceof InterpClass && "Ljava/lang/Class;".equals(params[i])) { + args[i] = hostClassFor((InterpClass) args[i]); + } else if (args[i] instanceof Object[] + && "[Ljava/lang/Class;".equals(params[i])) { + // A `Class[]` argument -- ordinary `Class.getMethod(..., + // Type.class)` and any varargs `Class...` call. We hand the + // interpreter an Object[] for `new Class[n]` (a Class[] would + // reject an InterpClass token at AASTORE), and a host method + // expecting a real Class[] needs one materialised at the + // boundary. Elements go through hostClassFor so pushed-only + // classes at least resolve to their nearest host ancestor, + // matching the scalar conversion above. + Object[] src = (Object[]) args[i]; + Object[] converted = classArrayFor(src); + if (converted != null) { + if (classArrayPairs == null) { + classArrayPairs = new Vector(); + } + classArrayPairs.addElement(src); + classArrayPairs.addElement(converted); + args[i] = converted; + } + } + } + // Elements of a reference array cross the same way a scalar argument + // does: as their peers. `Arrays.sort(items)` where the items implement + // a host Comparable would otherwise hand the host a wrapper it can + // only fail to cast. The array is not converted back afterwards -- + // `Arrays.asList(items)`, `Collections.addAll` and other collectors + // retain the passed array, and reverting elements in place would + // leave the host's alias holding InterpObject wrappers that do not + // implement the interfaces their peers do. Interpreted reads via + // AALOAD run each element through `fromHost`, so an element that + // stayed as its peer round-trips back to the wrapper on the way in + // and everything the interpreter compares by identity still matches. + for (Object arg : args) { + if (arg instanceof Object[]) { + toHostElements(arg, new Vector()); + } + } + // A typed-array parameter (`Component[]`, `MyButton[]`, + // `Component[][]`, `Object[][]`, ...) needs an actual array of that + // host component type. The interpreter's representation is a plain + // `Object[]` -- `ANEWARRAY` cannot allocate a Sub[] whose leaf + // exists in the bundle, and even Sub[] whose leaf is a host class + // extending Component came out as Object[]. Method dispatch on the + // JVM rejects a plain Object[] passed to a `Component[]` (or + // `Object[][]`) slot with an argument-type mismatch, so materialise + // the array here and copy the (already peer-converted) elements + // across. Class[] is handled above; a 1D Object[] parameter needs + // no conversion. Anything else with `[L...;` or `[[...` gets a + // typed array, recursively for multi-dimensional cases so covariance + // through the outer type reaches the innermost element type too. + Vector hostArrayPairs = null; + // Two-pass: collect every slot that needs a typed array, then per + // source pick the most specific type covering all its aliases, and + // materialise once. A single call site can pass the same `Button[]` + // as both `Component[]` and `Button[]`; Java hands the host one + // array whose covariance covers both slots, and two independently + // typed dsts (one per parameter type) would break that alias -- + // `first != second`, writes through one invisible through the + // other. Picking `Button[]` at the narrowest end satisfies both + // parameters at once because a Button[] passes reflection's check + // for Component[] via array covariance. + Vector arrayIntents = null; // triples (Integer argIndex, Object[] src, String elementDesc) + for (int i = 0; i < args.length && i < params.length; i++) { + if (!(args[i] instanceof Object[])) { + continue; + } + String p = params[i]; + if (!p.startsWith("[")) { + continue; + } + if ("[Ljava/lang/Object;".equals(p) + || "[Ljava/lang/Class;".equals(p)) { + continue; + } + String elementDesc = p.substring(1); + // Primitive-leaf arrays (`[I`, `[[D`) can never present as + // Object[] here -- they are int[], double[], etc. The rank-2 + // form `[[I` is a `[I[]` whose outer *is* Object[] though, so + // only reject when the element descriptor is a primitive. + if (!elementDesc.startsWith("L") && !elementDesc.startsWith("[")) { + continue; + } + Object[] src = (Object[]) args[i]; + // Skip when the host already handed us a typed array (a + // `String[]` returned by an earlier call). Only the interpreter's + // own `ANEWARRAY` produces exactly plain `Object[]`; anything + // typed enough for the host to accept is already assignable. + if (!"[Ljava.lang.Object;".equals(src.getClass().getName())) { + continue; + } + if (arrayIntents == null) { + arrayIntents = new Vector(); + } + arrayIntents.addElement(Integer.valueOf(i)); + arrayIntents.addElement(src); + arrayIntents.addElement(elementDesc); + } + if (arrayIntents != null) { + hostArrayPairs = new Vector(); + Vector handledSrcs = new Vector(); + for (int n = 0; n < arrayIntents.size(); n += 3) { + Object[] src = (Object[]) arrayIntents.elementAt(n + 1); + if (containsIdentity(handledSrcs, src)) { + continue; + } + handledSrcs.addElement(src); + String bestDesc = (String) arrayIntents.elementAt(n + 2); + for (int m = n + 3; m < arrayIntents.size(); m += 3) { + if (arrayIntents.elementAt(m + 1) != src) { //NOPMD CompareObjectsWithEquals - identity is the point + continue; + } + String cand = (String) arrayIntents.elementAt(m + 2); + bestDesc = moreSpecificElement(bestDesc, cand); + } + Object[] dst = materializeTypedArray(src, bestDesc, hostArrayPairs); + if (dst == null) { + continue; + } + if (!containsMaterialisation(hostArrayPairs, src, bestDesc)) { + hostArrayPairs.addElement(src); + hostArrayPairs.addElement(dst); + hostArrayPairs.addElement(bestDesc); + } + for (int m = n; m < arrayIntents.size(); m += 3) { + if (arrayIntents.elementAt(m + 1) != src) { //NOPMD CompareObjectsWithEquals - identity is the point + continue; + } + int argIdx = ((Integer) arrayIntents.elementAt(m)).intValue(); + args[argIdx] = dst; + } + } + } + st.hostCallDepth++; + // When this is the outermost host call, the wall clock it spends is not + // the program's to answer for: invokeAndBlock, a network read or a + // dialog can sit for seconds, and the budget is about interpreted code + // that never yields. Suppressing the check for the duration is not + // enough -- the entry clock keeps running, so the first checkpoint + // after a long call trips on time the host spent. The clock is moved + // forward by that interval instead. + long hostCallStart = st.hostCallDepth == 1 ? System.currentTimeMillis() : 0; + try { + if (target == null) { + if (hostInterceptor != null) { + Object answer = hostInterceptor.interceptStatic(owner, name, desc, args); + if (answer != InterpHostInterceptor.NOT_INTERCEPTED) { //NOPMD CompareObjectsWithEquals - a sentinel + return answer; + } + } + return linker.invokeStatic(owner, name, desc, args); + } + if (special) { + return linker.invokeSpecial(target, owner, name, desc, args); + } + return linker.invokeVirtual(target, owner, name, desc, args); + } catch (Throwable t) { + // Record where interpreted code was when the framework threw. + // Without this a failure inside a library method arrives with the + // interpreter's own stack and no message -- "java.lang. + // UnsupportedOperationException" and nothing else -- which says + // neither what was called nor from where. + Failure previous = lastFailure; + if (previous == null || previous.thrown != t) { //NOPMD CompareObjectsWithEquals - the same throwable instance, not an equal one + lastFailure = new Failure(t, t, snapshotStack(), + owner.replace('/', '.') + "." + name + desc); + } + throw t; + } finally { + // No fromHostElements sweep: the peers stay in place so a host + // method that retained the array (Arrays.asList, Collections. + // addAll, an executor's task queue) keeps its host-compatible + // view. Interpreted reads round-trip via `fromHost` at AALOAD. + // Class[] parameters: mirror the host's writes on the materialised + // Class[] back to the interpreter-owned Object[] so the caller + // sees mutations, matching Java's array-by-reference semantics. + // + // Every slot is copied unconditionally: a "did the host write this + // slot?" test based on post-call identity can't distinguish "host + // was read-only" from "host explicitly assigned the same value we + // passed in" (which for a pushed-only token materialised through + // `hostClassFor` to Object.class is `Object.class` on both sides). + // Copying always is the honest answer -- a pushed-only class was + // never a real host Class to begin with, so an InterpClass token + // that survives a host call and equals its ancestor stand-in is + // no more informative than the ancestor itself. Callers that need + // to compare against the original token afterwards should keep + // their own reference rather than re-read the array slot. + if (classArrayPairs != null) { + for (int i = 0; i < classArrayPairs.size(); i += 2) { + Object[] src = (Object[]) classArrayPairs.elementAt(i); + Object[] dst = (Object[]) classArrayPairs.elementAt(i + 1); + int len = src.length < dst.length ? src.length : dst.length; + for (int k = 0; k < len; k++) { + src[k] = dst[k]; + } + } + } + // Same reasoning as classArrayPairs above: a host method that + // reorders or fills its `Component[]` needs those writes visible + // through the interpreter's original array. Copy peers back into + // the src; AALOAD's fromHost hop turns them back into wrappers + // for interpreted reads. + if (hostArrayPairs != null) { + for (int i = 0; i < hostArrayPairs.size(); i += 3) { + Object[] src = (Object[]) hostArrayPairs.elementAt(i); + Object[] dst = (Object[]) hostArrayPairs.elementAt(i + 1); + int len = src.length < dst.length ? src.length : dst.length; + for (int k = 0; k < len; k++) { + src[k] = dst[k]; + } + } + } + st.hostCallDepth--; + if (st.hostCallDepth == 0 && st.runStartMs > 0) { + st.runStartMs += System.currentTimeMillis() - hostCallStart; + } + } + } + + /// Replaces peer-backed elements of a reference array with their peers. + /// + /// In place, and recursively for nested arrays, because the array itself is + /// the value being passed: a copy would lose whatever the host method did + /// to it. The seen list is what makes a self-referencing array terminate. + private static void toHostElements(Object array, Vector seen) { + if (!(array instanceof Object[]) || seen.contains(array)) { + return; + } + seen.addElement(array); + Object[] a = (Object[]) array; + for (int i = 0; i < a.length; i++) { + if (a[i] instanceof InterpObject) { + InterpObject io = (InterpObject) a[i]; + if (io.hostPeer != null) { + a[i] = io.hostPeer; + } + } else if (a[i] instanceof Object[]) { + toHostElements(a[i], seen); + } + } + } + + // `fromHostElements` used to sweep peer-populated arrays back to wrappers + // after the host call returned. Removed because host methods like + // `Arrays.asList` and `Collections.addAll` retain the array they were + // handed; reverting in place would leave the host's alias holding + // InterpObject wrappers that do not implement the interfaces their peers + // do. AALOAD converts elements through `fromHost` on read, so both sides + // see the representation they expect. + + /// The simple name of a class, as `Class.getSimpleName` reports it. + /// + /// Read from the bundle rather than worked out from the binary name, which + /// cannot be done: `Outer$1` is anonymous and has no simple name at all, + /// `Outer$1Local` is a local class called Local, and a `$` may equally be + /// part of a class's own identifier -- nested or top-level. javac records + /// which in the InnerClasses attribute, and the bundle now carries it. + private static String simpleNameOf(InterpClass c) { + if (c.simpleName != null) { + return c.simpleName; + } + // No entry in the attribute at all: a top-level class, whose simple + // name is the last segment of its binary name -- including any `$` it + // carries, which belongs to the class's own identifier. + String name = c.getName(); + int slash = name.lastIndexOf('/'); + return slash < 0 ? name : name.substring(slash + 1); + } + + /// Selects the method an `invokevirtual` actually runs. + /// + /// Not simply the receiver's, for two reasons the opcode does not express. + /// A *private* method is not virtual at all, and from JDK 11 javac emits + /// invokevirtual for one (nestmates replaced the synthetic bridges). A + /// *package-private* method is overridden only from within its own package + /// -- JVMS 5.4.5 -- so a public method of the same signature in another + /// package does not replace it, and the call still runs the one that was + /// written. + private InterpMethod resolveVirtual(InterpClass receiver, String owner, + String name, String desc) { + InterpClass named = bundle.findClass(owner); + InterpMethod declared = named == null ? null : named.declaredMethod(name, desc); + if (declared == null) { + // JVMS 5.4.3.3: superclass class methods win over interface + // defaults, and if the superclass method is inaccessible from the + // receiver (a package-private method in a different package) the + // resolution is an IllegalAccessError -- not a silent fallback to + // the interface default. The vtable filter earlier drops the + // inaccessible entry so subsequent virtual dispatch cannot land + // on it, but that filter would also let this path pick the + // interface's default and execute a method the JVM refuses. + // Walk the superclass chain here and raise the linkage error + // when appropriate. + InterpMethod superMethod = findClassMethodInSuperchain(receiver, name, desc); + if (superMethod != null && isPackagePrivate(superMethod) + && !samePackage(receiver.getName(), superMethod.owner.getName())) { + // IncompatibleClassChangeError rather than the strict + // IllegalAccessError, for the same reason the abstract-method + // path uses it: the CLDC11 subset does not carry + // IllegalAccessError, and a message naming the inaccessible + // method and the wrong package is worth more here than the + // exactly right type. IllegalAccessError is a subtype of + // IncompatibleClassChangeError on the JVM, so a `catch + // (IncompatibleClassChangeError)` in pushed code sees the + // same shape either way. + throw new InterpThrowable(new IncompatibleClassChangeError( + superMethod.owner.getName().replace('/', '.') + "." + name + desc + + " is package-private and " + receiver.getName().replace('/', '.') + + " is in a different package"), snapshotStack()); + } + return receiver.resolve(name, desc); + } + if (declared.isPrivate()) { + return declared; + } + if (!isPackagePrivate(declared)) { + return receiver.resolve(name, desc); + } + // The most derived class that may override it: one in the same package + // as the declaring class. Anything nearer the receiver but outside that + // package declares a different method that happens to share a name. + for (InterpClass k = receiver; k != null && k != named; k = k.superInterp) { //NOPMD CompareObjectsWithEquals - one class object, not an equal one + InterpMethod m = k.declaredMethod(name, desc); + if (m != null && !m.isPrivate() && samePackage(k.getName(), named.getName())) { + return m; + } + } + return declared; + } + + /// Adds an interface name to `interfaces` if the linker can resolve it and + /// the list does not already have it. Used to attach `Enum`-inherited + /// interfaces (Comparable, Serializable) to an enum's peer without a + /// second copy when the pushed class also happens to declare one. + private void addIfPresent(Vector interfaces, String name) { + if (!interfaces.contains(name) && linker.findClass(name) != null) { + interfaces.addElement(name); + } + } + + /// The nearest class-declared method up {@code receiver}'s interpreted + /// superclass chain (skipping the receiver itself), regardless of + /// visibility. Used to detect an inaccessible superclass method during + /// virtual resolution -- JVMS 5.4.3.3 makes that an IllegalAccessError, + /// not a fallback to an interface default. + private static InterpMethod findClassMethodInSuperchain(InterpClass receiver, + String name, String desc) { + InterpClass k = receiver.superInterp; + while (k != null) { + InterpMethod m = k.declaredMethod(name, desc); + if (m != null && !m.isStatic()) { + return m; + } + k = k.superInterp; + } + return null; + } + + /// Whether a method is package-private: none of public, protected, private. + private static boolean isPackagePrivate(InterpMethod m) { + return !m.isPrivate() && !m.isPublic() && !m.isProtected(); + } + + /// Whether two JVM internal names sit in the same package. + private static boolean samePackage(String a, String b) { + int i = a.lastIndexOf('/'); + int j = b.lastIndexOf('/'); + if (i != j) { + return false; + } + return i < 0 || a.regionMatches(0, b, 0, i); + } + + /// Whether a host class is a supertype of an interpreted one. + /// + /// The interpreted class itself is nothing to the host, but its supertypes + /// are: the host class it extends and every host interface it declares, + /// transitively. If the receiver is assignable from any of them it is + /// assignable from the pushed class, which is exactly what a program asking + /// `Runnable.class.isAssignableFrom(Task.class)` wants to know. + private boolean assignableFromInterp(Object hostClass, InterpClass c) throws Throwable { + if (c.isArray()) { + return assignableFromInterpArray(hostClass, c); + } + Vector externs = new Vector(); + c.collectHostSupertypes(externs); + for (int i = 0; i < externs.size(); i++) { + // The cast is outside the try on purpose: a catch of Throwable + // around it would be a handler for a failed cast, which ParparVM + // never raises -- see check-cast-semantics. + int ext = ((Integer) externs.elementAt(i)).intValue(); + Object supertype; + try { + supertype = resolveExternClass(ext); + } catch (Throwable absent) { + // A supertype the installed app does not have cannot make + // anything assignable to the receiver, and asking a type + // question is not a reason to raise NoClassDefFoundError. + continue; + } + Object answer = linker.invokeVirtual(hostClass, "java/lang/Class", + "isAssignableFrom", "(Ljava/lang/Class;)Z", + new Object[] {linker.classObject(supertype)}); + if (answer instanceof Boolean && ((Boolean) answer).booleanValue()) { + return true; + } + } + return false; + } + + /// Whether a host class is a supertype of an interpreted array type. + /// + /// An array token records only its component, so the walk above finds no + /// supertypes at all for one -- and yet every array in Java is an Object, a + /// Cloneable and a Serializable, and `Base[]` is an `S[]` for every host + /// supertype S of Base. Both are ordinary things to ask. + private boolean assignableFromInterpArray(Object hostClass, InterpClass c) throws Throwable { + if (assignableToHostNamed(hostClass, "java/lang/Object") + || assignableToHostNamed(hostClass, "java/lang/Cloneable") + || assignableToHostNamed(hostClass, "java/io/Serializable")) { + return true; + } + String name = c.getName(); + int rank = 0; + while (rank < name.length() && name.charAt(rank) == '[') { + rank++; + } + InterpClass leaf = c; + while (leaf.isArray()) { + leaf = leaf.arrayComponent; + } + // A multi-dimensional array's intermediate components are themselves + // arrays, and every array is Object/Cloneable/Serializable: `Pushed[][]` + // is a `Cloneable[]` because its `Pushed[]` component is a Cloneable. + // Check each intermediate rank against the marker interfaces so a + // hostClass of `Cloneable[]` or `Object[][]` on a rank-3 receiver still + // resolves to true. Rank 0 is the first block above; the leaf-supertype + // loop below covers the full rank. + for (int k = 1; k < rank; k++) { + StringBuilder mid = new StringBuilder(); + for (int i = 0; i < k; i++) { + mid.append('['); + } + if (assignableToHostNamed(hostClass, mid + "Ljava/lang/Object;") + || assignableToHostNamed(hostClass, mid + "Ljava/lang/Cloneable;") + || assignableToHostNamed(hostClass, mid + "Ljava/io/Serializable;")) { + return true; + } + } + StringBuilder brackets = new StringBuilder(); + for (int i = 0; i < rank; i++) { + brackets.append('['); + } + Vector externs = new Vector(); + leaf.collectHostSupertypes(externs); + for (int i = 0; i < externs.size(); i++) { + String supertype = externOwnerName(((Integer) externs.elementAt(i)).intValue()); + if (assignableToHostNamed(hostClass, brackets + "L" + supertype + ";")) { + return true; + } + } + return false; + } + + /// Whether the host class is assignable from the host type of this name, + /// answering false when the installed app does not have that type. + private boolean assignableToHostNamed(Object hostClass, String internalName) + throws Throwable { + Object other = linker.findClass(internalName); + if (other == null) { + return false; + } + Object answer = linker.invokeVirtual(hostClass, "java/lang/Class", "isAssignableFrom", + "(Ljava/lang/Class;)Z", new Object[] {linker.classObject(other)}); + return answer instanceof Boolean && ((Boolean) answer).booleanValue(); + } + + /// The parameter descriptors of a method descriptor, in order. + /// + /// `(ILjava/lang/String;[J)V` is `I`, `Ljava/lang/String;`, `[J` -- one + /// entry per argument, so the result lines up with the argument array. + private static String[] paramDescriptors(String desc) { + Vector out = new Vector(); + int i = desc.indexOf('(') + 1; + int end = desc.indexOf(')'); + if (i <= 0 || end < i) { + return new String[0]; + } + while (i < end) { + int start = i; + while (i < end && desc.charAt(i) == '[') { + i++; + } + if (i < end && desc.charAt(i) == 'L') { + int semi = desc.indexOf(';', i); + if (semi < 0 || semi > end) { + // A descriptor this malformed cannot be walked; the caller + // simply does not substitute, which is the safe direction. + return new String[0]; + } + i = semi + 1; + } else { + i++; + } + out.addElement(desc.substring(start, i)); + } + String[] answer = new String[out.size()]; + out.copyInto(answer); + return answer; + } + + /// A real host array of the requested element type, built from an + /// interpreter-owned Object[]. Elements are copied as-is (the caller has + /// already replaced peer-backed values with their peers), and a nested + /// plain `Object[]` recurses so multi-dimensional parameters + /// (`Component[][]`, `Object[][]`) also arrive as the exact host array + /// class the JVM will accept through reflection. A `Ljava/lang/Class;` + /// leaf routes each `InterpClass` token through {@link #hostClassFor}, + /// mirroring the 1D `Class[]` conversion so a nested `Class[][]` + /// containing a pushed class literal doesn't hit `ArrayStoreException` + /// on the leaf assignment. Returns null when the linker cannot produce + /// an array of this type at all -- the caller falls back to the untyped + /// array in that case. + /// + /// `innerPairs` -- when non-null -- collects (src, dst) for every + /// nested substitution so the caller's finally block can mirror host + /// writes on the inner arrays back through the interpreter's original + /// aliases (`Component[] row = matrix[0]` still sees the reorder). + private Object[] materializeTypedArray(Object[] src, String elementDescriptor, + Vector innerPairs) throws Throwable { + // A nested source array may appear more than once -- the matrix + // `[[row, row]]` where both outer slots are the same inner array, + // or the same Component[] passed both as its own argument and + // reachable through another. Materialising each occurrence + // independently gives the host two dst arrays for one src, and + // any host write through the second is overwritten again when + // the finally-block mirror copies both back onto src in turn. + // Reuse an existing dst -- but only when the earlier + // materialisation targeted the *same* element type. Reusing a + // `Component[]` dst for a `Button[]` slot would fail the + // reflective linker's argument-type check because Component[] is + // not assignable to Button[]; different requested types get their + // own dst arrays, each mirrored back independently. + Object[] existing = existingMaterialisation(innerPairs, src, elementDescriptor); + if (existing != null) { + return existing; + } + Object array = linker.newArray(elementDescriptor, src.length); + if (!(array instanceof Object[])) { + return null; + } + Object[] dst = (Object[]) array; + boolean nested = elementDescriptor.startsWith("["); + String innerDesc = nested ? elementDescriptor.substring(1) : null; + boolean classLeaf = !nested && "Ljava/lang/Class;".equals(elementDescriptor); + for (int j = 0; j < src.length; j++) { + Object el = src[j]; + if (nested && el instanceof Object[] + && "[Ljava.lang.Object;".equals(el.getClass().getName()) + && (innerDesc.startsWith("L") || innerDesc.startsWith("["))) { + Object[] inner = materializeTypedArray((Object[]) el, innerDesc, innerPairs); + if (inner != null) { + dst[j] = inner; + if (innerPairs != null + && !containsMaterialisation(innerPairs, el, innerDesc)) { + innerPairs.addElement(el); + innerPairs.addElement(inner); + innerPairs.addElement(innerDesc); + } + } else { + dst[j] = el; + } + } else if (classLeaf && el instanceof InterpClass) { + dst[j] = hostClassFor((InterpClass) el); + } else { + dst[j] = el; + } + } + return dst; + } + + /// `pairs` is a flat list of (src, dst, elementDescriptor) triples -- + /// the descriptor is what makes a `Component[]` materialisation + /// distinguishable from a `Button[]` materialisation of the same src, + /// so the reflective linker doesn't reject the narrower slot with a + /// stale wider dst. + private static Object[] existingMaterialisation(Vector pairs, Object src, + String elementDescriptor) { + if (pairs == null) { + return null; + } + for (int k = 0; k < pairs.size(); k += 3) { + if (pairs.elementAt(k) == src //NOPMD CompareObjectsWithEquals - identity is the point + && elementDescriptor.equals(pairs.elementAt(k + 2))) { + return (Object[]) pairs.elementAt(k + 1); + } + } + return null; + } + + private static boolean containsMaterialisation(Vector pairs, Object src, + String elementDescriptor) { + return existingMaterialisation(pairs, src, elementDescriptor) != null; + } + + private static boolean containsIdentity(Vector items, Object o) { + for (int i = 0; i < items.size(); i++) { + if (items.elementAt(i) == o) { //NOPMD CompareObjectsWithEquals - identity is the point + return true; + } + } + return false; + } + + /// The more specific of two array element descriptors -- `a` if it is + /// (transitively) a subtype of `b`, `b` if the reverse holds, and `a` + /// arbitrarily when the two are unrelated siblings. "More specific" + /// means the type whose array class satisfies both parameter slots via + /// Java's array covariance, so aliased Component[] and Button[] slots + /// of the same pushed Button[] can share a single Button[] dst and + /// keep alias identity intact. + private String moreSpecificElement(String a, String b) throws Throwable { + if (a.equals(b)) { + return a; + } + Object aArrayClass = linker.findClass("[" + a); + Object bArrayClass = linker.findClass("[" + b); + if (aArrayClass == null || bArrayClass == null) { + return a; + } + // A zero-length dummy of each type is cheap; the reflective + // isInstance check answers whether one array class is assignable + // to the other -- which is exactly the covariance rule Java uses + // to accept a wider array parameter. + Object bDummy = linker.newArray(b, 0); + if (linker.isInstance(aArrayClass, bDummy)) { + return b; // b's array is-a a's array, so b is more specific + } + Object aDummy = linker.newArray(a, 0); + if (linker.isInstance(bArrayClass, aDummy)) { + return a; // a's array is-a b's array, so a is more specific + } + // Unrelated (siblings that share only Object as an ancestor); + // pick one arbitrarily. The host will reject the mismatched slot + // -- but that mismatch is present in the pushed program, not + // introduced here. + return a; + } + + /// A real `Class[]` built from an interpreter-owned Object[] used to + /// stand in for one. Elements go through {@link #hostClassFor}, so a + /// pushed-only leaf resolves to its nearest host ancestor -- matching how + /// a scalar `Class` argument is converted; a real host Class element is + /// left as-is. Returns null when the linker cannot produce a Class[] at + /// all, letting the caller keep the Object[] rather than fail loudly for + /// a host method it turns out never to have needed one. + private Object[] classArrayFor(Object[] src) throws Throwable { + Object array = linker.newArray("Ljava/lang/Class;", src.length); + if (!(array instanceof Object[])) { + return null; + } + Object[] dst = (Object[]) array; + for (int i = 0; i < src.length; i++) { + Object element = src[i]; + if (element instanceof InterpClass) { + dst[i] = hostClassFor((InterpClass) element); + } else { + dst[i] = element; + } + } + return dst; + } + + /// The host class standing in for an interpreted one: the nearest ancestor + /// the installed app actually has, or `java.lang.Object`. + /// + /// There is no host class for a type that exists only in the bundle, and + /// nothing can conjure one. What the callers of this actually want is a + /// class loader and an identity in the app -- which the nearest host + /// ancestor provides. + private Object hostClassFor(InterpClass c) throws Throwable { + InterpClass k = c; + while (k != null) { + if (k.superExtern >= 0) { + // classObject, not the resolved handle: on iOS a resolved class + // is its numeric id, and a host method taking a Class wants the + // Class. Handing over the id makes the resource idiom fail on + // the platform it was most needed on. + return linker.classObject(resolveExternClass(k.superExtern)); + } + k = k.superInterp; + } + Object object = linker.findClass("java/lang/Object"); + return object == null ? null : linker.classObject(object); + } + + private void replaceOnStack(InterpFrame f, Object placeholder, Object created) { + for (int i = 0; i < f.sp; i++) { + if (f.stackRefs[i] == placeholder) { //NOPMD CompareObjectsWithEquals - a placeholder sentinel + f.stackRefs[i] = created; + } + } + for (int i = 0; i < f.refs.length; i++) { + if (f.refs[i] == placeholder) { //NOPMD CompareObjectsWithEquals - a placeholder sentinel + f.refs[i] = created; + } + } + } + + // ----------------------------------------------------------------- types + + /// Resolves an extern class reference, caching the answer in the bundle. + /// + /// Synchronized, because the two arrays are one logical entry and the + /// interpreter runs on every thread a pushed program touches. Publishing + /// them in the wrong order produced a race that is worth describing, since + /// the symptom pointed nowhere near the cause: a thread that set + /// `externResolveAttempted` before storing `externResolved` left a window + /// where another thread saw "attempted, and null" and reported + /// `NoClassDefFoundError: java/lang/StringBuilder` -- for a class that had + /// resolved perfectly well microseconds earlier, on a program that had done + /// nothing wrong. + /// + /// The lock is on the whole lookup rather than only the store: without it, + /// a non-null read of `externResolved[ext]` on one thread carries no + /// guarantee that the write is visible, so a fast path outside the monitor + /// would only make the window smaller rather than closing it. An + /// uncontended monitor is cheap next to interpreted dispatch. + /// Marker for "this is not one of Enum's methods", so that a null return + /// from one that is stays distinguishable. + private static final Object NOT_ENUM_METHOD = new Object(); + + /// The same, for java.lang.Object's methods. + private static final Object NOT_OBJECT_METHOD = new Object(); + + /// java.lang.Object's behaviour for an interpreted object with no peer. + /// + /// Only reached when nothing interpreted implements the method and there is + /// no host object to inherit it from. + /// Sentinel for "java.lang.Class does not answer this here". + private static final Object NOT_CLASS_METHOD = new Object(); + + /// java.lang.Class, for a type that exists only in the bundle. + /// + /// The set is what an application can reasonably ask of a class literal + /// without reflection, which the runtime does not have and the device could + /// not provide: naming, identity, and the two type tests. Anything beyond + /// that is refused by name rather than answered wrongly. + private Object classCall(InterpClass c, String name, Object[] args) throws Throwable { + if ("getName".equals(name) && args.length == 0) { + return c.getName().replace('/', '.'); + } + if ("isArray".equals(name) && args.length == 0) { + return c.isArray() ? Boolean.TRUE : Boolean.FALSE; + } + if ("getComponentType".equals(name) && args.length == 0) { + return c.arrayComponent; + } + if ("getSimpleName".equals(name) && args.length == 0 && c.isArray()) { + return classCall(c.arrayComponent, "getSimpleName", args) + "[]"; + } + if ("getSimpleName".equals(name) && args.length == 0) { + return simpleNameOf(c); + } + if ("toString".equals(name) && args.length == 0) { + return (c.isInterface() ? "interface " : "class ") + + c.getName().replace('/', '.'); + } + if ("hashCode".equals(name) && args.length == 0) { + return Integer.valueOf(System.identityHashCode(c)); + } + if ("equals".equals(name) && args.length == 1) { + return args[0] == c ? Boolean.TRUE : Boolean.FALSE; //NOPMD CompareObjectsWithEquals - Class identity is reference identity + } + if ("isInterface".equals(name) && args.length == 0) { + return c.isInterface() ? Boolean.TRUE : Boolean.FALSE; + } + if ("desiredAssertionStatus".equals(name) && args.length == 0) { + // Not a curiosity: javac compiles an `assert` into a that + // reads ThisClass.class.desiredAssertionStatus() into a synthetic + // $assertionsDisabled field, so without an answer here a class + // containing one assert fails to initialize and the push dies + // before the program runs. False is also what the device says -- + // java.lang.Class on ParparVM returns false unconditionally -- so + // an assert is inert here exactly as it is in a built app. + return Boolean.FALSE; + } + if ("isInstance".equals(name) && args.length == 1) { + return isInstanceOf(args[0], c.getName()) ? Boolean.TRUE : Boolean.FALSE; + } + if ("isAssignableFrom".equals(name) && args.length == 1) { + // The other type test Java offers without reflection, and the + // hierarchy to answer it with is already here. A host class is + // never a subtype of one only the bundle has, so anything that is + // not an interpreted token answers false. + if (args[0] == null) { + throw new InterpThrowable(new NullPointerException( + "isAssignableFrom(null)"), snapshotStack()); + } + if (!(args[0] instanceof InterpClass)) { + return Boolean.FALSE; + } + InterpClass other = (InterpClass) args[0]; + if (c.isArray() || other.isArray()) { + // Array covariance follows the components; an array is + // assignable to nothing else here but itself. + return c.isArray() && other.isArray() + && Boolean.TRUE.equals(classCall(c.arrayComponent, "isAssignableFrom", + new Object[] {other.arrayComponent})) + ? Boolean.TRUE : Boolean.FALSE; + } + return other.isSubclassOfInterp(c.getName()) ? Boolean.TRUE : Boolean.FALSE; + } + if ("getResourceAsStream".equals(name) && args.length == 1 + && args[0] instanceof String) { + // Java resolves a relative resource name against the *caller's* + // package -- `MyApp.class.getResourceAsStream("data.json")` reads + // `/com/example/data.json` -- and the bundle carries resources + // under exactly that path. Look them up here so the pushed + // program's own `theme.res`, JSON blobs and images reach the + // ordinary Class.getResourceAsStream idiom; a class-token receiver + // never reached the host path that would fall back to + // `localResource`, so the resource looked absent. + String path = (String) args[0]; + if (path.length() > 0 && path.charAt(0) != '/') { + String owner = c.getName(); + int slash = owner.lastIndexOf('/'); + path = slash < 0 ? "/" + path : "/" + owner.substring(0, slash + 1) + path; + } + byte[] data = (byte[]) bundle.getResources().get(path); + if (data == null && path.startsWith("/")) { + // Some resources are stored without the leading slash -- + // published verbatim by the caller. Try that spelling too + // rather than answering null for a resource that is present. + data = (byte[]) bundle.getResources().get(path.substring(1)); + } + return data == null ? null : new java.io.ByteArrayInputStream(data); + } + if ("getSuperclass".equals(name) && args.length == 0) { + if (c.isArray()) { + // Every array class reports Object, whatever its component is. + Object object = linker.findClass("java/lang/Object"); + return object == null ? null : linker.classObject(object); + } + if (c.superInterp != null) { + return c.superInterp; + } + // Null only for an interface and for Object itself. Every other + // interpreted class has a host parent -- Form, or Object -- and + // answering null there says the class has no superclass at all. + if (c.isInterface() || c.superExtern < 0) { + return null; + } + // classObject, because on iOS a resolved class is a numeric handle + // and the caller is about to treat this as a java.lang.Class. + return linker.classObject(resolveExternClass(c.superExtern)); + } + return NOT_CLASS_METHOD; + } + + private Object objectCall(InterpObject io, String name, Object[] args, boolean special) + throws Throwable { + if ("getClass".equals(name) && args.length == 0) { + // The interpreted class is its own class object: there is no host + // class to hand back, and the bundle is what knows the type. + return io.type; + } + if ("hashCode".equals(name) && args.length == 0) { + return Integer.valueOf(System.identityHashCode(io)); + } + if ("equals".equals(name) && args.length == 1) { + // The argument came off the interpreter's stack through + // `popBoxed`, which converts a peer-backed object back to the + // InterpObject it stands for -- but a peer that was set on an + // InterpObject with an interface-only shim would arrive here as + // the peer, not the wrapper, when the caller passed `this`. A + // plain `args[0] == io` would answer false for `value.equals(value)`. + // Compare identity through `fromHost` so both representations + // resolve to the same InterpObject. + Object other = fromHost(args[0]); + return other == io ? Boolean.TRUE : Boolean.FALSE; //NOPMD CompareObjectsWithEquals - Object.equals default is identity + } + if ("toString".equals(name) && args.length == 0) { + if (special) { + // `super.toString()` from an interpreted override. Calling + // io.toString() would dispatch straight back into that + // override: an infinite recursion reported as a stack + // overflow, in code that reads as ordinary Java. + return io.type.getName().replace('/', '.') + "@" + + Integer.toHexString(System.identityHashCode(io)); + } + return io.toString(); + } + // wait/notify on an object of a pushed-only class. The wrapper is a + // real Java object with a real monitor, and the interpreter locks that + // same wrapper for a synchronized block on a peerless object -- so + // producer/consumer code written against `new CustomLock()` works + // rather than dying on "not implemented". + if ("wait".equals(name)) { + if (args.length == 0) { + io.wait(); + } else if (args.length == 1) { + io.wait(((Long) args[0]).longValue()); + } else { + io.wait(((Long) args[0]).longValue(), ((Integer) args[1]).intValue()); + } + return null; + } + if ("notify".equals(name) && args.length == 0) { + // notify(), because that is the method the program called. + // Substituting notifyAll() would be the runtime quietly changing + // what the pushed code asked for. + io.notify(); //NOPMD UseNotifyAllInsteadOfNotify - implementing Object.notify + return null; + } + if ("notifyAll".equals(name) && args.length == 0) { + io.notifyAll(); + return null; + } + return NOT_OBJECT_METHOD; + } + + /// Stands in for a native interface whose native half is not in this app. + /// + /// Native code is the one thing that can never be pushed, so a cn1lib used + /// by pushed code always arrives half-present: its Java half is interpreted + /// like any other pushed class, and this is what its native half becomes. + static final class NativeStub { + final InterpClass iface; + + NativeStub(InterpClass iface) { + this.iface = iface; + } + + @Override + public String toString() { + return iface.getName().replace('/', '.') + "(unsupported on this runtime)"; + } + } + + /// Names the thing an enum was asked to compare itself to. + private static String describeForCompare(Object other) { + if (other == null) { + return "null"; + } + if (other instanceof InterpObject) { + return ((InterpObject) other).type.getName().replace('/', '.'); + } + return other.getClass().getName(); + } + + /// Whether two constants belong to the same enum, looking through the + /// anonymous subclass a constant with a class body gets. + private static boolean sameEnum(InterpObject a, InterpObject b) { + return declaringEnum(a) == declaringEnum(b); //NOPMD CompareObjectsWithEquals - one class object + } + + private static InterpClass declaringEnum(InterpObject o) { + InterpClass k = o.type; + while (k != null && k.superInterp != null && k.getName().indexOf('$') > 0) { + k = k.superInterp; + } + return k; + } + + /// java.lang.Enum's behaviour for an interpreted enum constant. + /// + /// Only the methods a constant inherits without overriding reach here; + /// anything the enum declares itself was already resolved and run. + private Object enumCall(InterpObject io, String name, Object[] args) { + if ("name".equals(name) || "toString".equals(name)) { + return io.enumName; + } + if ("ordinal".equals(name)) { + return Integer.valueOf(io.enumOrdinal); + } + if ("getDeclaringClass".equals(name)) { + // The enum type, not the constant's own class: a constant with a + // class body is an anonymous subclass, and Java's contract is that + // every constant of an enum answers with the enum. + InterpClass k = io.type; + while (k != null && k.superInterp != null && k.getName().indexOf('$') > 0) { + k = k.superInterp; + } + return k; + } + if ("equals".equals(name) && args.length == 1) { + // Enum identity is object identity: constants are singletons. + // popBoxed converted a peer-backed argument to the peer, so a + // self-equals for an interface-only-peer enum arrived here as + // the peer against the InterpObject wrapper; normalise through + // fromHost so both representations resolve to the same object. + Object other = fromHost(args[0]); + return other == io ? Boolean.TRUE : Boolean.FALSE; //NOPMD CompareObjectsWithEquals - Object.equals default is identity + } + if ("hashCode".equals(name)) { + return Integer.valueOf(System.identityHashCode(io)); + } + if ("compareTo".equals(name) && args.length == 1) { + // Java compares constants of one enum and throws otherwise -- + // including for null and for something that is not an enum at all, + // which a raw Comparable call can supply. Returning an ordering + // instead quietly corrupts any sorted collection the two ended up + // in together. `fromHost` normalises for the same reason as + // `equals` above: a peer-backed enum arrives as its peer. + Object other = fromHost(args[0]); + if (other == null) { + // Enum.compareTo(null) is a NullPointerException, not a + // ClassCastException: code that tells the two apart is code + // that would see the difference. + throw new NullPointerException("compareTo(null)"); + } + boolean comparable = other instanceof InterpObject + && ((InterpObject) other).enumOrdinal >= 0 + && sameEnum(io, (InterpObject) other); + if (!comparable) { + throw new ClassCastException(describeForCompare(other) + + " is not comparable to " + io.type.getName().replace('/', '.')); + } + return Integer.valueOf(io.enumOrdinal - ((InterpObject) other).enumOrdinal); + } + return NOT_ENUM_METHOD; + } + + /// Enum.valueOf over a bundle class, by scanning the constants the + /// class's static initializer already built. + private Object enumValueOf(InterpClass c, String name) throws Throwable { + ensureInitialized(c); + Object values = c.staticValue("$VALUES"); + if (values instanceof Object[]) { + Object[] a = (Object[]) values; + for (Object raw : a) { + // `$VALUES` is the interpreter's own Object[]. Its elements + // are the peers each enum constant was stored as; walk back to + // the InterpObject the peer stands for before matching. + Object constant = fromHost(raw); + if (constant instanceof InterpObject + && name.equals(((InterpObject) constant).enumName)) { + return constant; + } + } + } + throw new InterpThrowable(new IllegalArgumentException( + "No enum constant " + c.getName().replace('/', '.') + "." + name), + snapshotStack()); + } + + private static boolean isArray(Object o) { + return o instanceof Object[] || o instanceof int[] || o instanceof byte[] + || o instanceof char[] || o instanceof short[] || o instanceof long[] + || o instanceof float[] || o instanceof double[] || o instanceof boolean[]; + } + + /// A shallow copy of an array, which is what Object.clone does for one. + /// + /// Written out per kind rather than through java.lang.reflect.Array: + /// ParparVM has no reflection, and a reference to it would eliminate this + /// whole method on iOS. + private Object copyArray(Object a) throws Throwable { + if (a instanceof Object[]) { + Object[] s = (Object[]) a; + // A String[] must clone to a String[]. The interpreter's own + // reference arrays are Object[] and stay that way, but a host array + // arriving here carries a real component type, and a copy that lost + // it fails the moment it is handed back to a host method. + Object[] d = (Object[]) linker.cloneArray(s); + if (d == null) { + d = new Object[s.length]; + } + System.arraycopy(s, 0, d, 0, s.length); + return d; + } + if (a instanceof int[]) { + int[] s = (int[]) a; + int[] d = new int[s.length]; + System.arraycopy(s, 0, d, 0, s.length); + return d; + } + if (a instanceof byte[]) { + byte[] s = (byte[]) a; + byte[] d = new byte[s.length]; + System.arraycopy(s, 0, d, 0, s.length); + return d; + } + if (a instanceof char[]) { + char[] s = (char[]) a; + char[] d = new char[s.length]; + System.arraycopy(s, 0, d, 0, s.length); + return d; + } + if (a instanceof short[]) { + short[] s = (short[]) a; + short[] d = new short[s.length]; + System.arraycopy(s, 0, d, 0, s.length); + return d; + } + if (a instanceof long[]) { + long[] s = (long[]) a; + long[] d = new long[s.length]; + System.arraycopy(s, 0, d, 0, s.length); + return d; + } + if (a instanceof float[]) { + float[] s = (float[]) a; + float[] d = new float[s.length]; + System.arraycopy(s, 0, d, 0, s.length); + return d; + } + if (a instanceof double[]) { + double[] s = (double[]) a; + double[] d = new double[s.length]; + System.arraycopy(s, 0, d, 0, s.length); + return d; + } + boolean[] s = (boolean[]) a; + boolean[] d = new boolean[s.length]; + System.arraycopy(s, 0, d, 0, s.length); + return d; + } + + private Object resolveExternClass(int ext) throws Throwable { + Object c; + synchronized (bundle) { + if (bundle.externResolveAttempted[ext]) { + c = bundle.externResolved[ext]; + if (c == null) { + throw new InterpThrowable(new NoClassDefFoundError(externOwnerName(ext)), + snapshotStack()); + } + return c; + } + c = linker.findClass(externOwnerName(ext)); + // Result first, flag second: the flag is what makes the result + // readable, so it must never become true before there is one. + bundle.externResolved[ext] = c; + bundle.externResolveAttempted[ext] = true; + } + if (c == null) { + throw new InterpThrowable(new NoClassDefFoundError(externOwnerName(ext) + + " is not present in the installed app"), snapshotStack()); + } + return c; + } + + private boolean isInstanceOf(Object v, int ext) throws Throwable { + return isInstanceOf(v, externOwnerName(ext)); + } + + /// The same test against a type named directly, which is what + /// `Class.isInstance` has and an extern index is not. + private boolean isInstanceOf(Object v, String name) throws Throwable { + if (name.length() > 0 && name.charAt(0) == '[') { + return isArrayInstanceOf(v, name); + } + if (v instanceof InterpBacked) { + v = ((InterpBacked) v).getInterpObject(); + } + if (v instanceof NativeStub) { + // The cast the NativeLookup idiom always performs. The stub stands + // in for the interface it was asked for, and for NativeInterface + // above it. + InterpClass iface = ((NativeStub) v).iface; + return iface.isSubclassOfInterp(name) + || "com/codename1/system/NativeInterface".equals(name); + } + if ("java/lang/Object".equals(name)) { + // Object is recorded as an extern and no interpreted class lists it + // as an interpreted supertype, so the hierarchy walk below answers + // false -- for `x instanceof Object`, which is true of every + // non-null reference there has ever been. + return true; + } + if (v instanceof InterpObject) { + InterpObject io = (InterpObject) v; + if (io.type.isSubclassOfInterp(name)) { + return true; + } + Object hostClass = linker.findClass(name); + return hostClass != null && io.hostPeer != null + && linker.isInstance(hostClass, io.hostPeer); + } + Object hostClass = linker.findClass(name); + return hostClass != null && linker.isInstance(hostClass, v); + } + + /// `instanceof` and `checkcast` against an array type. + /// + /// An array of an interpreted type has no host class to ask -- there is no + /// `EnumProbe$Color` on the device -- and the interpreter represents every + /// reference array as `Object[]` regardless of its component type. So the + /// component is checked element by element instead, which is a real check + /// rather than a wave-through, and an empty array satisfies any component + /// type exactly as an empty `Color[]` would. + /// + /// The approximation is that an `Object[]` holding only Colors answers true + /// for `Color[]`. That follows from the representation, and it errs in the + /// direction the representation already commits to. + private boolean isArrayInstanceOf(Object v, String name) throws Throwable { + if (v == null) { + return false; + } + String component = name.substring(1); + if (component.length() == 1) { + // A primitive array: the concrete Java type answers exactly. + switch (component.charAt(0)) { + case 'Z': return v instanceof boolean[]; + case 'B': return v instanceof byte[]; + case 'C': return v instanceof char[]; + case 'S': return v instanceof short[]; + case 'I': return v instanceof int[]; + case 'J': return v instanceof long[]; + case 'F': return v instanceof float[]; + case 'D': return v instanceof double[]; + default: return false; + } + } + if (!(v instanceof Object[])) { + return false; + } + // When the leaf type is one the host has, the host can answer exactly, + // and exactly is better than the element scan below: casting an empty + // Object[] to String[] must throw, and scanning no elements says yes. + // The scan is for arrays whose leaf exists only in the bundle, where + // there is nothing to ask. + if (!isInterpretedLeaf(name)) { + Object hostArrayClass = linker.findClass(name); + if (hostArrayClass != null) { + return linker.isInstance(hostArrayClass, v); + } + } + if (component.charAt(0) == '[') { + Object[] a = (Object[]) v; + for (Object element : a) { + if (element != null && !isArrayInstanceOf(element, component)) { + return false; + } + } + return true; + } + String element = component.charAt(0) == 'L' && component.endsWith(";") + ? component.substring(1, component.length() - 1) + : component; + if ("java/lang/Object".equals(element)) { + return true; + } + Object[] a = (Object[]) v; + for (Object item : a) { + if (item == null) { + continue; + } + if (!isElementOf(item, element)) { + return false; + } + } + return true; + } + + private boolean isElementOf(Object v, String element) throws Throwable { + // Arrays hold peers when a peer exists, so an element read raw from the + // backing `Object[]` looks host-typed. Reach back to the interpreted + // object it stands for before answering, or a Color[] whose elements + // are stored as their shim peers rejects every element. + Object unwrapped = fromHost(v); + if (unwrapped instanceof InterpObject) { + InterpObject io = (InterpObject) unwrapped; + if (io.type.isSubclassOfInterp(element)) { + return true; + } + Object hostClass = linker.findClass(element); + return hostClass != null && io.hostPeer != null + && linker.isInstance(hostClass, io.hostPeer); + } + Object hostClass = linker.findClass(element); + return hostClass != null && linker.isInstance(hostClass, v); + } + + private String externOwnerName(int ext) { + return bundle.string(bundle.externOwner[ext]); + } + + // ---------------------------------------------------------------- arrays + + private static void checkNegativeSize(int n) throws InterpThrowable { + if (n < 0) { + throw new InterpThrowable(new NegativeArraySizeException(String.valueOf(n)), null); + } + } + + private static Object newPrimitiveArray(int atype, int count) { + switch (atype) { + case 4: return new boolean[count]; + case 5: return new char[count]; + case 6: return new float[count]; + case 7: return new double[count]; + case 8: return new byte[count]; + case 9: return new short[count]; + case 10: return new int[count]; + case 11: return new long[count]; + default: throw new IllegalStateException("bad newarray type " + atype); + } + } + + private void arrayLoad(InterpFrame f, int op) throws Throwable { + int index = f.popInt(); + Object array = f.popRef(); + checkArray(array, index); + switch (op) { + case InterpOpcodes.IALOAD: f.pushInt(((int[]) array)[index]); break; + case InterpOpcodes.LALOAD: f.pushLong(((long[]) array)[index]); break; + case InterpOpcodes.FALOAD: f.pushFloat(((float[]) array)[index]); break; + case InterpOpcodes.DALOAD: f.pushDouble(((double[]) array)[index]); break; + case InterpOpcodes.BALOAD: + // byte[] and boolean[] share the opcode; they are distinct + // array types and only the runtime type says which. + if (array instanceof boolean[]) { + f.pushInt(((boolean[]) array)[index] ? 1 : 0); + } else { + f.pushInt(((byte[]) array)[index]); + } + break; + case InterpOpcodes.CALOAD: f.pushInt(((char[]) array)[index]); break; + case InterpOpcodes.SALOAD: f.pushInt(((short[]) array)[index]); break; + default: + // AALOAD. Back to the interpreted object when the element is a + // peer: a host-typed array holds peers, and handing one to + // interpreted code means the next call -- owned by a class only + // the bundle has -- goes to a linker that cannot resolve it. + f.pushRef(fromHost(((Object[]) array)[index])); + break; + } + } + + private void arrayStore(InterpFrame f, int op) throws Throwable { + switch (op) { + case InterpOpcodes.LASTORE: { + long v = f.popLong(); + int i = f.popInt(); + Object a = f.popRef(); + checkArray(a, i); + ((long[]) a)[i] = v; + return; + } + case InterpOpcodes.DASTORE: { + double v = f.popDouble(); + int i = f.popInt(); + Object a = f.popRef(); + checkArray(a, i); + ((double[]) a)[i] = v; + return; + } + case InterpOpcodes.AASTORE: { + Object v = f.popRef(); + int i = f.popInt(); + Object a = f.popRef(); + checkArray(a, i); + // Store the peer whenever the value has one, even into a plain + // `Object[]` that holds pushed-only-type elements. An earlier + // version kept wrappers in exact `Object[]` on the theory that + // interpreter reads would see them again, but a host method + // like `Arrays.asList(items)` retains the array by reference; + // a later interpreted `items[0] = new Item()` would then leave + // a wrapper alongside the peers the host handed out, and a + // subsequent `Collections.sort` casts to `Comparable` on the + // wrong side. `AALOAD` routes reads through `fromHost` so the + // interpreter still sees its own object. The wrapper is stored + // only when no peer exists (a pushed-only type has none). + if (v instanceof InterpObject && !(a instanceof InterpObject[]) + && ((InterpObject) v).hostPeer != null) { + v = ((InterpObject) v).hostPeer; + } + ((Object[]) a)[i] = v; + return; + } + default: break; + } + int v = f.popInt(); + int i = f.popInt(); + Object a = f.popRef(); + checkArray(a, i); + switch (op) { + case InterpOpcodes.IASTORE: ((int[]) a)[i] = v; break; + case InterpOpcodes.FASTORE: ((float[]) a)[i] = Float.intBitsToFloat(v); break; + case InterpOpcodes.BASTORE: + if (a instanceof boolean[]) { + ((boolean[]) a)[i] = v != 0; + } else { + ((byte[]) a)[i] = (byte) v; + } + break; + case InterpOpcodes.CASTORE: ((char[]) a)[i] = (char) v; break; + case InterpOpcodes.SASTORE: ((short[]) a)[i] = (short) v; break; + default: throw new IllegalStateException("bad array store " + op); + } + } + + private void checkArray(Object array, int index) throws InterpThrowable { + if (array == null) { + throw new InterpThrowable(new NullPointerException("array is null"), snapshotStack()); + } + int len = arrayLength(array); + if (index < 0 || index >= len) { + throw new InterpThrowable(new ArrayIndexOutOfBoundsException( + "index " + index + " length " + len), snapshotStack()); + } + } + + /// The length of any array, without `java.lang.reflect`. + /// + /// `Array.getLength` is the obvious call and it is not available on + /// ParparVM, whose `java.lang.reflect.Array` has only `newInstance`. That + /// mattered more than a missing method usually does: the translator's + /// interp-host pass prunes methods referencing absent members, so a single + /// `Array.getLength` call was enough to eliminate the interpreter's main + /// loop. The iOS build then reported every pushed program as having run + /// successfully while executing none of it. + /// + /// The instanceof chain needs no reflection and no native support, so it + /// behaves identically on every platform. It is also the form this codebase + /// requires anyway -- ParparVM's CHECKCAST is unchecked, so a cast whose + /// failure you intend to handle does not throw there. + private static int arrayLength(Object array) { + if (array instanceof Object[]) { + return ((Object[]) array).length; + } + if (array instanceof int[]) { + return ((int[]) array).length; + } + if (array instanceof byte[]) { + return ((byte[]) array).length; + } + if (array instanceof char[]) { + return ((char[]) array).length; + } + if (array instanceof long[]) { + return ((long[]) array).length; + } + if (array instanceof double[]) { + return ((double[]) array).length; + } + if (array instanceof float[]) { + return ((float[]) array).length; + } + if (array instanceof short[]) { + return ((short[]) array).length; + } + if (array instanceof boolean[]) { + return ((boolean[]) array).length; + } + throw new IllegalArgumentException("not an array: " + array.getClass().getName()); + } + + // -------------------------------------------------------------- stack ops + + private static void dupSlots(InterpFrame f, int count, int under) { + int total = count + under; + long[] p = new long[total]; + Object[] r = new Object[total]; + for (int i = 0; i < total; i++) { + p[i] = f.stackPrim[f.sp - total + i]; + r[i] = f.stackRefs[f.sp - total + i]; + } + // The duplicated slots move down past `under` slots, and the originals + // shift up to sit above them. + int base = f.sp - total; + for (int i = 0; i < count; i++) { + f.stackPrim[base + i] = p[under + i]; + f.stackRefs[base + i] = r[under + i]; + } + for (int i = 0; i < under; i++) { + f.stackPrim[base + count + i] = p[i]; + f.stackRefs[base + count + i] = r[i]; + } + for (int i = 0; i < count; i++) { + f.stackPrim[base + count + under + i] = p[under + i]; + f.stackRefs[base + count + under + i] = r[under + i]; + } + f.sp += count; + } + + // --------------------------------------------------------- boxing bridge + + private void pushBoxed(InterpFrame f, int kind, Object value) { + if (kind == InterpOpcodes.RET_VOID) { + return; + } + if (kind == InterpOpcodes.RET_OBJECT) { + f.pushRef(fromHost(value)); + return; + } + long raw = InterpValues.unbox(kind, value); + if (InterpOpcodes.isCategory2(kind)) { + f.pushLong(raw); + } else { + f.pushInt((int) raw); + } + } + + /// Turns a host-visible peer back into the interpreted object it stands for. + /// + /// The round trip is the ordinary case, not an exotic one: interpreted code + /// hands its peer to the framework, the framework hands it back -- as a + /// listener argument, as an element of a list it sorted -- and from there + /// interpreted code has to see its own object again. Without this, a cast + /// to the interpreted class fails, because a shim instance is not an + /// instance of anything the bundle declares. + private static Object fromHost(Object value) { + return value instanceof InterpBacked + ? ((InterpBacked) value).getInterpObject() + : value; + } + + private Object popBoxed(InterpFrame f, int kind) { + if (kind == InterpOpcodes.RET_OBJECT) { + Object v = f.popRef(); + // An interpreted object crossing into host code has to go as its + // peer; the host cannot do anything with an InterpObject. + if (v instanceof InterpObject) { + InterpObject io = (InterpObject) v; + return io.hostPeer != null ? io.hostPeer : io; + } + return v; + } + if (InterpOpcodes.isCategory2(kind)) { + return InterpValues.box(kind, f.popLong(), null); + } + return InterpValues.box(kind, f.popInt(), null); + } + + // ------------------------------------------------------------ exceptions + + private Throwable toThrowable(Object t) { + if (t == null) { + return new InterpThrowable(new NullPointerException("throw null"), snapshotStack()); + } + // Rethrow: `catch (E e) { throw e; }` reaches ATHROW with the same + // instance the original throw recorded, and Java preserves the stack + // of the original throw rather than the rethrow site. Detect that + // by instance identity: `lastFailure.thrown` is what escapes host + // code (an InterpThrowable wrapper for interpreted throwables) and + // `.original` is what interpreted code sees on the stack (the + // InterpObject or bare host throwable) -- either match makes this a + // rethrow, and the recorded stack is kept rather than replaced with + // the rethrow site. + Failure prev = lastFailure; + boolean rethrow = prev != null //NOPMD CompareObjectsWithEquals - identity is the point + && (prev.thrown == t || prev.original == t); + if (t instanceof Throwable) { + // Deliberately not wrapped. A framework method that calls + // interpreted code and catches IllegalStateException has to keep + // catching it, so the exception has to stay the exception. The + // interpreted frames are recorded beside it instead, and + // [#interpretedStackFor] hands them back if it escapes. + if (!rethrow) { + lastFailure = new Failure(t, t, snapshotStack(), null); + } + return (Throwable) t; + } + InterpThrowable wrapped = new InterpThrowable(t, rethrow ? prev.stack : snapshotStack()); + // Record BOTH identities: `thrown` = wrapper (what host code sees + // and passes to [#interpretedStackFor]) and `original` = the + // InterpObject (what a subsequent interpreted `throw e` pops off + // the operand stack, since the catch handler pushed `getThrown()` + // rather than the wrapper). Without the second, the rethrow would + // fail the identity check and land a new snapshot at the rethrow + // site rather than preserving the original one. + lastFailure = new Failure(wrapped, t, wrapped.getInterpretedStack(), null); + return wrapped; + } + + /// Where interpreted code threw this exception, or null if it did not. + /// + /// A host exception thrown by interpreted code carries the host's own stack + /// trace, which names the interpreter's frames rather than the program's. + /// This is the program's, recorded when it was thrown. + public String[] interpretedStackFor(Throwable t) { + Failure f = lastFailure; + return f != null && f.thrown == t ? f.stack : null; //NOPMD CompareObjectsWithEquals - the same throwable instance + } + + /// The framework method that threw this, or null if interpreted code did. + public String hostCallFor(Throwable t) { + Failure f = lastFailure; + return f != null && f.thrown == t ? f.hostCall : null; //NOPMD CompareObjectsWithEquals - the same throwable instance + } + + private int findHandler(InterpMethod m, int insn, Object thrown, boolean unusedFilter) + throws Throwable { + // `unusedFilter` was a per-throwable filter used to gate cancellation + // to catch-all handlers only. That skipped javac's compiler-generated + // cleanup for try-with-resources (which is a typed `catch (Throwable)` + // rather than a catch-all), so the resource never closed on cancel. + // The runtime now allows any handler to match; `cancelRequested` + // stays set for the ThreadState, so the next checkpoint after the + // handler returns re-raises `InterpCancelled` -- a user + // `catch (Throwable)` around a loop cannot silence Stop for long + // because the back-edge checkpoint keeps firing it. The parameter + // is kept in the signature so callers do not all need touching for + // the removed distinction. + int[] t = m.exceptionTable; + for (int i = 0; i < t.length; i += 4) { + if (insn < t[i] || insn >= t[i + 1]) { + continue; + } + int typeExtern = t[i + 3]; + if (typeExtern < 0) { + return t[i + 2]; // finally / catch-all + } + if (thrown != null && isInstanceOf(thrown, typeExtern)) { + return t[i + 2]; + } + } + return -1; + } + + /// The interpreted call stack as `Class.method(File:line)` frames, + /// innermost first. Synthesised from the bundle's line table, because a + /// real `Throwable`'s stack trace would show the interpreter's own frames + /// instead of the user's. + String[] snapshotStack() { + Vector callStack = state().callStack; + String[] out = new String[callStack.size()]; + int j = 0; + for (int i = callStack.size() - 1; i >= 0; i--) { + InterpFrame f = (InterpFrame) callStack.elementAt(i); + int line = f.method.lineFor(f.insn); + String file = f.method.owner.sourceFile == null ? "Unknown" : f.method.owner.sourceFile; + out[j++] = f.method.owner.name.replace('/', '.') + "." + f.method.name + + "(" + file + (line >= 0 ? ":" + line : "") + ")"; + } + return out; + } + + /// Placeholder for a host object between `new` and its constructor. + private static final class PendingHostNew { + final int externIndex; + + PendingHostNew(int externIndex) { + this.externIndex = externIndex; + } + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpThrowable.java b/CodenameOne/src/com/codename1/impl/interp/InterpThrowable.java new file mode 100644 index 00000000000..4c8a49fa3b2 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpThrowable.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.interp; + +/// Carries a throwable raised by interpreted code across real Java frames. +/// +/// Interpreted code can throw an interpreted object -- an instance of a user +/// class extending `Exception` -- which is not a `java.lang.Throwable` and so +/// cannot be thrown directly. This wraps it, along with the interpreted stack +/// at the point it was raised, since a real stack trace would show the +/// interpreter's frames rather than the user's source lines. +/// +/// @author Shai Almog +public final class InterpThrowable extends RuntimeException { + private final Object thrown; + private final String[] interpretedStack; + + InterpThrowable(Object thrown, String[] interpretedStack) { + super(describe(thrown)); + this.thrown = thrown; + this.interpretedStack = interpretedStack == null ? new String[0] : interpretedStack; + } + + private static String describe(Object thrown) { + if (thrown instanceof Throwable) { + Throwable t = (Throwable) thrown; + String msg = t.getMessage(); + return t.getClass().getName() + (msg == null ? "" : ": " + msg); + } + if (thrown instanceof InterpObject) { + return ((InterpObject) thrown).getType().getName().replace('/', '.'); + } + return String.valueOf(thrown); + } + + /// The object that was thrown: a real `Throwable` when it came from the + /// host, an [InterpObject] when interpreted code threw one of its own. + public Object getThrown() { + return thrown; + } + + /// The host throwable this carries, or null when there is none. + /// + /// A pushed `class MyFailure extends IOException` arrives as an + /// InterpObject whose *peer* is the IOException -- the peer is the object + /// host code was handed, and the only one a `catch (IOException)` can + /// match. A shim putting a declared exception back therefore has to ask for + /// this rather than for [#getThrown], or it misses every subclass the + /// pushed program declares of the very type the method promises. + public Throwable hostThrowable() { + return hostThrowableOf(thrown); + } + + /// The host throwable standing for a thrown interpreted value, or null. + /// + /// Shared with the interpreter, which has the same question to answer when + /// it wraps an initializer failure: an ExceptionInInitializerError whose + /// cause is null tells the catching code nothing about what actually went + /// wrong. + static Throwable hostThrowableOf(Object thrown) { + if (thrown instanceof Throwable) { + return (Throwable) thrown; + } + if (thrown instanceof InterpObject) { + Object peer = ((InterpObject) thrown).hostPeer; + if (peer instanceof Throwable) { + return (Throwable) peer; + } + } + return null; + } + + /// The interpreted call stack at the throw, innermost first, formatted as + /// `Class.method(File:line)`. + public String[] getInterpretedStack() { + return interpretedStack; + } + + /// The interpreted stack rendered the way a Java stack trace reads, so it + /// can be shown on device or sent to the desktop for the IDE to linkify. + public String getInterpretedStackTrace() { + StringBuffer sb = new StringBuffer(getMessage()); + for (String frame : interpretedStack) { + sb.append("\n\tat ").append(frame); + } + return sb.toString(); + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpValues.java b/CodenameOne/src/com/codename1/impl/interp/InterpValues.java new file mode 100644 index 00000000000..8754d44063a --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpValues.java @@ -0,0 +1,209 @@ +/* + * 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.interp; + +import java.util.Vector; + +/// Descriptor parsing and the boxing convention at the interpreter's boundary. +/// +/// Inside a frame primitives live unboxed in a `long[]`; they are only boxed +/// when they cross into host code, which is where a real `Integer` or `Double` +/// is what reflection expects. Keeping that conversion in one place is what +/// stops a sub-int type (`boolean`, `byte`, `char`, `short`) from being handed +/// over as the `int` it is stored as -- a mismatch reflection rejects at the +/// call rather than silently coercing. +/// +/// @author Shai Almog +final class InterpValues { + private InterpValues() { + } + + /// The RET_* kind for a field or return descriptor. + static int kindOf(String desc) { + if (desc.length() == 0) { + return InterpOpcodes.RET_VOID; + } + switch (desc.charAt(0)) { + case 'V': return InterpOpcodes.RET_VOID; + case 'Z': return InterpOpcodes.RET_BOOLEAN; + case 'B': return InterpOpcodes.RET_BYTE; + case 'C': return InterpOpcodes.RET_CHAR; + case 'S': return InterpOpcodes.RET_SHORT; + case 'I': return InterpOpcodes.RET_INT; + case 'J': return InterpOpcodes.RET_LONG; + case 'F': return InterpOpcodes.RET_FLOAT; + case 'D': return InterpOpcodes.RET_DOUBLE; + default: return InterpOpcodes.RET_OBJECT; + } + } + + /// The kind of a method descriptor's return type. + static int returnKind(String methodDesc) { + int close = methodDesc.indexOf(')'); + return kindOf(methodDesc.substring(close + 1)); + } + + /// The kinds of a method descriptor's parameters, in order. + static int[] argumentKinds(String methodDesc) { + Vector kinds = new Vector(); + int i = 1; + while (i < methodDesc.length() && methodDesc.charAt(i) != ')') { + int start = i; + char c = methodDesc.charAt(i); + while (c == '[') { + i++; + c = methodDesc.charAt(i); + } + if (c == 'L') { + i = methodDesc.indexOf(';', i) + 1; + } else { + i++; + } + kinds.addElement(Integer.valueOf(kindOf(methodDesc.substring(start, i)))); + } + int[] result = new int[kinds.size()]; + for (int j = 0; j < result.length; j++) { + result[j] = ((Integer) kinds.elementAt(j)).intValue(); + } + return result; + } + + /// The parameter type descriptors of a method descriptor, in order. + static String[] argumentTypes(String methodDesc) { + Vector types = new Vector(); + int i = 1; + while (i < methodDesc.length() && methodDesc.charAt(i) != ')') { + int start = i; + char c = methodDesc.charAt(i); + while (c == '[') { + i++; + c = methodDesc.charAt(i); + } + if (c == 'L') { + i = methodDesc.indexOf(';', i) + 1; + } else { + i++; + } + types.addElement(methodDesc.substring(start, i)); + } + String[] result = new String[types.size()]; + types.copyInto(result); + return result; + } + + /// The zero value a field of this descriptor starts at. + static Object defaultValue(String desc) { + switch (kindOf(desc)) { + case InterpOpcodes.RET_BOOLEAN: return Boolean.FALSE; + case InterpOpcodes.RET_BYTE: return Byte.valueOf((byte) 0); + case InterpOpcodes.RET_CHAR: return Character.valueOf((char) 0); + case InterpOpcodes.RET_SHORT: return Short.valueOf((short) 0); + case InterpOpcodes.RET_INT: return Integer.valueOf(0); + case InterpOpcodes.RET_LONG: return Long.valueOf(0L); + case InterpOpcodes.RET_FLOAT: return Float.valueOf(0f); + case InterpOpcodes.RET_DOUBLE: return Double.valueOf(0d); + default: return null; + } + } + + /// The zero value for an already-computed kind, for a call that has to + /// return something without having run anything. + static Object defaultForKind(int kind) { + switch (kind) { + case InterpOpcodes.RET_BOOLEAN: return Boolean.FALSE; + case InterpOpcodes.RET_BYTE: return Byte.valueOf((byte) 0); + case InterpOpcodes.RET_CHAR: return Character.valueOf((char) 0); + case InterpOpcodes.RET_SHORT: return Short.valueOf((short) 0); + case InterpOpcodes.RET_INT: return Integer.valueOf(0); + case InterpOpcodes.RET_LONG: return Long.valueOf(0L); + case InterpOpcodes.RET_FLOAT: return Float.valueOf(0f); + case InterpOpcodes.RET_DOUBLE: return Double.valueOf(0d); + default: return null; + } + } + + /// Boxes a raw slot value for the crossing into host code. + /// + /// The kind matters: a `boolean` parameter is stored as 0/1 in a long slot, + /// and reflection will not accept an `Integer` where it wants a `Boolean`. + static Object box(int kind, long raw, Object ref) { + switch (kind) { + case InterpOpcodes.RET_BOOLEAN: return raw != 0 ? Boolean.TRUE : Boolean.FALSE; + case InterpOpcodes.RET_BYTE: return Byte.valueOf((byte) raw); + case InterpOpcodes.RET_CHAR: return Character.valueOf((char) raw); + case InterpOpcodes.RET_SHORT: return Short.valueOf((short) raw); + case InterpOpcodes.RET_INT: return Integer.valueOf((int) raw); + case InterpOpcodes.RET_LONG: return Long.valueOf(raw); + case InterpOpcodes.RET_FLOAT: return Float.valueOf(Float.intBitsToFloat((int) raw)); + case InterpOpcodes.RET_DOUBLE: return Double.valueOf(Double.longBitsToDouble(raw)); + default: return ref; + } + } + + /// Unboxes a value returned by host code into a raw slot value. Widening is + /// deliberate: the JVM keeps every sub-int type in an int-sized slot. + static long unbox(int kind, Object value) { + if (value == null) { + return 0; + } + switch (kind) { + case InterpOpcodes.RET_BOOLEAN: + return ((Boolean) value).booleanValue() ? 1 : 0; + case InterpOpcodes.RET_BYTE: + return ((Byte) value).byteValue(); + case InterpOpcodes.RET_CHAR: + return ((Character) value).charValue(); + case InterpOpcodes.RET_SHORT: + return ((Short) value).shortValue(); + case InterpOpcodes.RET_INT: + return ((Integer) value).intValue(); + case InterpOpcodes.RET_LONG: + return ((Long) value).longValue(); + case InterpOpcodes.RET_FLOAT: + // Raw bits, not canonicalising: `floatToIntBits` collapses + // every NaN pattern into 0x7fc00000, so a program that read a + // noncanonical NaN back with `floatToRawIntBits` after the + // host returned it would see the canonical pattern instead of + // the payload the JVM preserves. Rare, but legal. + return Float.floatToRawIntBits(((Float) value).floatValue()) & 0xffffffffL; + case InterpOpcodes.RET_DOUBLE: + // Raw bits for the same reason as the float case above. + return Double.doubleToRawLongBits(((Double) value).doubleValue()); + default: + return 0; + } + } + + /// Turns a JVM internal name or descriptor into the form + /// `Class.forName` expects. + static String binaryName(String internalOrDescriptor) { + if (internalOrDescriptor.startsWith("[")) { + return internalOrDescriptor.replace('/', '.'); + } + if (internalOrDescriptor.startsWith("L") && internalOrDescriptor.endsWith(";")) { + return internalOrDescriptor.substring(1, internalOrDescriptor.length() - 1) + .replace('/', '.'); + } + return internalOrDescriptor.replace('/', '.'); + } +} diff --git a/CodenameOne/src/com/codename1/impl/interp/InterpValuesAccess.java b/CodenameOne/src/com/codename1/impl/interp/InterpValuesAccess.java new file mode 100644 index 00000000000..fa3c0b48ab4 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/interp/InterpValuesAccess.java @@ -0,0 +1,56 @@ +/* + * 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.interp; + +/// Descriptor parsing for [InterpLinker] implementations, which live outside +/// this package. +/// +/// A linker is inherently platform-specific -- reflection on Android, invoke +/// thunks on iOS -- so it cannot live in the core, but every one of them has to +/// take a method descriptor apart in exactly the same way. Exposing the parser +/// keeps that one implementation rather than one per platform, each with its +/// own bugs around array and object descriptors. +/// +/// @author Shai Almog +public final class InterpValuesAccess { + private InterpValuesAccess() { + } + + /// The parameter type descriptors of a method descriptor, in order. + /// + /// #### Parameters + /// + /// - `methodDescriptor`: a JVM method descriptor, e.g. `(ILjava/lang/String;)V` + /// + /// #### Returns + /// + /// one descriptor per parameter, e.g. `{"I", "Ljava/lang/String;"}` + public static String[] argumentTypes(String methodDescriptor) { + return InterpValues.argumentTypes(methodDescriptor); + } + + /// The descriptor of a method's return type; `V` for void. + public static String returnType(String methodDescriptor) { + return methodDescriptor.substring(methodDescriptor.indexOf(')') + 1); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 7ee774f6815..8bd643cb0a9 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1581,6 +1581,10 @@ public void init(Object m) { } instance = this; + // Android has reflection, so the device runtime's linker needs nothing + // from the build; registering unconditionally costs one object and lets + // the runtime app work on a stock build. + com.codename1.impl.interp.InterpPlatform.register(new InterpAndroidLinker()); if(getActivity() != null && getActivity().hasUI()){ if (!hasActionBar()) { try { @@ -2297,6 +2301,12 @@ public void onClick(DialogInterface d, int which) { @Override public InputStream getResourceAsStream(Class cls, String resource) { + // A resource pushed by the device runtime wins over the app's own, + // so a pushed program shows its own theme rather than the host's. + InputStream local = localResource(resource); + if (local != null) { + return local; + } try { if (resource.startsWith("/")) { resource = resource.substring(1); @@ -13416,21 +13426,61 @@ public boolean isDebuggableBuild() { public String getHostOrIP() { try { InetAddress i = java.net.InetAddress.getLocalHost(); - if(i.isLoopbackAddress()) { - Enumeration nie = NetworkInterface.getNetworkInterfaces(); - while(nie.hasMoreElements()) { - NetworkInterface current = nie.nextElement(); - if(!current.isLoopback()) { - Enumeration iae = current.getInetAddresses(); - while(iae.hasMoreElements()) { - InetAddress currentI = iae.nextElement(); - if(!currentI.isLoopbackAddress()) { - return currentI.getHostAddress(); + if(!i.isLoopbackAddress()) { + return i.getHostAddress(); + } + // Walking the interfaces and taking the first non-loopback address + // is not good enough on Android: the first one up is routinely + // dummy0, whose only address is an IPv6 link-local, and callers get + // "fe80::...%dummy0" -- which is not an address anything can be + // reached on and cannot even say which network this device is on. + // Prefer a real IPv4 on a live interface, take a routable one over a + // link-local, and keep a non-link-local IPv6 as a fallback so an + // IPv6-only network (where getLocalHost resolves to loopback and no + // interface has an IPv4 at all) still reports a reachable address + // instead of localhost. + String linkLocalV4 = null; + String routableV6 = null; + String linkLocalV6 = null; + Enumeration nie = NetworkInterface.getNetworkInterfaces(); + while(nie.hasMoreElements()) { + NetworkInterface current = nie.nextElement(); + if(current.isLoopback() || !current.isUp()) { + continue; + } + Enumeration iae = current.getInetAddresses(); + while(iae.hasMoreElements()) { + InetAddress currentI = iae.nextElement(); + if(currentI.isLoopbackAddress()) { + continue; + } + if(currentI instanceof java.net.Inet4Address) { + if(currentI.isLinkLocalAddress()) { + if(linkLocalV4 == null) { + linkLocalV4 = currentI.getHostAddress(); } + continue; } + return currentI.getHostAddress(); + } + if(currentI.isLinkLocalAddress()) { + if(linkLocalV6 == null) { + linkLocalV6 = currentI.getHostAddress(); + } + } else if(routableV6 == null) { + routableV6 = currentI.getHostAddress(); } } } + if(linkLocalV4 != null) { + return linkLocalV4; + } + if(routableV6 != null) { + return routableV6; + } + if(linkLocalV6 != null) { + return linkLocalV6; + } return i.getHostAddress(); } catch(Throwable t) { com.codename1.io.Log.e(t); diff --git a/Ports/Android/src/com/codename1/impl/android/InterpAndroidLinker.java b/Ports/Android/src/com/codename1/impl/android/InterpAndroidLinker.java new file mode 100644 index 00000000000..e8eb977ab95 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/InterpAndroidLinker.java @@ -0,0 +1,476 @@ +/* + * 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.android; + +import com.codename1.impl.interp.InterpLinker; +import com.codename1.impl.interp.InterpValuesAccess; +import java.lang.reflect.Array; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashMap; +import java.util.Map; + +/** + * The reflection-backed {@link InterpLinker}, for platforms that have + * reflection: the JavaSE simulator and Android. + * + *

iOS needs a different backend entirely -- ParparVM has no + * {@code Method.invoke} -- which binds through the translator's per-method + * invoke thunks and symbol table instead. Both sit behind the same interface so + * the interpreter never branches on platform.

+ * + *

Lookups are memoised on (owner, name, descriptor). Reflection's own + * {@code getMethod} walks the hierarchy on every call and allocates a + * {@code Class[]} to do it; a pushed program calls the same handful of + * framework methods in a loop, so caching the resolved {@link Method} is the + * difference between "usable" and "visibly slow".

+ * + * @author Shai Almog + */ +public class InterpAndroidLinker implements InterpLinker { + private final ClassLoader loader; + // Concurrent, not plain HashMap: the interpreter is entered from every thread + // the pushed program touches, and a resolution cache is exactly the shared + // state that gets hit from all of them at once. An unsynchronised HashMap + // under concurrent put does not merely lose an entry -- it can return null + // for a key that is present, which surfaces as NoClassDefFoundError for a + // class that plainly exists. + private final Map classCache = + java.util.Collections.synchronizedMap(new HashMap()); + private final Map methodCache = + java.util.Collections.synchronizedMap(new HashMap()); + private final Map ctorCache = + java.util.Collections.synchronizedMap(new HashMap()); + private final Map fieldCache = + java.util.Collections.synchronizedMap(new HashMap()); + + public InterpAndroidLinker() { + this(InterpAndroidLinker.class.getClassLoader()); + } + + public InterpAndroidLinker(ClassLoader loader) { + this.loader = loader; + } + + public Object findClass(String internalName) { + Class c = classCache.get(internalName); + if (c != null) { + return c; + } + try { + c = resolve(internalName); + } catch (ClassNotFoundException e) { + return null; + } + classCache.put(internalName, c); + return c; + } + + public void initializeClass(String internalName) throws Throwable { + // findClass resolves with initialize=false on purpose; this is the one + // caller that wants the initializer to have run. + Object c = findClass(internalName); + if (c instanceof Class) { + Class.forName(((Class)c).getName(), true, loader); + } + } + + public void initializeDefaultBearingInterfaces(String internalName) throws Throwable { + Object c = findClass(internalName); + if (c instanceof Class) { + initializeDefaultBearing((Class)c, new java.util.HashSet()); + } + } + + /// Superinterfaces first, then the interface itself if it declares a + /// default method -- the order JLS 12.4.1 gives, applied to whatever depth + /// the app's own hierarchy has. + /// + /// Bounded by what has already been seen rather than by a depth number. + /// The walk has to terminate because a diamond visits the same interface + /// twice, not because a hierarchy is too deep -- and a cap of sixteen + /// edges silently skipped a legal ancestor, leaving the default methods it + /// declares uninitialized when a pushed class implemented it. + private void initializeDefaultBearing(Class iface, java.util.Set visited) throws Throwable { + if (!iface.isInterface() || !visited.add(iface)) { + return; + } + Class[] parents = iface.getInterfaces(); + for (int i = 0; i < parents.length; i++) { + initializeDefaultBearing(parents[i], visited); + } + java.lang.reflect.Method[] methods = iface.getDeclaredMethods(); + for (int i = 0; i < methods.length; i++) { + if (methods[i].isDefault()) { + Class.forName(iface.getName(), true, loader); + return; + } + } + } + + private Class resolve(String internalName) throws ClassNotFoundException { + if (internalName.length() == 1) { + switch (internalName.charAt(0)) { + case 'Z': return Boolean.TYPE; + case 'B': return Byte.TYPE; + case 'C': return Character.TYPE; + case 'S': return Short.TYPE; + case 'I': return Integer.TYPE; + case 'J': return Long.TYPE; + case 'F': return Float.TYPE; + case 'D': return Double.TYPE; + case 'V': return Void.TYPE; + default: break; + } + } + if (internalName.startsWith("[")) { + return Class.forName(internalName.replace('/', '.'), false, loader); + } + if (internalName.startsWith("L") && internalName.endsWith(";")) { + return Class.forName( + internalName.substring(1, internalName.length() - 1).replace('/', '.'), + false, loader); + } + return Class.forName(internalName.replace('/', '.'), false, loader); + } + + private Class[] paramTypes(String descriptor) throws ClassNotFoundException { + String[] descs = InterpValuesAccess.argumentTypes(descriptor); + Class[] types = new Class[descs.length]; + for (int i = 0; i < descs.length; i++) { + types[i] = resolve(descs[i]); + } + return types; + } + + private Method lookupMethod(String owner, String name, String descriptor) + throws ClassNotFoundException, NoSuchMethodException { + String key = owner + '.' + name + descriptor; + Method m = methodCache.get(key); + if (m != null) { + return m; + } + Class c = resolve(owner); + Class[] types = paramTypes(descriptor); + NoSuchMethodException last = null; + // Walk up rather than relying on getMethod: the method may be public on + // a package-private class, or declared on a supertype, and + // getDeclaredMethod alone would miss inherited declarations. + for (Class k = c; k != null; k = k.getSuperclass()) { + try { + m = k.getDeclaredMethod(name, types); + break; + } catch (NoSuchMethodException e) { + last = e; + } + } + if (m == null) { + m = findInInterfaces(c, name, types); + } + if (m == null) { + throw last != null ? last : new NoSuchMethodException(key); + } + m.setAccessible(true); + methodCache.put(key, m); + return m; + } + + private Method findInInterfaces(Class c, String name, Class[] types) { + // Collect every inheritable interface declaration reachable through + // this class or any of its superclasses, then pick the maximally + // specific per JLS 5.4.3.3. Returning the first depth-first hit + // instead would pick I.m over J.m on a class declaring + // `implements I, J` where `J extends I` overrides the default -- and + // the same problem happens when the more-specific interface is + // inherited through a superclass, which is why the pool is drawn from + // the whole chain, not just this class's own interfaces. + java.util.ArrayList candidates = new java.util.ArrayList(); + java.util.HashSet visited = new java.util.HashSet(); + for (Class k = c; k != null; k = k.getSuperclass()) { + for (Class iface : k.getInterfaces()) { + collectInterfaceCandidates(iface, name, types, candidates, visited); + } + } + int count = candidates.size(); + if (count == 0) { + return null; + } + if (count == 1) { + return candidates.get(0); + } + // Collect maximally specific candidates: keep only those that no + // other candidate's declaring interface subtypes. Also collapse + // duplicates by declaring class -- the same interface's default only + // needs to appear once in the set. + java.util.ArrayList maximal = new java.util.ArrayList(); + java.util.HashSet seenDeclaring = new java.util.HashSet(); + for (int i = 0; i < count; i++) { + Method mi = candidates.get(i); + Class declaringA = mi.getDeclaringClass(); + if (!seenDeclaring.add(declaringA)) { + continue; + } + boolean dominated = false; + for (int j = 0; j < count; j++) { + if (i == j) { + continue; + } + Class declaringB = candidates.get(j).getDeclaringClass(); + // Some other candidate is on a proper subinterface of this + // one, so it is more specific and this one is not maximal. + if (!declaringA.equals(declaringB) + && declaringA.isAssignableFrom(declaringB)) { + dominated = true; + break; + } + } + if (!dominated) { + maximal.add(mi); + } + } + if (maximal.size() == 1) { + return maximal.get(0); + } + // Multiple non-abstract maximally specific methods that do not + // dominate each other is IncompatibleClassChangeError per JVMS + // 5.4.3.3 -- possible after binary-compatible interface evolution + // (an interface adds a default that a sibling interface also + // declares). Silently picking one would run an arbitrary body the + // JVM refuses. All-abstract candidates would fall through here too, + // but the collector already dropped abstracts via caller filtering; + // any candidate that reaches this point is concrete. + if (maximal.size() > 1) { + StringBuilder message = new StringBuilder(); + for (int i = 0; i < maximal.size(); i++) { + if (i > 0) { + message.append(", "); + } + message.append(maximal.get(i).getDeclaringClass().getName()); + } + throw new IncompatibleClassChangeError("conflicting default methods for " + + name + " on " + c.getName() + ": " + message); + } + return candidates.get(0); + } + + private void collectInterfaceCandidates(Class iface, String name, Class[] types, + java.util.ArrayList candidates, + java.util.HashSet visited) { + if (!visited.add(iface)) { + return; + } + try { + Method m = iface.getDeclaredMethod(name, types); + int mods = m.getModifiers(); + // Only instance, non-private, non-abstract declarations are + // eligible interface defaults. Reflection ignores the receiver + // for a static invoke, so admitting `A.staticM()` when `B` + // declares a same-descriptor default would silently run A's + // body. Abstract methods do not compete for dispatch either -- + // they contribute no body -- so filtering them here keeps the + // maximally-specific check focused on real defaults and matches + // the JVMS 5.4.3.3 "non-abstract maximally specific" rule. + if (!Modifier.isStatic(mods) && !Modifier.isPrivate(mods) + && !Modifier.isAbstract(mods)) { + candidates.add(m); + } + } catch (NoSuchMethodException ignore) { + // Not declared here -- keep walking; a superinterface may declare it. + } + for (Class parent : iface.getInterfaces()) { + collectInterfaceCandidates(parent, name, types, candidates, visited); + } + } + + private Field lookupField(String owner, String name) + throws ClassNotFoundException, NoSuchFieldException { + String key = owner + '#' + name; + Field f = fieldCache.get(key); + if (f != null) { + return f; + } + Class c = resolve(owner); + NoSuchFieldException last = null; + for (Class k = c; k != null; k = k.getSuperclass()) { + try { + f = k.getDeclaredField(name); + break; + } catch (NoSuchFieldException e) { + last = e; + } + } + if (f == null) { + f = findFieldInInterfaces(c, name); + } + if (f == null) { + throw last != null ? last : new NoSuchFieldException(key); + } + f.setAccessible(true); + fieldCache.put(key, f); + return f; + } + + private Field findFieldInInterfaces(Class c, String name) { + if (c == null) { + return null; + } + Class[] ifaces = c.getInterfaces(); + for (int i = 0; i < ifaces.length; i++) { + try { + return ifaces[i].getDeclaredField(name); + } catch (NoSuchFieldException ignore) { + Field f = findFieldInInterfaces(ifaces[i], name); + if (f != null) { + return f; + } + } + } + return findFieldInInterfaces(c.getSuperclass(), name); + } + + public Object construct(Object hostClass, String descriptor, Object[] args) throws Throwable { + Class c = (Class) hostClass; + String key = c.getName() + "" + descriptor; + Constructor ctor = ctorCache.get(key); + if (ctor == null) { + ctor = c.getDeclaredConstructor(paramTypes(descriptor)); + ctor.setAccessible(true); + ctorCache.put(key, ctor); + } + try { + return ctor.newInstance(args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public Object invokeVirtual(Object target, String owner, String name, String descriptor, + Object[] args) throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + // Resolve against the *declared* owner, not the receiver's concrete + // class. Method.invoke already dispatches virtually, so the override is + // still reached -- and resolving on the concrete class would often land + // on a non-public implementation type (ArrayList$Itr for an iterator), + // where setAccessible now throws InaccessibleObjectException because + // java.base does not open java.util to an unnamed module. + Method m = lookupMethod(owner, name, descriptor); + try { + return m.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public Object invokeSpecial(Object target, String owner, String name, String descriptor, + Object[] args) throws Throwable { + Method m = lookupMethod(owner, name, descriptor); + try { + return m.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public boolean hasMethod(String owner, String name, String descriptor) { + try { + // The call is the question; lookupMethod either answers or throws. + lookupMethod(owner, name, descriptor); + return true; + } catch (ClassNotFoundException absent) { + return false; + } catch (NoSuchMethodException absent) { + return false; + } + } + + public Object invokeStatic(String owner, String name, String descriptor, Object[] args) + throws Throwable { + Method m = lookupMethod(owner, name, descriptor); + if (!Modifier.isStatic(m.getModifiers())) { + throw new IncompatibleClassChangeError(owner + "." + name + " is not static"); + } + try { + return m.invoke(null, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public Object getStatic(String owner, String name, String descriptor) throws Throwable { + return lookupField(owner, name).get(null); + } + + public void setStatic(String owner, String name, String descriptor, Object value) + throws Throwable { + lookupField(owner, name).set(null, value); + } + + public Object getField(Object target, String owner, String name, String descriptor) + throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + return lookupField(owner, name).get(target); + } + + public void setField(Object target, String owner, String name, String descriptor, Object value) + throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + lookupField(owner, name).set(target, value); + } + + public boolean isInstance(Object hostClass, Object value) { + return hostClass != null && ((Class) hostClass).isInstance(value); + } + + public Object newArray(String componentDescriptor, int length) throws Throwable { + return Array.newInstance(resolve(componentDescriptor), length); + } + + public Object newMultiArray(String arrayDescriptor, int[] dimensions) throws Throwable { + // The descriptor names the whole array type; strip one '[' per + // dimension being allocated to get the component Array.newInstance + // wants. + String component = arrayDescriptor.substring(dimensions.length); + return Array.newInstance(resolve(component), dimensions); + } + + public Object cloneArray(Object source) { + Class component = source.getClass().getComponentType(); + if (component == null) { + return null; + } + return Array.newInstance(component, Array.getLength(source)); + } + + public Object classObject(Object hostClass) { + return hostClass; + } +} diff --git a/Ports/CLDC11/src/java/lang/Double.java b/Ports/CLDC11/src/java/lang/Double.java index cf095463712..a21defe3922 100644 --- a/Ports/CLDC11/src/java/lang/Double.java +++ b/Ports/CLDC11/src/java/lang/Double.java @@ -68,6 +68,15 @@ public static long doubleToLongBits(double value){ return 0l; //TODO codavaj!! } + /// Returns the raw IEEE 754 double-precision bit pattern of a double, + /// without collapsing NaN payloads to the canonical + /// 0x7ff8000000000000L. Present so pushed programs can round-trip a + /// noncanonical NaN payload the JVM preserves -- the device runtime uses + /// this to write double slots and read them back. + public static long doubleToRawLongBits(double value){ + return 0l; //TODO codavaj!! + } + /// Returns the double value of this Double. public double doubleValue(){ return 0.0d; //TODO codavaj!! diff --git a/Ports/CLDC11/src/java/lang/ExceptionInInitializerError.java b/Ports/CLDC11/src/java/lang/ExceptionInInitializerError.java new file mode 100644 index 00000000000..e0f195363de --- /dev/null +++ b/Ports/CLDC11/src/java/lang/ExceptionInInitializerError.java @@ -0,0 +1,59 @@ +/* + * 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.lang; +/// Signals that an unexpected exception has occurred in a static initializer. +/// +/// Present so an interpreted class initializer can fail the way Java says it +/// fails: the first touch of a class whose `` threw reports this, and +/// every touch after it reports NoClassDefFoundError. Without the type, a +/// pushed program's `catch (ExceptionInInitializerError e)` names a class the +/// device does not have. +public class ExceptionInInitializerError extends java.lang.LinkageError { + private java.lang.Throwable exception; + + /// Constructs an ExceptionInInitializerError with no detail message. + public ExceptionInInitializerError(){ + } + + /// Constructs an ExceptionInInitializerError with the specified detail message. + /// s - the detail message. + public ExceptionInInitializerError(java.lang.String s){ + super(s); + } + + /// Constructs an ExceptionInInitializerError for the given throwable. + /// thrown - the exception the initializer threw. + public ExceptionInInitializerError(java.lang.Throwable thrown){ + this.exception = thrown; + } + + /// The exception the class initializer threw, or null. + public java.lang.Throwable getException(){ + return exception; + } + + /// Same as getException(), for code written against the Throwable API. + public java.lang.Throwable getCause(){ + return exception; + } +} diff --git a/Ports/CLDC11/src/java/lang/Float.java b/Ports/CLDC11/src/java/lang/Float.java index 723dcbf780e..6763273ddde 100644 --- a/Ports/CLDC11/src/java/lang/Float.java +++ b/Ports/CLDC11/src/java/lang/Float.java @@ -83,6 +83,14 @@ public static int floatToIntBits(float value){ return 0; //TODO codavaj!! } + /// Returns the raw IEEE 754 single-precision bit pattern of a float, without + /// collapsing NaN payloads to the canonical 0x7fc00000. Present so pushed + /// programs can round-trip a noncanonical NaN payload the JVM preserves -- + /// the device runtime uses this to write float slots and read them back. + public static int floatToRawIntBits(float value){ + return 0; //TODO codavaj!! + } + /// Returns the float value of this Float object. public float floatValue(){ return 0.0f; //TODO codavaj!! diff --git a/Ports/CLDC11/src/java/lang/StackOverflowError.java b/Ports/CLDC11/src/java/lang/StackOverflowError.java new file mode 100644 index 00000000000..523ce312580 --- /dev/null +++ b/Ports/CLDC11/src/java/lang/StackOverflowError.java @@ -0,0 +1,42 @@ +/* + * 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.lang; +/// Thrown when a stack overflow occurs because an application recurses too deeply. +/// +/// Present on every target -- ParparVM's `java.lang` has it, and so does every +/// JVM the simulator runs on. It was simply missing from this stub, so framework +/// code could not name the error it wanted to throw. +/// +/// Since: JDK1.0, CLDC 1.0 +public class StackOverflowError extends java.lang.VirtualMachineError{ + /// Constructs a StackOverflowError with no detail message. + public StackOverflowError(){ + } + + /// Constructs a StackOverflowError with the specified detail message. + /// s - the detail message. + public StackOverflowError(java.lang.String s){ + super(s); + } + +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/InterpJavaSELinker.java b/Ports/JavaSE/src/com/codename1/impl/javase/InterpJavaSELinker.java new file mode 100644 index 00000000000..718f2d54a73 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/InterpJavaSELinker.java @@ -0,0 +1,474 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.impl.interp.InterpLinker; +import com.codename1.impl.interp.InterpValuesAccess; +import java.lang.reflect.Array; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashMap; +import java.util.Map; + +/** + * The reflection-backed {@link InterpLinker} for the JavaSE simulator port. + * + *

A near-copy of {@code com.codename1.impl.android.InterpAndroidLinker}: + * both use standard reflection to bind interpreted call sites to real Java + * methods and fields on the platform. iOS needs a different backend entirely + * (ParparVM has no {@code Method.invoke}, so it binds through the translator's + * per-method invoke thunks and symbol table). The two files stay separate + * rather than shared through a common class so each port stays a self- + * contained artefact -- the framework core cannot depend on either port, and + * moving the shared code out would need a fourth home that carried the same + * dependency shape.

+ * + *

Lookups are memoised on (owner, name, descriptor). Reflection's own + * {@code getMethod} walks the hierarchy on every call and allocates a + * {@code Class[]} to do it; a pushed program calls the same handful of + * framework methods in a loop, so caching the resolved {@link Method} is the + * difference between "usable" and "visibly slow".

+ * + * @author Shai Almog + */ +public class InterpJavaSELinker implements InterpLinker { + private final ClassLoader loader; + // Concurrent, not plain HashMap: the interpreter is entered from every thread + // the pushed program touches, and a resolution cache is exactly the shared + // state that gets hit from all of them at once. An unsynchronised HashMap + // under concurrent put does not merely lose an entry -- it can return null + // for a key that is present, which surfaces as NoClassDefFoundError for a + // class that plainly exists. + private final Map classCache = + java.util.Collections.synchronizedMap(new HashMap()); + private final Map methodCache = + java.util.Collections.synchronizedMap(new HashMap()); + private final Map ctorCache = + java.util.Collections.synchronizedMap(new HashMap()); + private final Map fieldCache = + java.util.Collections.synchronizedMap(new HashMap()); + + public InterpJavaSELinker() { + this(InterpJavaSELinker.class.getClassLoader()); + } + + public InterpJavaSELinker(ClassLoader loader) { + this.loader = loader; + } + + public Object findClass(String internalName) { + Class c = classCache.get(internalName); + if (c != null) { + return c; + } + try { + c = resolve(internalName); + } catch (ClassNotFoundException e) { + return null; + } + classCache.put(internalName, c); + return c; + } + + public void initializeClass(String internalName) throws Throwable { + // findClass resolves with initialize=false on purpose; this is the one + // caller that wants the initializer to have run. + Object c = findClass(internalName); + if (c instanceof Class) { + Class.forName(((Class)c).getName(), true, loader); + } + } + + public void initializeDefaultBearingInterfaces(String internalName) throws Throwable { + Object c = findClass(internalName); + if (c instanceof Class) { + initializeDefaultBearing((Class)c, new java.util.HashSet()); + } + } + + /// Superinterfaces first, then the interface itself if it declares a + /// default method -- the order JLS 12.4.1 gives, applied to whatever depth + /// the app's own hierarchy has. + /// + /// Bounded by what has already been seen rather than by a depth number. + /// The walk has to terminate because a diamond visits the same interface + /// twice, not because a hierarchy is too deep -- and a cap of sixteen + /// edges silently skipped a legal ancestor, leaving the default methods it + /// declares uninitialized when a pushed class implemented it. + private void initializeDefaultBearing(Class iface, java.util.Set visited) throws Throwable { + if (!iface.isInterface() || !visited.add(iface)) { + return; + } + Class[] parents = iface.getInterfaces(); + for (int i = 0; i < parents.length; i++) { + initializeDefaultBearing(parents[i], visited); + } + java.lang.reflect.Method[] methods = iface.getDeclaredMethods(); + for (int i = 0; i < methods.length; i++) { + if (methods[i].isDefault()) { + Class.forName(iface.getName(), true, loader); + return; + } + } + } + + private Class resolve(String internalName) throws ClassNotFoundException { + if (internalName.length() == 1) { + switch (internalName.charAt(0)) { + case 'Z': return Boolean.TYPE; + case 'B': return Byte.TYPE; + case 'C': return Character.TYPE; + case 'S': return Short.TYPE; + case 'I': return Integer.TYPE; + case 'J': return Long.TYPE; + case 'F': return Float.TYPE; + case 'D': return Double.TYPE; + case 'V': return Void.TYPE; + default: break; + } + } + if (internalName.startsWith("[")) { + return Class.forName(internalName.replace('/', '.'), false, loader); + } + if (internalName.startsWith("L") && internalName.endsWith(";")) { + return Class.forName( + internalName.substring(1, internalName.length() - 1).replace('/', '.'), + false, loader); + } + return Class.forName(internalName.replace('/', '.'), false, loader); + } + + private Class[] paramTypes(String descriptor) throws ClassNotFoundException { + String[] descs = InterpValuesAccess.argumentTypes(descriptor); + Class[] types = new Class[descs.length]; + for (int i = 0; i < descs.length; i++) { + types[i] = resolve(descs[i]); + } + return types; + } + + private Method lookupMethod(String owner, String name, String descriptor) + throws ClassNotFoundException, NoSuchMethodException { + String key = owner + '.' + name + descriptor; + Method m = methodCache.get(key); + if (m != null) { + return m; + } + Class c = resolve(owner); + Class[] types = paramTypes(descriptor); + NoSuchMethodException last = null; + // Walk up rather than relying on getMethod: the method may be public on + // a package-private class, or declared on a supertype, and + // getDeclaredMethod alone would miss inherited declarations. + for (Class k = c; k != null; k = k.getSuperclass()) { + try { + m = k.getDeclaredMethod(name, types); + break; + } catch (NoSuchMethodException e) { + last = e; + } + } + if (m == null) { + m = findInInterfaces(c, name, types); + } + if (m == null) { + throw last != null ? last : new NoSuchMethodException(key); + } + m.setAccessible(true); + methodCache.put(key, m); + return m; + } + + private Method findInInterfaces(Class c, String name, Class[] types) { + // Collect every inheritable interface declaration reachable through + // this class or any of its superclasses, then pick the maximally + // specific per JLS 5.4.3.3. Returning the first depth-first hit + // instead would pick I.m over J.m on a class declaring + // `implements I, J` where `J extends I` overrides the default -- and + // the same problem happens when the more-specific interface is + // inherited through a superclass, which is why the pool is drawn from + // the whole chain, not just this class's own interfaces. + java.util.ArrayList candidates = new java.util.ArrayList(); + java.util.HashSet visited = new java.util.HashSet(); + for (Class k = c; k != null; k = k.getSuperclass()) { + for (Class iface : k.getInterfaces()) { + collectInterfaceCandidates(iface, name, types, candidates, visited); + } + } + int count = candidates.size(); + if (count == 0) { + return null; + } + if (count == 1) { + return candidates.get(0); + } + // Collect maximally specific candidates: keep only those that no + // other candidate's declaring interface subtypes. Duplicates by + // declaring class collapse -- the same interface's default only + // needs to appear once in the set. + java.util.ArrayList maximal = new java.util.ArrayList(); + java.util.HashSet seenDeclaring = new java.util.HashSet(); + for (int i = 0; i < count; i++) { + Method mi = candidates.get(i); + Class declaringA = mi.getDeclaringClass(); + if (!seenDeclaring.add(declaringA)) { + continue; + } + boolean dominated = false; + for (int j = 0; j < count; j++) { + if (i == j) { + continue; + } + Class declaringB = candidates.get(j).getDeclaringClass(); + if (!declaringA.equals(declaringB) + && declaringA.isAssignableFrom(declaringB)) { + dominated = true; + break; + } + } + if (!dominated) { + maximal.add(mi); + } + } + if (maximal.size() == 1) { + return maximal.get(0); + } + // Multiple non-abstract maximally specific methods that do not + // dominate each other is IncompatibleClassChangeError per JVMS + // 5.4.3.3 -- possible after binary-compatible interface evolution. + // Silently picking one would run an arbitrary body the JVM refuses. + if (maximal.size() > 1) { + StringBuilder message = new StringBuilder(); + for (int i = 0; i < maximal.size(); i++) { + if (i > 0) { + message.append(", "); + } + message.append(maximal.get(i).getDeclaringClass().getName()); + } + throw new IncompatibleClassChangeError("conflicting default methods for " + + name + " on " + c.getName() + ": " + message); + } + return candidates.get(0); + } + + private void collectInterfaceCandidates(Class iface, String name, Class[] types, + java.util.ArrayList candidates, + java.util.HashSet visited) { + if (!visited.add(iface)) { + return; + } + try { + Method m = iface.getDeclaredMethod(name, types); + int mods = m.getModifiers(); + // Only instance, non-private, non-abstract declarations are + // eligible interface defaults. Reflection ignores the receiver + // for a static invoke, so admitting `A.staticM()` when `B` + // declares a same-descriptor default would silently run A's + // body. Abstract methods contribute no body, so filtering them + // here keeps the maximally-specific check focused on real + // defaults and matches the JVMS 5.4.3.3 "non-abstract maximally + // specific" rule. + if (!Modifier.isStatic(mods) && !Modifier.isPrivate(mods) + && !Modifier.isAbstract(mods)) { + candidates.add(m); + } + } catch (NoSuchMethodException ignore) { + // Not declared here -- keep walking; a superinterface may declare it. + } + for (Class parent : iface.getInterfaces()) { + collectInterfaceCandidates(parent, name, types, candidates, visited); + } + } + + private Field lookupField(String owner, String name) + throws ClassNotFoundException, NoSuchFieldException { + String key = owner + '#' + name; + Field f = fieldCache.get(key); + if (f != null) { + return f; + } + Class c = resolve(owner); + NoSuchFieldException last = null; + for (Class k = c; k != null; k = k.getSuperclass()) { + try { + f = k.getDeclaredField(name); + break; + } catch (NoSuchFieldException e) { + last = e; + } + } + if (f == null) { + f = findFieldInInterfaces(c, name); + } + if (f == null) { + throw last != null ? last : new NoSuchFieldException(key); + } + f.setAccessible(true); + fieldCache.put(key, f); + return f; + } + + private Field findFieldInInterfaces(Class c, String name) { + if (c == null) { + return null; + } + Class[] ifaces = c.getInterfaces(); + for (int i = 0; i < ifaces.length; i++) { + try { + return ifaces[i].getDeclaredField(name); + } catch (NoSuchFieldException ignore) { + Field f = findFieldInInterfaces(ifaces[i], name); + if (f != null) { + return f; + } + } + } + return findFieldInInterfaces(c.getSuperclass(), name); + } + + public Object construct(Object hostClass, String descriptor, Object[] args) throws Throwable { + Class c = (Class) hostClass; + String key = c.getName() + "" + descriptor; + Constructor ctor = ctorCache.get(key); + if (ctor == null) { + ctor = c.getDeclaredConstructor(paramTypes(descriptor)); + ctor.setAccessible(true); + ctorCache.put(key, ctor); + } + try { + return ctor.newInstance(args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public Object invokeVirtual(Object target, String owner, String name, String descriptor, + Object[] args) throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + // Resolve against the *declared* owner, not the receiver's concrete + // class. Method.invoke already dispatches virtually, so the override is + // still reached -- and resolving on the concrete class would often land + // on a non-public implementation type (ArrayList$Itr for an iterator), + // where setAccessible now throws InaccessibleObjectException because + // java.base does not open java.util to an unnamed module. + Method m = lookupMethod(owner, name, descriptor); + try { + return m.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public Object invokeSpecial(Object target, String owner, String name, String descriptor, + Object[] args) throws Throwable { + Method m = lookupMethod(owner, name, descriptor); + try { + return m.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public boolean hasMethod(String owner, String name, String descriptor) { + try { + // The call is the question; lookupMethod either answers or throws. + lookupMethod(owner, name, descriptor); + return true; + } catch (ClassNotFoundException absent) { + return false; + } catch (NoSuchMethodException absent) { + return false; + } + } + + public Object invokeStatic(String owner, String name, String descriptor, Object[] args) + throws Throwable { + Method m = lookupMethod(owner, name, descriptor); + if (!Modifier.isStatic(m.getModifiers())) { + throw new IncompatibleClassChangeError(owner + "." + name + " is not static"); + } + try { + return m.invoke(null, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public Object getStatic(String owner, String name, String descriptor) throws Throwable { + return lookupField(owner, name).get(null); + } + + public void setStatic(String owner, String name, String descriptor, Object value) + throws Throwable { + lookupField(owner, name).set(null, value); + } + + public Object getField(Object target, String owner, String name, String descriptor) + throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + return lookupField(owner, name).get(target); + } + + public void setField(Object target, String owner, String name, String descriptor, Object value) + throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + lookupField(owner, name).set(target, value); + } + + public boolean isInstance(Object hostClass, Object value) { + return hostClass != null && ((Class) hostClass).isInstance(value); + } + + public Object newArray(String componentDescriptor, int length) throws Throwable { + return Array.newInstance(resolve(componentDescriptor), length); + } + + public Object newMultiArray(String arrayDescriptor, int[] dimensions) throws Throwable { + // The descriptor names the whole array type; strip one '[' per + // dimension being allocated to get the component Array.newInstance + // wants. + String component = arrayDescriptor.substring(dimensions.length); + return Array.newInstance(resolve(component), dimensions); + } + + public Object cloneArray(Object source) { + Class component = source.getClass().getComponentType(); + if (component == null) { + return null; + } + return Array.newInstance(component, Array.getLength(source)); + } + + public Object classObject(Object hostClass) { + return hostClass; + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index d0b590befb4..4f9eb97a8a0 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -9806,6 +9806,14 @@ public void init(Object m) { inInit = true; installGeneratedSvgRegistry(); + // The device runtime's reflection-backed linker. Registered here for + // the same reason iOS registers its own in IOSImplementation.init: + // pushed code cannot bind without a linker, and the JavaSE simulator + // is the documented target for `cn1-push.sh --lan` during + // development. Reflection is always present on the JVM, so this + // linker has no availability check like the iOS one does. + com.codename1.impl.interp.InterpPlatform.register(new InterpJavaSELinker()); + // Make the desktop stdio and loopback socket MCP transports available to // com.codename1.mcp.MCP. MCPStdioTransport.register(); @@ -16707,6 +16715,12 @@ public void captureVideo(VideoCaptureConstraints constraints, com.codename1.ui.e public InputStream getResourceAsStream(Class cls, String resource) { + // A resource pushed by the device runtime wins over the app's own, + // so a pushed program shows its own theme rather than the host's. + InputStream local = localResource(resource); + if (local != null) { + return local; + } if (!resource.startsWith("/")) { System.out.println("ERROR: resources must reside in the root directory thus must start with a '/' character in Codename One! Invalid resource: " + resource); return null; diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.h b/Ports/iOSPort/nativeSources/cn1_debugger.h index 3bc4703be0e..679b401e8a7 100644 --- a/Ports/iOSPort/nativeSources/cn1_debugger.h +++ b/Ports/iOSPort/nativeSources/cn1_debugger.h @@ -27,6 +27,15 @@ #ifdef CN1_ON_DEVICE_DEBUG #include "cn1_globals.h" +// The translator-generated metadata ABI -- cn1_field_entry, cn1_invoke_arg, +// cn1_invoke_result, cn1_invoke_thunk_t and the three register_* entry points. +// These used to be declared here, which meant generated code could only be +// compiled by a target that shipped this iOS-port header, and tied the invoke +// thunks to the notion of a debugger session. The on-device interpreter binds +// through the same thunks with no proxy attached, so the translator owns them +// now; cn1_debugger.m still provides the real registry implementations, whose +// strong definitions override the weak sinks in cn1_reflect. +#include "cn1_reflect.h" /** @@ -44,47 +53,24 @@ */ extern void cn1_debugger_start(void); -/** - * Per-class instance-field descriptor emitted by the translator (one - * static array per generated class), then registered with the debugger - * runtime by a __attribute__((constructor)) shim that the translator also - * emits. The runtime uses these to answer CMD_GET_OBJECT_FIELDS without - * any reflection / RTTI from ParparVM. - * - * offset is from the start of the object struct (i.e. offsetof). type is - * a JVM type-char ('I','J','F','D','Z','B','S','C','L' — 'L' covers - * arrays too since arrays are JAVA_OBJECT in the struct). - */ -typedef struct cn1_field_entry { - int fieldId; - int offset; - char type; - const char* name; -} cn1_field_entry; - -/** - * Translator-generated constructors call this once at process load to - * publish the class's field table to the debugger runtime. classId is - * the cn1_class_id_XXX constant. +/* + * cn1_field_entry and cn1_debugger_register_fields are declared in + * cn1_reflect.h, included above. The runtime uses the field tables to answer + * CMD_GET_OBJECT_FIELDS without any reflection / RTTI from ParparVM. */ -extern void cn1_debugger_register_fields(int classId, - const cn1_field_entry* table, - int count); -/** - * Publishes a class's {@code clazz} address under its classId, from the same - * translator-generated constructor that registers the field table. +/* + * cn1_debugger_register_class is declared in cn1_reflect.h, included above. * * Everything the debugger is handed as an object reference is untrusted: the * IDE echoes back ids it was given earlier, and a local slot can hold a value - * the frame never initialised on the branch it stopped in. With this registry + * the frame never initialised on the branch it stopped in. With that registry * the runtime can decide whether a candidate pointer really is a Java object * by checking that its class word is the registered clazz for the classId it * claims — an exact identity test, not a range guess. Before it existed, a * bogus reference was dereferenced directly and took the app down mid-session * (issue #5333). */ -extern void cn1_debugger_register_class(int classId, struct clazz* cls); /** * Copies {@code len} bytes from a possibly-invalid address, returning 0 @@ -177,54 +163,20 @@ extern int cn1_debugger_var_in_scope(const struct cn1_var_entry* v, int line); /* --- Method invocation -------------------------------------------------- */ -/** - * Argument or scratch slot for a debugger-driven method invocation. All - * args travel as a flat array of these; the thunk reads the right field - * for each declared parameter. Floats/doubles round-trip through the bit - * width of their integer counterparts since debug clients pass them as - * raw 32/64-bit values. - */ -typedef union cn1_invoke_arg { - JAVA_INT i; - JAVA_LONG j; - JAVA_FLOAT f; - JAVA_DOUBLE d; - JAVA_OBJECT o; -} cn1_invoke_arg; - -/** - * Result of a debugger-driven method invocation. {@code type} is a JVM - * type-char ('V', 'I', 'J', 'F', 'D', 'L', 'Z', 'B', 'S', 'C') or 'X' - * if the call threw — in which case {@code value.o} carries the - * Throwable. - */ -typedef struct cn1_invoke_result { - char type; - cn1_invoke_arg value; -} cn1_invoke_result; - -/** - * Translator-emitted per-method shim. The thunk unpacks {@code args} - * into the typed C parameters the underlying translated function - * expects, dispatches through {@code virtual_(...)} (instance) or - * the static symbol (static), and packs the return into {@code result}. - * Exceptions are caught and surfaced as result.type='X'. +/* + * cn1_invoke_arg, cn1_invoke_result, cn1_invoke_thunk_t and + * cn1_debugger_register_invoke_thunk are declared in cn1_reflect.h, included + * above. * - * Runs on the suspended Java thread so it has a valid - * {@code threadStateData} context. - */ -typedef void (*cn1_invoke_thunk_t)(struct ThreadLocalData* threadStateData, - JAVA_OBJECT thisObj, - const cn1_invoke_arg* args, - cn1_invoke_result* result); - -/** - * Translator-emitted constructor registers each method's thunk at - * process load. methodId matches the same value the sidecar carries, - * so the proxy can look up by name → methodId and forward to the - * device with no further mapping. + * When the debugger drives a call, the thunk runs on the suspended Java thread + * so it has a valid threadStateData context and no collection can race it. The + * interpreter calls the same thunks on a live thread instead, which is why the + * constructor thunks hold their freshly allocated receiver in a C local -- the + * collector's conservative native-stack scan is what keeps it alive there. + * + * methodId matches the value the symbol sidecar carries, so a consumer can look + * up by name -> methodId and dispatch with no further mapping. */ -extern void cn1_debugger_register_invoke_thunk(int methodId, cn1_invoke_thunk_t thunk); #ifdef __BLOCKS__ /** diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.m b/Ports/iOSPort/nativeSources/cn1_debugger.m index ef944b24f8b..f2a14a089aa 100644 --- a/Ports/iOSPort/nativeSources/cn1_debugger.m +++ b/Ports/iOSPort/nativeSources/cn1_debugger.m @@ -401,6 +401,61 @@ static cn1_invoke_thunk_t invoke_thunk_for(int methodId) { return g_invokeThunks[methodId]; } +/** + * The invoke thunk for a methodId, or null. + * + * Exported for the on-device interpreter, which dispatches through the same + * thunks with no debugger session attached -- they are the only way to call a + * method by name on a runtime with no reflection. Overrides the weak + * definition in cn1_reflect. + */ +cn1_invoke_thunk_t cn1_reflect_thunk_for_method(int methodId) { + return invoke_thunk_for(methodId); +} + +/** The field entry for (classId, fieldId), or null. Exported for the same reason. */ +const cn1_field_entry* cn1_reflect_field_for(int classId, int fieldId) { + return field_lookup_by_class_and_id(classId, fieldId, NULL); +} + +/* + * Static field accessors, indexed by fieldId exactly like the invoke thunks. + * + * Instance fields are reachable through a class's offset table, because an + * instance field is an offset from a receiver. A static field is a named C + * global with no receiver and no table, so the translator emits a typed + * accessor pair per field and one uniform wrapper that this indexes. Same + * shape, same growth policy, same lock as the thunk registry above -- the two + * are registered from the same generated constructors. + */ +static cn1_static_accessor_t* g_staticAccessors = NULL; +static int g_staticAccessorCap = 0; + +void cn1_debugger_register_static_accessor(int fieldId, cn1_static_accessor_t accessor) { + if (fieldId < 0) return; + pthread_mutex_lock(&g_invokeRegMutex); + if (fieldId >= g_staticAccessorCap) { + int newCap = g_staticAccessorCap == 0 ? CN1_INVOKE_REG_INITIAL_CAP + : g_staticAccessorCap * 2; + while (fieldId >= newCap) newCap *= 2; + cn1_static_accessor_t* n = (cn1_static_accessor_t*)realloc(g_staticAccessors, + newCap * sizeof(cn1_static_accessor_t)); + if (!n) { pthread_mutex_unlock(&g_invokeRegMutex); return; } + memset(n + g_staticAccessorCap, 0, + (newCap - g_staticAccessorCap) * sizeof(cn1_static_accessor_t)); + g_staticAccessors = n; + g_staticAccessorCap = newCap; + } + g_staticAccessors[fieldId] = accessor; + pthread_mutex_unlock(&g_invokeRegMutex); +} + +/** The accessor for a static fieldId, or null. Overrides the weak definition. */ +cn1_static_accessor_t cn1_reflect_static_accessor_for(int fieldId) { + if (fieldId < 0 || fieldId >= g_staticAccessorCap) return NULL; + return g_staticAccessors[fieldId]; +} + /** * Read a field value into 8 host-endian bytes plus a JVM type-char. * Object refs become the JAVA_OBJECT pointer reinterpreted as uint64 so diff --git a/Ports/iOSPort/nativeSources/cn1_debugger_objects.c b/Ports/iOSPort/nativeSources/cn1_debugger_objects.c index 8ebde026e75..5e8e81bedf8 100644 --- a/Ports/iOSPort/nativeSources/cn1_debugger_objects.c +++ b/Ports/iOSPort/nativeSources/cn1_debugger_objects.c @@ -69,6 +69,22 @@ void cn1_debugger_register_class(int classId, struct clazz* cls) { pthread_mutex_unlock(&g_classRegMutex); } +/** + * The clazz registered under a classId, or null. + * + * Exported because the on-device interpreter needs it from another translation + * unit and with no debugger session attached -- it is how a pushed program's + * `ldc SomeClass.class` and `instanceof` reach a real clazz. Overrides the weak + * definition in cn1_reflect. + */ +struct clazz* cn1_reflect_clazz_for(int classId) { + if (classId < 0) return NULL; + pthread_mutex_lock(&g_classRegMutex); + struct clazz* result = classId < g_classByIdCap ? g_classById[classId] : NULL; + pthread_mutex_unlock(&g_classRegMutex); + return result; +} + /** * Copies len bytes from a possibly-invalid address, returning 0 instead of * faulting when the address is not mapped. diff --git a/Ports/iOSPort/nativeSources/cn1_interp_ios.m b/Ports/iOSPort/nativeSources/cn1_interp_ios.m new file mode 100644 index 00000000000..70b4d104329 --- /dev/null +++ b/Ports/iOSPort/nativeSources/cn1_interp_ios.m @@ -0,0 +1,600 @@ +/* + * 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 native half of the iOS device runtime. + * + * ParparVM has no reflection, so an interpreter cannot ask a class for a method + * by name. What an interp-host build provides instead is a per-method invoke + * thunk registered under a numeric id (see ByteCodeClass's + * appendOnDeviceDebugInvokeThunks) and a symbol table mapping JVM names and + * descriptors to those ids. This file is the bridge: it hands the symbol table + * to Java, and dispatches a thunk once Java has resolved an id. + * + * Everything here is inert without CN1_INTERP_HOST -- the thunks simply are not + * in the binary -- so each entry point degrades to "unsupported" rather than + * failing at the first call. + */ + +#include "cn1_globals.h" +#include "cn1_reflect.h" +#include "java_lang_String.h" +/* The exceptions this file raises. Each __NEW_INSTANCE_ constructor is declared + in its own generated header, and clang treats a missing declaration as an + error rather than an implicit int-returning function. */ +#include "java_lang_IllegalArgumentException.h" +#include "java_lang_NegativeArraySizeException.h" +#include "java_lang_NoSuchFieldError.h" +#include "java_lang_NullPointerException.h" +#include "java_lang_UnsupportedOperationException.h" +#import + +#ifdef CN1_ON_DEVICE_DEBUG +#include "cn1_debugger.h" +extern const unsigned char* cn1_debug_symbols_data(void); +extern int cn1_debug_symbols_length(void); +#endif + +/* Declared in cn1_reflect.h; the strong definitions live with the registries + in cn1_debugger.m / cn1_debugger_objects.c. */ +extern JAVA_OBJECT fromNSString(CODENAME_ONE_THREAD_STATE, NSString* str); + +#include +#include + +/* Kind codes, matching com.codename1.impl.interp.InterpOpcodes. */ +#define K_VOID 0 +#define K_INT 1 +#define K_LONG 2 +#define K_FLOAT 3 +#define K_DOUBLE 4 +#define K_OBJECT 5 +#define K_BOOLEAN 6 +#define K_BYTE 7 +#define K_CHAR 8 +#define K_SHORT 9 + +JAVA_BOOLEAN com_codename1_impl_ios_InterpIOSNative_isInterpHostBuild___R_boolean( + CODENAME_ONE_THREAD_STATE) { +#if defined(CN1_INTERP_HOST) && defined(CN1_ON_DEVICE_DEBUG) + return JAVA_TRUE; +#else + return JAVA_FALSE; +#endif +} + +/* + * The symbol table is linked in gzip-compressed, because it is large and highly + * repetitive. ParparVM's java.util has no zip package, so it is inflated here + * and handed over as text: the alternative would be shipping an inflater in + * Java purely for this. + */ +JAVA_OBJECT com_codename1_impl_ios_InterpIOSNative_symbolTable___R_java_lang_String( + CODENAME_ONE_THREAD_STATE) { +#if defined(CN1_INTERP_HOST) && defined(CN1_ON_DEVICE_DEBUG) + const unsigned char* gz = cn1_debug_symbols_data(); + int gzLen = cn1_debug_symbols_length(); + if (gz == NULL || gzLen <= 0) { + return newStringFromCString(threadStateData, ""); + } + /* The sidecar is written by java.util.zip.GZIPOutputStream, so it is a gzip + stream. NSDataCompressionAlgorithmZlib is, despite the name, raw DEFLATE + with no header of any kind -- handing it the gzip bytes fails, and fails + silently by returning nil, which presents as "this build has no + interpreter bindings" on an app that plainly has them. + + So strip the framing: a fixed 10-byte header, then whichever optional + fields FLG announces, and an 8-byte CRC/length trailer at the end. Java + sets no optional flags today; the parsing is here so that a future + toolchain that does set one does not reintroduce a silent nil. */ + int deflateStart = 10; + int deflateEnd = gzLen - 8; + if (gzLen < 18 || gz[0] != 0x1f || gz[1] != 0x8b || gz[2] != 8) { + return newStringFromCString(threadStateData, ""); + } + unsigned char flg = gz[3]; + if (flg & 0x04) { /* FEXTRA */ + if (deflateStart + 2 > deflateEnd) { + return newStringFromCString(threadStateData, ""); + } + int xlen = gz[deflateStart] | (gz[deflateStart + 1] << 8); + deflateStart += 2 + xlen; + } + if (flg & 0x08) { /* FNAME, NUL terminated */ + while (deflateStart < deflateEnd && gz[deflateStart] != 0) deflateStart++; + deflateStart++; + } + if (flg & 0x10) { /* FCOMMENT, NUL terminated */ + while (deflateStart < deflateEnd && gz[deflateStart] != 0) deflateStart++; + deflateStart++; + } + if (flg & 0x02) { /* FHCRC */ + deflateStart += 2; + } + if (deflateStart >= deflateEnd) { + return newStringFromCString(threadStateData, ""); + } + NSData* compressed = [NSData dataWithBytes:gz + deflateStart + length:deflateEnd - deflateStart]; + NSData* plain = nil; + if (@available(iOS 13.0, *)) { + NSError* err = nil; + plain = [compressed decompressedDataUsingAlgorithm:NSDataCompressionAlgorithmZlib + error:&err]; + if (err != nil) { + plain = nil; + } + } + if (plain == nil) { + return newStringFromCString(threadStateData, ""); + } + NSString* text = [[NSString alloc] initWithData:plain encoding:NSUTF8StringEncoding]; + if (text == nil) { + return newStringFromCString(threadStateData, ""); + } + return fromNSString(threadStateData, text); +#else + return newStringFromCString(threadStateData, ""); +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_InterpIOSNative_invokeById___int_java_lang_Object_long_1ARRAY_java_lang_Object_1ARRAY_int_1ARRAY_int_int_long_1ARRAY_R_java_lang_Object( + CODENAME_ONE_THREAD_STATE, JAVA_INT methodId, JAVA_OBJECT target, + JAVA_OBJECT prims, JAVA_OBJECT objs, JAVA_OBJECT kinds, + JAVA_INT argCount, JAVA_INT returnKind, JAVA_OBJECT resultOut) { +#if defined(CN1_INTERP_HOST) && defined(CN1_ON_DEVICE_DEBUG) + cn1_invoke_thunk_t thunk = cn1_reflect_thunk_for_method(methodId); + if (thunk == NULL) { + throwException(threadStateData, + __NEW_INSTANCE_java_lang_UnsupportedOperationException(threadStateData)); + return JAVA_NULL; + } + if (argCount < 0 || argCount > 32) { + throwException(threadStateData, + __NEW_INSTANCE_java_lang_IllegalArgumentException(threadStateData)); + return JAVA_NULL; + } + + cn1_invoke_arg argv[32]; + memset(argv, 0, sizeof(argv)); + JAVA_ARRAY primArray = (JAVA_ARRAY)prims; + JAVA_ARRAY objArray = (JAVA_ARRAY)objs; + JAVA_ARRAY kindArray = (JAVA_ARRAY)kinds; + JAVA_ARRAY_LONG* primData = primArray == JAVA_NULL + ? NULL : (JAVA_ARRAY_LONG*)primArray->data; + JAVA_ARRAY_OBJECT* objData = objArray == JAVA_NULL + ? NULL : (JAVA_ARRAY_OBJECT*)objArray->data; + JAVA_ARRAY_INT* kindData = kindArray == JAVA_NULL + ? NULL : (JAVA_ARRAY_INT*)kindArray->data; + + for (int i = 0; i < argCount; i++) { + int k = kindData == NULL ? K_OBJECT : (int)kindData[i]; + JAVA_LONG raw = primData == NULL ? 0 : (JAVA_LONG)primData[i]; + switch (k) { + case K_OBJECT: + argv[i].o = objData == NULL ? JAVA_NULL : (JAVA_OBJECT)objData[i]; + break; + case K_LONG: + argv[i].j = raw; + break; + case K_FLOAT: { + /* Floats travel as their raw int bits, so the value survives + the trip through a long slot unchanged. */ + uint32_t bits = (uint32_t)raw; + memcpy(&argv[i].f, &bits, 4); + break; + } + case K_DOUBLE: { + uint64_t bits = (uint64_t)raw; + memcpy(&argv[i].d, &bits, 8); + break; + } + default: + argv[i].i = (JAVA_INT)raw; + break; + } + } + + cn1_invoke_result result; + memset(&result, 0, sizeof(result)); + thunk(threadStateData, target, argv, &result); + + if (result.type == 'X') { + /* The callee threw. Re-raise it on this thread so the interpreter's + exception table -- and any Java catch above it -- sees a real + throwable rather than a silently swallowed failure. */ + throwException(threadStateData, result.value.o); + return JAVA_NULL; + } + + if (returnKind == K_OBJECT) { + return result.value.o; + } + if (resultOut != JAVA_NULL) { + JAVA_ARRAY out = (JAVA_ARRAY)resultOut; + JAVA_ARRAY_LONG* outData = (JAVA_ARRAY_LONG*)out->data; + switch (returnKind) { + case K_LONG: + outData[0] = (JAVA_ARRAY_LONG)result.value.j; + break; + case K_FLOAT: { + uint32_t bits; + memcpy(&bits, &result.value.f, 4); + outData[0] = (JAVA_ARRAY_LONG)bits; + break; + } + case K_DOUBLE: { + uint64_t bits; + memcpy(&bits, &result.value.d, 8); + outData[0] = (JAVA_ARRAY_LONG)bits; + break; + } + case K_VOID: + outData[0] = 0; + break; + default: + outData[0] = (JAVA_ARRAY_LONG)result.value.i; + break; + } + } + return JAVA_NULL; +#else + throwException(threadStateData, + __NEW_INSTANCE_java_lang_UnsupportedOperationException(threadStateData)); + return JAVA_NULL; +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_InterpIOSNative_getFieldById___int_java_lang_Object_int_long_1ARRAY_R_java_lang_Object( + CODENAME_ONE_THREAD_STATE, JAVA_INT fieldId, JAVA_OBJECT target, + JAVA_INT kind, JAVA_OBJECT resultOut) { +#if defined(CN1_INTERP_HOST) && defined(CN1_ON_DEVICE_DEBUG) + if (target == JAVA_NULL) { + throwException(threadStateData, + __NEW_INSTANCE_java_lang_NullPointerException(threadStateData)); + return JAVA_NULL; + } + const cn1_field_entry* entry = cn1_reflect_field_for( + target->__codenameOneParentClsReference->classId, fieldId); + if (entry == NULL) { + throwException(threadStateData, + __NEW_INSTANCE_java_lang_NoSuchFieldError(threadStateData)); + return JAVA_NULL; + } + char* base = (char*)target; + void* slot = base + entry->offset; + if (kind == K_OBJECT) { + return *(JAVA_OBJECT*)slot; + } + if (resultOut != JAVA_NULL) { + JAVA_ARRAY out = (JAVA_ARRAY)resultOut; + JAVA_ARRAY_LONG* outData = (JAVA_ARRAY_LONG*)out->data; + switch (entry->type) { + case 'J': outData[0] = (JAVA_ARRAY_LONG)(*(JAVA_LONG*)slot); break; + case 'D': { + uint64_t bits; + memcpy(&bits, slot, 8); + outData[0] = (JAVA_ARRAY_LONG)bits; + break; + } + case 'F': { + uint32_t bits; + memcpy(&bits, slot, 4); + outData[0] = (JAVA_ARRAY_LONG)bits; + break; + } + case 'Z': case 'B': outData[0] = (JAVA_ARRAY_LONG)(*(JAVA_BYTE*)slot); break; + case 'C': outData[0] = (JAVA_ARRAY_LONG)(*(JAVA_CHAR*)slot); break; + case 'S': outData[0] = (JAVA_ARRAY_LONG)(*(JAVA_SHORT*)slot); break; + default: outData[0] = (JAVA_ARRAY_LONG)(*(JAVA_INT*)slot); break; + } + } + return JAVA_NULL; +#else + throwException(threadStateData, + __NEW_INSTANCE_java_lang_UnsupportedOperationException(threadStateData)); + return JAVA_NULL; +#endif +} + +/* + * Static fields. + * + * An instance field is an offset from a receiver, which is what the field table + * above records. A static has no receiver: the translator gives it a named C + * global plus typed accessor functions, so there is nothing to index by offset. + * Under interp-host it also emits one uniform wrapper per static, registered by + * fieldId, and these two entry points dispatch through it. + * + * Reading through the generated getter rather than the global is what makes the + * class's static initializer run first, so an interpreted GETSTATIC initialises + * the class exactly as compiled code would. + */ +JAVA_OBJECT com_codename1_impl_ios_InterpIOSNative_getStaticById___int_int_long_1ARRAY_R_java_lang_Object( + CODENAME_ONE_THREAD_STATE, JAVA_INT fieldId, JAVA_INT kind, JAVA_OBJECT resultOut) { +#if defined(CN1_INTERP_HOST) && defined(CN1_ON_DEVICE_DEBUG) + cn1_static_accessor_t acc = cn1_reflect_static_accessor_for(fieldId); + if (acc == NULL) { + throwException(threadStateData, + __NEW_INSTANCE_java_lang_NoSuchFieldError(threadStateData)); + return JAVA_NULL; + } + cn1_invoke_arg value; + memset(&value, 0, sizeof(value)); + char type = 'V'; + acc(threadStateData, 0, &value, &type); + if (kind == K_OBJECT) { + return value.o; + } + if (resultOut != JAVA_NULL) { + JAVA_ARRAY out = (JAVA_ARRAY)resultOut; + JAVA_ARRAY_LONG* outData = (JAVA_ARRAY_LONG*)out->data; + switch (type) { + case 'J': outData[0] = (JAVA_ARRAY_LONG)value.j; break; + case 'D': { + uint64_t bits; + memcpy(&bits, &value.d, 8); + outData[0] = (JAVA_ARRAY_LONG)bits; + break; + } + case 'F': { + uint32_t bits; + memcpy(&bits, &value.f, 4); + outData[0] = (JAVA_ARRAY_LONG)bits; + break; + } + default: outData[0] = (JAVA_ARRAY_LONG)value.i; break; + } + } + return JAVA_NULL; +#else + throwException(threadStateData, + __NEW_INSTANCE_java_lang_UnsupportedOperationException(threadStateData)); + return JAVA_NULL; +#endif +} + +JAVA_VOID com_codename1_impl_ios_InterpIOSNative_setStaticById___int_int_long_java_lang_Object( + CODENAME_ONE_THREAD_STATE, JAVA_INT fieldId, JAVA_INT kind, + JAVA_LONG rawValue, JAVA_OBJECT refValue) { +#if defined(CN1_INTERP_HOST) && defined(CN1_ON_DEVICE_DEBUG) + cn1_static_accessor_t acc = cn1_reflect_static_accessor_for(fieldId); + if (acc == NULL) { + throwException(threadStateData, + __NEW_INSTANCE_java_lang_NoSuchFieldError(threadStateData)); + return; + } + cn1_invoke_arg value; + memset(&value, 0, sizeof(value)); + char type = 'V'; + if (kind == K_OBJECT) { + value.o = refValue; + } else { + /* The accessor reports the field's own type, so ask it first and then + unpack the raw bits into the matching slot. A float arrives as its + IEEE bit pattern in the low 32 bits, not as a widened double. */ + cn1_invoke_arg probe; + memset(&probe, 0, sizeof(probe)); + acc(threadStateData, 0, &probe, &type); + switch (type) { + case 'J': value.j = (JAVA_LONG)rawValue; break; + case 'D': { + uint64_t bits = (uint64_t)rawValue; + memcpy(&value.d, &bits, 8); + break; + } + case 'F': { + uint32_t bits = (uint32_t)rawValue; + memcpy(&value.f, &bits, 4); + break; + } + default: value.i = (JAVA_INT)rawValue; break; + } + } + acc(threadStateData, 1, &value, &type); +#else + throwException(threadStateData, + __NEW_INSTANCE_java_lang_UnsupportedOperationException(threadStateData)); +#endif +} + +JAVA_VOID com_codename1_impl_ios_InterpIOSNative_setFieldById___int_java_lang_Object_int_long_java_lang_Object( + CODENAME_ONE_THREAD_STATE, JAVA_INT fieldId, JAVA_OBJECT target, + JAVA_INT kind, JAVA_LONG rawValue, JAVA_OBJECT refValue) { +#if defined(CN1_INTERP_HOST) && defined(CN1_ON_DEVICE_DEBUG) + if (target == JAVA_NULL) { + throwException(threadStateData, + __NEW_INSTANCE_java_lang_NullPointerException(threadStateData)); + return; + } + const cn1_field_entry* entry = cn1_reflect_field_for( + target->__codenameOneParentClsReference->classId, fieldId); + if (entry == NULL) { + throwException(threadStateData, + __NEW_INSTANCE_java_lang_NoSuchFieldError(threadStateData)); + return; + } + char* base = (char*)target; + void* slot = base + entry->offset; + if (kind == K_OBJECT) { + *(JAVA_OBJECT*)slot = refValue; + return; + } + switch (entry->type) { + case 'J': *(JAVA_LONG*)slot = (JAVA_LONG)rawValue; break; + case 'D': { + uint64_t bits = (uint64_t)rawValue; + memcpy(slot, &bits, 8); + break; + } + case 'F': { + uint32_t bits = (uint32_t)rawValue; + memcpy(slot, &bits, 4); + break; + } + case 'Z': case 'B': *(JAVA_BYTE*)slot = (JAVA_BYTE)rawValue; break; + case 'C': *(JAVA_CHAR*)slot = (JAVA_CHAR)rawValue; break; + case 'S': *(JAVA_SHORT*)slot = (JAVA_SHORT)rawValue; break; + default: *(JAVA_INT*)slot = (JAVA_INT)rawValue; break; + } +#else + throwException(threadStateData, + __NEW_INSTANCE_java_lang_UnsupportedOperationException(threadStateData)); +#endif +} + +JAVA_BOOLEAN com_codename1_impl_ios_InterpIOSNative_isInstanceOfId___int_java_lang_Object_R_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_INT classId, JAVA_OBJECT value) { + if (value == JAVA_NULL || classId < 0) { + return JAVA_FALSE; + } + return instanceofFunction(classId, value->__codenameOneParentClsReference->classId) + ? JAVA_TRUE : JAVA_FALSE; +} + +/// The class id of an object's actual class. +/// +/// Virtual dispatch needs it. A call site names the type it was compiled +/// against -- java.util.List for `list.add(x)` -- and invoking the method that +/// name resolves to would run AbstractList's, which throws. The receiver's own +/// class is the only thing that says which override to run. +JAVA_INT com_codename1_impl_ios_InterpIOSNative_classIdOf___java_lang_Object_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT value) { + if (value == JAVA_NULL) { + return -1; + } + return value->__codenameOneParentClsReference->classId; +} + +JAVA_OBJECT com_codename1_impl_ios_InterpIOSNative_classObjectById___int_R_java_lang_Object( + CODENAME_ONE_THREAD_STATE, JAVA_INT classId) { +#if defined(CN1_INTERP_HOST) && defined(CN1_ON_DEVICE_DEBUG) + struct clazz* c = cn1_reflect_clazz_for(classId); + if (c == NULL) { + return JAVA_NULL; + } + return (JAVA_OBJECT)c; +#else + return JAVA_NULL; +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_InterpIOSNative_newObjectArray___int_int_R_java_lang_Object( + CODENAME_ONE_THREAD_STATE, JAVA_INT arrayClassId, JAVA_INT length) { + if (length < 0) { + throwException(threadStateData, + __NEW_INSTANCE_java_lang_NegativeArraySizeException(threadStateData)); + return JAVA_NULL; + } + /* The array's own clazz when the build registered one, so a host array + carries its real type: `(String[]) value` is a checkcast against the + array class, and an Object[] fails it however its elements look. Rank 1 + to 3 of every class is registered by the interp-host build; anything + else -- deeper ranks, or an array of a class only the bundle has -- gets + the Object[] the interpreter uses for its own arrays anyway. */ + struct clazz* arrayClass = cn1_reflect_clazz_for(arrayClassId); + if (arrayClass == NULL) { + arrayClass = (struct clazz*)&class_array1__java_lang_Object; + } + return allocArray(threadStateData, length, arrayClass, sizeof(JAVA_OBJECT), 1); +} + +JAVA_OBJECT com_codename1_impl_ios_InterpIOSNative_newArrayLike___java_lang_Object_int_R_java_lang_Object( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT source, JAVA_INT length) { + if (source == JAVA_NULL || length < 0) { + return JAVA_NULL; + } + /* An empty array shaped exactly like the source: its own clazz, its own + dimension count and its own element size. Cloning a host reference array + through the generic path produced a plain Object[], and a String[] that + has become an Object[] fails the next checkcast and cannot be handed to + a method declaring String[] -- so a clone silently broke the value it + was copying. Taking the clazz from the object needs no registry lookup + and is right for ranks and component types no table anticipated. */ + struct clazz* arrayClass = source->__codenameOneParentClsReference; + if (arrayClass == NULL) { + return JAVA_NULL; + } + JAVA_ARRAY src = (JAVA_ARRAY)source; + return allocArray(threadStateData, length, arrayClass, src->primitiveSize, + src->dimensions); +} + +/* + * The class-initializer registry. + * + * ParparVM initializes a class on first entry into one of its methods and from + * the generated static-field accessors. Neither is something the device runtime + * can reach for a class that declares no static field and whose methods it has + * no reason to call -- and it has to initialize a host superclass before an + * interpreted subclass's own initializer runs, or the parent's static state is + * built after the child's. + * + * So the interp-host build registers every class's __STATIC_INITIALIZER_ here, + * from the same __attribute__((constructor)) that publishes its fields, and the + * runtime asks for one by class id. The generated function is idempotent, so a + * request for a class that is already initialized costs a comparison. + */ +static cn1_class_init_t* g_classInits = NULL; +static int g_classInitCap = 0; +static pthread_mutex_t g_classInitMutex = PTHREAD_MUTEX_INITIALIZER; + +void cn1_register_class_initializer(int classId, cn1_class_init_t fn) { + if (classId < 0 || fn == NULL) { + return; + } + pthread_mutex_lock(&g_classInitMutex); + if (classId >= g_classInitCap) { + int newCap = g_classInitCap == 0 ? 1024 : g_classInitCap * 2; + while (classId >= newCap) { + newCap *= 2; + } + cn1_class_init_t* n = (cn1_class_init_t*)realloc( + g_classInits, newCap * sizeof(cn1_class_init_t)); + if (!n) { + pthread_mutex_unlock(&g_classInitMutex); + return; + } + memset(n + g_classInitCap, 0, + (newCap - g_classInitCap) * sizeof(cn1_class_init_t)); + g_classInits = n; + g_classInitCap = newCap; + } + g_classInits[classId] = fn; + pthread_mutex_unlock(&g_classInitMutex); +} + +JAVA_VOID com_codename1_impl_ios_InterpIOSNative_initializeClassById___int( + CODENAME_ONE_THREAD_STATE, JAVA_INT classId) { + cn1_class_init_t fn = NULL; + pthread_mutex_lock(&g_classInitMutex); + if (classId >= 0 && classId < g_classInitCap) { + fn = g_classInits[classId]; + } + pthread_mutex_unlock(&g_classInitMutex); + if (fn != NULL) { + /* Outside the lock: the initializer runs arbitrary Java, which may + initialize another class and re-enter this. */ + fn(threadStateData); + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index b186feeb4bf..ae472c75074 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -260,6 +260,12 @@ public void init(Object m) { if("true".equals(Display.getInstance().getProperty("DisableScreenshots", ""))) { nativeInstance.setDisableScreenshots(true); } + // Only an interp-host build carries the invoke thunks and symbol table + // the linker needs; on an ordinary build this leaves the registry empty + // and the device runtime reports itself unavailable. + if(InterpIOSLinker.isAvailable()) { + com.codename1.impl.interp.InterpPlatform.register(new InterpIOSLinker()); + } } @Override @@ -4166,6 +4172,12 @@ private long getResourceNSData(String resource) { } public InputStream getResourceAsStream(Class cls, String resource) { + // A resource pushed by the device runtime wins over the app's own, + // so a pushed program shows its own theme rather than the host's. + InputStream local = localResource(resource); + if (local != null) { + return local; + } // Flatten resources int lastSlash = resource.lastIndexOf("/"); if ( lastSlash != -1 ){ diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/InterpIOSLinker.java b/Ports/iOSPort/src/com/codename1/impl/ios/InterpIOSLinker.java new file mode 100644 index 00000000000..4fd8f84107d --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/InterpIOSLinker.java @@ -0,0 +1,486 @@ +/* + * 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.ios; + +import com.codename1.impl.interp.InterpLinker; +import com.codename1.impl.interp.InterpValuesAccess; + +/** + * Binds interpreted code to the app on iOS. + * + *

The Android linker is {@code java.lang.reflect}; there is no equivalent + * here. ParparVM's {@code Method} and {@code Constructor} are empty stubs and + * {@code struct clazz} has no name-to-method table, so "call the method named + * setTitle" is not a question the runtime can answer.

+ * + *

What an interp-host build does provide is a per-method invoke thunk keyed + * by a numeric id, plus a symbol table mapping JVM names and descriptors to + * those ids. This class is the join: {@link InterpIOSSymbols} turns a name into + * an id, and {@link InterpIOSNative} dispatches the thunk. Ids are memoised per + * call site, because the symbol-table lookup walks a superclass chain and a + * pushed program calls the same few framework methods in a loop.

+ * + *

Values cross the native boundary unboxed -- primitives as raw bits in a + * {@code long[]}, references in an {@code Object[]}, selected per argument by a + * kind code. The C side cannot box or unbox, since doing so would need the very + * reflection this exists to replace.

+ * + * @author Shai Almog + */ +public class InterpIOSLinker implements InterpLinker { + // Kind codes, matching com.codename1.impl.interp.InterpOpcodes. + private static final int K_VOID = 0; + private static final int K_INT = 1; + private static final int K_LONG = 2; + private static final int K_FLOAT = 3; + private static final int K_DOUBLE = 4; + private static final int K_OBJECT = 5; + private static final int K_BOOLEAN = 6; + private static final int K_BYTE = 7; + private static final int K_CHAR = 8; + private static final int K_SHORT = 9; + + private final InterpIOSSymbols symbols = InterpIOSSymbols.getInstance(); + private final java.util.Hashtable methodIdCache = new java.util.Hashtable(); + private final java.util.Hashtable classIdCache = new java.util.Hashtable(); + + /** Whether this build can run pushed code at all. */ + public static boolean isAvailable() { + return InterpIOSNative.isInterpHostBuild() + && InterpIOSSymbols.getInstance().isAvailable(); + } + + public void initializeClass(String internalName) { + // A real entry point rather than a side effect: the interp-host build + // registers every class's __STATIC_INITIALIZER_ under its class id, and + // that function is idempotent. Reading a static field would also run it + // -- the generated accessor calls it first -- but a class can have an + // observable static block and declare no static field at all, and then + // there is nothing to read. + // + // A failure propagates. Java requires that a superclass initializer + // throwing aborts the subclass's initialization, and swallowing it here + // would let the subclass complete on top of a parent that never ran. + // The whole chain, top down. A generated __STATIC_INITIALIZER_ runs its + // own class's and does not reach its parent's -- compiled code + // never needs it to, because entering the parent's constructor or + // reading its statics does that -- so initializing one class here would + // leave a grandparent's static block unrun. + // The whole chain, however long it is. A fixed array truncated it, and + // the classes it dropped were the ones nearest java/lang/Object -- the + // ones most likely to carry a static block something else depends on. + java.util.Vector chain = new java.util.Vector(); + String at = internalName; + while (at != null && !chain.contains(at)) { + chain.addElement(at); + at = symbols.superName(at); + } + // Default-bearing interfaces of each host class in the chain, before + // that class's own initializer, walked with one shared visited set so a + // diamond is not initialized twice. The runtime's separate interface + // walk covers interfaces declared by the interpreted subclass, not + // ones inherited through a host superclass -- so without this an + // interface `` can run after its implementor's, reversing the + // order JLS 12.4.1 requires. `initializeDefaultBearing` skips a class + // node itself (it only marks interfaces), so the host class's own + // initializer stays this method's job. + java.util.Hashtable defaultsVisited = new java.util.Hashtable(); + for (int i = chain.size() - 1; i >= 0; i--) { + int id = symbols.classId((String)chain.elementAt(i)); + if (id < 0) { + continue; + } + initializeDefaultBearing(id, defaultsVisited); + InterpIOSNative.initializeClassById(id); + } + } + + public void initializeDefaultBearingInterfaces(String internalName) { + // JLS 12.4.1: initializing a class initializes the superinterfaces that + // declare a default method, and only those. The class row's eighth + // column says which ones do, so this can honour the rule instead of + // leaving each interface to initialize on its own first use -- which is + // entry into one of its methods, and may never happen at all. + int id = symbols.classId(internalName); + if (id < 0) { + return; + } + initializeDefaultBearing(id, new java.util.Hashtable()); + } + + /// Superinterfaces first, then the interface itself when it declares a + /// default method. Bounded by what has been seen: an interface hierarchy is + /// a DAG whose diamonds would otherwise be walked twice, and a depth cap + /// would answer wrongly on a hierarchy that is merely deep. + private void initializeDefaultBearing(int classId, java.util.Hashtable visited) { + Integer key = Integer.valueOf(classId); + if (visited.get(key) != null) { + return; + } + visited.put(key, Boolean.TRUE); + int[] ifaces = symbols.interfacesOf(classId); + if (ifaces != null) { + for (int i = 0; i < ifaces.length; i++) { + initializeDefaultBearing(ifaces[i], visited); + } + } + // Including the one this walk started from: the caller passes an + // interface the interpreted class implements directly, not the class + // itself, and that interface is as much a candidate as its ancestors. + // Only an interface is ever marked, so a class row cannot be caught by + // this -- and the class's own initializer is initializeClass's job. + if (symbols.declaresDefaultMethod(classId)) { + InterpIOSNative.initializeClassById(classId); + } + } + + public Object findClass(String internalName) { + // Array descriptors resolve too: the interp-host build emits a class + // row per rank keyed by `[Ljava/lang/String;`, so `String[].class` is a + // lookup like any other rather than a NoClassDefFoundError. + Integer cached = (Integer)classIdCache.get(internalName); + if (cached != null) { + return cached.intValue() < 0 ? null : cached; + } + int id = symbols.classId(internalName); + classIdCache.put(internalName, Integer.valueOf(id)); + return id < 0 ? null : Integer.valueOf(id); + } + + /** + * A host class is represented to the interpreter as its class id, boxed. + * There is no {@code java.lang.Class} to hand back that could answer + * anything useful -- ParparVM's Class has no member enumeration -- so the id + * is the only handle with meaning. + */ + private int idOf(Object hostClass) { + return hostClass instanceof Integer ? ((Integer)hostClass).intValue() : -1; + } + + private int methodId(String owner, String name, String descriptor) { + String key = owner + '.' + name + descriptor; + Integer cached = (Integer)methodIdCache.get(key); + if (cached != null) { + return cached.intValue(); + } + int id = symbols.methodId(owner, name, descriptor); + methodIdCache.put(key, Integer.valueOf(id)); + return id; + } + + public Object construct(Object hostClass, String descriptor, Object[] args) throws Throwable { + int classId = idOf(hostClass); + String owner = classId < 0 ? null : symbols.classNameFor(classId); + if (owner == null) { + throw new NoClassDefFoundError("unknown host class"); + } + // Exact-owner lookup, no superclass walk. A subclass whose requested + // descriptor is missing (SDK newer than the installed runtime, + // a subclass that declares only some parent constructors) has to fail + // loudly here -- the generic resolver would find a same-descriptor + // constructor on the superclass and its thunk would allocate a Base + // instance for `new Sub(args)`, hiding the missing device API. + int id = symbols.declaredMethodId(owner, "", descriptor); + if (id < 0) { + throw new NoSuchMethodError(owner + "." + descriptor + + " is not present in the installed app"); + } + // A constructor thunk allocates its own receiver and returns it, + // which is why this passes no target and expects an object back. + return invokeWithId(id, descriptor, null, args, K_OBJECT); + } + + public Object invokeVirtual(Object target, String owner, String name, String descriptor, + Object[] args) throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + // Resolution starts at the receiver's own class, not at the type the + // call site was compiled against. `List.add(x)` names java.util.List, + // and the first `add` found from there is AbstractList's, whose body + // throws UnsupportedOperationException -- so every collection call from + // interpreted code failed while Form.show(), whose call site names the + // receiver's own class, worked. There is no vtable to consult from + // here, so the walk up from the real class is the dispatch. + return invoke(receiverClass(target, owner), name, descriptor, target, args, + kindOf(InterpValuesAccess.returnType(descriptor))); + } + + /// The receiver's actual class name, falling back to the declared owner. + private String receiverClass(Object target, String owner) { + int classId = InterpIOSNative.classIdOf(target); + if (classId < 0) { + return owner; + } + String name = symbols.classNameFor(classId); + return name == null ? owner : name; + } + + public Object invokeSpecial(Object target, String owner, String name, String descriptor, + Object[] args) throws Throwable { + return invoke(owner, name, descriptor, target, args, + kindOf(InterpValuesAccess.returnType(descriptor))); + } + + public Object invokeStatic(String owner, String name, String descriptor, Object[] args) + throws Throwable { + return invoke(owner, name, descriptor, null, args, + kindOf(InterpValuesAccess.returnType(descriptor))); + } + + private Object invoke(String owner, String name, String descriptor, Object target, + Object[] args, int returnKind) throws Throwable { + int id = methodId(owner, name, descriptor); + if (id < 0) { + throw new NoSuchMethodError(owner + "." + name + descriptor + + " is not present in the installed app"); + } + return invokeWithId(id, descriptor, target, args, returnKind); + } + + /// The marshalling half of {@link #invoke}, split out so a caller that + /// resolved the id another way (constructor: exact-owner lookup) can + /// dispatch without going back through the generic resolver. + private Object invokeWithId(int id, String descriptor, Object target, + Object[] args, int returnKind) throws Throwable { + String[] argTypes = InterpValuesAccess.argumentTypes(descriptor); + int count = argTypes.length; + long[] prims = new long[count == 0 ? 1 : count]; + Object[] objs = new Object[count == 0 ? 1 : count]; + int[] kinds = new int[count == 0 ? 1 : count]; + for (int i = 0; i < count; i++) { + int k = kindOf(argTypes[i]); + kinds[i] = k; + Object a = args == null || i >= args.length ? null : args[i]; + if (k == K_OBJECT) { + objs[i] = a; + } else { + prims[i] = rawOf(k, a); + } + } + long[] out = new long[1]; + Object ref = InterpIOSNative.invokeById(id, target, prims, objs, kinds, count, + returnKind, out); + if (returnKind == K_OBJECT) { + return ref; + } + return boxed(returnKind, out[0]); + } + + /** + * Reads a host static. + * + *

A static has no receiver to hang an offset off, so the instance-field + * table cannot cover it. An interp-host build emits one accessor per static + * instead, registered under the same id space, and this dispatches through + * it. Reading that way also runs the declaring class's static initializer, + * which is what a compiled GETSTATIC does.

+ */ + public Object getStatic(String owner, String name, String descriptor) throws Throwable { + int fieldId = symbols.staticFieldId(owner, name); + if (fieldId < 0) { + throw new NoSuchFieldError(owner + "." + name + + " is not present in the installed app"); + } + int kind = kindOf(descriptor); + long[] out = new long[1]; + Object ref = InterpIOSNative.getStaticById(fieldId, kind, out); + return kind == K_OBJECT ? ref : boxed(kind, out[0]); + } + + public void setStatic(String owner, String name, String descriptor, Object value) + throws Throwable { + int fieldId = symbols.staticFieldId(owner, name); + if (fieldId < 0) { + throw new NoSuchFieldError(owner + "." + name + + " is not present in the installed app"); + } + int kind = kindOf(descriptor); + InterpIOSNative.setStaticById(fieldId, kind, + kind == K_OBJECT ? 0 : rawOf(kind, value), + kind == K_OBJECT ? value : null); + } + + public Object getField(Object target, String owner, String name, String descriptor) + throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + int fieldId = symbols.fieldId(owner, name); + if (fieldId < 0) { + throw new NoSuchFieldError(owner + "." + name); + } + int kind = kindOf(descriptor); + long[] out = new long[1]; + Object ref = InterpIOSNative.getFieldById(fieldId, target, kind, out); + return kind == K_OBJECT ? ref : boxed(kind, out[0]); + } + + public void setField(Object target, String owner, String name, String descriptor, Object value) + throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + int fieldId = symbols.fieldId(owner, name); + if (fieldId < 0) { + throw new NoSuchFieldError(owner + "." + name); + } + int kind = kindOf(descriptor); + InterpIOSNative.setFieldById(fieldId, target, kind, + kind == K_OBJECT ? 0 : rawOf(kind, value), + kind == K_OBJECT ? value : null); + } + + public boolean hasMethod(String owner, String name, String descriptor) { + return symbols.methodId(owner, name, descriptor) >= 0; + } + + public boolean isInstance(Object hostClass, Object value) { + int id = idOf(hostClass); + return id >= 0 && InterpIOSNative.isInstanceOfId(id, value); + } + + public Object cloneArray(Object source) { + // ParparVM arrays carry their element type in the object itself, which + // Java cannot read but a native can. Allocating from the source's own + // clazz is what keeps `String[] copy = original.clone()` a String[]: + // an Object[] of the right length passes nothing that checks the type, + // so the copy failed the first cast or host call it reached. + if (!(source instanceof Object[])) { + // Primitive arrays are copied by the caller, which knows their type + // from Java. + return null; + } + return InterpIOSNative.newArrayLike(source, ((Object[]) source).length); + } + + public Object newArray(String componentDescriptor, int length) throws Throwable { + int kind = kindOf(componentDescriptor); + switch (kind) { + case K_BOOLEAN: return new boolean[length]; + case K_BYTE: return new byte[length]; + case K_CHAR: return new char[length]; + case K_SHORT: return new short[length]; + case K_INT: return new int[length]; + case K_LONG: return new long[length]; + case K_FLOAT: return new float[length]; + case K_DOUBLE: return new double[length]; + default: return InterpIOSNative.newObjectArray( + arrayClassId(componentDescriptor), length); + } + } + + /** + * The class id of an array with this component, or -1. + * + *

The interp-host build publishes a class row per array rank keyed by + * the descriptor, so this is a lookup. It answers -1 for a component the + * app does not have -- an array of a bundle-only class -- and the caller + * then gets the untyped array the interpreter uses for its own.

+ */ + private int arrayClassId(String componentDescriptor) { + return symbols.classId("[" + componentDescriptor); + } + + public Object newMultiArray(String arrayDescriptor, int[] dimensions) throws Throwable { + if (dimensions.length == 0) { + return null; + } + if (dimensions.length == 1) { + return newArray(arrayDescriptor.substring(1), dimensions[0]); + } + // The outer array's own type too, for the same reason: `(String[][]) v` + // is a checkcast against the outer array class. + Object outerArray = InterpIOSNative.newObjectArray( + symbols.classId(arrayDescriptor), dimensions[0]); + Object[] outer = (Object[]) outerArray; + int[] rest = new int[dimensions.length - 1]; + System.arraycopy(dimensions, 1, rest, 0, rest.length); + for (int i = 0; i < dimensions[0]; i++) { + outer[i] = newMultiArray(arrayDescriptor.substring(1), rest); + } + return outer; + } + + public Object classObject(Object hostClass) { + int id = idOf(hostClass); + return id < 0 ? null : InterpIOSNative.classObjectById(id); + } + + // ------------------------------------------------------------ conversions + + private static int kindOf(String descriptor) { + if (descriptor == null || descriptor.length() == 0) { + return K_VOID; + } + switch (descriptor.charAt(0)) { + case 'V': return K_VOID; + case 'Z': return K_BOOLEAN; + case 'B': return K_BYTE; + case 'C': return K_CHAR; + case 'S': return K_SHORT; + case 'I': return K_INT; + case 'J': return K_LONG; + case 'F': return K_FLOAT; + case 'D': return K_DOUBLE; + default: return K_OBJECT; + } + } + + private static long rawOf(int kind, Object value) { + if (value == null) { + return 0; + } + switch (kind) { + case K_BOOLEAN: return ((Boolean)value).booleanValue() ? 1 : 0; + case K_BYTE: return ((Byte)value).byteValue(); + case K_CHAR: return ((Character)value).charValue(); + case K_SHORT: return ((Short)value).shortValue(); + case K_INT: return ((Integer)value).intValue(); + case K_LONG: return ((Long)value).longValue(); + // Raw bits: `floatToIntBits`/`doubleToLongBits` collapse every NaN + // pattern to the canonical form. A pushed program that computed a + // noncanonical NaN via `Float.intBitsToFloat(0x7fc00001)` and + // handed it to a host method would then see `0x7fc00000` come back + // through `floatToRawIntBits`, unlike normal JVM execution and + // unlike the reflection linker's marshalling path. + case K_FLOAT: return Float.floatToRawIntBits(((Float)value).floatValue()) & 0xffffffffL; + case K_DOUBLE: return Double.doubleToRawLongBits(((Double)value).doubleValue()); + default: return 0; + } + } + + private static Object boxed(int kind, long raw) { + switch (kind) { + case K_BOOLEAN: return raw != 0 ? Boolean.TRUE : Boolean.FALSE; + case K_BYTE: return Byte.valueOf((byte)raw); + case K_CHAR: return Character.valueOf((char)raw); + case K_SHORT: return Short.valueOf((short)raw); + case K_INT: return Integer.valueOf((int)raw); + case K_LONG: return Long.valueOf(raw); + case K_FLOAT: return Float.valueOf(Float.intBitsToFloat((int)raw)); + case K_DOUBLE: return Double.valueOf(Double.longBitsToDouble(raw)); + default: return null; + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/InterpIOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/InterpIOSNative.java new file mode 100644 index 00000000000..12e8dec137c --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/InterpIOSNative.java @@ -0,0 +1,132 @@ +/* + * 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.ios; + +/** + * The native half of the iOS device runtime: everything the interpreter needs + * that only C can provide. + * + *

These are ParparVM native methods rather than a {@code NativeInterface} + * because they must take and return {@code Object}, which the native-interface + * convention does not allow.

+ * + *

The argument-passing convention is deliberately flat. Primitives travel as + * raw {@code long} bits in one array and references in another, selected per + * argument by a kind code, so the C side never has to box or unbox -- it would + * have to call generated accessors to do that, which is exactly the reflection + * ParparVM does not have. The same convention carries the result back: + * primitives through {@code resultOut[0]}, references through the return + * value.

+ * + * @author Shai Almog + */ +class InterpIOSNative { + private InterpIOSNative() { + } + + /** + * The translator's symbol table as text, or an empty string when this build + * has none (i.e. was not built with {@code ios.interpHost=true}). + */ + static native String symbolTable(); + + /** + * Calls a method by its symbol-table id. + * + * @param methodId id from the symbol table + * @param target receiver, or null for a static method or a constructor + * @param prims raw bits for primitive arguments, by position + * @param objs references for object arguments, by position + * @param kinds per-argument kind, matching InterpOpcodes' RET_* codes + * @param argCount number of arguments + * @param returnKind kind of the return value + * @param resultOut receives the raw bits of a primitive result + * @return the reference result, or null + */ + static native Object invokeById(int methodId, Object target, long[] prims, Object[] objs, + int[] kinds, int argCount, int returnKind, long[] resultOut); + + /** Reads an instance field by its symbol-table id. */ + static native Object getFieldById(int fieldId, Object target, int kind, long[] resultOut); + + /** Writes an instance field by its symbol-table id. */ + static native void setFieldById(int fieldId, Object target, int kind, long rawValue, + Object refValue); + + /** + * Reads a static field by its symbol-table id. + * + *

Separate from {@link #getFieldById} because a static is reached + * differently: there is no receiver and no offset, only a generated + * accessor registered under the id. Reading through it also runs the + * declaring class's static initializer, as a compiled GETSTATIC would.

+ */ + static native Object getStaticById(int fieldId, int kind, long[] resultOut); + + /** Writes a static field by its symbol-table id. */ + static native void setStaticById(int fieldId, int kind, long rawValue, Object refValue); + + /** Whether {@code value} is an instance of the class with this id. */ + static native boolean isInstanceOfId(int classId, Object value); + + /** The {@code java.lang.Class} for a class id, or null. */ + static native Object classObjectById(int classId); + + /** + * Runs a class's static initializer, if it has not run already. + * + *

The generated initializer is idempotent, so this is safe to call on a + * class that is already initialized -- and necessary for one that declares + * no static field, which has no accessor to reach it through.

+ */ + static native void initializeClassById(int classId); + + /// The class id of an object's actual class, or -1 for null. + /// + /// Needed for virtual dispatch: the call site names the type the code was + /// compiled against, and only the receiver knows which override to run. + static native int classIdOf(Object value); + + /** + * Allocates a reference array of the class named by {@code arrayClassId}. + * + *

The id is the *array* class -- {@code [Ljava/lang/String;} -- not its + * component, because that is what the allocation needs and what a checkcast + * against `String[]` compares.

+ */ + static native Object newObjectArray(int arrayClassId, int length); + + /** + * Allocates an empty array shaped exactly like {@code source} -- the same + * class, rank and element size -- or null when it cannot be read. + * + *

This is what makes {@code clone()} keep a host array's type. The + * component of an existing array is written into the object itself, so + * copying it needs no registry entry and works for ranks and component + * types no table anticipated.

+ */ + static native Object newArrayLike(Object source, int length); + + /** True when the running binary carries invoke thunks. */ + static native boolean isInterpHostBuild(); +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/InterpIOSSymbols.java b/Ports/iOSPort/src/com/codename1/impl/ios/InterpIOSSymbols.java new file mode 100644 index 00000000000..8d0fe2d4a51 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/InterpIOSSymbols.java @@ -0,0 +1,588 @@ +/* + * 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.ios; + +import java.util.Hashtable; + +/** + * The translator's symbol table, as the interpreter needs it. + * + *

ParparVM has no reflection: {@code struct clazz} carries no name-to-method + * mapping, so nothing can call a method it did not name at compile time. What an + * interp-host build does emit is a symbol table -- class, method and field rows + * carrying JVM names and descriptors against numeric ids -- plus a per-method + * invoke thunk registered under the same method id. Together they are enough to + * go from "com/codename1/ui/Form.setTitle(Ljava/lang/String;)V" to a call.

+ * + *

Parsing happens here, in Java, rather than in C. The table is a few + * megabytes of tab-separated text and the work is string splitting and hashing, + * which is miserable to write in C against a runtime with no string library + * worth the name. The native layer's only job is to hand over the bytes and to + * dispatch a thunk once an id is known.

+ * + * @author Shai Almog + */ +class InterpIOSSymbols { + /** "owner.name+descriptor" -> method id. */ + private final Hashtable methodIds = new Hashtable(); + + /** + * The ids of methods that cannot be reached by interface dispatch -- + * static and private members. Kept apart from {@link #methodIds} because + * an explicit {@code invokestatic Interface.staticM()} still has to + * resolve the id, while a virtual call through an implementor must not + * pick it up over a same-descriptor default declared on another + * superinterface. Filtering in the collection step is what enforces + * "static and private interface methods are not inherited" without + * hiding the ids from every dispatch. + */ + private final Hashtable interfaceIneligible = new Hashtable(); + + /** "owner#name" -> instance field id. */ + private final Hashtable fieldIds = new Hashtable(); + + /** + * "owner#name" -> static field id. + * + * Kept apart from the instance map because the two are reached by different + * native calls -- an instance field by offset from a receiver, a static + * through a generated accessor -- and a class may legally declare a static + * and an instance field of the same name. + */ + private final Hashtable staticFieldIds = new Hashtable(); + + /** + * owner -> "id|descriptor" for one of its static fields. + * + *

Reading a static goes through a generated accessor, and that accessor + * runs the class's initializer first. It is the only handle Java has on + * ParparVM's per-class initializer, which is otherwise reached implicitly + * by entering a method of the class.

+ */ + private final Hashtable oneStaticField = new Hashtable(); + + /** JVM internal class name -> class id. */ + private final Hashtable classIds = new Hashtable(); + + /** class id -> JVM internal class name. */ + private final Hashtable classNames = new Hashtable(); + + /** class id -> superclass id, as an Integer. */ + private final Hashtable superIds = new Hashtable(); + + /** + * class id -> the ids of the interfaces it implements, as an int[]. + * + *

Needed because a default method lives on an interface and nowhere in + * the superclass chain. {@code new ArrayList().sort(c)} targets + * {@code java/util/List.sort}; a walk that knows only about superclasses + * visits AbstractList and Object, finds nothing, and reports NoSuchMethod + * for a method the app certainly has.

+ */ + private final Hashtable interfaceIds = new Hashtable(); + + /** + * The ids of interfaces that declare a default method. + * + *

JLS 12.4.1 initializes an interface when a class implementing it is + * initialized only when it declares one, so this is what separates the + * interfaces that have to be initialized with an implementing class from + * the ones that must not be. The interp-host build writes it as the class + * row's eighth column, because nothing else in the table records access + * flags.

+ */ + private final Hashtable defaultBearing = new Hashtable(); + + private static InterpIOSSymbols instance; + + /** Loads the table on first use; it is immutable afterwards. */ + static synchronized InterpIOSSymbols getInstance() { + if (instance == null) { + instance = new InterpIOSSymbols(); + instance.load(); + } + return instance; + } + + /** True when this build carries a symbol table at all. */ + boolean isAvailable() { + return !classIds.isEmpty(); + } + + private void load() { + String table = InterpIOSNative.symbolTable(); + if (table == null || table.length() == 0) { + return; + } + int pos = 0; + int len = table.length(); + while (pos < len) { + int nl = table.indexOf('\n', pos); + if (nl < 0) { + nl = len; + } + parseRow(table, pos, nl); + pos = nl + 1; + } + } + + private void parseRow(String table, int start, int end) { + if (end - start < 5) { + return; + } + // Rows are tab separated and the first column is the kind. Only the + // three kinds the interpreter dispatches on are kept; line and var rows + // are for the debugger and would triple the memory held here. + if (table.charAt(start) == 'c' && table.startsWith("class\t", start)) { + String[] p = split(table.substring(start, end)); + if (p.length >= 6) { + Integer id = Integer.valueOf(p[1]); + classIds.put(p[5], id); + classNames.put(id, p[5]); + // A class row carries no superclass id when it has no super -- + // java.lang.Object, and the interfaces. + if (p[4].length() > 0) { + superIds.put(id, Integer.valueOf(p[4])); + } + if (p.length >= 7 && p[6].length() > 0) { + interfaceIds.put(id, parseIds(p[6])); + } + if (p.length >= 8 && "1".equals(p[7])) { + defaultBearing.put(id, Boolean.TRUE); + } + } + } else if (table.charAt(start) == 'm' && table.startsWith("method\t", start)) { + String[] p = split(table.substring(start, end)); + if (p.length >= 5) { + String ownerName = (String)classNames.get(Integer.valueOf(p[2])); + if (ownerName != null) { + // The translator spells a constructor __INIT__; the bundle + // and every call site use the JVM's . + String name = "__INIT__".equals(p[3]) ? "" : p[3]; + Integer methodId = Integer.valueOf(p[1]); + methodIds.put(ownerName + "." + name + p[4], methodId); + // Columns 5 (isStatic) and 6 (isPrivate), each optional + // for compatibility with older sidecars that stopped at + // column 4 or 5. A "1" in either marks the id as not + // reachable by interface dispatch. + boolean staticFlag = p.length >= 6 && "1".equals(p[5]); + boolean privateFlag = p.length >= 7 && "1".equals(p[6]); + if (staticFlag || privateFlag) { + interfaceIneligible.put(methodId, Boolean.TRUE); + } + } + } + } else if (table.charAt(start) == 'f' && table.startsWith("field\t", start)) { + String[] p = split(table.substring(start, end)); + if (p.length >= 5) { + String ownerName = (String)classNames.get(Integer.valueOf(p[1])); + if (ownerName != null) { + fieldIds.put(ownerName + "#" + p[3], Integer.valueOf(p[2])); + } + } + } else if (table.charAt(start) == 's' && table.startsWith("sfield\t", start)) { + String[] p = split(table.substring(start, end)); + if (p.length >= 5) { + String ownerName = (String)classNames.get(Integer.valueOf(p[1])); + if (ownerName != null) { + staticFieldIds.put(ownerName + "#" + p[3], Integer.valueOf(p[2])); + if (oneStaticField.get(ownerName) == null) { + oneStaticField.put(ownerName, p[2] + "|" + p[4]); + } + } + } + } + } + + private static String[] split(String row) { + // A hand-rolled split: String.split takes a regex, and the regex engine + // is not something to run several hundred thousand times at startup. + int count = 1; + for (int i = 0; i < row.length(); i++) { + if (row.charAt(i) == '\t') { + count++; + } + } + String[] out = new String[count]; + int idx = 0; + int from = 0; + for (int i = 0; i < row.length(); i++) { + if (row.charAt(i) == '\t') { + out[idx++] = row.substring(from, i); + from = i + 1; + } + } + out[idx] = row.substring(from); + return out; + } + + /** A comma-separated id list, as an int[]. */ + private static int[] parseIds(String list) { + int count = 1; + for (int i = 0; i < list.length(); i++) { + if (list.charAt(i) == ',') { + count++; + } + } + int[] out = new int[count]; + int idx = 0; + int from = 0; + for (int i = 0; i < list.length(); i++) { + if (list.charAt(i) == ',') { + out[idx++] = Integer.parseInt(list.substring(from, i)); + from = i + 1; + } + } + out[idx] = Integer.parseInt(list.substring(from)); + return out; + } + + /** The JVM internal name for a class id, or null. */ + String classNameFor(int classId) { + return (String)classNames.get(Integer.valueOf(classId)); + } + + /** The class id for a JVM internal name, or -1. */ + int classId(String internalName) { + Integer id = (Integer)classIds.get(internalName); + return id == null ? -1 : id.intValue(); + } + + /** + * The method id for a call, searching up the superclass chain. + * + *

The chain walk is what makes an inherited method reachable: a call site + * naming {@code Interp_Form.show()} has to find {@code Form.show()}, which + * only the superclass declares.

+ */ + int methodId(String owner, String name, String descriptor) { + // Two passes, in JLS 5.4.3.3 order: class methods first, the whole + // superclass chain, then interface defaults if none is found. + // Reversing this -- looking at each class's interfaces before moving + // up -- can pick a default over a concrete method the superclass + // inherited, which Java gives precedence to. A subinterface overriding + // an Object method with a default while the concrete class inherits + // Object's implementation is the shape that breaks. + // + // Bounded by what has been seen rather than by a count in both passes: + // a superclass chain is finite and a self-referential table is the + // only way the walk could not end. A number would instead stop partway + // up a merely deep hierarchy and answer "no such method" for a method + // the app has. + String currentOwner = owner; + Hashtable classSeen = new Hashtable(); + while (currentOwner != null && classSeen.get(currentOwner) == null) { + classSeen.put(currentOwner, Boolean.TRUE); + Integer id = (Integer)methodIds.get(currentOwner + "." + name + descriptor); + if (id != null) { + return id.intValue(); + } + currentOwner = superName(currentOwner); + } + // No class method: consult interfaces. Candidates from every class in + // the chain are pooled first, then the maximally specific one is + // selected across the whole set -- selecting per class would return + // `I.m` from a receiver's direct `implements I` and never see the + // superclass's `J extends I` override. A shared visited set across + // the walk means a diamond (a class and its superclass both reaching + // the same interface) is walked once. + java.util.Vector candidateOwners = new java.util.Vector(); + java.util.Vector candidateIds = new java.util.Vector(); + Hashtable interfaceVisited = new Hashtable(); + currentOwner = owner; + Hashtable classSeenAgain = new Hashtable(); + while (currentOwner != null && classSeenAgain.get(currentOwner) == null) { + classSeenAgain.put(currentOwner, Boolean.TRUE); + collectInterfaceCandidates(currentOwner, name, descriptor, interfaceVisited, + candidateOwners, candidateIds); + currentOwner = superName(currentOwner); + } + return selectMaximallySpecific(candidateOwners, candidateIds); + } + + /** + * The maximally specific candidate from a pooled set, or -1 when empty. + * + *

JLS 5.4.3.3: keep only candidates no other candidate's declaring + * interface subtypes. Ties are arbitrary per JLS -- taking the first + * pooled candidate makes the answer deterministic within a build.

+ */ + private int selectMaximallySpecific(java.util.Vector candidateOwners, + java.util.Vector candidateIds) { + int count = candidateOwners.size(); + if (count == 0) { + return -1; + } + if (count == 1) { + return ((Integer)candidateIds.elementAt(0)).intValue(); + } + // Collect all maximally specific candidates, deduplicating by + // declaring interface (the same interface's default appears once). + java.util.Vector maximalOwners = new java.util.Vector(); + java.util.Vector maximalIds = new java.util.Vector(); + for (int i = 0; i < count; i++) { + String candidate = (String)candidateOwners.elementAt(i); + if (maximalOwners.contains(candidate)) { + continue; + } + boolean dominated = false; + for (int j = 0; j < count; j++) { + if (i == j) { + continue; + } + // If some other candidate's declaring interface is a proper + // subinterface of this one, this one is not maximally + // specific -- Java would take the subinterface's method. + if (isSubinterfaceOf( + (String)candidateOwners.elementAt(j), candidate)) { + dominated = true; + break; + } + } + if (!dominated) { + maximalOwners.addElement(candidate); + maximalIds.addElement(candidateIds.elementAt(i)); + } + } + if (maximalOwners.size() == 1) { + return ((Integer)maximalIds.elementAt(0)).intValue(); + } + // Multiple maximally specific non-dominated candidates is + // IncompatibleClassChangeError per JVMS 5.4.3.3 -- possible after + // binary-compatible interface evolution, and silently picking one + // would run an arbitrary body the JVM refuses. Throwing a + // RuntimeException here propagates back through the linker to + // dispatch, where the interpreter's usual host-exception path picks + // it up. + if (maximalOwners.size() > 1) { + StringBuilder message = new StringBuilder(); + for (int i = 0; i < maximalOwners.size(); i++) { + if (i > 0) { + message.append(", "); + } + message.append(((String)maximalOwners.elementAt(i)).replace('/', '.')); + } + throw new IncompatibleClassChangeError("conflicting default methods: " + message); + } + return ((Integer)candidateIds.elementAt(0)).intValue(); + } + + private void collectInterfaceCandidates(String owner, String name, String descriptor, + Hashtable visited, + java.util.Vector candidateOwners, + java.util.Vector candidateIds) { + if (visited.get(owner) != null) { + return; + } + visited.put(owner, Boolean.TRUE); + Integer ownerId = (Integer)classIds.get(owner); + if (ownerId == null) { + return; + } + int[] ifaces = (int[])interfaceIds.get(ownerId); + if (ifaces == null) { + return; + } + for (int i = 0; i < ifaces.length; i++) { + String ifaceName = (String)classNames.get(Integer.valueOf(ifaces[i])); + if (ifaceName == null) { + continue; + } + Integer id = (Integer)methodIds.get(ifaceName + "." + name + descriptor); + // Only instance, non-private members are inherited through an + // interface. A static or private declaration keeps its id in the + // table for explicit invokestatic / invokespecial resolution, but + // must not compete as a candidate for virtual dispatch through an + // implementor -- otherwise a static `A.m()` gets picked over a + // default `B.m()` when the receiver implements both. + if (id != null && interfaceIneligible.get(id) == null) { + candidateOwners.addElement(ifaceName); + candidateIds.addElement(id); + } + // Keep walking even when this interface declares the method: a + // subinterface below may override it, and that override is the + // one JLS would pick. + collectInterfaceCandidates(ifaceName, name, descriptor, visited, + candidateOwners, candidateIds); + } + } + + /// Whether {@code candidate} is a proper subinterface of {@code parent}. + /// A fresh visited set per call -- reusing the candidate-collection one + /// would cut this walk short at an already-considered interface. + private boolean isSubinterfaceOf(String candidate, String parent) { + if (candidate.equals(parent)) { + return false; + } + Hashtable seen = new Hashtable(); + return walkSuperinterfacesFor(candidate, parent, seen); + } + + private boolean walkSuperinterfacesFor(String owner, String target, Hashtable seen) { + if (seen.get(owner) != null) { + return false; + } + seen.put(owner, Boolean.TRUE); + Integer ownerId = (Integer)classIds.get(owner); + if (ownerId == null) { + return false; + } + int[] ifaces = (int[])interfaceIds.get(ownerId); + if (ifaces == null) { + return false; + } + for (int i = 0; i < ifaces.length; i++) { + String ifaceName = (String)classNames.get(Integer.valueOf(ifaces[i])); + if (ifaceName == null) { + continue; + } + if (target.equals(ifaceName)) { + return true; + } + if (walkSuperinterfacesFor(ifaceName, target, seen)) { + return true; + } + } + return false; + } + + /** + * The method id declared exactly on this class, or -1. + * + *

No superclass walk. Constructor resolution has to use this: a + * subclass whose exact {@code } descriptor is missing (an SDK newer + * than the installed runtime, a class that declares only some + * constructors) has to fail loudly rather than silently pick up the + * parent's {@code } and hand back a base-class instance for + * {@code new Sub(args)}.

+ */ + int declaredMethodId(String owner, String name, String descriptor) { + Integer id = (Integer)methodIds.get(owner + "." + name + descriptor); + return id == null ? -1 : id.intValue(); + } + + /** + * "id|descriptor" for one static field this class declares, or null. + * + *

Declared by this class exactly, not inherited: reading an inherited + * static initializes the class that declares it, which is the wrong one.

+ */ + String anyStaticField(String owner) { + return (String)oneStaticField.get(owner); + } + + /** The instance field id for an access, searching up the superclass chain. */ + int fieldId(String owner, String name) { + return lookupField(fieldIds, owner, name); + } + + /** + * The static field id for an access, searching up the superclass chain. + * + *

The chain walk matters as much here as for methods: a pushed program + * may read {@code SomeSubclass.SOME_CONSTANT} where only the superclass + * declares it.

+ */ + int staticFieldId(String owner, String name) { + return lookupField(staticFieldIds, owner, name); + } + + private int lookupField(Hashtable table, String owner, String name) { + String currentOwner = owner; + // See methodId: the walk ends because the chain does, not at a count. + Hashtable seen = new Hashtable(); + while (currentOwner != null && seen.get(currentOwner) == null) { + seen.put(currentOwner, Boolean.TRUE); + Integer id = (Integer)table.get(currentOwner + "#" + name); + if (id != null) { + return id.intValue(); + } + // Interfaces as well as superclasses: a constant declared on an + // interface is read through whatever implements it, and an + // interface reached through another interface is ordinary Java. + // A superclass-only walk answers -1 for a field the app has. + int fromInterface = interfaceFieldId(table, currentOwner, name, new Hashtable()); + if (fromInterface >= 0) { + return fromInterface; + } + currentOwner = superName(currentOwner); + } + return -1; + } + + /** Searches a class's interfaces, and theirs, for a field. */ + private int interfaceFieldId(Hashtable table, String owner, String name, + Hashtable visited) { + // As with methods: a visited set terminates without inventing a maximum + // depth for somebody else's interface hierarchy. + if (visited.get(owner) != null) { + return -1; + } + visited.put(owner, Boolean.TRUE); + Integer ownerId = (Integer)classIds.get(owner); + if (ownerId == null) { + return -1; + } + int[] ifaces = (int[])interfaceIds.get(ownerId); + if (ifaces == null) { + return -1; + } + for (int i = 0; i < ifaces.length; i++) { + String ifaceName = (String)classNames.get(Integer.valueOf(ifaces[i])); + if (ifaceName == null) { + continue; + } + Integer id = (Integer)table.get(ifaceName + "#" + name); + if (id != null) { + return id.intValue(); + } + int deeper = interfaceFieldId(table, ifaceName, name, visited); + if (deeper >= 0) { + return deeper; + } + } + return -1; + } + + /** Whether this interface declares a default method. */ + boolean declaresDefaultMethod(int classId) { + return defaultBearing.get(Integer.valueOf(classId)) != null; + } + + /** The ids of the interfaces this class implements directly, or null. */ + int[] interfacesOf(int classId) { + return (int[])interfaceIds.get(Integer.valueOf(classId)); + } + + String superName(String internalName) { + Integer id = (Integer)classIds.get(internalName); + if (id == null) { + return null; + } + Integer superId = (Integer)superIds.get(id); + if (superId == null) { + return null; + } + return (String)classNames.get(superId); + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/DeviceRuntimeSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/DeviceRuntimeSnippets.java new file mode 100644 index 00000000000..429fd61e0f6 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/DeviceRuntimeSnippets.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets; + +import com.codename1.impl.interp.InterpRuntime; + +/** + * Snippets for the device runtime chapter of the developer guide. + * + * @author Shai Almog + */ +class DeviceRuntimeSnippets { + InterpRuntime runtime; + + void limits() { + // tag::device-runtime-limits[] + runtime.setEdtBudgetMs(2000); // 0 disables the check + runtime.setMaxDepth(512); + runtime.requestCancel(); // safe from another thread + // end::device-runtime-limits[] + } + + void whichDeviceAmIOn() { + // tag::device-runtime-which-device[] + Object o = System.out; + throw new IllegalStateException("read as " + o.getClass().getName()); + // end::device-runtime-which-device[] + } +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/device-runtime.sh b/docs/demos/common/src/main/snippets/developer-guide/device-runtime.sh new file mode 100644 index 00000000000..074d33a53e4 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/device-runtime.sh @@ -0,0 +1,6 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::device-runtime-bash-001[] +scripts/cn1-push.sh src/main/java 18234 +scripts/cn1-push.sh src/main/java 18234 --main com.example.Other +// end::device-runtime-bash-001[] diff --git a/docs/developer-guide/Device-Runtime.asciidoc b/docs/developer-guide/Device-Runtime.asciidoc new file mode 100644 index 00000000000..ac5280c9b55 --- /dev/null +++ b/docs/developer-guide/Device-Runtime.asciidoc @@ -0,0 +1,462 @@ += Device Runtime + +The device runtime is a third way to run Codename One code, alongside the simulator and a full device build. You install one app on a phone, and from then on push compiled classes to it from your machine and see them run natively -- no rebuild, no signing, no round trip to a build server. + +It's a development tool, not a shipping mechanism. Applications you ship are still compiled ahead of time: ParparVM translates them to C for iOS and the Android build produces real dex. Nothing about the device runtime changes how a released app is built or how fast it runs. + +== What it's for, and what it's not + +The device runtime gives you the iteration speed of the simulator with the fidelity of a real device for everything that doesn't depend on native code or on the optimizer. Layout on a real screen, real fonts, real input, real density, real network conditions. + +It's the wrong tool for three things: + +Performance measurement. Your code is interpreted and the host app is built with the optimizer off. Numbers taken here mean nothing about a released build. + +Native integration. Native code can't be pushed. A `cn1lib`'s Java half runs; its native half is only available if it was compiled into the runtime app. + +Anything that depends on app identity. Bundle id, icons, splash screens, URL schemes, push certificates, background modes and app extensions all belong to the runtime app, not to your program. + +== How it works + +Three pieces, in the order they run. + +*The bundle writer* runs on your machine. It reads your compiled classes with ASM, resolves the constant pool into flat tables, converts branch targets from labels to instruction indices, and writes a `.cn1ip` file containing the code, the symbols it references in the host app, and your source. + +The instruction set is the JVM's own. Only the operands change. Keeping the opcodes means every semantic question -- what `dup2` does to a `long`, when `athrow` unwinds, how `invokespecial` differs from `invokevirtual` -- has one authoritative answer that can be checked against a real JVM, rather than a reinterpretation that has to be rediscovered by testing. + +*The bundle* carries your source, and this isn't optional. `InterpBundleReader` refuses to load a bundle whose interpreted classes aren't all covered by source files. Apple permits an app to download and run code only where the user can see and edit it (App Store Review Guideline 2.5.2), so the runtime enforces that rather than trusting the tool chain to have included it. + +*The interpreter* runs on the device. It walks the int array, and reaches the host app through two interfaces: + +`InterpLinker` is how interpreted code calls into the app. On Android and in the simulator that's `java.lang.reflect`. On iOS there is no reflection at all -- `Method.invoke` doesn't exist and ParparVM's `struct clazz` has no name-to-method table -- so the iOS backend binds through the per-method invoke thunks and symbol table that the interp-host build emits. + +The linker is supplied by the port, not by the app: `AndroidImplementation` and `IOSImplementation` each register one with `InterpPlatform` during startup. That's a registry rather than a lookup by class name because a name lookup is precisely what ParparVM can't do. A build with no device runtime registers nothing and the whole feature reduces to one null field. + +`InterpObjectFactory` is how the app calls back into interpreted code. See <>. + +== Building the runtime app + +The runtime app isn't built like a normal Codename One app. Set the build hint: + +[source] +---- +codename1.arg.ios.interpHost=true +---- + +That switches the translator into `CN1_INTERP_HOST` mode, which changes four things at once: + +*Invoke thunks, field tables and the symbol table are emitted.* These are the same structures on-device debugging uses. They're what gives the interpreter a way to call a method it only knows by name. + +*The optimizer is off.* Not for speed -- for correctness. The optimizer devirtualizes calls, and a devirtualized call is a direct branch that no runtime-installed override can intercept. + +*Dead code elimination is off.* Pushed code may call any part of the API, including classes the runtime app itself never mentions. + +*Vtable layout is published.* The symbol table gains `vtsize` and `vtable` rows so the runtime can find the slot to patch for a given method. + +*Static fields become reachable.* Instance fields are covered by a per-class offset table, because an instance field is an offset from a receiver. A static has no receiver: the translator gives it a named C global and typed `get_static_`/`set_static_` functions, with no table to index. Interp-host adds one uniform accessor per static, registered by field id, plus `sfield` rows in the symbol table. Without it every host static read failed -- which is most programs, since `System.out.println` compiles to a `GETSTATIC` of `System.out`. Reads go through the generated getter rather than the global, so the declaring class's static initializer runs exactly as it would for compiled code. + +// vale-skip: Microsoft.Quotes: "not present in the installed app" is the literal text the runtime would print, so a period inside the quotes would misquote it. +An ordinary on-device-debug build skips thunk generation for `java.io`, `java.net` and `java.nio`, because those packages have hand-written native sidecars and a thunk forces a C wrapper live whose implementation may be missing. Interp-host lifts that: it has already disabled dead code elimination, so those wrappers are live either way, and a program calling `File.getPath()` shouldn't be told the method is "not present in the installed app". `com.codename1.impl` stays excluded in every mode -- that's where the drift is real, and interpreted code reaches the port through the framework rather than directly. + +=== What this costs + +Measured on `hellocodenameone`, a real application with native interfaces, camera, health and NFC: + +[options="header"] +|=== +| | Normal build | Interp host +| Translate + project generation | 64 s | 195 s +| Generated `.m` files | 2,696 | 5,547 +| Generated sources | 249 MB | 623 MB +| Classes retained | culled | 5,449 (all) +| Simulator `.app` (Debug) | -- | 192 MB +| Symbol table in the binary| -- | 2.5 MB gzipped +|=== + +The binary is large because nothing is culled and nothing is optimized. That's the trade the whole design rests on: this app is a development tool, so it can afford to be big and slow in exchange for being able to run anything. + +Budget for the wall clock rather than being surprised by it. On an M-series laptop a full interp-host cycle -- translate, generate the Xcode project, compile, link, install -- runs to about an hour. The translator itself is only a few minutes of that. Most of the rest splits between clang (5,500 files, ~340 MB of build log) and the Ruby project-generation hooks, which walk every source file and scale with the file count that interp-host doubles. Static-field accessors added to it again. + +That cost is why the emission gate matters: `mvn -f vm/tests/pom.xml test -Dtest='InterpHost*'` compiles generated C with cmake in about a minute and catches anything that would fail the real compile. Run it before every device build, not after. + +== Performance + +Only your code is interpreted. The framework is compiled ahead of time, so a `Form` constructor that adds thirty components spends most of its time in AOT code and a few thousand interpreted operations on the glue. That's the case the runtime is designed for, and it's comfortably fast enough to be invisible. + +Expect two orders of magnitude slower than compiled code for the interpreted portion. Where that shows up: + +*`paint(Graphics)` on a custom component.* Called per component per frame. A handful of drawing calls is fine; per-pixel work in an interpreted `paint` isn't. + +*`Animation.animate()`.* Same shape, same limit. + +*Game loops.* Push them and they will run, but the frame rate will tell you nothing. Build a real device build for anything frame-rate sensitive. + +*Hot algorithms in your own code.* Sorting a large list with an interpreted `Comparator`, hand-rolled parsing, string building in a tight loop. Note that `java.util`, `String` and `StringBuilder` aren't interpreted -- only classes from your own source roots are -- so the collection itself is at full speed and only your comparator isn't. + +=== The fuel counter + +Every back edge and method entry decrements a counter. When it reaches zero the interpreter checks whether it has been asked to stop and whether it has held the event thread too long. Back-edge-only accounting costs under 2%. + +The budget covers *one entry into the interpreter*, not the age of the session. Every callback the framework makes -- a button press, a `paint`, a serial call -- is a fresh entry with a fresh budget. Measuring from the start of the run instead is a mistake worth naming: it makes the budget expire once and stay expired, so every callback arriving later than `edtBudgetMs` after the program started fails instantly with "ran without yielding" having executed nothing at all. In an application whose whole life is callbacks, that's every button press. + +This is what makes a runaway program recoverable. `while (true) {}` in a pushed program raises `InterpCancelledException` instead of freezing the app. It extends `Error`, not `Exception`, on purpose: pushed code routinely wraps loops in `catch (Exception e)`, and a cancellation user code can swallow isn't a cancellation. + +Accounting pauses while inside a host call. A legitimate `invokeAndBlock` waiting on the network can take thirty seconds and must not look like a spin loop. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/DeviceRuntimeSnippets.java[tag=device-runtime-limits,indent=0] +---- + +== Frames, threads and blocking + +One interpreted frame is one real Java frame -- the interpreter recurses rather than driving heap-allocated frames from a trampoline. + +That's a deliberate cost. `Display.invokeAndBlock` runs a nested event loop on the caller's native stack, and every blocking Codename One idiom is built on it: `Dialog.show()`, a synchronous `NetworkManager` call, `Display.invokeAndBlock` itself. Interpreted code has to be able to sit in the middle of one, which it can only do if its frames are real. + +The consequence is that interpreted depth is bounded by the real stack. `setMaxDepth` caps it well short and raises an interpreted `StackOverflowError`, because overflowing the real stack on a device is a process death with no diagnosis. + +== Extending framework classes + +Writing `class MyForm extends Form` in a pushed program has to produce something the framework accepts as a `Form`, whose overrides it then calls. The framework was compiled before your class existed, and neither platform lets you define a class at run time. + +*iOS* uses runtime clazz synthesis. ParparVM allocates each class's vtable on the heap and fills it slot by slot, so a subclass can be built by copying the parent's `struct clazz`, copying its vtable, and repointing the overridden slots at a trampoline into the interpreter. Nothing is generated and nothing is written to executable memory, which is what makes it acceptable on iOS. + +Three details matter. The synthetic class reuses the *parent's* classId, because `instanceofFunction` indexes a static table that a fresh id would have no row in -- so `instanceof Form` answers true, which is what you want, at the cost of `getClass().getName()` reporting the parent. The synthetic clazz must register with the conservative collector's clazz registry or its instances look like false positives. And the parent's static initializer has to have run before its vtable exists at all. + +*Android* can't patch a vtable and can't load dex at run time, so the guard is compiled ahead of time: a generated subclass per extensible framework class, each overridable method either delegating to the interpreter or calling `super`. + +What actually ships uses the generated-subclass approach on *both* platforms. Android could have covered interfaces with `java.lang.reflect.Proxy` and generated only classes; iOS has no `Proxy`, because `Proxy` is `defineClass` wearing a disguise. Two factories that fail in different places is worse than one that fails in the same place twice, so `ShimObjectFactory` generates shims for classes and for interfaces alike and both platforms run identical code. + +*Nothing about the set is curated.* it's every public, non-final, constructible class and every public interface the device exposes -- both the framework under `com.codename1` and the device's own `java.*`, taken from the `codenameone-java-runtime` artifact that applications actually compile against. That's 851 classes and 269 interfaces, and about 111,000 methods once the `super_` bridges are counted. + +The `java.*` half isn't optional. `implements Runnable` appears in a large share of applications, and an early version of this generator scanned only `com.codename1` -- which dropped `Runnable` with no diagnostic and would have failed those applications at push time. + +Deriving it needs the device's own class files rather than reflection over the running JDK, because the two disagree in four separate ways: which methods exist (`InputStream.readAllBytes` is JDK-only), which are `final` (`Calendar.add` is final on the device and abstract in the JDK), which interfaces a class implements (the JDK's `Writer` is `Appendable`, `Closeable` and `Flushable`; the device's is only `AutoCloseable`), and which constructors exist (`Timer(String, boolean)` is JDK-only). The generator reads them out of the runtime jar with ASM. + +A difference isn't by itself a reason to give up on a class, and treating it as one was a real bug rather than a hypothetical: because the JDK's `Throwable` implements `Serializable` and the device's doesn't, every exception type in the framework was refused, which made `class MyException extends RuntimeException` -- table stakes for an application -- impossible to push. `Serializable` and `Cloneable` declare no methods at all, so the difference is invisible to a subclass. Where the extra interface does declare methods the shim implements them, because javac here demands it and there they override nothing and cost a method each. Generating on a different JDK from the one the application compiles with produces shims that can't exist -- `LambdaMetafactory` is non-final on 8 and final on 17 -- so the script pins `JAVA17_HOME`. A hand-maintained list would be a promise that applications only subclass what somebody anticipated, and the failure when that's wrong isn't an error message but an override the framework never calls. + +The cost lands on Android, which carries it with multidex the way large applications routinely do, and which this app can afford because it's a development tool built with the optimizer off and nothing culled. On iOS a full interp-host cycle -- translate, `xcodebuild`, install -- measures under sixteen minutes, so the generated set isn't the bottleneck it was assumed to be when the design was written. + +=== Why iOS still uses shims + +The plan called for replacing them on iOS with runtime vtable synthesis, and the mechanism works: `InterpHostVtableSynthesisIntegrationTest` compiles the generated C and runs it. It's not wired into the port, for a reason that only became visible once the generic generator existed. + +Synthesis was meant to solve two problems. The first -- that a shim approach could never reach `java.*` without marking up every non-final class in the tree -- turned out not to be a problem at all: the generator derives that set from `codenameone-java-runtime` automatically. The second is real but unusable. Synthesis patches vtable slots and so ignores Java's access rules, which would make the fourteen types blocked by a package-private abstract method -- `CodeEditor`, `RichTextArea`, `SurfaceText`, `SurfaceVector` among them -- extensible on iOS. + +They would not be extensible on Android, which can neither patch a vtable nor load dex. A program that runs on one platform and fails on the other is worse than one that fails on both, so the runtime's real capability is the intersection, and against that intersection synthesis buys nothing a developer can use. What it costs is one to two thousand lines of C in the collector's and `instanceof`'s path, a second object model to reason about, and per-signature trampolines for about 27,000 distinct signatures. + +The test stays, because the day Android grows an equivalent is the day this becomes worth doing. + +=== A known deviation, no ArrayStoreException for pushed types + +The interpreter represents every reference array as `Object[]`, because an array of a type only the bundle has can't be allocated as anything else -- the host has no such class to allocate. The array instance therefore carries no component type, and a store into it can't be checked: + +`A[] values = new A[1]; Object[] alias = values; alias[0] = new B();` succeeds here for unrelated pushed classes `A` and `B`, where Java throws `ArrayStoreException`. + +`instanceof` and `checkcast` against such an array answer by inspecting the elements, which is why the cast that follows still behaves. An empty array trivially satisfies any element predicate, so `new A[0] instanceof B[]` returns true for unrelated pushed `A` and `B` where Java returns false. Against a *host* component type the host is asked instead and answers exactly, so this deviation is confined to arrays whose leaf class exists only in the bundle. + +Closing it means carrying the component type with every interpreted array -- a wrapper object around `Object[]`, threaded through every array opcode and every crossing into host code. That's a large change to the hottest paths in the interpreter for a mistake the compiler already rejects unless the program goes out of its way to alias the array. It's written down rather than done, and the deviation is asserted in `InterpRuntimeContractTest` so it stays a known one. + + +The shims are generated during the app build -- an `exec-maven-plugin` execution in `common/pom.xml` writes them to `target/generated-sources/shims` and `build-helper` adds that as a source root -- so nothing derived is committed. Generation is required to be reproducible for exactly the reason it might look safer to check the output in: two builds of the same commit must ship the same contract, and `scripts/generate-interp-shims.sh` asserts that alongside "every shim compiles" and "the load-bearing shims exist" after you edit `GenerateInterpShims`. + +=== What the runtime carries, and what it doesn't + +The point of running on a device is the half no simulator can imitate, so the runtime carries the native half of the camera, on-device inference, AR, the health store, media, Bluetooth, VR, and surfaces. Pushed code calling any of them reaches the real SDK on the real device. + +That needed a second mechanism. The build decides which native SDK to link by scanning the app for references to the API that fronts one, and it reads field and method descriptors -- a class literal is invisible to it, being an LDC rather than a type instruction. Most of these types are final or have no accessible constructor (`TextRecognizer` is final), so no shim mentions them, nothing references them and the SDK was left out: the runtime reported the interesting half unsupported on the one device that supported it. The generator now also emits `InterpNativeCapabilities`, a class declaring a field of each such type and nothing else. None of it runs. + +=== Mocked rather than missing + +Three subsystems can't be provided for real, and are answered by mocks instead of being left to report themselves unsupported. Reporting unsupported debugs nothing, and these are things developers debug constantly. + +*Purchases.* `Purchase.getInAppPurchase()` returns a `MockPurchase`: managed payments and item listing supported, every SKU a product priced `$0.00 (mock)`, every purchase succeeding, `restore()` returning what this session bought. What it exercises is the code around the call -- what your app does when a purchase succeeds twice, or when a restore returns something you no longer sell -- which is the part that ships broken and the part a store sandbox is slowest to reach. + +*Social login.* `FacebookConnect.getInstance()` and `GoogleConnect.getInstance()` return mocks that report native login supported and complete it with a fabricated token, on the event thread where a real provider would. The seam is the framework's own: a provider implementation registers itself through `implClass`, exactly as the ports' `FacebookImpl` does, so the mock arrives through the mechanism the framework already has rather than through anything bolted on. That's also why those two classes live in `com.codename1.social` in this app -- the constructors and the callback proxy are package-private. + +*Maps.* No mock needed. A `NativeMap` with no native provider wired in delegates to an embedded `MapView`, so a pushed program gets a real vector map with no API key. + +The mocks say so. The first time a pushed program touches one the runtime raises a dialog naming the subsystem, the status line records it, and the values themselves read as fake -- a price of `$0.00 (mock)`, a token of `mock-facebook-token-not-valid-anywhere`. A mock that looked real would be worse than none: somebody would ship code that only ever succeeded because nothing was. + +One subsystem stays out entirely: `com.codename1.car`. Android Auto and CarPlay are separate surfaces with their own manifests, templates and review process, and nothing about a car app can be driven from a pushed program. + +Surfaces needs one thing from the runtime rather than an exclusion: widget kinds are compiled into an app, so the build demands a `surfaces.json`. The runtime declares two generic kinds, and a pushed program can use those. It's the same bargain as the permission union -- what the host declared is what the guest gets. + +==== The size, and what it costs + +ML Kit's bundled models and pipelines are 287MB of native libraries across four ABIs. A universal APK with everything linked is 323MB, which is why an earlier version of this dropped the whole group and shipped 11MB -- and with it the reason to use a device at all. + +The answer is an ABI split, not a smaller feature set. One ABI is **110MB**, `scripts/run-device-runtime-android.sh` builds `arm64-v8a` by default (`CN1_ANDROID_ABI` overrides), and that's every device worth testing on plus the only emulator image that runs at speed on an Apple-silicon Mac. The store job ships an app bundle, where Play delivers one ABI per device by itself. + +Two limits follow from the shim approach, and both are recorded rather than remembered. + +A class implementing several host interfaces at once has no shim, because a shim covers one supertype. + +A method whose signature names a type the device doesn't have isn't shimmed. The framework compiles against a full JDK and the device doesn't: `Reader.read(java.nio.CharBuffer)` is a real method of a real framework class, and referencing it from a shim fails the bytecode compliance gate because ParparVM has no `CharBuffer`. The generator asks `vm/JavaAPI/src` directly rather than discovering each absent type one build at a time. + +Generic supertypes are resolved rather than erased. `CaseInsensitiveOrder implements Comparator`, so `compare(T,T)` is written `compare(String,String)` -- the erasure `compare(Object,Object)` is a different method and clashes with the real one. The substitution composes through the hierarchy, which matters: `ComponentSelector implements Set` but `add` is declared on `Collection` one level further up, and carrying the binding down is what turns it into `add(Component)`. Where the target is itself generic -- `BooleanProperty` -- the shim extends it raw, so every inherited signature erases and no substitution applies. That's Java's rule, not a shortcut. + +Thirty-two types have no shim, and every one of them names the reason. They fall into three groups. + +Java forbids subclassing them from another package at all: a package-private abstract method leaves no legal subclass outside its own package. `SurfaceNode.serializeContent` and `AbstractEditorComponent.getEditorType` are the two in the current API, which between them account for `SurfaceText`, `SurfaceVector`, `CodeEditor` and `RichTextArea`. + +An abstract method's signature names a type the device doesn't have, so the shim could be written here and couldn't be referenced there -- `Format.parseObject` needs `ParsePosition`, `Charset.newDecoder` needs `CharsetDecoder`. + +The two tool chains contradict each other outright. `Calendar.add` is abstract in the JDK and `final` on the device, so a concrete subclass must declare it to compile here and must not declare it to compile there. No shim can satisfy both, and `java.util.Calendar` therefore has none. This is the one group worth watching: it's the shape of skew that produces a class nobody can extend. + +The generator names the blocking method in all three cases, and a shim that fails to compile is treated as a generator bug that fails the build -- never pruned. That rule exists because a compile-and-drop loop once ate `Interp_ui_Form` without saying so, and a device runtime that can't subclass `Form` is useless. + +[IMPORTANT] +==== +There is no drop-what-fails fallback, and its absence is a decision rather than an omission. **A shim that won't compile is a bug in the generator, not a property of the framework**, and both the app build and `generate-interp-shims.sh` fail so it gets fixed. + +That distinction isn't academic. An earlier version compiled the generated set, dropped whatever `javac` rejected, and repeated until clean -- and it ate `Interp_ui_Form` without saying so, because `Form` re-declares `getComponentForm()` final where `Component` doesn't. A device runtime that can't subclass `Form` is useless, and the mechanism designed to make the build robust was what hid it. Twenty-two types were on that dropped list; every one turned out to be a generator defect -- missing constructor `throws` clauses, unresolved type variables, interface abstracts shadowing concrete implementations -- and fixing them took the list to zero. + +One hand-written exception remains, in `tools/unshimmable-by-contract.txt`: `Simd`, whose `alloca` intrinsics must not escape the frame that allocated them. That's a semantic contract no compiler reports, which is the only kind of entry that belongs there. +==== + +The generator also asserts that `Component`, `Container`, `Form`, `Label`, `Button`, `Dialog` and `ActionListener` were produced. With pruning gone this is belt and braces, but it costs nothing and it's the check that would have caught the `Form` regression the moment it happened rather than a build cycle later. + +If a platform can't produce a peer for a type, `InterpObjectFactory.canExtend` returns false and the runtime raises an error naming the type. That's deliberate: a peer that exists but is never dispatched to produces a program that runs, does the wrong thing, and reports nothing. + +== Edge cases + +These are the ones that will actually bite you. + +=== String concatenation must be compiled inline + +From JDK 9 onwards `javac` compiles `"a" + b` into an `invokedynamic` against `StringConcatFactory`. ParparVM has no runtime `invokedynamic` -- the translator desugars it at build time, and a pushed bundle gets no such pass. + +Compile pushed code with `-XDstringConcat=inline`; `cn1-push.sh` already does. The bundle writer rejects any `invokedynamic` it doesn't recognise, naming the bootstrap method, rather than producing something that fails obscurely on the device. + +Lambdas and method references are handled without a flag. `InterpLambdaDesugar` rewrites each site into the class `LambdaMetafactory` would have spun -- captures in fields, the single abstract method forwarding to the lambda body, the call site becoming a plain static call -- so lambdas, method references, constructor references and bound receivers all work. The generated class inherits the source file of the class that contained the lambda, which is both true and required, since the runtime won't execute a class whose source it can't show. + +=== The API on the device isn't the API on your machine + +The runtime app ships on a store cadence; your SDK doesn't. A program compiled against a newer framework than the installed app will reference symbols that aren't there. + +The device is the source of truth. Resolution of host symbols is lazy, so a program that never touches a missing symbol never fails because of it; one that does fail gets a `NoClassDefFoundError` naming the class and saying it's not present in the installed app. + +The bundle format is versioned and a mismatch is refused outright, with a message telling you to rebuild. + +=== The JavaAPI is a subset, at member granularity + +ParparVM's `java.*` is smaller than the JDK's, and not only class by class. `java.util.Locale` exists but has no `ROOT`. `java.lang.String` exists but has no `toLowerCase(Locale)`. `java.io.PrintWriter`, `java.math.BigInteger` and `java.math.BigDecimal` don't exist at all -- which the Kotlin standard library reaches for. + +In a normal build you never notice, because the code that references them is unreachable and gets culled. An interp-host build keeps everything, so the translator prunes method bodies whose references can't be resolved and logs each one. A pruned method returns zero or null rather than failing to link -- the honest answer for an API the platform doesn't have. + +`java.util.regex.Matcher.quoteReplacement` is worth naming on its own: it's declared as an instance method here where the JDK specifies it static, so any `INVOKESTATIC` against it can't be translated. + +[WARNING] +==== +A pruned method returning null is the right answer for `Kotlin`'s `BigDecimal` helpers. It's a catastrophe inside the interpreter, and this isn't hypothetical. + +`InterpRuntime.run` -- the main dispatch loop -- called `java.lang.reflect.Array.getLength`. ParparVM's `Array` has only `newInstance`, so the pass pruned the loop to an empty function. The iOS build then linked, launched, accepted a push, and replied `OK: ran YourProgram` for every program including one whose `main` was nothing but `throw`. It executed no bytecode at all and said so nowhere. + +`Parser.failIfLoadBearing` now makes pruning anything in `com.codename1.impl.interp` or the iOS linker a hard build failure naming the method and the missing member. If you hit it, add the member to `vm/JavaAPI` or rewrite the method to avoid it -- `arrayLength` in `InterpRuntime` is the pattern, an `instanceof` chain that needs no reflection and behaves identically everywhere. + +The general lesson: a silent fallback is only safe where "did nothing" and "was never needed" are the same outcome. +==== + +=== Field access isn't virtual + +If `Base` declares `v` and `Mid extends Base` shadows it, code compiled inside `Base` reads `Base.v` even for a `Mid` instance. The interpreter resolves from the owner named in the field reference, not from the object's runtime type. Worth knowing because getting it wrong produces a wrong number rather than an error. + +=== Monitors are the real thing + +`synchronized` means what it says, against host code locking the same object included. Interpreted frames run on real threads and everything they can lock is a real object, so the host monitor isn't an obstacle -- it's the mechanism. + +The awkwardness is only that Java offers a block and not an explicit monitor-enter. A synchronized *method* has no monitor instruction at all -- `ACC_SYNCHRONIZED` is the only record of it -- so the invocation is wrapped in a real `synchronized` block, on the peer where there is one, because the peer is the object host code holds. A synchronized *block* runs its guarded region nested inside a real `synchronized` block; the matching `monitorexit` hands control back to the enclosing level, and leaving that block is what releases the lock. An exception thrown out of the region unlocks on the way past, and javac's synthetic handler releasing the monitor a second time is a no-op. + +Because it's the genuine monitor, `wait` and `notify` work. A private lock table keyed by identity would have been simpler and couldn't have offered either. + +=== A program brings its own resources + +The bundle carries every file in the pushed tree that's not a `.java` -- `theme.res`, CSS, images -- keyed by the path an application loads it with. Without them a pushed program wears the runtime host's theme, which is the wrong application's design and reads as a bug in yours. + +They're published to `CodenameOneImplementation`, not to `Display`. That's not a detail: `Resources.openLayered("/theme")` and `UIManager.initFirstTheme` resolve inside the framework, which asks the implementation directly, and a hook on `Display` would never see them. Each port checks the published set before the classpath, and the set is cleared on every push so a program that ships no theme doesn't inherit the previous one's. + +=== A linker dispatches on the receiver, not the call site + +`list.add(x)` where `list` is declared `java.util.List` compiles to a call naming `java.util.List`. Resolving the method from that name finds `AbstractList.add`, whose body is `throw new UnsupportedOperationException()`. A linker has to start from the receiver's actual class and walk up from there; that walk *is* the virtual dispatch, because there is no vtable to consult from Java. + +Android gets this free -- `Method.invoke` dispatches virtually. iOS doesn't, and the symptom is worth recognizing: every collection call from pushed code fails with a bare `UnsupportedOperationException` and no message, while `Form.show()` works, because that call site happens to name the receiver's own class. + +=== Enums are interpreted, not shimmed + +`java.lang.Enum` can't be named as a superclass in Java source, so no generated shim for it exists or can. It needs none: an enum constant is a name, an ordinal and the handful of methods that read them, and the interpreter answers those itself. The constant gets no peer, and what the framework sees of it's whatever interfaces the enum declares. + +`values()` and `valueOf(String)` are compiled by `javac` into code that clones a `$VALUES` array and calls `Enum.valueOf(Class, String)` reflectively. Neither works as written here -- the class isn't on the device and there is no reflection -- so the runtime resolves `valueOf` against the bundle and implements array `clone` directly. + +=== A network error must not phone home + +Codename One's default `Lifecycle.handleNetworkError` calls `Log.sendLogAsync()`, which makes another blocking request on the event thread. When the network is what's broken, that request fails too and re-enters the handler: the event thread ends up parked in nested `invokeAndBlock` calls with no way out, and the app is wedged. It also shows a modal dialog, which nobody is present to dismiss on a device being driven from a desktop. + +A runtime host must override that handler to report the error and stop. This isn't specific to the interpreter -- any Codename One app can hit it -- but a pushed program pointed at a wrong URL hits it immediately. + +=== Native interfaces degrade rather than fail + +A `cn1lib` is Java plus native code. The Java half is interpreted like the rest of your program. For the native half, `NativeLookup.create` returns the real implementation if it was compiled into the runtime app, and otherwise a stub whose `isSupported()` returns false. Your program compiles and runs; the feature reports itself unavailable. + +=== `super.` needs the generated bridge, not the peer's method + +A generated shim overrides the framework method and asks the interpreter for it. When the interpreted override then calls `super.paint(g)`, routing that to the peer's `paint` lands back on the override -- unbounded recursion, once per frame, on the event thread. It reads as a frozen app rather than as a stack overflow. + +That's what the `super_` bridges on every shim are for: `super.paint(g)` from interpreted code is dispatched to `super_paint`, which is the only path to the framework implementation. The same applies to any `super.` call on a host superclass. + +=== Pushing an application, not a file + +`cn1-push.sh` takes a source tree as well as a single file, compiles the whole tree, +and carries every source -- the runtime won't run a class whose source it +can't show, and an application is many files. + +The entry point is discovered rather than named: a `main(String[])` if the +bundle has one, otherwise a `Lifecycle` subclass, which is what a real Codename +One application has. A `Lifecycle` is entered the way the platform enters one -- +constructed through the ordinary interpreted path so it gets its generated peer, +then `init(null)` and `start()`, falling through to the `super_` bridge for +whichever of those the application doesn't override. Pass `--main` to override +the choice. + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/device-runtime.sh[tag=device-runtime-bash-001,indent=0] +---- + +=== A pushed `main` runs on the event thread + +Exactly as an application's `start()` does, and for the same reason: it builds UI, and Codename One requires that on the EDT. + +The consequence is that `Display.callSeriallyAndWait` fails from a pushed `main` with "This method MUST NOT be invoked on the EDT" -- you are already on the thread you would be waiting for. `callSerially` is fine, and so is `invokeAndBlock`, which exists precisely to block on the EDT without wedging it. + +=== The runtime is entered from several threads at once + +This is the normal case, not an edge one. The thread running a pushed `main` and the event thread calling an interpreted `paint` through a shim are both inside the interpreter, continuously. Execution state -- depth, fuel, the interpreted call stack -- therefore belongs to the thread, not to the runtime. Sharing it makes the depth cap trip on the wrong thread and stack traces name another thread's frames. + +The caches are the other half of that, and they're where this actually went wrong twice. Both bugs presented identically and neither pointed at concurrency: + +* The extern-class cache is two arrays, a result and an "attempted" flag, which are one logical entry. Setting the flag before storing the result leaves a window where another thread reads "attempted, and null" and reports `NoClassDefFoundError: java/lang/StringBuilder` -- for a class that resolved without trouble microseconds earlier. Store the result first, and take a lock over the pair. +* The reflection linker memoizes resolved classes and methods in a `HashMap`. Concurrent `put` on an unsynchronized `HashMap` doesn't merely lose an entry: it can return null for a key that's present, producing the same impossible `NoClassDefFoundError`. + +If you add a cache to this code path, assume every thread in the program will hit it at once, because they will. + +=== A shim's fields are null while `super()` runs + +Java assigns a subclass's fields only after `super()` returns, and framework constructors call overridable methods -- `Form`'s does. So a shim's `$runtime` is null for part of its own construction. The generated guard defers to `super` in that window, which is correct rather than defensive: the interpreted object genuinely has no state yet. + +=== `show()` doesn't take effect in the same event-thread pass + +`Form.show()` queues the switch. Reading `Display.getCurrent()` immediately after it returns reports the *previous* screen, which makes a working push look like it did nothing. Read it on a later pass. + +=== Failures name your source, not the interpreter's + +An uncaught throwable carries an interpreted stack built from the bundle's line table: + +[source] +---- +java.lang.ArithmeticException: / by zero + at Boom.div(Boom.java:3) + at Boom.main(Boom.java:6) +---- + +A real stack trace here would name the interpreter's own frames, which tells you nothing about your bug. + +== Testing + +The interpreter is verified by differential testing: each case is compiled once and run twice, on a real JVM and through the interpreter, and the two must agree -- including how the program fails. + +Nothing asserts an expected string. The JVM is the oracle, so a case can't encode a mistaken idea of what Java does. The corpus targets where interpreters actually go wrong: value widths and overflow, NaN's asymmetric comparisons, shift-count masking, the stack-shuffling opcodes whose meaning is in slots rather than values, which exception handler wins and whether `finally` still runs, and dispatch through an inheritance chain. + +[source] +---- +mvn -Punittests -pl core-unittests \ + -Dtest='InterpConformanceTest,InterpRuntimeContractTest' test +---- + +The runtime's own guarantees -- cancellation, the EDT budget, the depth cap, source-line traces, refusing a source-less bundle -- are covered on their own in `InterpRuntimeContractTest`. `InterpHostSubclassTest` pins the parts that only bite once a peer exists: `super.` reaching the framework rather than recursing, constructor arguments surviving the chain, and the runtime being entered from several threads at once. + +Unit tests can't tell you whether an interp-host build actually links, which is where this feature fails. Two scripts run the whole loop against a real device: + +[source] +---- +scripts/run-device-runtime-ios.sh PushedDemo.java # translate, xcodebuild, install, push +scripts/run-device-runtime-android.sh PushedDemo.java # build, install, adb forward, push +---- + +[IMPORTANT] +==== +Two artifacts repackage code from elsewhere, and each has cost a full build cycle to a change that appeared to do nothing: + +* **`maven/parparvm`** carries the translator the build actually executes. Installing `vm/ByteCodeTranslator` alone leaves the previous translator in place, and the failure is an error message identical to the one you just fixed. +* **`maven/ios`** bundles `iOSPort.jar`, which embeds a copy of the core classes. A change to `com.codename1.impl.interp` is invisible to an iOS build until this is rebuilt -- installing `maven/core` isn't enough. + +Both symptoms look like your edit was wrong rather than absent, which is what makes them expensive. `run-device-runtime-ios.sh` now rebuilds `core`, `parparvm` and `ios` before translating, which costs about a minute and removes the category. +==== + +Don't reach for the iOS script first when you have changed what the translator *emits*. A full interp-host cycle is around forty-five minutes, and `InterpHostVtableSynthesisIntegrationTest` compiles generated C with cmake and runs it in about a minute: + +[source] +---- +mvn -f vm/tests/pom.xml test -Dtest='InterpHost*' +---- + +That's not a substitute for the device -- it can't tell you whether the interpreter actually executed anything -- but it catches every emission bug that would fail the C compile. It found the static-accessor signature bug (the generated setter takes thread state only for object fields, not primitives) at the same moment the iOS build did, for one seventieth of the wait. + +[IMPORTANT] +==== +Both devices listen on the same port, and a pushed program prints the same thing on either. A push can therefore land on the wrong device and report success, and nothing in the output says which one answered. + +That's not hypothetical: a leftover `adb forward tcp:18234` sent several "iOS" pushes to the Android emulator, and the results were indistinguishable from the real thing until Android's own logcat showed the `PUSHED:` lines. The iOS script now clears the mapping, but any push you run by hand can hit it. + +Make a probe answer the question rather than trusting the message. Reading a host static whose implementation differs is enough: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/DeviceRuntimeSnippets.java[tag=device-runtime-which-device,indent=0] +---- + +`java.io.PrintStream` is iOS. `com.android.internal.os.AndroidPrintStream` is Android. Anything that could have come from either device has told you nothing. +==== + +Both device scripts delete the generated native project before rebuilding. That's not tidiness. Neither the Xcode project nor the Gradle project is regenerated in place -- the build copies into whatever is already there -- so a changed framework jar or native source keeps its old copy and the app runs the previous version. It presents as something else entirely: a protocol error from the device rejecting a bundle the current writer plainly produced. + +== Pairing and approval + +The listener speaks two protocol versions and they exist for different situations. + +Protocol *v1* is an unauthenticated push. It's the only thing `cn1-push.sh` sends, and what a development build accepts, because that build's listener is bound to loopback: reaching it requires `adb forward` on an authorized device or the iOS simulator's own loopback, so possession of the device is the authentication. Iterating on the framework would be intolerable if every push needed a dialog. It's refused on any connection that didn't arrive over loopback, and that's a property of the connection rather than a mode -- treating it as a mode once meant a device that had seen a network address refused USB pushes for the rest of its life. + +Protocol *v3* pairs first, then authenticates every connection. It's what `DevicePush` speaks, and pushing to a phone over Wi-Fi is its job rather than the shell helper's: a third copy of the derivation in a script would only drift from the two that have to agree. + +Pairing is two round trips. The IDE prints a six-digit code and sends its peer id and a friendly name; the device asks a human to type the code, replies with its own device id and a fresh 32-byte challenge, and both ends then derive the same 256-bit secret from `(code, peerId, deviceId)` -- 20,000 iterations of HMAC-SHA-256, which is what makes grinding the six digits cost something. The computer answers the challenge under that secret and the device stores it only on a match. The secret is never transmitted, in either direction, at any point. + +Every push afterward answers a fresh challenge, and the answer covers the bundle as well as the challenge. A captured frame therefore authenticates exactly one connection, and a program can't be substituted behind a valid answer. + +Pairing doesn't buy silent access. Once a connection has authenticated, it still raises "Approve connection from <name>?" with *Once*, *Always* and *Deny*, and only *Always* is remembered, per peer. `DeviceRuntimePairing.forgetAll()` drops the lot, secrets included. The order is deliberate: authenticate, then ask. Prompting first would let anyone on the network raise dialogs on somebody's phone until they tapped Approve to make them stop. + +[NOTE] +==== +There was a v2, in which a peer id sent in the clear authorized every later push. That's a bearer token on a LAN -- capture one frame and push forever, and what you push is arbitrary code. It's gone rather than deprecated: nothing had shipped that spoke it, and leaving it in would have made the fix optional for an attacker. +==== + +[WARNING] +==== +What this still doesn't defeat is a passive observer of the *pairing* exchange itself. Six digits is 10^6 candidates, and an attacker holding the nonce and the answer can grind them offline; the iteration count sets the price but doesn't remove the attack. Observing any number of *pushes* -- the exposure that actually persists, since pairing happens once -- tells them nothing. Closing the pairing gap for real means a PAKE, not a longer code, and it's the next thing to do if this transport is ever exposed beyond a local network. +==== + +// vale-skip: Microsoft.Quotes: "that code did not match" is the literal text the device shows, so a period inside the quotes would misquote it. +The two implementations of the derivation -- Codename One's HMAC on the device, the JDK's in the push tool, since ParparVM has no `javax.crypto` -- are held together by `InterpPairingSecretTest`, which runs both and compares. They have to agree to the byte, and the symptom of disagreement is "that code did not match", which reads exactly like a typo. + +== Store considerations + +Running downloaded code is allowed on both stores, conditionally, and the conditions shape the design. + +Apple's guideline 2.5.2 permits an app that teaches or tests code to download it, provided the source is completely viewable and editable by the user. That's why the bundle carries source and the runtime refuses one that doesn't. There is precedent: thebaselab's Code App ships OpenJDK's Zero interpreter on the App Store, and the same property holds here -- an interpreter with no JIT writes no executable memory. + +The sharper risk is guideline 4.7.2, which forbids exposing native platform APIs to software that's not embedded in the binary. Exposing the Codename One API to pushed code is the product, so that argument has to be made explicitly rather than assumed. + +// vale-skip: Microsoft.Quotes: the quoted phrase is Google Play's policy text, quoted exactly, so a period inside the quotes would misquote the policy. +Google Play's Device and Network Abuse policy bans downloading dex, JAR or `.so` files, but exempts "code that runs in a virtual machine or an interpreter". The `.cn1ip` format is none of those, and the interpreter never touches Android APIs directly -- the AOT-compiled framework does. + +Two practical consequences: the runtime app must declare the union of permissions pushed code might use, and that union has to be justifiable to App Review and declared without embellishment on Play's Data Safety form. And the transport must be point-to-point and consented to, not an anonymous push endpoint. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 2a45b01e83a..6046bc9847e 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -171,6 +171,8 @@ include::On-Device-Debugging.asciidoc[] include::On-Device-Debugging-Android.asciidoc[] +include::Device-Runtime.asciidoc[] + include::Working-With-Javascript.asciidoc[] include::Working-with-Mac-OS-X.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 4f0144ce536..d45cffc0921 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -657,6 +657,30 @@ unretraceable [Pp]ragmas? # ----------------------------------------------------------------------------- +# Device runtime (Device-Runtime.asciidoc). VM and translator vocabulary: these +# are the names of the things, not approximations of them. +# ----------------------------------------------------------------------------- +# The Dalvik executable format, by its universal lowercase name. +dex +# The virtual method table, and ParparVM's own spelling of the class struct. +[Vv]table +[Cc]lazz +# Devirtualization is the optimizer pass whose absence interp-host depends on. +[Dd]evirtualiz(e|es|ed|ing|ation) +# What the translator does to invokedynamic and to lambdas, at build time. +[Dd]esugar(s|ed|ing)? +# The JVM's term for a type above another in the hierarchy. +[Ss]upertypes? +# What a recursive interpreter does, and what a Throwable is. +recurses +recursing +throwable +# What a cache does to a resolved class or method. +[Mm]emoiz(e|es|ed|ing) +# The build system the spike tests drive. +cmake +# thebaselab publishes Code App, the App Store precedent for 2.5.2. +thebaselab # Smart home (Smart-Home.asciidoc) terminology. # ----------------------------------------------------------------------------- # The reciprocal colour-temperature unit both HomeKit and Matter express a diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index f44e664c65e..a5393eb4662 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4468,6 +4468,13 @@ public void usesClassMethod(String cls, String method) { boolean isReleaseBuild = !request.getArg("ios.buildType", "debug").equals("debug"); String onDeviceDebug = !isReleaseBuild && Boolean.valueOf(request.getArg("ios.onDeviceDebug", "false")) ? "true" : "false"; + // The device runtime host app -- the one that interprets bytecode + // bundles pushed from a developer's machine. Unlike onDeviceDebug + // this is NOT forced off for release builds: the host app ships to + // the App Store, and the interpreter is the product. It is opt-in + // per project and no ordinary app sets it. + String interpHost = + Boolean.valueOf(request.getArg("ios.interpHost", "false")) ? "true" : "false"; if (enableGalleryMultiselect && photoLibraryUsage) { @@ -4498,6 +4505,7 @@ public void usesClassMethod(String cls, String method) { parparCmd.add("-Dcn1.sqlite=" + usesDatabaseCipher); parparCmd.add("-Dcn1.sqlcipher=" + usesDatabaseCipher); parparCmd.add("-Dcn1.onDeviceDebug=" + onDeviceDebug); + parparCmd.add("-Dcn1.interpHost=" + interpHost); parparCmd.add("-DbundleVersionNumber=" + bundleVersionNumber); // The UNION of every enabled product's list, in ONE argument. These used to be // mutually exclusive branches on the claim that the Mac list already covered the diff --git a/maven/core-unittests/pom.xml b/maven/core-unittests/pom.xml index 584ed4d1e2f..7af778b0af9 100644 --- a/maven/core-unittests/pom.xml +++ b/maven/core-unittests/pom.xml @@ -199,5 +199,17 @@ ${project.version} test + + + com.codenameone + codenameone-parparvm + ${project.version} + test + diff --git a/maven/core-unittests/spotbugs-exclude.xml b/maven/core-unittests/spotbugs-exclude.xml index 70f3ede56a1..7c824990c5d 100644 --- a/maven/core-unittests/spotbugs-exclude.xml +++ b/maven/core-unittests/spotbugs-exclude.xml @@ -409,4 +409,26 @@ + + + + + + + + + diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpConformanceTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpConformanceTest.java new file mode 100644 index 00000000000..d0fcc009c88 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpConformanceTest.java @@ -0,0 +1,653 @@ +/* + * 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.interp; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The interpreter must produce the same observable behaviour as a real JVM + * running the same bytecode. + * + *

Each case is compiled once and run twice -- on this JVM and through the + * interpreter -- and the two outputs must match, including how the program + * fails. Nothing here asserts an expected string: the JVM is the oracle, so a + * case cannot encode the author's mistaken idea of what Java does.

+ * + *

The corpus targets the places an interpreter actually gets wrong: value + * widths and overflow, NaN's asymmetric comparisons, the stack-shuffling + * opcodes whose meaning is in slots rather than values, which exception handler + * wins and whether {@code finally} still runs, and virtual dispatch through an + * interpreted hierarchy.

+ * + * @author Shai Almog + */ +class InterpConformanceTest { + + static Stream cases() { + List out = new ArrayList(); + out.add(c("Arithmetic", + "public class Arithmetic { public static void main(String[] a) {" + + " int x = 7, y = -3;" + + " System.out.println(x + y); System.out.println(x - y);" + + " System.out.println(x * y); System.out.println(x / y);" + + " System.out.println(x % y); System.out.println(-x);" + + " System.out.println(Integer.MAX_VALUE + 1);" + + " System.out.println(Integer.MIN_VALUE - 1);" + + " System.out.println(Integer.MIN_VALUE / -1);" + + " long big = 9000000000L;" + + " System.out.println(big * 3); System.out.println(big / 7);" + + " System.out.println(Long.MAX_VALUE + 1);" + + "}}")); + out.add(c("Shifts", + "public class Shifts { public static void main(String[] a) {" + + " int x = -16;" + + " System.out.println(x << 2); System.out.println(x >> 2);" + + " System.out.println(x >>> 2);" + // Shift counts are masked to 5 bits for int and 6 for long -- + // a detail an interpreter gets wrong by using the raw count. + + " System.out.println(x << 33); System.out.println(x >>> 33);" + + " long y = -16L;" + + " System.out.println(y << 65); System.out.println(y >>> 65);" + + " System.out.println(x & 0xff); System.out.println(x | 0xf0);" + + " System.out.println(x ^ 0x0f);" + + "}}")); + out.add(c("Conversions", + "public class Conversions { public static void main(String[] a) {" + + " int i = 300;" + + " System.out.println((byte) i); System.out.println((char) i);" + + " System.out.println((short) i);" + + " System.out.println((int) 3.99); System.out.println((int) -3.99);" + + " System.out.println((long) 1e19); System.out.println((int) 1e19);" + + " System.out.println((int) Double.NaN);" + + " System.out.println((float) 0.1);" + + " System.out.println((double) 1 / 3);" + + " System.out.println((char) 65);" + + "}}")); + out.add(c("FloatEdge", + "public class FloatEdge { public static void main(String[] a) {" + + " double nan = Double.NaN;" + + " System.out.println(nan < 1.0); System.out.println(nan > 1.0);" + + " System.out.println(nan == nan);" + + " System.out.println(1.0 / 0.0); System.out.println(-1.0 / 0.0);" + + " System.out.println(0.0 / 0.0);" + + " System.out.println(0.1 + 0.2);" + + " System.out.println(Math.min(-0.0, 0.0));" + + " float f = 1.1f; System.out.println(f * 3);" + + "}}")); + out.add(c("ControlFlow", + "public class ControlFlow { public static void main(String[] a) {" + + " int total = 0;" + + " for (int i = 0; i < 20; i++) { if (i % 3 == 0) continue;" + + " if (i == 17) break; total += i; }" + + " System.out.println(total);" + + " int j = 0; while (j < 5) { total += j++; }" + + " do { total--; } while (total > 20);" + + " System.out.println(total);" + + " for (int i = 0; i < 3; i++) for (int k = 0; k < 3; k++)" + + " if (k == 2) total += i * k;" + + " System.out.println(total);" + + "}}")); + out.add(c("Switches", + "public class Switches { public static void main(String[] a) {" + // Dense -> tableswitch, sparse -> lookupswitch; both encodings + // are variable length and are the two the writer synthesises. + + " for (int i = 0; i < 6; i++) { switch (i) {" + + " case 0: System.out.println(\"zero\"); break;" + + " case 1: System.out.println(\"one\"); break;" + + " case 2: System.out.println(\"two\"); break;" + + " case 3: System.out.println(\"three\"); break;" + + " default: System.out.println(\"many\"); } }" + + " for (int i = 0; i < 3; i++) { switch (i * 1000) {" + + " case 0: System.out.println(\"a\"); break;" + + " case 1000: System.out.println(\"b\"); break;" + + " default: System.out.println(\"c\"); } }" + + " String s = \"beta\"; switch (s) {" + + " case \"alpha\": System.out.println(1); break;" + + " case \"beta\": System.out.println(2); break;" + + " default: System.out.println(3); }" + + "}}")); + out.add(c("Arrays", + "public class Arrays { public static void main(String[] a) {" + + " int[] xs = new int[5];" + + " for (int i = 0; i < xs.length; i++) xs[i] = i * i;" + + " System.out.println(java.util.Arrays.toString(xs));" + + " byte[] bs = {1, -2, 3}; System.out.println(java.util.Arrays.toString(bs));" + + " boolean[] flags = new boolean[2]; flags[1] = true;" + + " System.out.println(flags[0] + \",\" + flags[1]);" + + " char[] cs = {'h','i'}; System.out.println(new String(cs));" + + " double[] ds = {1.5, 2.5}; System.out.println(ds[0] + ds[1]);" + + " long[] ls = {1L << 40}; System.out.println(ls[0]);" + + " int[][] grid = new int[3][4]; grid[2][3] = 9;" + + " System.out.println(grid.length + \",\" + grid[0].length + \",\" + grid[2][3]);" + + " String[] ss = new String[2]; ss[0] = \"x\";" + + " System.out.println(ss[0] + \",\" + ss[1]);" + + "}}")); + out.add(c("ArrayFailures", + "public class ArrayFailures { public static void main(String[] a) {" + + " int[] xs = new int[2];" + + " try { int v = xs[5]; System.out.println(v); }" + + " catch (ArrayIndexOutOfBoundsException e) { System.out.println(\"aioobe\"); }" + + " try { int[] bad = new int[-1]; System.out.println(bad.length); }" + + " catch (NegativeArraySizeException e) { System.out.println(\"nase\"); }" + + " int[] nil = null;" + + " try { System.out.println(nil.length); }" + + " catch (NullPointerException e) { System.out.println(\"npe\"); }" + + " try { System.out.println(1 / (xs.length - 2)); }" + + " catch (ArithmeticException e) { System.out.println(\"div0\"); }" + + "}}")); + out.add(c("Exceptions", + "public class Exceptions {" + + " static int f(int n) { if (n == 0) throw new IllegalStateException(\"boom\");" + + " return 10 / n; }" + + " public static void main(String[] a) {" + + " try { System.out.println(f(2)); } catch (RuntimeException e) {" + + " System.out.println(\"unexpected\"); }" + + " try { f(0); } catch (IllegalStateException e) {" + + " System.out.println(\"caught \" + e.getMessage()); }" + // The nearest enclosing handler must win, and finally must run + // on both the normal and the exceptional path. + + " try { try { f(0); } finally { System.out.println(\"inner finally\"); } }" + + " catch (Exception e) { System.out.println(\"outer\"); }" + + " try { System.out.println(\"body\"); } finally { System.out.println(\"finally\"); }" + + " StringBuilder order = new StringBuilder();" + + " try { try { throw new java.io.IOException(\"io\"); }" + + " catch (RuntimeException e) { order.append(\"wrong\"); }" + + " finally { order.append(\"f1\"); } }" + + " catch (Exception e) { order.append(\"|\").append(e.getMessage()); }" + + " System.out.println(order);" + + "}}")); + out.add(c("UncaughtPropagates", + "public class UncaughtPropagates { public static void main(String[] a) {" + + " System.out.println(\"before\");" + + " throw new IllegalArgumentException(\"stop here\");" + + "}}")); + out.add(c("StringsAndBoxing", + "public class StringsAndBoxing { public static void main(String[] a) {" + + " String s = \"abc\";" + + " System.out.println(s.length() + s);" + + " System.out.println(s.toUpperCase() + s.substring(1) + s.indexOf('b'));" + + " System.out.println(\"x\" + 1 + 2 + 'c' + 1.5 + true + null);" + + " Integer boxed = 42; int unboxed = boxed;" + + " System.out.println(boxed + unboxed);" + + " System.out.println(Integer.valueOf(7).equals(7));" + + " System.out.println(String.valueOf(3.0) + Integer.parseInt(\"12\"));" + + " StringBuilder sb = new StringBuilder();" + + " for (int i = 0; i < 4; i++) sb.append(i).append(',');" + + " System.out.println(sb.toString());" + + " System.out.println(\"a,b,,c\".split(\",\").length);" + + "}}")); + out.add(c("Collections", + "import java.util.*;" + + "public class Collections { public static void main(String[] a) {" + + " List l = new ArrayList();" + + " l.add(\"b\"); l.add(\"a\"); l.add(\"c\");" + + " java.util.Collections.sort(l);" + + " System.out.println(l);" + + " Map m = new HashMap();" + + " m.put(\"one\", 1); m.put(\"two\", 2);" + + " System.out.println(m.get(\"one\") + m.get(\"two\"));" + + " Iterator it = l.iterator();" + + " int n = 0; while (it.hasNext()) { it.next(); n++; }" + + " System.out.println(n);" + + " for (String s : l) System.out.print(s);" + + " System.out.println();" + + "}}")); + out.add(c("Inheritance", + "public class Inheritance {" + + " static class Base { int v = 1;" + + " String who() { return \"base\"; }" + + " String describe() { return who() + \":\" + v; } }" + + " static class Mid extends Base { int v = 2;" + + " String who() { return \"mid\"; } }" + + " static class Leaf extends Mid {" + + " String who() { return \"leaf/\" + super.who(); } }" + + " public static void main(String[] a) {" + + " Base b = new Leaf();" + // describe() is inherited code calling an overridden method on + // itself; the override has to win there too. + + " System.out.println(b.describe());" + + " System.out.println(b.who());" + // Fields are not virtual: the static type picks the field. + + " System.out.println(b.v + \",\" + ((Mid) b).v);" + + " System.out.println(b instanceof Mid);" + + " System.out.println(b instanceof Leaf);" + + " Object o = \"str\";" + + " System.out.println(o instanceof String);" + + " try { Mid m = (Mid) (Object) \"nope\"; System.out.println(m); }" + + " catch (ClassCastException e) { System.out.println(\"cce\"); }" + + "}}")); + out.add(c("InterfacesAndStatics", + "public class InterfacesAndStatics {" + + " interface Greeter { String greet(); }" + + " static class En implements Greeter { public String greet() { return \"hello\"; } }" + + " static class Fr implements Greeter { public String greet() { return \"bonjour\"; } }" + + " static int counter;" + + " static final String NAME;" + + " static { NAME = \"static-init\"; counter = 10; }" + + " static int bump() { return ++counter; }" + + " public static void main(String[] a) {" + + " Greeter[] gs = { new En(), new Fr() };" + + " for (Greeter g : gs) System.out.println(g.greet());" + + " System.out.println(NAME);" + + " System.out.println(bump() + bump() + counter);" + + "}}")); + out.add(c("Recursion", + "public class Recursion {" + + " static long fib(int n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }" + + " static int depth(int n) { return n == 0 ? 0 : 1 + depth(n - 1); }" + + " public static void main(String[] a) {" + + " System.out.println(fib(20));" + + " System.out.println(depth(100));" + + "}}")); + out.add(c("StackShuffles", + "public class StackShuffles {" + + " static int sideEffect(StringBuilder sb, int v) { sb.append(v); return v; }" + + " public static void main(String[] a) {" + + " StringBuilder sb = new StringBuilder();" + // Compound assignment on an array element compiles to + // dup2/dup_x2 shapes; long ones exercise the category-2 cases. + + " int[] xs = new int[3];" + + " xs[1] += 5; xs[1] *= 3; xs[sideEffect(sb, 2)] -= 4;" + + " System.out.println(java.util.Arrays.toString(xs) + sb);" + + " long[] ls = new long[2];" + + " ls[0] += 7L; ls[0] *= 6L; ls[1] = ls[0]--;" + + " System.out.println(ls[0] + \",\" + ls[1]);" + + " double[] ds = new double[1]; ds[0] += 1.5; ds[0] /= 0.5;" + + " System.out.println(ds[0]);" + + " int i = 0; i = i++ + ++i; System.out.println(i);" + + "}}")); + out.add(c("TernaryAndLogic", + "public class TernaryAndLogic { public static void main(String[] a) {" + + " int x = 5;" + + " System.out.println(x > 3 ? \"big\" : \"small\");" + + " boolean t = true, f = false;" + + " System.out.println(t && f); System.out.println(t || f);" + + " System.out.println(!t); System.out.println(t ^ f);" + + " Object nil = null;" + + " System.out.println(nil == null ? \"null\" : nil.toString());" + + " System.out.println(x > 1 && x < 10 || x == 100);" + + " int count = 0;" + + " if (f && ++count > 0) { count += 100; }" + + " System.out.println(count);" + + "}}")); + // Lambdas and method references. These do not survive as bytecode: + // InterpLambdaDesugar rewrites each invokedynamic into a real class + // before the bundle is written, because neither device target can spin + // one at run time. The JVM runs the original indy, so this is a direct + // check that the rewrite means the same thing. + out.add(c("Lambdas", + "import java.util.*;" + + "public class Lambdas {" + + " interface Op { int apply(int v); default Op twice() { return v -> apply(apply(v)); } }" + + " interface Two { String join(String a, int b); }" + + " static int half(int v) { return v / 2; }" + + " private final int base = 10;" + + " int bound(int v) { return base + v; }" + + " public static void main(String[] a) {" + + " Op inc = v -> v + 1;" + + " System.out.println(inc.apply(41));" + + " System.out.println(inc.twice().apply(1));" + + " Op ref = Lambdas::half;" + + " System.out.println(ref.apply(9));" + + " Lambdas self = new Lambdas();" + + " Op boundRef = self::bound;" + + " System.out.println(boundRef.apply(5));" + + " Two t = (s, n) -> s + n;" + + " System.out.println(t.join(\"x\", 3));" + + " List l = new ArrayList();" + + " l.add(\"b\"); l.add(\"a\"); l.add(\"c\");" + + " Collections.sort(l, (p, q) -> p.compareTo(q));" + + " System.out.println(l);" + + " Runnable r = () -> System.out.println(\"ran\");" + + " r.run();" + + "}}")); + // A lambda that captures a long and returns a boxed value: the + // metafactory's adaptation rules -- widen, box, unbox, cast -- are + // where a hand-written desugarer goes wrong. + out.add(c("LambdaAdaptation", + "public class LambdaAdaptation {" + + " interface Boxed { Object get(Integer v); }" + + " interface Prim { long get(long v); }" + + " public static void main(String[] a) {" + + " final long captured = 1L << 40;" + + " Prim p = v -> v + captured;" + + " System.out.println(p.get(2));" + + " Boxed b = v -> Integer.valueOf(v.intValue() * 2);" + + " System.out.println(b.get(Integer.valueOf(21)));" + + "}}")); + // Enums. java.lang.Enum cannot be subclassed from source, so no shim + // for it exists or can; the interpreter answers name/ordinal/compareTo + // itself and rewrites valueOf against the bundle. + out.add(c("Enums", + "public class Enums {" + + " enum Color { RED, GREEN, BLUE;" + + " String low() { return name().toLowerCase(); } }" + + " public static void main(String[] a) {" + + " for (Color c : Color.values()) System.out.println(c + \" \" + c.ordinal());" + + " Color g = Color.GREEN;" + + " switch (g) { case GREEN: System.out.println(\"green\"); break;" + + " default: System.out.println(\"other\"); }" + + " System.out.println(Color.valueOf(\"BLUE\"));" + + " System.out.println(g.low());" + + " System.out.println(g.compareTo(Color.RED));" + + " System.out.println(g.equals(Color.GREEN));" + + " System.out.println(Color.values().length);" + + " try { Color.valueOf(\"PINK\"); } catch (IllegalArgumentException e) {" + + " System.out.println(\"no PINK\"); }" + + "}}")); + // isAssignableFrom over pushed types: the hierarchy is in the bundle, + // and it is the other type test Java offers without reflection. + out.add(c("ClassAssignability", + "public class ClassAssignability {" + + " interface Marker {}" + + " static class Base implements Marker {}" + + " static class Child extends Base {}" + + " static class Other {}" + + " static class Task implements Runnable { public void run() {} }" + + " public static void main(String[] a) {" + + " System.out.println(Base.class.isAssignableFrom(Child.class));" + + " System.out.println(Child.class.isAssignableFrom(Base.class));" + + " System.out.println(Base.class.isAssignableFrom(Base.class));" + + " System.out.println(Marker.class.isAssignableFrom(Child.class));" + + " System.out.println(Base.class.isAssignableFrom(Other.class));" + + " System.out.println(Base.class.isAssignableFrom(String.class));" + + " System.out.println(Base[].class.isAssignableFrom(Base[].class));" + + " System.out.println(Base[].class.isAssignableFrom(Child[].class));" + + " System.out.println(Runnable.class.isAssignableFrom(Task.class));" + + " System.out.println(Object.class.isAssignableFrom(Base.class));" + + " System.out.println(Runnable.class.isAssignableFrom(Base.class));" + + " System.out.println(Object.class.isAssignableFrom(Base[].class));" + + " System.out.println(Cloneable.class.isAssignableFrom(Base[].class));" + + " System.out.println(java.io.Serializable.class.isAssignableFrom(Base[].class));" + + " System.out.println(Object[].class.isAssignableFrom(Base[].class));" + + " System.out.println(Runnable.class.isAssignableFrom(Base[].class));" + + "}}")); + // getSimpleName over the shapes javac actually produces: a member + // class, a local one, an anonymous one, and an array of each. + out.add(c("SimpleNames", + "public class SimpleNames {" + + " static class Member {}" + + " static class Inner$Part {}" + + " public static void main(String[] a) {" + + " class Local {}" + + " Runnable anon = new Runnable() { public void run() {} };" + + " System.out.println(SimpleNames.class.getSimpleName());" + + " System.out.println(Member.class.getSimpleName());" + + " System.out.println(\"[\" + Local.class.getSimpleName() + \"]\");" + + " System.out.println(\"[\" + anon.getClass().getSimpleName() + \"]\");" + + " System.out.println(Member[].class.getSimpleName());" + + " System.out.println(SimpleNames.class.getName());" + + " System.out.println(Dollar$Name.class.getSimpleName());" + + " System.out.println(Inner$Part.class.getSimpleName());" + + " System.out.println(Member.class.getName());" + + "}}" + + "class Dollar$Name {}")); + // Pushed objects inside an array handed to a host method: the elements + // have to cross as their peers, and come back as themselves after the + // host has reordered them. + out.add(c("ArraysOfPushedObjects", + "import java.util.*;" + + "public class ArraysOfPushedObjects {" + + " static class Item implements Comparable {" + + " final int v; Item(int v) { this.v = v; }" + + " public int compareTo(Object o) { return v - ((Item)o).v; }" + + " public String toString() { return \"i\" + v; } }" + + " public static void main(String[] a) {" + + " Item[] items = new Item[]{ new Item(3), new Item(1), new Item(2) };" + + " Arrays.sort(items);" + + " System.out.println(items[0] + \",\" + items[1] + \",\" + items[2]);" + + " System.out.println(items[0].v + \":\" + items[2].v);" + + " List l = new ArrayList();" + + " l.add(items[2]); l.add(items[0]);" + + " Collections.sort(l);" + + " System.out.println(l.get(0) + \",\" + l.get(1));" + + " System.out.println(((Item)l.get(0)).v);" + // A host-typed array holds the peers, and must keep them: this + // is the shape a pushed class implementing a host interface + // gets when the array is declared with that interface. + + " Comparable[] host = new Comparable[]{ items[2], items[0] };" + + " Arrays.sort(host);" + + " System.out.println(host[0] + \",\" + host[1]);" + + " System.out.println(((Item)host[0]).v);" + + "}}")); + // A private method is not virtual, and from JDK 11 javac emits + // invokevirtual for one anyway -- so resolving from the receiver runs + // the subclass's same-named method instead of the one written. + out.add(c("PrivateNotVirtual", + "public class PrivateNotVirtual {" + + " static class Base {" + + " private String label() { return \"base\"; }" + + " String value() { return \"value:\" + label(); }" + + " String direct() { return label(); } }" + + " static class Child extends Base {" + + " private String label() { return \"child\"; }" + + " String childValue() { return \"child:\" + label(); } }" + + " public static void main(String[] a) {" + + " System.out.println(new Child().value());" + + " System.out.println(new Child().direct());" + + " System.out.println(new Child().childValue());" + + " System.out.println(new Base().value());" + + "}}")); + // An enum constant that overrides toString. The interpreter answers + // name() itself, and answering it for toString as well made the + // override apply to interpreted callers and not to host ones -- so the + // same constant printed two ways depending on who asked. + out.add(c("EnumToStringOverride", + "public class EnumToStringOverride {" + + " enum Color { RED, GREEN;" + + " public String toString() { return name().toLowerCase(); } }" + + " public static void main(String[] a) {" + + " System.out.println(Color.RED);" + + " System.out.println(\"v=\" + Color.GREEN);" + + " System.out.println(String.valueOf(Color.RED));" + + " StringBuilder sb = new StringBuilder(); sb.append(Color.GREEN);" + + " System.out.println(sb.toString());" + + " System.out.println(Color.RED.name() + \" \" + Color.valueOf(\"GREEN\"));" + + "}}")); + // An `assert` compiles to a that reads + // ThisClass.class.desiredAssertionStatus(), so a class containing one + // failed to initialize before that question had an answer -- the push + // died before the program ran. + out.add(c("Assertions", + "public class Assertions {" + + " static int sum(int a, int b) { assert a >= 0; return a + b; }" + + " public static void main(String[] a) {" + + " int n = 1;" + + " assert n == 1 : \"never\";" + + " System.out.println(\"ran \" + sum(n, 2));" + + "}}")); + // A class token passed where the host declares Object is stored, not + // converted: substituting the nearest host ancestor's Class there put + // Object.class in the collection, and reading it back no longer equalled + // the literal the program still held. + out.add(c("ClassTokenIdentity", + "import java.util.*;" + + "public class ClassTokenIdentity {" + + " static class Thing {}" + + " public static void main(String[] a) {" + + " List list = new ArrayList();" + + " list.add(Thing.class);" + + " list.add(String.class);" + + " System.out.println(list.get(0) == Thing.class);" + + " System.out.println(list.get(1) == String.class);" + + " Map m = new HashMap(); m.put(Thing.class, \"t\");" + + " System.out.println(m.get(Thing.class));" + + "}}")); + // An intersection cast names extra interfaces and extra erasures of the + // same method through altMetafactory. A synthesized class carrying only + // the first interface fails the call site's own cast, and a call through + // the other interface's erased signature resolves to nothing. + out.add(c("IntersectionLambda", + "import java.io.Serializable;" + + "public class IntersectionLambda {" + + " interface StringSupplier { String get(); }" + + " interface ObjectSupplier { Object get(); }" + + " public static void main(String[] a) {" + + " StringSupplier s = (StringSupplier & ObjectSupplier) () -> \"x\";" + + " System.out.println(s.get());" + + " System.out.println(((ObjectSupplier) s).get());" + + " System.out.println(s instanceof ObjectSupplier);" + + " Runnable r = (Runnable & Serializable) () -> System.out.println(\"ran\");" + + " r.run();" + + " System.out.println(r instanceof Serializable);" + + "}}")); + // An interpreted class whose toString the host has to reach when it + // converts the object to a string. + out.add(c("ToStringOverride", + "public class ToStringOverride {" + + " static class P { public String toString() { return \"P!\"; } }" + + " public static void main(String[] a) {" + + " System.out.println(new P());" + + " System.out.println(\"\" + new P());" + + " System.out.println(String.valueOf(new P()));" + + " StringBuilder sb = new StringBuilder(); sb.append(new P());" + + " System.out.println(sb.toString());" + + "}}")); + // Array clone and casts of arrays of an interpreted type -- both are + // what an enum's generated values() does, and neither has a host class + // to resolve against. + out.add(c("ArrayCloneAndCast", + "public class ArrayCloneAndCast {" + + " static class E { final int v; E(int v) { this.v = v; } }" + + " public static void main(String[] a) {" + + " E[] src = new E[]{ new E(1), new E(2) };" + + " E[] copy = (E[])src.clone();" + + " System.out.println(copy.length + \" \" + copy[1].v + \" \" + (copy == src));" + + " int[] nums = new int[]{1,2,3};" + + " int[] n2 = (int[])nums.clone(); n2[0] = 9;" + + " System.out.println(nums[0] + \" \" + n2[0]);" + + " Object o = src;" + + " System.out.println(o instanceof E[]);" + + "}}")); + // `new Object[0] instanceof E[]` is deliberately absent: the + // interpreter represents every reference array as Object[] and has no + // component type to compare, so it answers by inspecting elements and + // an empty array satisfies anything. That deviation is asserted + // explicitly in InterpRuntimeContractTest rather than hidden here. + // Boolean and other narrow fields: the value on the stack is an int, + // the field is declared Z, and the two have to agree about how it is + // boxed on the way in and out. + out.add(c("NarrowFields", + "public class NarrowFields {" + + " private boolean flag; private byte b; private short s; private char ch;" + + " boolean once() { boolean was = flag; flag = true; return was; }" + + " public static void main(String[] a) {" + + " NarrowFields n = new NarrowFields();" + + " System.out.println(n.once());" + + " System.out.println(n.once());" + + " n.b = 7; n.s = 300; n.ch = 'q';" + + " System.out.println(n.b + \" \" + n.s + \" \" + n.ch);" + + " boolean[] arr = new boolean[2]; arr[1] = true;" + + " System.out.println(arr[0] + \" \" + arr[1]);" + + " System.out.println(n.flag ? \"set\" : \"unset\");" + + "}}")); + // Monitors. Contended, so ignoring them loses increments; nested and + // re-entrant, so structured locking is exercised; and with a throw out + // of a guarded region, so the lock has to be released on that path too + // -- a leak there hangs the next acquirer rather than failing. + // + // Each counter is guarded by exactly one monitor. Guarding one counter + // with two different monitors -- a static synchronized method takes the + // class's, a synchronized block takes whatever it names -- is a race, + // and the first version of this case had it: the JVM's own answer moved + // between runs, which is the oracle telling you the test is wrong. + out.add(c("Monitors", + "public class Monitors {" + + " static int byLock; static int byMethod; int byInstance;" + + " static final Object LOCK = new Object();" + + " static synchronized void bumpStatic() { byMethod++; }" + + " synchronized void bumpInstance() { byInstance++; }" + + " static void guarded() { synchronized (LOCK) { byLock++; } }" + + " static void nested() { synchronized (LOCK) { synchronized (LOCK) { byLock++; } } }" + + " static void thrower() {" + + " try { synchronized (LOCK) { throw new IllegalStateException(\"x\"); } }" + + " catch (IllegalStateException e) { byLock++; } }" + + " public static void main(String[] a) throws Exception {" + + " final Monitors shared = new Monitors();" + + " Thread[] t = new Thread[4];" + + " for (int i = 0; i < t.length; i++) {" + + " t[i] = new Thread(new Runnable() { public void run() {" + + " for (int j = 0; j < 2000; j++) {" + + " bumpStatic(); shared.bumpInstance(); guarded(); nested(); } } }); }" + + " for (int i = 0; i < t.length; i++) { t[i].start(); }" + + " for (int i = 0; i < t.length; i++) { t[i].join(); }" + + " thrower();" + + " synchronized (LOCK) { byLock++; }" + + " System.out.println(byMethod + \" \" + shared.byInstance + \" \" + byLock);" + + "}}")); + // wait/notify, which is the reason the monitors are the objects' own. + // A private lock table keyed by identity would give mutual exclusion + // and nothing else: wait() demands the caller own that object's + // monitor, and would throw IllegalMonitorStateException here. + out.add(c("WaitNotify", + "public class WaitNotify {" + + " static final Object LOCK = new Object();" + + " static int value = -1; static boolean ready;" + + " public static void main(String[] a) throws Exception {" + + " Thread consumer = new Thread(new Runnable() { public void run() {" + + " synchronized (LOCK) {" + + " while (!ready) {" + + " try { LOCK.wait(); } catch (InterruptedException e) { } }" + + " System.out.println(\"got \" + value); } } });" + + " Thread producer = new Thread(new Runnable() { public void run() {" + + " synchronized (LOCK) { value = 42; ready = true; LOCK.notifyAll(); } } });" + + " consumer.start();" + + " Thread.sleep(100);" + + " producer.start();" + + " consumer.join(); producer.join();" + + " System.out.println(\"done\");" + + "}}")); + return out.stream(); + } + + private static Arguments c(String name, String source) { + return Arguments.of(name, source); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("cases") + @DisplayName("interpreted execution matches the JVM") + void interpreterMatchesTheJvm(String className, String source) throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth(className, source); + InterpTestHarness.Result jvm = r[0]; + InterpTestHarness.Result interp = r[1]; + + assertEquals(jvm.output, interp.output, + "stdout differs for " + className + + "\n--- jvm ---\n" + jvm + + "\n--- interpreter ---\n" + interp); + assertEquals(String.valueOf(jvm.failure), String.valueOf(interp.failure), + "failure differs for " + className + + "\n--- jvm ---\n" + jvm + + "\n--- interpreter ---\n" + interp); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpHostSubclassTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpHostSubclassTest.java new file mode 100644 index 00000000000..f1ace872cbd --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpHostSubclassTest.java @@ -0,0 +1,656 @@ +/* + * 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.interp; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * An interpreted class extending a host class, exercised the way the framework + * actually drives one. + * + *

These pin the two defects that only appeared on a device, both of which + * the single-threaded conformance suite could not have caught.

+ * + *

The first is {@code super.} dispatch. A generated shim overrides the + * framework method and asks the interpreter for it; when the interpreted + * override then calls {@code super.paint(g)}, routing that back to the peer's + * {@code paint} lands on the override again. On a device that is unbounded + * recursion once per frame on the event thread, which reads as a frozen app + * rather than as a stack overflow.

+ * + *

The second is concurrency. The runtime is entered from the thread running + * a pushed {@code main} and from the event thread calling an interpreted + * {@code paint}, at the same time. Depth, fuel and the call stack have to + * belong to the thread, not to the runtime.

+ * + * @author Shai Almog + */ +public class InterpHostSubclassTest { + + /** Stands in for a framework class: non-final, with an overridable method. */ + public static class HostBase { + private final String tag; + + public HostBase() { + this("default"); + } + + public HostBase(String tag) { + this.tag = tag; + } + + public String tag() { + return tag; + } + + public String render() { + return "host:" + tag; + } + + /** Framework code calling an overridable method on itself. */ + public String describe() { + return "[" + render() + "]"; + } + + /** + * Declares a checked exception, as framework methods routinely do -- + * {@code Row.getString} throws IOException, and so does half of + * {@code com.codename1.io}. + */ + public String risky() throws java.io.IOException { + return "host-risky"; + } + + /** + * Final, so a generated shim can neither override it nor bridge it -- + * the generator skips final methods, since neither is possible. A + * pushed subclass may still write {@code super.stamp()}, and that is + * ordinary Java rather than a mistake. + */ + public final String stamp() { + return "stamp:" + tag; + } + } + + /** + * A framework exception a pushed program may subclass. + * + *

{@code CalendarException}, {@code IOException} -- an interpreted class + * extending one is ordinary, and what host code catches is its peer.

+ */ + public static class HostFailure extends java.io.IOException { + public HostFailure(String message) { + super(message); + } + } + + /** + * An unchecked framework exception, so a pushed subclass can be thrown from + * a static initializer -- which Java forbids for a checked one. + */ + public static class HostTrouble extends RuntimeException { + public HostTrouble(String message) { + super(message); + } + } + + /** Its shim. */ + public static final class HostTroubleShim extends HostTrouble implements InterpBacked { + private final InterpObject $interp; + + HostTroubleShim(InterpObject o, String message) { + super(message); + this.$interp = o; + } + + public InterpObject getInterpObject() { + return $interp; + } + } + + /** The shim for it, as the generator would emit one for a class shim. */ + public static final class HostFailureShim extends HostFailure implements InterpBacked { + private final InterpObject $interp; + + HostFailureShim(InterpObject o, String message) { + super(message); + this.$interp = o; + } + + public InterpObject getInterpObject() { + return $interp; + } + } + + /** Stands in for a generated shim, written exactly as the generator emits one. */ + public static final class HostBaseShim extends HostBase implements InterpBacked { + private final InterpObject $interp; + private final InterpRuntime $runtime; + + HostBaseShim(InterpRuntime rt, InterpObject o) { + this.$runtime = rt; + this.$interp = o; + } + + HostBaseShim(InterpRuntime rt, InterpObject o, String tag) { + super(tag); + this.$runtime = rt; + this.$interp = o; + } + + public InterpObject getInterpObject() { + return $interp; + } + + @Override + public String render() { + Object r = $runtime == null ? InterpRuntime.NOT_OVERRIDDEN + : $runtime.dispatch($interp, "render", "()Ljava/lang/String;", new Object[]{}); + if (r == InterpRuntime.NOT_OVERRIDDEN) { + return super.render(); + } + return (String) r; + } + + public String super_render() { + return super.render(); + } + + /** + * Written the way the generator emits a method with a throws clause: + * the interpreter cannot throw a checked exception through its own + * signature, so it wraps one, and the shim -- which declares exactly + * what the framework declares -- unwraps it again. + */ + @Override + public String risky() throws java.io.IOException { + Object r; + try { + r = $runtime == null ? InterpRuntime.NOT_OVERRIDDEN + : $runtime.dispatch($interp, "risky", "()Ljava/lang/String;", + new Object[]{}); + } catch (InterpThrowable t) { + Throwable thrown = t.hostThrowable(); + if (thrown instanceof java.io.IOException) { + throw (java.io.IOException) thrown; + } + throw t; + } + if (r == InterpRuntime.NOT_OVERRIDDEN || r == InterpRuntime.DETACHED) { + return super.risky(); + } + return (String) r; + } + } + + /** A factory over the shim above, mirroring the device's. */ + private static final class TestFactory implements InterpObjectFactory { + private InterpRuntime runtime; + + void attach(InterpRuntime rt) { + this.runtime = rt; + } + + private static final String HOST_BASE = + HostBase.class.getName().replace('.', '/'); + + private static final String HOST_FAILURE = + HostFailure.class.getName().replace('.', '/'); + + private static final String HOST_TROUBLE = + HostTrouble.class.getName().replace('.', '/'); + + public String peerClassName(Object peer) { + // The JVM reports this faithfully; only ParparVM does not. + return peer == null ? null : peer.getClass().getName().replace('.', '/'); + } + + public boolean canExtend(String hostSuperclassName) { + return hostSuperclassName == null || "java/lang/Object".equals(hostSuperclassName) + || HOST_BASE.equals(hostSuperclassName) + || HOST_FAILURE.equals(hostSuperclassName) + || HOST_TROUBLE.equals(hostSuperclassName); + } + + public Object createPeer(InterpObject object, String hostSuperclassName, + String[] hostInterfaceNames, String descriptor, Object[] args) { + if (HOST_TROUBLE.equals(hostSuperclassName)) { + return new HostTroubleShim(object, + args.length > 0 && args[0] instanceof String ? (String) args[0] : null); + } + if (HOST_FAILURE.equals(hostSuperclassName)) { + return new HostFailureShim(object, + args.length > 0 && args[0] instanceof String ? (String) args[0] : null); + } + if (!HOST_BASE.equals(hostSuperclassName)) { + return null; + } + if ("(Ljava/lang/String;)V".equals(descriptor)) { + return new HostBaseShim(runtime, object, (String) args[0]); + } + return new HostBaseShim(runtime, object); + } + } + + private static InterpRuntime load(String className, String source) throws Exception { + Path dir = Files.createTempDirectory("interp-subclass"); + Files.write(dir.resolve(className + ".java"), source.getBytes(StandardCharsets.UTF_8)); + // The fixture extends a class declared in this test, so it has to + // compile against these test classes. Surefire often hands + // java.class.path a manifest-only jar, so take the location of this + // class instead -- that is the directory the fixture needs. + String cp = InterpHostSubclassTest.class.getProtectionDomain() + .getCodeSource().getLocation().getPath() + + java.io.File.pathSeparator + System.getProperty("java.class.path"); + java.io.ByteArrayOutputStream diagnostics = new java.io.ByteArrayOutputStream(); + int rc = javax.tools.ToolProvider.getSystemJavaCompiler().run(null, null, diagnostics, + "-g", "-nowarn", "-XDstringConcat=inline", + "-cp", cp, + "-d", dir.toString(), dir.resolve(className + ".java").toString()); + if (rc != 0) { + throw new IllegalStateException("fixture did not compile:\n" + + diagnostics.toString("UTF-8")); + } + byte[] bundleBytes = InterpTestHarness.buildBundle(dir, className, source); + InterpBundle bundle = InterpBundleReader.read(new ByteArrayInputStream(bundleBytes)); + ReflectionInterpLinker linker = new ReflectionInterpLinker(); + TestFactory factory = new TestFactory(); + InterpRuntime rt = new InterpRuntime(bundle, linker, factory); + factory.attach(rt); + rt.setEdtBudgetMs(0); + return rt; + } + + private static final String FINAL_SUPER_SOURCE = + "public class SubFinal extends com.codename1.impl.interp.InterpHostSubclassTest.HostBase {\n" + + " public SubFinal() { super(\"pushed\"); }\n" + + " private String superStamp() { return super.stamp(); }\n" + + " public static String viaSuper() { return new SubFinal().superStamp(); }\n" + + " public static String viaPlain() { return new SubFinal().stamp(); }\n" + + " public static void main(String[] a) {}\n" + + "}\n"; + + /** + * {@code super.} on a *final* host method has no bridge to call, because + * the shim generator skips what it cannot override. Insisting on + * {@code super_stamp} anyway reported a missing method for a program that + * is perfectly legal Java; with nothing overriding the method, calling it + * directly is exactly what the super call means. + */ + @Test + @DisplayName("super. on a final host method calls it directly") + void superCallOnAFinalMethodNeedsNoBridge() throws Throwable { + InterpRuntime rt = load("SubFinal", FINAL_SUPER_SOURCE); + InterpClass c = rt.getBundle().findClass("SubFinal"); + assertEquals("stamp:pushed", + rt.invoke(c.declaredMethod("viaSuper", "()Ljava/lang/String;"), + null, new Object[0])); + // The same value without `super.`, so the assertion above is about the + // route rather than about what stamp() returns. + assertEquals("stamp:pushed", + rt.invoke(c.declaredMethod("viaPlain", "()Ljava/lang/String;"), + null, new Object[0])); + } + + /** + * A stopped program's peers stay alive: a timer, a network response or a + * listener the framework still holds will call one, and the runtime answers + * that it is detached. A generated interface shim has nothing to defer to + * for an abstract method, and reading that answer as "not implemented" made + * it throw AbstractMethodError on the event thread -- a failure raised by + * stopping a program cleanly. The sentinel is distinct so a shim can tell + * the two apart. + */ + @Test + @DisplayName("a callback after detach is answered, not turned into an error") + void detachAnswersWithItsOwnSentinel() throws Throwable { + InterpRuntime rt = load("Sub", SUBCLASS_SOURCE); + InterpObject o = (InterpObject) ((InterpBacked) rt.invoke( + rt.getBundle().findClass("Sub") + .declaredMethod("make", "()Ljava/lang/Object;"), null, new Object[0])) + .getInterpObject(); + + assertEquals("interp+host:pushed", + rt.dispatch(o, "render", "()Ljava/lang/String;", new Object[0]), + "before detaching, the override answers"); + + rt.detach(); + Object answer = rt.dispatch(o, "render", "()Ljava/lang/String;", new Object[0]); + assertTrue(answer == InterpRuntime.DETACHED, + "a detached runtime should say so, got " + answer); + assertTrue(answer != InterpRuntime.NOT_OVERRIDDEN, + "and must not be mistaken for a missing implementation"); + } + + /** + * A peerless interpreted object outlives its program the same way a peer + * does -- as a key in a host collection, or something waiting to be logged + * -- and its own toString/equals/hashCode ask the interpreter too. Casting + * the detached sentinel to the return type turned printing such an object + * into a ClassCastException. + */ + @Test + @DisplayName("a peerless object stays usable after its program is detached") + void peerlessObjectMethodsSurviveDetach() throws Throwable { + InterpRuntime rt = load("Bare", + "public class Bare {\n" + + " public String toString() { return \"bare!\"; }\n" + + " public boolean equals(Object o) { return o == this; }\n" + + " public int hashCode() { return 42; }\n" + + " public static Object make() { return new Bare(); }\n" + + " public static void main(String[] a) {}\n" + + "}\n"); + Object o = rt.invoke(rt.getBundle().findClass("Bare") + .declaredMethod("make", "()Ljava/lang/Object;"), null, new Object[0]); + assertEquals("bare!", o.toString()); + assertEquals(42, o.hashCode()); + + rt.detach(); + // Whatever these answer, they must answer: the object is still reachable + // from host code that has no idea the program was stopped. + assertNotNull(o.toString()); + o.hashCode(); + assertTrue(o.equals(o), "identity survives detaching"); + } + + /** + * An interpreted implementation that throws what the framework method + * declares has to arrive as that exception. Wrapped in the interpreter's + * own carrier it bypasses {@code catch (IOException)} entirely, and the + * caller sees an unexpected runtime exception from a method whose contract + * says it throws IOException. + */ + @Test + @DisplayName("a declared checked exception crosses back to host code intact") + void checkedExceptionsSurviveTheShimBoundary() throws Throwable { + InterpRuntime rt = load("Risky", + "public class Risky extends com.codename1.impl.interp.InterpHostSubclassTest.HostBase {\n" + + " public String risky() throws java.io.IOException {\n" + + " throw new java.io.IOException(\"boom\");\n" + + " }\n" + + " public static Object make() { return new Risky(); }\n" + + " public static void main(String[] a) {}\n" + + "}\n"); + HostBase peer = (HostBase) rt.invoke(rt.getBundle().findClass("Risky") + .declaredMethod("make", "()Ljava/lang/Object;"), null, new Object[0]); + + java.io.IOException caught = null; + try { + peer.risky(); + } catch (java.io.IOException e) { + caught = e; + } + assertNotNull(caught, "the declared exception should reach its own catch clause"); + assertEquals("boom", caught.getMessage()); + } + + /** + * The pushed program's own subclass of a declared exception has to reach the + * same catch clause. It arrives as an InterpObject whose peer is the host + * exception, so a shim that inspected the InterpObject matched nothing and + * rethrew the interpreter's carrier -- past a catch for the very type the + * method declares. + */ + @Test + @DisplayName("a pushed subclass of a declared exception is caught as that exception") + void interpretedExceptionSubclassesCrossAsTheirPeer() throws Throwable { + InterpRuntime rt = load("Thrower", + "public class Thrower extends com.codename1.impl.interp.InterpHostSubclassTest.HostBase {\n" + + " static class Mine extends com.codename1.impl.interp.InterpHostSubclassTest.HostFailure {\n" + + " Mine() { super(\"mine\"); }\n" + + " }\n" + + " public String risky() throws java.io.IOException { throw new Mine(); }\n" + + " public static Object make() { return new Thrower(); }\n" + + " public static void main(String[] a) {}\n" + + "}\n"); + HostBase peer = (HostBase) rt.invoke(rt.getBundle().findClass("Thrower") + .declaredMethod("make", "()Ljava/lang/Object;"), null, new Object[0]); + + java.io.IOException caught = null; + try { + peer.risky(); + } catch (java.io.IOException e) { + caught = e; + } + assertNotNull(caught, "a pushed IOException subclass should reach catch (IOException)"); + assertEquals("mine", caught.getMessage()); + } + + /** + * What {@code catch (ExceptionInInitializerError)} learns about the + * failure. JLS 12.4.2 wraps a non-Error initializer failure, and the + * wrapper's whole value is its cause -- which was null whenever the pushed + * program threw a class of its own, since the interpreted object is not + * itself a Throwable. The peer is. + */ + @Test + @DisplayName("an initializer failure keeps the interpreted exception as its cause") + void initializerFailuresCarryTheirCause() throws Throwable { + InterpRuntime rt = load("Initializes", + "public class Initializes {\n" + + " static class Mine extends com.codename1.impl.interp.InterpHostSubclassTest.HostTrouble {\n" + + " Mine() { super(\"from clinit\"); }\n" + + " }\n" + + " static class Holder { static int V; static { if (V == 0) { throwIt(); } } }\n" + + " static void throwIt() { throw new Mine(); }\n" + + " public static int touch() { return Holder.V; }\n" + + " public static void main(String[] a) {}\n" + + "}\n"); + Throwable caught = null; + try { + rt.invoke(rt.getBundle().findClass("Initializes").declaredMethod("touch", "()I"), + null, new Object[0]); + } catch (Throwable t) { + caught = t; + } + assertNotNull(caught, "the initializer should fail"); + Object thrown = caught instanceof InterpThrowable + ? ((InterpThrowable) caught).getThrown() : caught; + assertTrue(thrown instanceof ExceptionInInitializerError, + "JLS 12.4.2 wraps a non-Error failure, got " + thrown); + Throwable cause = ((ExceptionInInitializerError) thrown).getCause(); + assertNotNull(cause, "the wrapper must say what went wrong"); + assertEquals("from clinit", cause.getMessage(), + "the cause should be the peer of the interpreted exception"); + } + + private static final String SUBCLASS_SOURCE = + "public class Sub extends com.codename1.impl.interp.InterpHostSubclassTest.HostBase {\n" + + " public Sub() { super(\"pushed\"); }\n" + + " @Override public String render() { return \"interp+\" + super.render(); }\n" + + " public static Object make() { return new Sub(); }\n" + + " public static void main(String[] a) {}\n" + + "}\n"; + + /** + * {@code super.render()} must reach the framework implementation. Routing it + * back through the peer's override would recurse until the stack or the + * depth cap gave out. + */ + @Test + @DisplayName("super. from an interpreted override reaches the host implementation") + void superCallReachesTheHostImplementation() throws Throwable { + InterpRuntime rt = load("Sub", SUBCLASS_SOURCE); + Object peer = rt.invoke(rt.getBundle().findClass("Sub") + .declaredMethod("make", "()Ljava/lang/Object;"), null, new Object[0]); + + assertTrue(peer instanceof HostBase, "the peer should be a HostBase"); + // "interp+" proves the override ran; "host:pushed" proves super. reached + // the framework body rather than looping back into the override. + assertEquals("interp+host:pushed", ((HostBase) peer).render()); + } + + /** + * The superclass constructor the interpreted class chained to has to be the + * one the peer runs, or its arguments are silently discarded. + */ + @Test + @DisplayName("the superclass constructor arguments reach the peer") + void superConstructorArgumentsAreNotLost() throws Throwable { + InterpRuntime rt = load("Sub", SUBCLASS_SOURCE); + Object peer = rt.invoke(rt.getBundle().findClass("Sub") + .declaredMethod("make", "()Ljava/lang/Object;"), null, new Object[0]); + assertEquals("pushed", ((HostBase) peer).tag()); + } + + /** + * Framework code calling an overridable method on itself must reach the + * interpreted override -- this is what {@code Form.show()} calling + * {@code paint()} depends on. + */ + @Test + @DisplayName("a host self-call reaches the interpreted override") + void hostSelfCallReachesTheOverride() throws Throwable { + InterpRuntime rt = load("Sub", SUBCLASS_SOURCE); + Object peer = rt.invoke(rt.getBundle().findClass("Sub") + .declaredMethod("make", "()Ljava/lang/Object;"), null, new Object[0]); + assertEquals("[interp+host:pushed]", ((HostBase) peer).describe()); + } + + /** + * The event thread calling an interpreted override while another thread is + * running interpreted code is the normal case on a device, not an edge one: + * every repaint does it. Shared depth/fuel/call-stack state made the two + * corrupt each other. + */ + @Test + @DisplayName("concurrent entry from two threads stays correct") + void concurrentEntryIsThreadSafe() throws Throwable { + final InterpRuntime rt = load("Sub", SUBCLASS_SOURCE); + final InterpMethod make = rt.getBundle().findClass("Sub") + .declaredMethod("make", "()Ljava/lang/Object;"); + final HostBase peer = (HostBase) rt.invoke(make, null, new Object[0]); + + final int threads = 4; + final int iterations = 200; + final CountDownLatch start = new CountDownLatch(1); + final CountDownLatch done = new CountDownLatch(threads); + final AtomicReference failure = new AtomicReference(); + + for (int t = 0; t < threads; t++) { + new Thread(new Runnable() { + public void run() { + try { + start.await(); + for (int i = 0; i < iterations; i++) { + String s = peer.render(); + if (!"interp+host:pushed".equals(s)) { + throw new AssertionError("got " + s); + } + } + } catch (Throwable e) { + failure.compareAndSet(null, e); + } finally { + done.countDown(); + } + } + }, "interp-concurrency-" + t).start(); + } + start.countDown(); + assertTrue(done.await(60, TimeUnit.SECONDS), "threads did not finish"); + assertNull(failure.get(), "concurrent dispatch failed: " + failure.get()); + } + + /** + * A depth cap tripped on one thread must not affect another. With shared + * state a deep call on the pushed-program thread would make the event + * thread's next repaint fail for no reason. + */ + @Test + @DisplayName("the depth cap is per thread") + void theDepthCapIsPerThread() throws Throwable { + final InterpRuntime rt = load("Deep", + "public class Deep { static int f(int n) { return f(n + 1); }\n" + + " static int shallow() { return 7; }\n" + + " public static void main(String[] a) {} }"); + rt.setEdtBudgetMs(0); + rt.setMaxDepth(32); + + final InterpMethod deep = rt.getBundle().findClass("Deep").declaredMethod("f", "(I)I"); + final InterpMethod shallow = rt.getBundle().findClass("Deep") + .declaredMethod("shallow", "()I"); + + // Exhaust the cap on this thread. + try { + rt.invoke(deep, null, new Object[]{Integer.valueOf(0)}); + throw new AssertionError("expected the depth cap to fire"); + } catch (InterpThrowable expected) { + assertTrue(expected.getThrown() instanceof StackOverflowError); + } + + final AtomicReference result = new AtomicReference(); + final AtomicReference failure = new AtomicReference(); + Thread other = new Thread(new Runnable() { + public void run() { + try { + result.set(rt.invoke(shallow, null, new Object[0])); + } catch (Throwable t) { + failure.set(t); + } + } + }); + other.start(); + other.join(30000); + + assertNull(failure.get(), "the other thread should be unaffected: " + failure.get()); + assertEquals(Integer.valueOf(7), result.get()); + } + + /** After the cap fires, the same thread must be usable again. */ + @Test + @DisplayName("depth unwinds cleanly so the thread stays usable") + void depthUnwindsCleanly() throws Throwable { + InterpRuntime rt = load("Deep2", + "public class Deep2 { static int f(int n) { return f(n + 1); }\n" + + " static int shallow() { return 5; }\n" + + " public static void main(String[] a) {} }"); + rt.setEdtBudgetMs(0); + rt.setMaxDepth(32); + InterpClass c = rt.getBundle().findClass("Deep2"); + + for (int attempt = 0; attempt < 3; attempt++) { + try { + rt.invoke(c.declaredMethod("f", "(I)I"), null, new Object[]{Integer.valueOf(0)}); + throw new AssertionError("expected the depth cap to fire"); + } catch (InterpThrowable expected) { + assertNotNull(expected.getThrown()); + } + assertEquals(Integer.valueOf(5), + rt.invoke(c.declaredMethod("shallow", "()I"), null, new Object[0])); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpPairingSecretTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpPairingSecretTest.java new file mode 100644 index 00000000000..f43ac7c6534 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpPairingSecretTest.java @@ -0,0 +1,173 @@ +/* + * 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.interp; + +import com.codename1.junit.UITestBase; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The pairing secret, checked against the other implementation of itself. + * + *

There are two, and there have to be. The device runs on ParparVM, which + * has no {@code javax.crypto}, so it uses Codename One's own HMAC; the desktop + * push tool is an ordinary build tool and uses the JDK's. If those two ever + * disagree by a byte, nothing pairs -- and the symptom is "that code did not + * match", which reads exactly like a typo and sends people to the wrong place + * entirely. So the test drives both halves and compares.

+ * + * @author Shai Almog + */ +class InterpPairingSecretTest extends UITestBase { + + /** The push tool's copy, reached reflectively -- it is a private detail. */ + private static Object invokeDesktop(String name, Class[] types, Object[] args) + throws Exception { + Class push = Class.forName("com.codename1.tools.translator.DevicePush"); + Method m = push.getDeclaredMethod(name, types); + m.setAccessible(true); + return m.invoke(null, args); + } + + @Test + @DisplayName("the device and the push tool derive the same secret") + void bothEndsDeriveTheSameSecret() throws Exception { + byte[] device = InterpPairingSecret.derive("123456", "peer-a", "device-b"); + byte[] desktop = (byte[]) invokeDesktop("deriveSecret", + new Class[]{String.class, String.class, String.class}, + new Object[]{"123456", "peer-a", "device-b"}); + assertEquals(32, device.length, "HMAC-SHA-256 is 32 bytes"); + assertEquals(InterpPairingSecret.hex(device), InterpPairingSecret.hex(desktop), + "the two implementations of the derivation have diverged"); + } + + @Test + @DisplayName("the device and the push tool answer a challenge identically") + void bothEndsAnswerAChallengeIdentically() throws Exception { + byte[] secret = InterpPairingSecret.derive("000042", "peer", "device"); + String challenge = "ff00ff00"; + byte[] bundle = "a pushed program".getBytes(StandardCharsets.UTF_8); + + assertEquals(InterpPairingSecret.respond(secret, challenge), + invokeDesktop("respond", + new Class[]{byte[].class, String.class, byte[].class}, + new Object[]{secret, challenge, null}), + "the pairing answer differs between the two ends"); + assertEquals(InterpPairingSecret.respond(secret, challenge, bundle), + invokeDesktop("respond", + new Class[]{byte[].class, String.class, byte[].class}, + new Object[]{secret, challenge, bundle}), + "the push answer differs between the two ends"); + } + + @Test + @DisplayName("both ends agree on how slow the derivation is") + void theIterationCountsAgree() throws Exception { + Class push = Class.forName("com.codename1.tools.translator.DevicePush"); + Field f = push.getDeclaredField("PAIRING_ITERATIONS"); + f.setAccessible(true); + assertEquals(InterpPairingSecret.ITERATIONS, f.getInt(null), + "a mismatched iteration count makes every pairing fail as a wrong code"); + } + + /** + * The secret is what stops a captured peer id from being a credential, so + * every public input has to be bound into it. If the device id were not, a + * code typed on one phone would pair a different one; if the peer id were + * not, two computers pairing with the same phone would hold the same key. + */ + @Test + @DisplayName("every input changes the secret") + void theSecretBindsAllThreeInputs() { + String base = InterpPairingSecret.hex( + InterpPairingSecret.derive("123456", "peer", "device")); + assertNotEquals(base, InterpPairingSecret.hex( + InterpPairingSecret.derive("123457", "peer", "device")), "the code"); + assertNotEquals(base, InterpPairingSecret.hex( + InterpPairingSecret.derive("123456", "other", "device")), "the peer id"); + assertNotEquals(base, InterpPairingSecret.hex( + InterpPairingSecret.derive("123456", "peer", "other")), "the device id"); + assertEquals(base, InterpPairingSecret.hex( + InterpPairingSecret.derive(" 123456 ", "peer", "device")), + "a code typed with stray spaces on a phone is the same code"); + } + + /** + * The property that makes replay useless: an answer is worth exactly one + * connection, and it covers the program, so an intercepted push cannot have + * a different bundle put behind its valid answer. + */ + @Test + @DisplayName("an answer is specific to its challenge and its bundle") + void answersAreNotReusable() { + byte[] secret = InterpPairingSecret.derive("123456", "peer", "device"); + byte[] bundle = new byte[]{1, 2, 3}; + byte[] tampered = new byte[]{1, 2, 4}; + + assertNotEquals(InterpPairingSecret.respond(secret, "aaaa", bundle), + InterpPairingSecret.respond(secret, "bbbb", bundle), + "a captured answer must not authenticate the next connection"); + assertNotEquals(InterpPairingSecret.respond(secret, "aaaa", bundle), + InterpPairingSecret.respond(secret, "aaaa", tampered), + "the answer must cover the bundle, not only the challenge"); + assertNotEquals(InterpPairingSecret.respond(secret, "aaaa", bundle), + InterpPairingSecret.respond( + InterpPairingSecret.derive("999999", "peer", "device"), + "aaaa", bundle), + "a different code must not produce the right answer"); + } + + @Test + @DisplayName("challenges do not repeat") + void challengesAreFresh() { + String a = InterpPairingSecret.challenge(); + assertEquals(64, a.length(), "32 bytes as hex"); + assertNotEquals(a, InterpPairingSecret.challenge()); + } + + @Test + @DisplayName("hex survives a round trip and comparison rejects a near miss") + void hexRoundTripsAndComparisonIsExact() { + byte[] secret = InterpPairingSecret.derive("123456", "peer", "device"); + assertEquals(InterpPairingSecret.hex(secret), + InterpPairingSecret.hex(InterpPairingSecret.unhex( + InterpPairingSecret.hex(secret)))); + String answer = InterpPairingSecret.respond(secret, "aaaa"); + assertTrue(InterpPairingSecret.matches(answer, answer)); + assertFalse(InterpPairingSecret.matches(answer, + InterpPairingSecret.respond(secret, "aaab"))); + assertFalse(InterpPairingSecret.matches(answer, null)); + assertFalse(InterpPairingSecret.matches(answer, answer + "0"), + "a longer string that starts the same is not a match"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpPlatformRegistrationTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpPlatformRegistrationTest.java new file mode 100644 index 00000000000..32136b774a1 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpPlatformRegistrationTest.java @@ -0,0 +1,96 @@ +/* + * 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.interp; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Each port must register its linker, and this is the only thing that says so. + * + *

A port that does not register one leaves {@code InterpPlatform.isAvailable()} + * false, and the device runtime reports "this build has no interpreter + * bindings" -- which reads like a build-flag problem and sends you looking at + * the build. Nothing else fails: the app installs, starts, and refuses to run + * anything.

+ * + *

The registration has already been lost once, to a rebase that rewrote + * {@code AndroidImplementation.java} wholesale over a line-ending difference. + * One line vanished out of fourteen thousand and no test noticed, because + * every test that could have noticed needs a device. This one reads the source + * instead, which is worth more than its ugliness: it costs nothing and it runs + * on every push.

+ * + * @author Shai Almog + */ +class InterpPlatformRegistrationTest { + + /// Repository root, found by walking up from the module the test runs in. + private static File repositoryRoot() { + File at = new File("").getAbsoluteFile(); + for (int i = 0; i < 6 && at != null; i++) { + if (new File(at, "CodenameOne/src/com/codename1/impl/interp").isDirectory()) { + return at; + } + at = at.getParentFile(); + } + throw new IllegalStateException("cannot find the repository root"); + } + + private static void assertRegisters(String portSource, String linker) throws Exception { + File f = new File(repositoryRoot(), portSource); + assertTrue(f.isFile(), portSource + " is missing"); + String text = new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + assertTrue(text.indexOf("InterpPlatform.register(new " + linker) >= 0, + portSource + " must call InterpPlatform.register(new " + linker + "()):" + + " without it the port has no interpreter bindings and the device" + + " runtime refuses every push, with a message that blames the build"); + } + + @Test + @DisplayName("the Android port registers its linker") + void androidRegistersItsLinker() throws Exception { + assertRegisters("Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java", + "InterpAndroidLinker"); + } + + @Test + @DisplayName("the iOS port registers its linker") + void iosRegistersItsLinker() throws Exception { + assertRegisters("Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java", + "InterpIOSLinker"); + } + + @Test + @DisplayName("the JavaSE simulator port registers its linker") + void javaseRegistersItsLinker() throws Exception { + assertRegisters("Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java", + "InterpJavaSELinker"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpRuntimeContractTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpRuntimeContractTest.java new file mode 100644 index 00000000000..e9ac0acc8b1 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpRuntimeContractTest.java @@ -0,0 +1,992 @@ +/* + * 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.interp; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The device runtime's own guarantees, as distinct from "does it execute + * bytecode correctly" -- that is the conformance suite's job. + * + *

These are the properties that make it safe to hand a phone to a stranger + * with a socket open: a runaway program can be stopped, a program cannot hold + * the event thread indefinitely, runaway recursion is a diagnosable error + * rather than a process death, failures name the user's own source lines, and + * code whose source cannot be shown does not run at all.

+ * + * @author Shai Almog + */ +class InterpRuntimeContractTest { + + private static InterpRuntime load(String className, String source) throws Exception { + Path dir = Files.createTempDirectory("interp-contract"); + Path src = dir.resolve(className + ".java"); + Files.write(src, source.getBytes(StandardCharsets.UTF_8)); + javax.tools.JavaCompiler javac = javax.tools.ToolProvider.getSystemJavaCompiler(); + int rc = javac.run(null, null, null, "-g", "-nowarn", "-XDstringConcat=inline", + "-d", dir.toString(), src.toString()); + if (rc != 0) { + throw new IllegalStateException("fixture did not compile"); + } + byte[] bundleBytes = InterpTestHarness.buildBundle(dir, className, source); + InterpBundle bundle = InterpBundleReader.read(new ByteArrayInputStream(bundleBytes)); + ReflectionInterpLinker linker = new ReflectionInterpLinker(); + ProxyInterpObjectFactory factory = new ProxyInterpObjectFactory(linker); + InterpRuntime rt = new InterpRuntime(bundle, linker, factory); + factory.attach(rt); + return rt; + } + + /** + * The package a source declares is read as Java reads it, not by looking at + * what a line starts with. `/* license *""/ package com.example;` is one + * ordinary line, and taking it for the default package stored the source + * under a key the runtime never looks up -- so the push was refused for + * missing source that had been supplied. + */ + @Test + @DisplayName("a package declaration is found past comments and on one line") + void thePackageIsParsedRatherThanPatternMatched() throws Exception { + assertEquals("com.example", packageOf( + "/* license */ package com.example;\npublic class A {}\n")); + assertEquals("com.example", packageOf( + "// a comment\n\npackage com.example ;\npublic class A {}\n")); + assertEquals("com.example", packageOf( + "/*\n * a block\n */\npackage com.example;\n")); + assertEquals("", packageOf("public class A {}\n")); + assertEquals("", packageOf("// package com.example;\npublic class A {}\n")); + // javac decodes \\uXXXX before it reads anything else, so this is a + // package declaration -- odd, legal, and invisible to a raw scan. + assertEquals("com.example", + packageOf("\\u0070ackage com.example;\npublic class A {}\n")); + assertEquals("", packageOf("\\\\u0070ackage com.example;\npublic class A {}\n")); + // Kotlin's package declaration has no semicolon; a `;`-only terminator + // used to skip through the class body and return a garbled key that + // the reader looked up against nothing. + assertEquals("com.example", + packageOf("package com.example\n\nclass A { fun f() {} }\n")); + assertEquals("com.example", + packageOf("/* license */\npackage com.example\n\nfun main() {}\n")); + // A Kotlin source may escape reserved-word segments with backticks -- + // `package com.`is`.foo` -- but kotlinc's SourceFile attribute names + // the file at `com/is/Foo.kt`. Storing it under the raw path with + // backticks left the reader looking for a source the writer did not + // key, and the entire push was refused as missing source. + assertEquals("com.is.foo", + packageOf("package com.`is`.foo\n\nfun main() {}\n")); + // Whitespace *inside* backticks is part of the escaped segment; + // kotlinc keeps it, so the writer has to as well. Stripping every + // space folded `com.`foo bar`.baz` to `com.foobar.baz` and the + // reader then asked for a key nobody had written. + assertEquals("com.foo bar.baz", + packageOf("package com.`foo bar`.baz\n\nfun main() {}\n")); + // A `package-info.java` may carry an annotation whose argument is a + // class literal (`@Foo(String.class) package p;`). The `class` in + // `String.class` is not a type declaration -- the preceding dot + // proves it -- so the scanner must not stop there. Stopping used to + // return the default package for a file that declared one, key its + // source at the wrong path, and get the whole push refused. + assertEquals("p", packageOf( + "@p.A(String.class)\npackage p;\n")); + // Kotlin spells the class literal with `::class`. Same problem, same + // fix: the `class` after `::` is the literal, not a declaration. + assertEquals("p", packageOf( + "@file:A(String::class)\npackage p\n\nfun main() {}\n")); + // Kotlin's `"""..."""` raw strings (and Java text blocks) contain + // lone `"` characters. Treating each quote as its own delimiter + // exposed a fake `package` word inside the literal and stored the + // source at the wrong key; the whole push was then refused as + // missing source. The scanner has to recognise the triple-quote as + // one token. + assertEquals("real.p", packageOf( + "@file:Tag(\"\"\"x\" package fake \"\"\")\npackage real.p\n\nfun main() {}\n")); + // A `package-info.java` annotation whose argument is an array + // initializer -- `@p.A({String.class}) package p;` -- contains a + // `{` before the package keyword. Stopping at the first `{` + // returned the default package for a file that declared one; + // paren tracking lets the scanner recognise the brace as part of + // the annotation's array value rather than the class body. + assertEquals("p", packageOf( + "@p.A({String.class})\npackage p;\n")); + } + + private static String packageOf(String source) throws Exception { + Class writer = Class.forName("com.codename1.tools.translator.InterpBundleWriter"); + return (String) writer.getMethod("packageOf", String.class).invoke(null, source); + } + + /** + * A package-private method is overridden only from inside its own package + * (JVMS 5.4.5). A public method of the same signature in another package is + * a different method that happens to share a name, and dispatch that + * followed the receiver ran it instead -- silently changing what a library + * class does when a program subclasses it from its own package. + */ + @Test + @DisplayName("a package-private method is not overridden from another package") + void packagePrivateMethodsAreNotOverriddenAcrossPackages() throws Throwable { + Path dir = Files.createTempDirectory("interp-packages"); + String aSource = "package a;\n" + + "public class A {\n" + + " String label() { return \"a\"; }\n" + + " public String value() { return \"v:\" + label(); }\n" + + "}\n"; + String bSource = "package b;\n" + + "public class B extends a.A {\n" + + " public String label() { return \"b\"; }\n" + + " public static String call() { return new B().value(); }\n" + + " public static String own() { return new B().label(); }\n" + + " public static void main(String[] args) { }\n" + + "}\n"; + Files.createDirectories(dir.resolve("a")); + Files.createDirectories(dir.resolve("b")); + Path aFile = dir.resolve("a/A.java"); + Path bFile = dir.resolve("b/B.java"); + Files.write(aFile, aSource.getBytes(StandardCharsets.UTF_8)); + Files.write(bFile, bSource.getBytes(StandardCharsets.UTF_8)); + javax.tools.JavaCompiler javac = javax.tools.ToolProvider.getSystemJavaCompiler(); + int rc = javac.run(null, null, null, "-g", "-nowarn", "-XDstringConcat=inline", + "-d", dir.toString(), aFile.toString(), bFile.toString()); + assertEquals(0, rc, "the two-package fixture should compile"); + + byte[] bundleBytes = InterpTestHarness.buildBundle(dir, "b/B", + new String[]{"a/A.java", "b/B.java"}, new String[]{aSource, bSource}); + InterpBundle bundle = InterpBundleReader.read(new ByteArrayInputStream(bundleBytes)); + ReflectionInterpLinker linker = new ReflectionInterpLinker(); + ProxyInterpObjectFactory factory = new ProxyInterpObjectFactory(linker); + InterpRuntime rt = new InterpRuntime(bundle, linker, factory); + factory.attach(rt); + + InterpClass b = bundle.findClass("b/B"); + // The JVM answers "v:a" here -- B.label() does not override a's -- and + // "b" for the call B makes on itself. + assertEquals("v:a", rt.invoke(b.declaredMethod("call", "()Ljava/lang/String;"), + null, new Object[0])); + assertEquals("b", rt.invoke(b.declaredMethod("own", "()Ljava/lang/String;"), + null, new Object[0])); + } + + /** + * A pushed program that never returns has to be stoppable, or the Stop + * button is decoration and the only recovery is killing the app. The + * existing BeanShell playground has no such check. + */ + @Test + @DisplayName("a runaway loop can be cancelled from another thread") + void aRunawayLoopCanBeCancelled() throws Exception { + final InterpRuntime rt = load("Spin", + "public class Spin { public static void main(String[] a) {" + + " long n = 0; while (true) { n++; } }}"); + rt.setEdtBudgetMs(0); + + final AtomicReference outcome = new AtomicReference(); + Thread runner = new Thread(new Runnable() { + public void run() { + try { + rt.runMain(new String[0]); + } catch (Throwable t) { + outcome.set(t); + } + } + }); + runner.start(); + Thread.sleep(300); + rt.requestCancel(); + runner.join(15000); + + assertTrue(!runner.isAlive(), "the interpreter did not stop when asked"); + Throwable t = outcome.get(); + assertNotNull(t, "cancelling should surface as a throwable"); + assertTrue(unwrap(t) instanceof InterpCancelled, + "expected cancellation, got " + unwrap(t)); + } + + /** + * Holding the event thread is the failure users actually hit. The budget + * turns it into an error naming the elapsed time rather than a frozen app. + */ + @Test + @DisplayName("a program that never yields is stopped by the EDT budget") + void theEdtBudgetStopsANonYieldingProgram() throws Throwable { + InterpRuntime rt = load("Hog", + "public class Hog { public static void main(String[] a) {" + + " long n = 0; while (true) { n++; } }}"); + rt.setEdtBudgetMs(250); + + long started = System.currentTimeMillis(); + final InterpRuntime runtime = rt; + Throwable e = runWhereTheBudgetApplies(new Runnable() { + public void run() { + try { + runtime.runMain(new String[0]); + } catch (RuntimeException re) { + throw re; + } catch (Throwable t) { + throw new IllegalStateException(t); + } + } + }); + assertNotNull(e, "expected the EDT budget to fire"); + assertTrue(unwrap(e) instanceof InterpCancelled, + "expected cancellation, got " + unwrap(e)); + long elapsed = System.currentTimeMillis() - started; + assertTrue(elapsed < 15000, + "the budget should fire promptly, took " + elapsed + "ms"); + assertTrue(e.getMessage().indexOf("without yielding") > 0, + "the message should explain why, was: " + e.getMessage()); + } + + /// Time the host spent is not the program's to answer for. + /// + /// `invokeAndBlock`, a network read or a dialog can sit for seconds, and + /// the budget is about interpreted code that never yields. Suppressing the + /// check for the duration of a host call is not enough: the entry clock + /// kept running, so the first checkpoint after a long call tripped on time + /// the host had spent and every program that waited for anything died on + /// its next loop. + @Test + @DisplayName("time inside a host call is not charged to the EDT budget") + void aLongHostCallDoesNotSpendTheBudget() throws Throwable { + InterpRuntime rt = load("Waiter", + "public class Waiter {" + + " public static void main(String[] a) {" + + " try { Thread.sleep(1500); } catch (InterruptedException e) { }" + + " int n = 0; for (int i = 0; i < 20000; i++) { n += i; }" + + " System.out.println(n); } }"); + rt.setEdtBudgetMs(800); + + final InterpRuntime runtime = rt; + Throwable e = runWhereTheBudgetApplies(new Runnable() { + public void run() { + try { + runtime.runMain(new String[0]); + } catch (RuntimeException re) { + throw re; + } catch (Throwable t) { + throw new IllegalStateException(t); + } + } + }); + assertNull(e, "waiting on the host should not spend the budget, got " + + (e == null ? "" : String.valueOf(unwrap(e)))); + } + + /// The budget covers one entry into the interpreter, not the session. + /// + /// A real application's callbacks arrive long after its main returned -- + /// every button press is one -- and each is a fresh entry. Measuring from + /// the start of the run instead made the budget expire once and stay + /// expired, so every later callback failed instantly with "ran without + /// yielding" having executed nothing. + @Test + @DisplayName("a callback long after main still gets a full budget") + void theBudgetIsPerEntryNotPerSession() throws Throwable { + InterpRuntime rt = load("Later", + "public class Later {" + + " public static void main(String[] a) { }" + + " public static int work() {" + + " int n = 0; for (int i = 0; i < 200000; i++) { n += i; } return n; } }"); + rt.setEdtBudgetMs(2000); + rt.runMain(new String[0]); + + // Longer than the budget, so a session-wide clock would already be spent. + Thread.sleep(2200); + + InterpClass c = rt.getBundle().findClass("Later"); + InterpMethod work = c.declaredMethod("work", "()I"); + assertNotNull(work, "work() should be present"); + Object result = rt.invoke(work, null, new Object[0]); + // Computed the same way rather than hard-coded, so the assertion says + // "the same as Java" instead of encoding an arithmetic slip. + int expected = 0; + for (int i = 0; i < 200000; i++) { + expected += i; + } + assertEquals(Integer.valueOf(expected), result, + "the callback should run to completion"); + } + + /** + * Recursion that catches its own StackOverflowError and recurses again + * takes no back edge, and back edges were the only place progress was + * counted -- so the Stop button had nothing to act on and the event thread + * was held for good. + */ + @Test + @DisplayName("recursion that swallows its own overflow can still be stopped") + void recursionWithoutBackEdgesIsStillCancellable() throws Throwable { + final InterpRuntime rt = load("Swallow", + "public class Swallow {" + + " static int f(int n) {" + + " try { return f(n + 1); }" + + " catch (StackOverflowError e) { return f(n + 1); } }" + + " public static void main(String[] a) { f(0); } }"); + final AtomicReference outcome = new AtomicReference(); + Thread runner = new Thread(new Runnable() { + public void run() { + try { + rt.runMain(new String[0]); + } catch (Throwable t) { + outcome.set(t); + } + } + }); + runner.start(); + Thread.sleep(300); + rt.requestCancel(); + runner.join(15000); + assertTrue(!runner.isAlive(), "the interpreter did not stop when asked"); + assertTrue(unwrap(outcome.get()) instanceof InterpCancelled, + "expected cancellation, got " + unwrap(outcome.get())); + } + + /** Runaway recursion must be diagnosable, not a native stack overflow. */ + @Test + @DisplayName("unbounded recursion raises a StackOverflowError with an interpreted trace") + void unboundedRecursionIsBounded() throws Throwable { + InterpRuntime rt = load("Deep", + "public class Deep { static int f(int n) { return f(n + 1); }" + + " public static void main(String[] a) { System.out.println(f(0)); } }"); + rt.setEdtBudgetMs(0); + rt.setMaxDepth(64); + + try { + rt.runMain(new String[0]); + throw new AssertionError("expected the depth cap to fire"); + } catch (InterpThrowable e) { + assertTrue(e.getThrown() instanceof StackOverflowError, + "expected StackOverflowError, got " + e.getThrown()); + String[] stack = e.getInterpretedStack(); + assertTrue(stack.length > 0, "an interpreted trace should be captured"); + assertTrue(stack[0].startsWith("Deep.f("), + "innermost frame should be the recursing method, was " + stack[0]); + assertTrue(stack[0].indexOf("Deep.java:") > 0, + "the frame should name the user's source line, was " + stack[0]); + } + } + + /** + * A failure has to point at the user's code. A real stack trace would name + * the interpreter's frames, which tells the user nothing about their bug. + */ + @Test + @DisplayName("an uncaught failure reports the user's own source lines") + void failuresNameUserSourceLines() throws Throwable { + InterpRuntime rt = load("Boom", + "public class Boom {\n" + + " static int div(int a, int b) {\n" + + " return a / b;\n" + + " }\n" + + " public static void main(String[] args) {\n" + + " System.out.println(div(1, 0));\n" + + " }\n" + + "}\n"); + rt.setEdtBudgetMs(0); + + try { + rt.runMain(new String[0]); + throw new AssertionError("expected an ArithmeticException"); + } catch (InterpThrowable e) { + assertTrue(e.getThrown() instanceof ArithmeticException, + "expected ArithmeticException, got " + e.getThrown()); + String trace = e.getInterpretedStackTrace(); + assertTrue(trace.indexOf("Boom.div(Boom.java:3)") > 0, + "trace should point at the division, was:\n" + trace); + assertTrue(trace.indexOf("Boom.main(Boom.java:6)") > 0, + "trace should include the caller, was:\n" + trace); + } + } + + /** + * `catch (E e) { throw e; }` is Java's usual pattern for filtering. The + * IDE has to see the site the exception was originally raised at, not the + * `throw e` a handler happened to run through, because the rethrow site is + * uninformative -- it is where a caller decided not to handle the failure, + * not where the failure happened. + */ + @Test + @DisplayName("a rethrown exception keeps the original throw site in its trace") + void rethrowPreservesOriginalStack() throws Throwable { + InterpRuntime rt = load("Rethrow", + "public class Rethrow {\n" + + " static void inner() { throw new IllegalStateException(\"boom\"); }\n" + + " static void middle() {\n" + + " try { inner(); }\n" + + " catch (IllegalStateException e) { throw e; }\n" + + " }\n" + + " public static void main(String[] a) { middle(); }\n" + + "}\n"); + rt.setEdtBudgetMs(0); + + try { + rt.runMain(new String[0]); + throw new AssertionError("expected the exception to escape"); + } catch (Throwable outer) { + Throwable e = outer instanceof InterpThrowable ? ((InterpThrowable) outer).getCause() : outer; + if (e == null) { + e = outer; + } + String[] stack = rt.interpretedStackFor(e); + assertTrue(stack != null && stack.length > 0, + "the rethrown exception should still carry the interpreted stack"); + assertTrue(stack[0].startsWith("Rethrow.inner("), + "the innermost frame should be the original throw site, was " + stack[0]); + } + } + + /** + * Apple allows an app to download and run code only where the user can see + * and edit the source (guideline 2.5.2). The runtime enforces that rather + * than relying on the tool chain to have included it. + */ + @Test + @DisplayName("a bundle without sources is refused") + void aBundleWithoutSourcesIsRefused() throws Exception { + Path dir = Files.createTempDirectory("interp-nosource"); + String source = "public class Bare { public static void main(String[] a) {} }"; + Path src = dir.resolve("Bare.java"); + Files.write(src, source.getBytes(StandardCharsets.UTF_8)); + javax.tools.ToolProvider.getSystemJavaCompiler() + .run(null, null, null, "-g", "-nowarn", "-d", dir.toString(), src.toString()); + + Class writerClass = Class.forName("com.codename1.tools.translator.InterpBundleWriter"); + Object writer = writerClass.getDeclaredConstructor().newInstance(); + writerClass.getMethod("addClassFile", java.io.File.class) + .invoke(writer, dir.resolve("Bare.class").toFile()); + writerClass.getMethod("setMainClass", String.class).invoke(writer, "Bare"); + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + writerClass.getMethod("write", java.io.OutputStream.class).invoke(writer, bos); + + final byte[] bundleBytes = bos.toByteArray(); + java.io.IOException e = assertThrows(java.io.IOException.class, + new org.junit.jupiter.api.function.Executable() { + public void execute() throws Throwable { + InterpBundleReader.read(new ByteArrayInputStream(bundleBytes)); + } + }); + assertTrue(e.getMessage().indexOf("source") >= 0, + "the refusal should say why, was: " + e.getMessage()); + } + + /** + * A bundle built by a newer SDK than the installed app must fail with a + * clear message. The app ships on a store cadence and the SDK does not, so + * this mismatch is normal rather than exceptional. + */ + @Test + @DisplayName("a bundle from an unknown format version is refused by version") + void anUnknownFormatVersionIsRefused() throws Exception { + Path dir = Files.createTempDirectory("interp-version"); + String source = "public class V { public static void main(String[] a) {} }"; + Files.write(dir.resolve("V.java"), source.getBytes(StandardCharsets.UTF_8)); + javax.tools.ToolProvider.getSystemJavaCompiler().run(null, null, null, + "-g", "-nowarn", "-d", dir.toString(), dir.resolve("V.java").toString()); + byte[] good = InterpTestHarness.buildBundle(dir, "V", source); + + final byte[] tampered = good.clone(); + // Bytes 4..7 are the version, immediately after the magic word. + tampered[4] = 0; + tampered[5] = 0; + tampered[6] = 0; + tampered[7] = 99; + + java.io.IOException e = assertThrows(java.io.IOException.class, + new org.junit.jupiter.api.function.Executable() { + public void execute() throws Throwable { + InterpBundleReader.read(new ByteArrayInputStream(tampered)); + } + }); + assertTrue(e.getMessage().indexOf("version") >= 0, + "the refusal should mention the version, was: " + e.getMessage()); + } + + /** + * An interpreted class implementing a host interface has to be usable + * as that interface by host code that has never heard of it. This + * is the half of the object-factory problem that reflection solves + * completely; extending a host class is the half that needs a per-platform + * mechanism. + */ + @Test + @DisplayName("an interpreted class implementing a host interface is callable from host code") + void anInterpretedClassCanImplementAHostInterface() throws Throwable { + InterpRuntime rt = load("Callback", + "import java.util.concurrent.Callable;\n" + + "public class Callback implements Callable {\n" + + " private final String tag;\n" + + " public Callback(String tag) { this.tag = tag; }\n" + + " public String call() { return \"called:\" + tag; }\n" + + " public static Object make() { return new Callback(\"x\"); }\n" + + " public static void main(String[] a) {}\n" + + "}\n"); + rt.setEdtBudgetMs(0); + + InterpClass c = rt.getBundle().findClass("Callback"); + assertNotNull(c, "the bundle should contain the class"); + InterpMethod make = c.declaredMethod("make", "()Ljava/lang/Object;"); + assertNotNull(make, "make() should be present"); + + Object peer = rt.invoke(make, null, new Object[0]); + assertTrue(peer instanceof java.util.concurrent.Callable, + "the peer should be usable as the host interface, was " + + (peer == null ? "null" : peer.getClass().getName())); + // Host code calling through the interface, with no knowledge of the + // interpreter, must reach the interpreted body. + assertEquals("called:x", ((java.util.concurrent.Callable) peer).call()); + } + + /// A deviation from the JVM, asserted so it stays a known one. + /// + /// The interpreter represents every reference array as `Object[]`. Nothing + /// records the component type an `anewarray` was given, so `instanceof` + /// against an array type is answered by inspecting the elements -- which + /// makes an empty array satisfy any component type, where the JVM would + /// compare the array's own runtime type and say no. + /// + /// It is the safe direction of the two. Answering `false` for an empty + /// array would break the cast in every generated `values()`, and an enum + /// with no constants is far more ordinary than code that asks whether an + /// `Object[]` is a `Color[]`. + @Test + @DisplayName("an empty array satisfies any component type -- a known deviation") + void emptyArrayInstanceOfIsPermissive() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("EmptyArrayCast", + "public class EmptyArrayCast {" + + " static class E {}" + + " public static void main(String[] a) {" + + " System.out.println(new Object[0] instanceof E[]);" + + "}}"); + assertEquals("false", r[0].output.trim(), "the JVM compares the array's own type"); + assertEquals("true", r[1].output.trim(), + "the interpreter has no component type to compare and inspects elements"); + } + + /// A class initializer that throws must not leave the class looking + /// initialized. + /// + /// The obvious implementation is one boolean set before `` runs -- + /// which is genuinely necessary, since an initializer that reaches back + /// into its own class has to be let through or it recurses forever. What + /// that boolean cannot express is failure: leave it set after the + /// initializer throws and every later read of a static field returns + /// whatever half of the initializer managed to assign, and the program + /// misbehaves somewhere far away instead of reporting the class as broken. + /// + /// The first touch surfaces ExceptionInInitializerError, as the JVM does, + /// and every touch after it says the class is unusable. + @Test + @DisplayName("a class whose initializer throws stays broken") + void aFailedClassInitializerIsSticky() throws Throwable { + InterpRuntime rt = load("InitFails", + "public class InitFails {" + + " static class Boom { static int V; static { V = 7;" + + " if (V > 0) { throw new IllegalStateException(\"boom\"); } } }" + + " public static void main(String[] a) {" + + " for (int i = 0; i < 2; i++) {" + + " try { System.out.println(i + \":\" + Boom.V); }" + + " catch (Throwable t) { System.out.println(i + \":\" + t.getClass().getName()); }" + + " }" + + "}}"); + rt.setEdtBudgetMs(0); + + java.io.PrintStream originalOut = System.out; + java.io.ByteArrayOutputStream captured = new java.io.ByteArrayOutputStream(); + try { + System.setOut(new java.io.PrintStream(captured, true, "UTF-8")); + rt.runMain(new String[0]); + } finally { + System.setOut(originalOut); + } + String out = captured.toString("UTF-8").trim(); + assertTrue(out.startsWith("0:java.lang.ExceptionInInitializerError"), + "the initializer's own failure should reach the caller, got: " + out); + assertTrue(out.endsWith("1:java.lang.NoClassDefFoundError"), + "a second touch must report the class as unusable, not hand back " + + "the fields the failed initializer assigned; got: " + out); + } + + /// Two classes may name the same source file, and both sources must ship. + /// + /// `Util.java` in `a` and `Util.java` in `b` is ordinary. The bundle used to + /// key sources by file name, so the second overwrote the first and the + /// runtime refused the whole program with "bundle is missing the source file + /// Util.java" -- for a file that was sitting in the tree it was handed. + /// Since showing the source is the condition under which this app is allowed + /// to run pushed code at all, losing one is not a cosmetic bug. + @Test + @DisplayName("same-named sources in different packages both survive the bundle") + void sourcesAreKeyedByPackage() throws Exception { + Path dir = Files.createTempDirectory("interp-source-keys"); + Path src = dir.resolve("src"); + Files.createDirectories(src.resolve("a")); + Files.createDirectories(src.resolve("b")); + Files.write(src.resolve("a/Util.java"), + "package a;\npublic class Util { public static int v() { return 1; } }\n" + .getBytes(StandardCharsets.UTF_8)); + Files.write(src.resolve("b/Util.java"), + "package b;\npublic class Util { public static int v() { return 2; } }\n" + .getBytes(StandardCharsets.UTF_8)); + Path classes = dir.resolve("classes"); + Files.createDirectories(classes); + int rc = javax.tools.ToolProvider.getSystemJavaCompiler().run(null, null, null, + "-g", "-nowarn", "-d", classes.toString(), + src.resolve("a/Util.java").toString(), src.resolve("b/Util.java").toString()); + assertEquals(0, rc, "fixture did not compile"); + + Class writerClass = Class.forName("com.codename1.tools.translator.InterpBundleWriter"); + Object writer = writerClass.getDeclaredConstructor().newInstance(); + writerClass.getMethod("addClassFile", java.io.File.class) + .invoke(writer, classes.resolve("a/Util.class").toFile()); + writerClass.getMethod("addClassFile", java.io.File.class) + .invoke(writer, classes.resolve("b/Util.class").toFile()); + writerClass.getMethod("addSourceTree", java.io.File.class).invoke(writer, src.toFile()); + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + writerClass.getMethod("write", java.io.OutputStream.class).invoke(writer, bos); + + // Reading is itself the assertion for the load-bearing half: the reader + // refuses a bundle whose classes have no source. + InterpBundle bundle = InterpBundleReader.read(new ByteArrayInputStream(bos.toByteArray())); + assertNotNull(bundle.getSource("a/Util.java"), "a's source was dropped"); + assertNotNull(bundle.getSource("b/Util.java"), "b's source was dropped"); + assertTrue(bundle.getSource("a/Util.java").contains("package a;")); + assertTrue(bundle.getSource("b/Util.java").contains("package b;")); + } + + /// A source file goes to the source section, not to both. + /// + /// `addResourceTree` used to reject only `.java`, so a Kotlin project's + /// `.kt` files landed in both the source section (via `addSourceTree`) and + /// the resource section as well. That doubled the source payload and a + /// Kotlin-heavy program could cross the service's 64 MiB bundle limit even + /// though the executable half would fit; extending the exclusion to every + /// source extension keeps the two sections disjoint. + @Test + @DisplayName("addResourceTree does not double a Kotlin source into the resource section") + void kotlinSourcesDoNotShipTwice() throws Exception { + Path dir = Files.createTempDirectory("interp-src-once"); + Files.write(dir.resolve("Foo.kt"), + "package example\n\nfun main() {}\n".getBytes(StandardCharsets.UTF_8)); + Files.write(dir.resolve("theme.res"), new byte[]{1, 2, 3}); + + Class writerClass = Class.forName("com.codename1.tools.translator.InterpBundleWriter"); + Object writer = writerClass.getDeclaredConstructor().newInstance(); + writerClass.getMethod("addResourceTree", java.io.File.class).invoke(writer, dir.toFile()); + + java.lang.reflect.Field resourcesField = writerClass.getDeclaredField("resources"); + resourcesField.setAccessible(true); + java.util.Map resources = (java.util.Map) resourcesField.get(writer); + assertTrue(resources.containsKey("/theme.res"), + "non-source resources should still be collected"); + for (Object key : resources.keySet()) { + String k = (String) key; + assertTrue(!k.endsWith(".kt") && !k.endsWith(".java"), + "a source file leaked into the resource section: " + k); + } + } + + /// A class literal for a pushed type has no host class object behind it, so + /// the token on the stack is the interpreter's own. The bytecode does not + /// know that and goes on calling `java.lang.Class` methods on it, which no + /// linker can serve -- a reflective one rejects the receiver and a native + /// one has no clazz pointer for a class the device was never built with. + @Test + @DisplayName("Class methods work on a class literal for a pushed type") + void classLiteralsAnswerTheUsualQuestions() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("ClassOps", + "public class ClassOps {" + + " interface Marker {}" + + " static class Thing implements Marker {}" + + " public static void main(String[] a) {" + + " System.out.println(Thing.class.getName());" + + " System.out.println(Thing.class.getSimpleName());" + + " System.out.println(Marker.class.isInterface());" + + " System.out.println(Thing.class.isInstance(new Thing()));" + + " System.out.println(new Thing().getClass().getSimpleName());" + + " System.out.println(Thing.class.equals(Thing.class));" + + "}}"); + assertEquals(r[0].output, r[1].output, + "the interpreter should answer Class the way the JVM does"); + } + + /// `new Entry[1][]` names its component `[LEntry;`, not `Entry`, so a + /// bundle-membership test that did not look through the brackets asked the + /// host to load a class only the bundle has. + @Test + @DisplayName("arrays of a pushed type allocate in the interpreter at every rank") + void arraysOfInterpretedTypesAllocate() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("ArrayRanks", + "public class ArrayRanks {" + + " static class Entry { int v; Entry(int v) { this.v = v; } }" + + " public static void main(String[] a) {" + + " Entry[] one = new Entry[2];" + + " one[0] = new Entry(7);" + + " System.out.println(one.length + \":\" + one[0].v + \":\" + one[1]);" + + " Entry[][] partial = new Entry[2][];" + + " partial[0] = one;" + + " System.out.println(partial.length + \":\" + partial[0][0].v + \":\" + partial[1]);" + + " Entry[][] full = new Entry[2][3];" + + " full[1][2] = new Entry(9);" + + " System.out.println(full.length + \":\" + full[1].length + \":\" + full[1][2].v);" + + "}}"); + assertEquals(r[0].output, r[1].output, + "allocating an array of a pushed type should not reach the host loader"); + } + + /// JLS 12.4.1: initializing a class initializes the superinterfaces that + /// declare a default method -- and only those. Initializing all of them + /// would run initializers Java never runs, which is as wrong as running + /// them late. + @Test + @DisplayName("an interface with a default method is initialized with its implementor") + void defaultBearingInterfacesInitializeWithTheClass() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("IfaceInit", + "public class IfaceInit {" + + " static String log = \"\";" + + " static String note(String s) { log = log + s; return s; }" + + " interface WithDefault { String V = note(\"D\"); default int x() { return 1; } }" + + " interface Plain { String V = note(\"P\"); }" + + " static class C implements WithDefault, Plain {}" + + " public static void main(String[] a) {" + + " new C();" + + " System.out.println(\"after new C: \" + log);" + + " System.out.println(Plain.V);" + + " System.out.println(\"after reading Plain.V: \" + log);" + + "}}"); + assertEquals(r[0].output, r[1].output, + "interface initialization order should match the JVM's"); + } + + /// `B.Z` where an interface declares Z compiles to a field reference owned + /// by B, which declares no such field. Searching only the superclass chain + /// answered with B and produced the field's *default* value -- a wrong + /// number rather than an error, which is the worst way to be wrong. + @Test + @DisplayName("statics declared by an interface resolve through implementors") + void interfaceStaticsResolveThroughImplementors() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("IfaceStatics", + "public class IfaceStatics {" + + " interface I { int Z = 7; String S = \"seven\"; }" + + " static class B implements I {}" + + " static class C extends B {}" + + " public static void main(String[] a) {" + + " System.out.println(B.Z + \":\" + B.S + \":\" + C.Z);" + + "}}"); + assertEquals(r[0].output, r[1].output); + } + + /// `B.m()` where B inherits a static m from A records B as the owner. The + /// vtable holds instance methods only, by design, so the lookup missed and + /// fell through to the host -- which has never heard of B. + @Test + @DisplayName("a static method inherited from an interpreted class resolves") + void inheritedStaticMethodsResolve() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("StaticInherit", + "public class StaticInherit {" + + " static class A { static int twice(int v) { return v * 2; } }" + + " static class B extends A {}" + + " public static void main(String[] a) {" + + " System.out.println(B.twice(21));" + + "}}"); + assertEquals(r[0].output, r[1].output); + } + + /// An interpreted object with no peer reaches host code as itself, and host + /// code puts it in a HashMap. Identity equality there is not a missing + /// nicety: keys the program considers equal hash differently and every + /// lookup misses, quietly. + @Test + @DisplayName("equals and hashCode reach the interpreted overrides") + void equalsAndHashCodeAreDelegated() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("EqualsKeys", + "import java.util.HashMap;" + + "public class EqualsKeys {" + + " static class Key {" + + " final int v;" + + " Key(int v) { this.v = v; }" + + " public boolean equals(Object o) { return o instanceof Key && ((Key)o).v == v; }" + + " public int hashCode() { return v; }" + + " }" + + " public static void main(String[] a) {" + + " HashMap m = new HashMap();" + + " m.put(new Key(1), \"one\");" + + " System.out.println(m.get(new Key(1)));" + + " System.out.println(new Key(2).equals(new Key(2)));" + + "}}"); + assertEquals(r[0].output, r[1].output); + } + + /// `super.toString()` in an override used to dispatch straight back into + /// that override: unbounded recursion reported as a stack overflow, in code + /// that reads as ordinary Java. + @Test + @DisplayName("super.toString does not recurse into the override") + void superToStringReachesObject() throws Throwable { + InterpRuntime rt = load("SuperToString", + "public class SuperToString {" + + " static class T { public String toString() { return \"T:\" + super.toString().length(); } }" + + " public static void main(String[] a) {" + + " System.out.println(new T().toString().startsWith(\"T:\"));" + + "}}"); + rt.setEdtBudgetMs(0); + java.io.PrintStream out = System.out; + java.io.ByteArrayOutputStream captured = new java.io.ByteArrayOutputStream(); + try { + System.setOut(new java.io.PrintStream(captured, true, "UTF-8")); + rt.runMain(new String[0]); + } finally { + System.setOut(out); + } + assertEquals("true", captured.toString("UTF-8").trim()); + } + + /// wait/notify on an object of a pushed-only class: the wrapper is a real + /// Java object with a real monitor, and a synchronized block on a peerless + /// object locks that same wrapper. Without this the runtime reported the + /// methods as not implemented, so ordinary producer/consumer code failed. + @Test + @DisplayName("wait and notify work on a pushed-only class") + void waitAndNotifyWorkOnPushedObjects() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("WaitNotify", + "public class WaitNotify {" + + " static class Lock {}" + + " static boolean ready;" + + " public static void main(String[] a) throws Exception {" + + " final Lock lock = new Lock();" + + " Thread t = new Thread(new Runnable() { public void run() {" + + " synchronized (lock) { ready = true; lock.notifyAll(); } } });" + + " synchronized (lock) {" + + " t.start();" + + " while (!ready) { lock.wait(2000); }" + + " }" + + " System.out.println(\"woken:\" + ready);" + + "}}"); + assertEquals(r[0].output, r[1].output); + } + + /// Object is recorded as an extern and no interpreted class lists it as an + /// interpreted supertype, so the hierarchy walk answered false for + /// `x instanceof Object` -- true of every non-null reference there has been. + @Test + @DisplayName("a pushed object is an instance of Object") + void everythingIsAnObject() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("ObjectInstance", + "public class ObjectInstance {" + + " static class Thing {}" + + " public static void main(String[] a) {" + + " Object o = new Thing();" + + " System.out.println((o instanceof Object) + \":\" + (((Object)new Thing()) != null));" + + "}}"); + assertEquals(r[0].output, r[1].output); + } + + /// JLS 12.4.2 wraps a non-Error initializer failure, which is how Java code + /// catches it. An Error passes through unwrapped, as the spec says. + @Test + @DisplayName("a non-Error initializer failure arrives as ExceptionInInitializerError") + void initializerFailuresAreWrapped() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("InitWrap", + "public class InitWrap {" + + " static class Boom { static int V; static { V = 1; if (V > 0) {" + + " throw new IllegalStateException(\"boom\"); } } }" + + " public static void main(String[] a) {" + + " try { System.out.println(Boom.V); }" + + " catch (ExceptionInInitializerError e) {" + + " System.out.println(\"wrapped:\" + e.getException().getClass().getName()); }" + + "}}"); + assertEquals(r[0].output, r[1].output); + } + + /// A deviation from the JVM, asserted so it stays a known one. + /// + /// An array of a pushed-only type is an `Object[]` -- there is no host class + /// to allocate one of -- so the instance carries no component type and a + /// store into it cannot be checked. Java throws ArrayStoreException here. + /// Closing it means a wrapper object around every interpreted array, + /// threaded through every array opcode and every crossing into host code: + /// see the developer guide for why that is written down rather than done. + @Test + @DisplayName("an aliased pushed-type array accepts a foreign element -- a known deviation") + void arrayStoreIsUncheckedForPushedTypes() throws Exception { + InterpTestHarness.Result[] r = InterpTestHarness.runBoth("ArrayStoreDeviation", + "public class ArrayStoreDeviation {" + + " static class A {}" + + " static class B {}" + + " public static void main(String[] x) {" + + " Object[] alias = new A[1];" + + " try { alias[0] = new B(); System.out.println(\"stored\"); }" + + " catch (ArrayStoreException e) { System.out.println(\"refused\"); }" + + "}}"); + assertEquals("refused", r[0].output.trim(), "the JVM checks the component type"); + assertEquals("stored", r[1].output.trim(), + "the interpreter has no component type to check"); + } + + /// Runs on the thread the wall-clock budget applies to. + /// + /// The budget is the event thread's rule -- a worker computing for ten + /// seconds blocks nothing -- so a test of it has to be on the event thread + /// whenever there is one. Surefire reuses the JVM, so whether Display has + /// been initialized depends on which tests ran first, and asserting from + /// whatever thread JUnit happens to use would pass or fail on that. + private static Throwable runWhereTheBudgetApplies(final Runnable body) { + final Throwable[] thrown = new Throwable[1]; + Runnable capture = new Runnable() { + public void run() { + try { + body.run(); + } catch (Throwable t) { + thrown[0] = t; + } + } + }; + if (com.codename1.ui.Display.isInitialized()) { + com.codename1.ui.Display.getInstance().callSeriallyAndWait(capture); + } else { + capture.run(); + } + return thrown[0]; + } + + private static Throwable unwrap(Throwable t) { + if (t instanceof InterpThrowable) { + Object thrown = ((InterpThrowable) t).getThrown(); + if (thrown instanceof Throwable) { + return (Throwable) thrown; + } + } + return t; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpTestHarness.java b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpTestHarness.java new file mode 100644 index 00000000000..2a15512bb40 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/interp/InterpTestHarness.java @@ -0,0 +1,257 @@ +/* + * 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.interp; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; + +/** + * Compiles a small Java program, runs it twice -- once on this JVM, once in the + * interpreter -- and compares what it printed. + * + *

Differential testing is the only practical way to be confident about an + * interpreter. There are hundreds of behaviours to get right (integer overflow, + * NaN comparisons, {@code dup2_x2} on mixed categories, which exception handler + * wins) and a hand-written expectation for each is both laborious and only as + * good as the author's memory of the spec. Running the same bytecode on a real + * JVM produces the authoritative answer for free.

+ * + *

The harness drives the real bundle pipeline -- javac, then the translator's + * {@code InterpBundleWriter}, then {@link InterpBundleReader} -- so the format + * and both of its ends are exercised on every case rather than being assumed.

+ * + * @author Shai Almog + */ +final class InterpTestHarness { + private InterpTestHarness() { + } + + /** Output of one run: what the program printed, or how it failed. */ + static final class Result { + final String output; + final String failure; + + Result(String output, String failure) { + this.output = output; + this.failure = failure; + } + + boolean failed() { + return failure != null; + } + + public String toString() { + return failed() ? "FAILED: " + failure : output; + } + } + + /** + * Compiles {@code source} (a single class named {@code className} with a + * {@code main}), runs it on this JVM and in the interpreter, and returns + * both results. + */ + static Result[] runBoth(String className, String source) throws Exception { + Path dir = Files.createTempDirectory("interp-conformance"); + Path src = dir.resolve(className + ".java"); + Files.write(src, source.getBytes(StandardCharsets.UTF_8)); + + JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); + if (javac == null) { + throw new IllegalStateException("no system Java compiler; run tests on a JDK"); + } + // -g keeps the line table, which the runtime requires: a bundle without + // source information is refused, since the user has to be able to read + // what runs. + // + // -XDstringConcat=inline is not optional. From JDK 9 onwards javac + // compiles `"a" + b` to an invokedynamic against StringConcatFactory, + // and ParparVM has no runtime invokedynamic at all -- the translator + // desugars it at build time. A pushed bundle has no such pass, so the + // push pipeline compiles concatenation the old way, to StringBuilder. + // This is the same flag the real pipeline uses; the harness passes it + // so the corpus is compiled exactly as pushed code will be. + int rc = javac.run(null, null, null, + "-g", "-nowarn", "-XDstringConcat=inline", + "-d", dir.toString(), src.toString()); + if (rc != 0) { + throw new IllegalStateException("fixture did not compile"); + } + + return new Result[]{ + runOnJvm(dir, className), + runInInterpreter(dir, className, source) + }; + } + + private static Result runOnJvm(Path dir, String className) { + PrintStream originalOut = System.out; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + URLClassLoader loader = null; + try { + loader = new URLClassLoader(new URL[]{dir.toUri().toURL()}, null); + Class c = Class.forName(className, true, loader); + Method main = c.getMethod("main", String[].class); + System.setOut(new PrintStream(captured, true, "UTF-8")); + main.invoke(null, (Object) new String[0]); + return new Result(captured.toString("UTF-8"), null); + } catch (Throwable t) { + Throwable cause = t.getCause() != null ? t.getCause() : t; + try { + return new Result(captured.toString("UTF-8"), describe(cause)); + } catch (Exception e) { + return new Result("", describe(cause)); + } + } finally { + System.setOut(originalOut); + if (loader != null) { + try { + loader.close(); + } catch (Exception ignore) { + // nothing useful to do + } + } + } + } + + private static Result runInInterpreter(Path dir, String className, String source) + throws Exception { + byte[] bundleBytes = buildBundle(dir, className, source); + InterpBundle bundle = InterpBundleReader.read(new ByteArrayInputStream(bundleBytes)); + + ReflectionInterpLinker linker = new ReflectionInterpLinker(); + ProxyInterpObjectFactory factory = new ProxyInterpObjectFactory(linker); + InterpRuntime runtime = new InterpRuntime(bundle, linker, factory); + factory.attach(runtime); + // The conformance corpus includes deliberately long loops; the EDT + // budget is a device concern and would only make the suite flaky here. + runtime.setEdtBudgetMs(0); + + PrintStream originalOut = System.out; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + try { + System.setOut(new PrintStream(captured, true, "UTF-8")); + runtime.runMain(new String[0]); + return new Result(captured.toString("UTF-8"), null); + } catch (InterpThrowable it) { + Object thrown = it.getThrown(); + return new Result(captured.toString("UTF-8"), + thrown instanceof Throwable ? describe((Throwable) thrown) : String.valueOf(thrown)); + } catch (Throwable t) { + return new Result(captured.toString("UTF-8"), describe(t)); + } finally { + System.setOut(originalOut); + } + } + + /** Builds a bundle from every class file under {@code dir}. */ + /** + * A bundle from a tree with more than one source file. + * + *

The reader refuses a bundle whose classes have no source, so a fixture + * spanning two packages has to carry both -- keyed by package-qualified + * path, which is how the runtime looks one up.

+ */ + static byte[] buildBundle(Path dir, String mainClass, String[] sourceNames, + String[] sources) throws Exception { + Class writerClass = Class.forName("com.codename1.tools.translator.InterpBundleWriter"); + Object writer = writerClass.getDeclaredConstructor().newInstance(); + Method addClassFile = writerClass.getMethod("addClassFile", File.class); + Method addSource = writerClass.getMethod("addSource", String.class, String.class); + Method setMain = writerClass.getMethod("setMainClass", String.class); + Method write = writerClass.getMethod("write", java.io.OutputStream.class); + + List classFiles = new ArrayList(); + collectClassFiles(dir.toFile(), classFiles); + for (File f : classFiles) { + addClassFile.invoke(writer, f); + } + for (int i = 0; i < sourceNames.length; i++) { + addSource.invoke(writer, sourceNames[i], sources[i]); + } + setMain.invoke(writer, mainClass); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + write.invoke(writer, bos); + return bos.toByteArray(); + } + + static byte[] buildBundle(Path dir, String mainClass, String source) throws Exception { + // Loaded reflectively so core-unittests does not need a compile-time + // dependency on the translator module. + Class writerClass = Class.forName("com.codename1.tools.translator.InterpBundleWriter"); + Object writer = writerClass.getDeclaredConstructor().newInstance(); + Method addClassFile = writerClass.getMethod("addClassFile", File.class); + Method addSource = writerClass.getMethod("addSource", String.class, String.class); + Method setMain = writerClass.getMethod("setMainClass", String.class); + Method write = writerClass.getMethod("write", java.io.OutputStream.class); + + List classFiles = new ArrayList(); + collectClassFiles(dir.toFile(), classFiles); + for (File f : classFiles) { + addClassFile.invoke(writer, f); + } + addSource.invoke(writer, mainClass + ".java", source); + setMain.invoke(writer, mainClass); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + write.invoke(writer, bos); + return bos.toByteArray(); + } + + private static void collectClassFiles(File dir, List out) { + File[] kids = dir.listFiles(); + if (kids == null) { + return; + } + for (File f : kids) { + if (f.isDirectory()) { + collectClassFiles(f, out); + } else if (f.getName().endsWith(".class")) { + out.add(f); + } + } + } + + /** + * Renders a failure so the two runs can be compared. Only the type and + * message are used: a JVM stack trace names the JVM's frames and an + * interpreted one names the interpreter's, so including either would make + * every failing case differ for the wrong reason. + */ + private static String describe(Throwable t) { + String msg = t.getMessage(); + return t.getClass().getName() + (msg == null ? "" : ": " + msg); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/interp/ProxyInterpObjectFactory.java b/maven/core-unittests/src/test/java/com/codename1/impl/interp/ProxyInterpObjectFactory.java new file mode 100644 index 00000000000..b7ff73124d4 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/interp/ProxyInterpObjectFactory.java @@ -0,0 +1,150 @@ +/* + * 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.interp; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +/** + * An {@link InterpObjectFactory} for platforms with reflection, covering the + * interface case with {@link Proxy}. + * + *

{@code Proxy} solves half the problem completely and for free: an + * interpreted class implementing {@code ActionListener} becomes a real + * {@code ActionListener} the framework can hold and call, with no code + * generation, no dex loading, and nothing for App Review to object to.

+ * + *

It solves none of the other half. {@code Proxy} cannot extend a class, so + * an interpreted {@code class MyForm extends Form} still needs a per-platform + * mechanism -- vtable synthesis on iOS, generated subclasses on Android. This + * factory reports that honestly through {@link #canExtend} rather than + * producing a peer that the framework would accept and then never dispatch + * to.

+ * + * @author Shai Almog + */ +public class ProxyInterpObjectFactory implements InterpObjectFactory { + private final InterpLinker linker; + private InterpRuntime runtime; + + public ProxyInterpObjectFactory(InterpLinker linker) { + this.linker = linker; + } + + /** Runtime used to dispatch calls that arrive on a proxy. */ + public void attach(InterpRuntime runtime) { + this.runtime = runtime; + } + + public String peerClassName(Object peer) { + // The JVM reports this faithfully; only ParparVM does not. + return peer == null ? null : peer.getClass().getName().replace('.', '/'); + } + + public boolean canExtend(String hostSuperclassName) { + // java.lang.Object is not really "extending" anything -- every class + // has it as an ancestor and no dispatch depends on it. + return hostSuperclassName == null || "java/lang/Object".equals(hostSuperclassName); + } + + public Object createPeer(final InterpObject object, + String hostSuperclassName, + String[] hostInterfaceNames, + String superConstructorDescriptor, + Object[] superConstructorArgs) throws Throwable { + if (!canExtend(hostSuperclassName)) { + throw new UnsupportedOperationException( + "this platform cannot produce an interpreted subclass of " + + hostSuperclassName.replace('/', '.') + + "; extending host classes needs the platform object factory " + + "(vtable synthesis on iOS, generated subclasses on Android)"); + } + if (hostInterfaceNames == null || hostInterfaceNames.length == 0) { + return null; + } + // InterpBacked alongside them, exactly as a generated shim carries it: + // it is how a peer handed back by host code is recognised as standing + // for an interpreted object, and without it a value that made the round + // trip arrives as the peer and fails every cast to its own class. + Class[] ifaces = new Class[hostInterfaceNames.length + 1]; + for (int i = 0; i < hostInterfaceNames.length; i++) { + ifaces[i] = Class.forName(hostInterfaceNames[i].replace('/', '.')); + } + ifaces[hostInterfaceNames.length] = InterpBacked.class; + return Proxy.newProxyInstance(getClass().getClassLoader(), ifaces, + new InvocationHandler() { + public Object invoke(Object proxy, Method method, Object[] args) + throws Throwable { + if ("getInterpObject".equals(method.getName()) + && (args == null || args.length == 0)) { + return object; + } + String desc = descriptorOf(method); + InterpMethod m = object.getType().resolve(method.getName(), desc); + if (m == null) { + // Object's own methods reach a proxy too, and an + // interpreted class that does not override them + // should behave like any other object. + if ("toString".equals(method.getName()) && args == null) { + return object.toString(); + } + if ("hashCode".equals(method.getName()) && args == null) { + return Integer.valueOf(System.identityHashCode(object)); + } + if ("equals".equals(method.getName()) && args != null + && args.length == 1) { + return Boolean.valueOf(proxy == args[0]); + } + throw new AbstractMethodError( + object.getType().getName() + "." + method.getName()); + } + return runtime.invoke(m, object, args); + } + }); + } + + /** The JVM descriptor of a reflected method. */ + static String descriptorOf(Method m) { + StringBuilder sb = new StringBuilder("("); + Class[] params = m.getParameterTypes(); + for (int i = 0; i < params.length; i++) { + sb.append(descriptorOf(params[i])); + } + return sb.append(')').append(descriptorOf(m.getReturnType())).toString(); + } + + static String descriptorOf(Class c) { + if (c == Void.TYPE) return "V"; + if (c == Boolean.TYPE) return "Z"; + if (c == Byte.TYPE) return "B"; + if (c == Character.TYPE) return "C"; + if (c == Short.TYPE) return "S"; + if (c == Integer.TYPE) return "I"; + if (c == Long.TYPE) return "J"; + if (c == Float.TYPE) return "F"; + if (c == Double.TYPE) return "D"; + if (c.isArray()) return c.getName().replace('.', '/'); + return "L" + c.getName().replace('.', '/') + ";"; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/interp/ReflectionInterpLinker.java b/maven/core-unittests/src/test/java/com/codename1/impl/interp/ReflectionInterpLinker.java new file mode 100644 index 00000000000..a609437da86 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/interp/ReflectionInterpLinker.java @@ -0,0 +1,441 @@ +/* + * 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.interp; + +import java.lang.reflect.Array; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashMap; +import java.util.Map; + +/** + * The reflection-backed {@link InterpLinker}, for platforms that have + * reflection: the JavaSE simulator and Android. + * + *

iOS needs a different backend entirely -- ParparVM has no + * {@code Method.invoke} -- which binds through the translator's per-method + * invoke thunks and symbol table instead. Both sit behind the same interface so + * the interpreter never branches on platform.

+ * + *

Lookups are memoised on (owner, name, descriptor). Reflection's own + * {@code getMethod} walks the hierarchy on every call and allocates a + * {@code Class[]} to do it; a pushed program calls the same handful of + * framework methods in a loop, so caching the resolved {@link Method} is the + * difference between "usable" and "visibly slow".

+ * + * @author Shai Almog + */ +public class ReflectionInterpLinker implements InterpLinker { + private final ClassLoader loader; + // Concurrent, not plain HashMap: the interpreter is entered from every thread + // the pushed program touches, and a resolution cache is exactly the shared + // state that gets hit from all of them at once. An unsynchronised HashMap + // under concurrent put does not merely lose an entry -- it can return null + // for a key that is present, which surfaces as NoClassDefFoundError for a + // class that plainly exists. + private final Map classCache = + java.util.Collections.synchronizedMap(new HashMap()); + private final Map methodCache = + java.util.Collections.synchronizedMap(new HashMap()); + private final Map ctorCache = + java.util.Collections.synchronizedMap(new HashMap()); + private final Map fieldCache = + java.util.Collections.synchronizedMap(new HashMap()); + + public ReflectionInterpLinker() { + this(ReflectionInterpLinker.class.getClassLoader()); + } + + public ReflectionInterpLinker(ClassLoader loader) { + this.loader = loader; + } + + public void initializeDefaultBearingInterfaces(String internalName) throws Throwable { + Object c = findClass(internalName); + if (c instanceof Class) { + initializeDefaultBearing((Class) c, 0); + } + } + + private void initializeDefaultBearing(Class iface, int depth) throws Throwable { + if (!iface.isInterface() || depth > 16) { + return; + } + for (Class parent : iface.getInterfaces()) { + initializeDefaultBearing(parent, depth + 1); + } + for (java.lang.reflect.Method m : iface.getDeclaredMethods()) { + if (m.isDefault()) { + Class.forName(iface.getName(), true, + ReflectionInterpLinker.class.getClassLoader()); + return; + } + } + } + + public void initializeClass(String internalName) throws Throwable { + Object c = findClass(internalName); + if (c instanceof Class) { + Class.forName(((Class) c).getName(), true, + ReflectionInterpLinker.class.getClassLoader()); + } + } + + public Object findClass(String internalName) { + Class c = classCache.get(internalName); + if (c != null) { + return c; + } + try { + c = resolve(internalName); + } catch (ClassNotFoundException e) { + return null; + } + classCache.put(internalName, c); + return c; + } + + private Class resolve(String internalName) throws ClassNotFoundException { + if (internalName.length() == 1) { + switch (internalName.charAt(0)) { + case 'Z': return Boolean.TYPE; + case 'B': return Byte.TYPE; + case 'C': return Character.TYPE; + case 'S': return Short.TYPE; + case 'I': return Integer.TYPE; + case 'J': return Long.TYPE; + case 'F': return Float.TYPE; + case 'D': return Double.TYPE; + case 'V': return Void.TYPE; + default: break; + } + } + if (internalName.startsWith("[")) { + return Class.forName(internalName.replace('/', '.'), false, loader); + } + if (internalName.startsWith("L") && internalName.endsWith(";")) { + return Class.forName( + internalName.substring(1, internalName.length() - 1).replace('/', '.'), + false, loader); + } + return Class.forName(internalName.replace('/', '.'), false, loader); + } + + private Class[] paramTypes(String descriptor) throws ClassNotFoundException { + String[] descs = InterpValues.argumentTypes(descriptor); + Class[] types = new Class[descs.length]; + for (int i = 0; i < descs.length; i++) { + types[i] = resolve(descs[i]); + } + return types; + } + + private Method lookupMethod(String owner, String name, String descriptor) + throws ClassNotFoundException, NoSuchMethodException { + String key = owner + '.' + name + descriptor; + Method m = methodCache.get(key); + if (m != null) { + return m; + } + Class c = resolve(owner); + Class[] types = paramTypes(descriptor); + NoSuchMethodException last = null; + // Walk up rather than relying on getMethod: the method may be public on + // a package-private class, or declared on a supertype, and + // getDeclaredMethod alone would miss inherited declarations. + for (Class k = c; k != null; k = k.getSuperclass()) { + try { + m = k.getDeclaredMethod(name, types); + break; + } catch (NoSuchMethodException e) { + last = e; + } + } + if (m == null) { + m = findInInterfaces(c, name, types); + } + if (m == null) { + throw last != null ? last : new NoSuchMethodException(key); + } + m.setAccessible(true); + methodCache.put(key, m); + return m; + } + + private Method findInInterfaces(Class c, String name, Class[] types) { + // Every inheritable interface declaration reachable through this + // class or a superclass is a candidate; the maximally specific one + // per JLS 5.4.3.3 wins. Selecting the first depth-first hit picks a + // superinterface's default over a subinterface's override -- and the + // same happens when the more-specific interface is inherited through + // a superclass, which is why the pool is drawn from the whole chain. + java.util.ArrayList candidates = new java.util.ArrayList(); + java.util.HashSet visited = new java.util.HashSet(); + for (Class k = c; k != null; k = k.getSuperclass()) { + for (Class iface : k.getInterfaces()) { + collectInterfaceCandidates(iface, name, types, candidates, visited); + } + } + int count = candidates.size(); + if (count == 0) { + return null; + } + if (count == 1) { + return candidates.get(0); + } + java.util.ArrayList maximal = new java.util.ArrayList(); + java.util.HashSet seenDeclaring = new java.util.HashSet(); + for (int i = 0; i < count; i++) { + Method mi = candidates.get(i); + Class declaringA = mi.getDeclaringClass(); + if (!seenDeclaring.add(declaringA)) { + continue; + } + boolean dominated = false; + for (int j = 0; j < count; j++) { + if (i == j) { + continue; + } + Class declaringB = candidates.get(j).getDeclaringClass(); + if (!declaringA.equals(declaringB) + && declaringA.isAssignableFrom(declaringB)) { + dominated = true; + break; + } + } + if (!dominated) { + maximal.add(mi); + } + } + if (maximal.size() == 1) { + return maximal.get(0); + } + if (maximal.size() > 1) { + // JVMS 5.4.3.3: multiple non-abstract maximally specific methods + // that do not dominate each other is IncompatibleClassChangeError. + StringBuilder message = new StringBuilder(); + for (int i = 0; i < maximal.size(); i++) { + if (i > 0) { + message.append(", "); + } + message.append(maximal.get(i).getDeclaringClass().getName()); + } + throw new IncompatibleClassChangeError("conflicting default methods for " + + name + " on " + c.getName() + ": " + message); + } + return candidates.get(0); + } + + private void collectInterfaceCandidates(Class iface, String name, Class[] types, + java.util.ArrayList candidates, + java.util.HashSet visited) { + if (!visited.add(iface)) { + return; + } + try { + Method m = iface.getDeclaredMethod(name, types); + int mods = m.getModifiers(); + // Static, private, and abstract declarations do not compete as + // interface defaults: static/private are not inherited through + // an interface, and abstract contributes no body. Filtering + // here matches JVMS 5.4.3.3 "non-abstract maximally specific". + if (!Modifier.isStatic(mods) && !Modifier.isPrivate(mods) + && !Modifier.isAbstract(mods)) { + candidates.add(m); + } + } catch (NoSuchMethodException ignore) { + // Not declared here -- a superinterface may declare it. + } + for (Class parent : iface.getInterfaces()) { + collectInterfaceCandidates(parent, name, types, candidates, visited); + } + } + + private Field lookupField(String owner, String name) + throws ClassNotFoundException, NoSuchFieldException { + String key = owner + '#' + name; + Field f = fieldCache.get(key); + if (f != null) { + return f; + } + Class c = resolve(owner); + NoSuchFieldException last = null; + for (Class k = c; k != null; k = k.getSuperclass()) { + try { + f = k.getDeclaredField(name); + break; + } catch (NoSuchFieldException e) { + last = e; + } + } + if (f == null) { + f = findFieldInInterfaces(c, name); + } + if (f == null) { + throw last != null ? last : new NoSuchFieldException(key); + } + f.setAccessible(true); + fieldCache.put(key, f); + return f; + } + + private Field findFieldInInterfaces(Class c, String name) { + if (c == null) { + return null; + } + Class[] ifaces = c.getInterfaces(); + for (int i = 0; i < ifaces.length; i++) { + try { + return ifaces[i].getDeclaredField(name); + } catch (NoSuchFieldException ignore) { + Field f = findFieldInInterfaces(ifaces[i], name); + if (f != null) { + return f; + } + } + } + return findFieldInInterfaces(c.getSuperclass(), name); + } + + public Object construct(Object hostClass, String descriptor, Object[] args) throws Throwable { + Class c = (Class) hostClass; + String key = c.getName() + "" + descriptor; + Constructor ctor = ctorCache.get(key); + if (ctor == null) { + ctor = c.getDeclaredConstructor(paramTypes(descriptor)); + ctor.setAccessible(true); + ctorCache.put(key, ctor); + } + try { + return ctor.newInstance(args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public Object invokeVirtual(Object target, String owner, String name, String descriptor, + Object[] args) throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + // Resolve against the *declared* owner, not the receiver's concrete + // class. Method.invoke already dispatches virtually, so the override is + // still reached -- and resolving on the concrete class would often land + // on a non-public implementation type (ArrayList$Itr for an iterator), + // where setAccessible now throws InaccessibleObjectException because + // java.base does not open java.util to an unnamed module. + Method m = lookupMethod(owner, name, descriptor); + try { + return m.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public Object invokeSpecial(Object target, String owner, String name, String descriptor, + Object[] args) throws Throwable { + Method m = lookupMethod(owner, name, descriptor); + try { + return m.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public boolean hasMethod(String owner, String name, String descriptor) { + try { + return lookupMethod(owner, name, descriptor) != null; + } catch (Throwable absent) { + return false; + } + } + + public Object invokeStatic(String owner, String name, String descriptor, Object[] args) + throws Throwable { + Method m = lookupMethod(owner, name, descriptor); + if (!Modifier.isStatic(m.getModifiers())) { + throw new IncompatibleClassChangeError(owner + "." + name + " is not static"); + } + try { + return m.invoke(null, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + public Object getStatic(String owner, String name, String descriptor) throws Throwable { + return lookupField(owner, name).get(null); + } + + public void setStatic(String owner, String name, String descriptor, Object value) + throws Throwable { + lookupField(owner, name).set(null, value); + } + + public Object getField(Object target, String owner, String name, String descriptor) + throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + return lookupField(owner, name).get(target); + } + + public void setField(Object target, String owner, String name, String descriptor, Object value) + throws Throwable { + if (target == null) { + throw new NullPointerException(owner + "." + name); + } + lookupField(owner, name).set(target, value); + } + + public boolean isInstance(Object hostClass, Object value) { + return hostClass != null && ((Class) hostClass).isInstance(value); + } + + public Object cloneArray(Object source) { + Class component = source.getClass().getComponentType(); + if (component == null) { + return null; + } + return java.lang.reflect.Array.newInstance(component, java.lang.reflect.Array.getLength(source)); + } + + public Object newArray(String componentDescriptor, int length) throws Throwable { + return Array.newInstance(resolve(componentDescriptor), length); + } + + public Object newMultiArray(String arrayDescriptor, int[] dimensions) throws Throwable { + // The descriptor names the whole array type; strip one '[' per + // dimension being allocated to get the component Array.newInstance + // wants. + String component = arrayDescriptor.substring(dimensions.length); + return Array.newInstance(resolve(component), dimensions); + } + + public Object classObject(Object hostClass) { + return hostClass; + } +} diff --git a/scripts/cn1-device-runtime/.mvn/jvm.config b/scripts/cn1-device-runtime/.mvn/jvm.config new file mode 100644 index 00000000000..e53c5109123 --- /dev/null +++ b/scripts/cn1-device-runtime/.mvn/jvm.config @@ -0,0 +1 @@ +-Dcn1.kotlin=true diff --git a/scripts/cn1-device-runtime/.mvn/wrapper/maven-wrapper.properties b/scripts/cn1-device-runtime/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000000..d58dfb70bab --- /dev/null +++ b/scripts/cn1-device-runtime/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/scripts/cn1-device-runtime/android/pom.xml b/scripts/cn1-device-runtime/android/pom.xml new file mode 100644 index 00000000000..cf88574319e --- /dev/null +++ b/scripts/cn1-device-runtime/android/pom.xml @@ -0,0 +1,134 @@ + + + 4.0.0 + + com.codenameone.devruntime + cn1-device-runtime + 1.0-SNAPSHOT + + com.codenameone.devruntime + cn1-device-runtime-android + 1.0-SNAPSHOT + + cn1-device-runtime-android + + + UTF-8 + 17 + 17 + android + android + android-device + + + src/main/empty + + + + src/main/java + + + src/main/resources + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + build-android + package + + build + + + + + + + + + + + com.codenameone + codenameone-core + provided + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + tests + test + + + + + + + run-android + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/../common/codenameone_settings.properties + + + + + + + + maven-antrun-plugin + + + adb-install + verify + + run + + + + Running adb install + + + + + + + Trying to start app on device using adb + + + + + + + + + + + + + + + + + + diff --git a/scripts/cn1-device-runtime/common/codenameone_settings.properties b/scripts/cn1-device-runtime/common/codenameone_settings.properties new file mode 100644 index 00000000000..3cdd3eaead4 --- /dev/null +++ b/scripts/cn1-device-runtime/common/codenameone_settings.properties @@ -0,0 +1,60 @@ +#Updated keystore +#Sun Jul 26 13:28:43 IDT 2026 +codename1.android.keystore=/Users/shai/dev/cn1/scripts/hellocodenameone/android/../common/androidCerts/KeyChain.ks +codename1.android.keystoreAlias=androidKey +codename1.android.keystorePassword=password +codename1.arg.android.androidAuto.poi=true +codename1.arg.android.health.privacyPolicyUrl=https\://www.codenameone.com/privacy-policy.html +codename1.arg.android.health.read=steps,heart_rate +codename1.arg.android.health.write=steps +# Shimming the whole Codename One API makes the build's bytecode scanner see +# every feature, so every credential-gated one has to be configured. A shipping +# device-runtime app supplies real values -- it wants those natives compiled in, +# because pushed code may use them. These placeholders exist so the sample app +# builds; nothing here calls Play billing or FCM at runtime. +codename1.arg.android.licenseKey=PLACEHOLDER_NOT_A_REAL_LVL_KEY +codename1.arg.android.messagingService=auto +codename1.arg.android.useAndroidX=true +codename1.arg.ios.applicationQueriesSchemes=cydia +codename1.arg.ios.carplay.audio=true +codename1.arg.ios.maps.provider=apple +codename1.arg.ios.newStorageLocation=true +codename1.arg.ios.NSHealthShareUsageDescription=Used by the CI smoke test to verify the com.codename1.health native bridge compiles. The app never reads real health data. +codename1.arg.ios.NSHealthUpdateUsageDescription=Used by the CI smoke test to verify the com.codename1.health write path compiles. The app never writes real health data. +codename1.arg.ios.uiscene=true +codename1.arg.java.version=17 +codename1.cssTheme=true +codename1.displayName=CN1 Device Runtime +codename1.icon=icon.png +codename1.ios.appid=Q5GHSKAL2F.com.codenameone.devruntime +codename1.ios.certificate= +codename1.ios.certificatePassword= +codename1.ios.debug.certificate= +codename1.ios.debug.certificatePassword= +codename1.ios.debug.provision= +codename1.ios.provision= +codename1.ios.release.certificate= +codename1.ios.release.certificatePassword= +codename1.ios.release.provision= +codename1.j2me.nativeTheme=nbproject/nativej2me.res +# A pushed Kotlin class calls into the Kotlin stdlib on ordinary generated +# ops -- `Intrinsics.checkNotNullParameter` at every method entry -- and +# without the stdlib on the runtime app's classpath the first such call is +# a linkage error. Enable Kotlin so the plugin pulls the stdlib in, even +# though nothing in the runtime app is written in Kotlin. +codename1.kotlin=true +codename1.languageLevel=5 +codename1.mainName=DeviceRuntimeApp +codename1.packageName=com.codenameone.devruntime +codename1.rim.certificatePassword= +codename1.rim.signtoolCsk= +codename1.rim.signtoolDb= +codename1.secondaryTitle=Hello World +codename1.tvMain=com.codenameone.examples.hellocodenameone.HelloCodenameOne +codename1.vendor=CodenameOne +codename1.version=1.0 +codename1.watchMain=com.codenameone.examples.hellocodenameone.HelloCodenameOneWatch +# Android 8+. The default of 19 predates libraries the build injects, and a +# development tool has no reason to reach further back than the phones people +# actually debug on. +codename1.arg.android.min_sdk_version=26 diff --git a/scripts/cn1-device-runtime/common/icon.png b/scripts/cn1-device-runtime/common/icon.png new file mode 100644 index 00000000000..1f4fa5dd252 Binary files /dev/null and b/scripts/cn1-device-runtime/common/icon.png differ diff --git a/scripts/cn1-device-runtime/common/pom.xml b/scripts/cn1-device-runtime/common/pom.xml new file mode 100644 index 00000000000..d2c917a21b9 --- /dev/null +++ b/scripts/cn1-device-runtime/common/pom.xml @@ -0,0 +1,504 @@ + + + 4.0.0 + + com.codenameone.devruntime + cn1-device-runtime + 1.0-SNAPSHOT + + com.codenameone.devruntime + cn1-device-runtime-common + 1.0-SNAPSHOT + jar + + + + + + com.codenameone.devruntime + cn1-device-runtime-tools + ${project.version} + provided + + + com.codenameone + java-runtime + ${cn1.version} + provided + + + com.codenameone + codenameone-core + provided + + + + org.ow2.asm + asm + 9.8 + provided + + + org.ow2.asm + asm-tree + 9.8 + provided + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 1.6.0 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + -no-reflect + -no-jdk + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + simulator + + javase + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + uwp + + windows + win + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.4.0 + + + add-generated-shim-source + generate-sources + add-source + + + ${project.build.directory}/generated-sources/shims + + + + + add-parparvm-common-benchmark-source + generate-sources + add-source + + + ${project.basedir}/../../../vm/benchmarks/common/src/main/java + + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + 3.6.1 + + + resolve-jar-paths + initialize + properties + + + + + org.codehaus.mojo + exec-maven-plugin + + + generate-interp-shims + generate-sources + exec + + + ${java.home}/bin/java + + compile + + -cp + + com.codenameone.devruntime.tools.GenerateInterpShims + ${project.build.directory}/generated-sources/shims + ${com.codenameone:codenameone-core:jar} + --java-runtime + ${com.codenameone:java-runtime:jar} + --exclude + ${project.basedir}/../tools/unshimmable-by-contract.txt + + + + + + + com.codenameone + codenameone-maven-plugin + + + + transcode-svg + generate-sources + + transcode-svg + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + bytecode-compliance + css + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + + + + diff --git a/scripts/cn1-device-runtime/common/src/main/java/com/codename1/social/DeviceRuntimeSocialMocks.java b/scripts/cn1-device-runtime/common/src/main/java/com/codename1/social/DeviceRuntimeSocialMocks.java new file mode 100644 index 00000000000..68dda86ee1b --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/java/com/codename1/social/DeviceRuntimeSocialMocks.java @@ -0,0 +1,275 @@ +/* + * 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.social; + +import com.codename1.io.AccessToken; +import com.codenameone.devruntime.DeviceRuntimeMocks; +import com.codename1.ui.Display; + +/** + * Social login that succeeds without anybody logging in. + * + *

Debugging a social flow on a device otherwise means real credentials, a + * provider console entry naming this app's bundle id and signing certificate, + * and a human typing a password on a phone. A runtime hosting somebody else's + * program can arrange none of that, and none of it is what the developer is + * trying to test -- what they are testing is what their code does after the + * callback fires.

+ * + *

In this package because that is where the seam is: a provider's + * implementation registers itself with {@code implClass} and the port's own + * {@code FacebookImpl} does exactly this, so {@code getInstance()} returns the + * mock through the framework's own mechanism rather than through anything the + * runtime bolts on. The constructors and the callback proxy are package-private + * too, which is the other reason a subclass has to live here.

+ * + *

No real identity is involved. The token is fabricated, it + * authenticates against nothing, and a server that accepts it is broken. The + * runtime says so on screen the first time a pushed program logs in.

+ * + * @author Shai Almog + */ +public final class DeviceRuntimeSocialMocks { + private DeviceRuntimeSocialMocks() { + } + + /// Registers the mocks as the providers' implementations. + public static void install() { + FacebookConnect.setImplClass(Facebook.class); + GoogleConnect.setImplClass(Google.class); + } + + /** + * Forgets what the last pushed program did. + * + *

A provider is a singleton and the framework caches it, so without this + * the next program starts already logged in as the previous one -- and + * holds a callback belonging to a runtime that has since been detached, so + * a later login would call into a dead program before reaching the live + * one. {@code doLogout()} is the API's own way to clear it: token, native + * state and the preference it was persisted under.

+ */ + public static void reset() { + generation++; + reset(FacebookConnect.getInstance()); + reset(GoogleConnect.getInstance()); + } + + private static void reset(Login login) { + if (login instanceof Facebook) { + ((Facebook) login).remember(null); + } else if (login instanceof Google) { + ((Google) login).remember(null); + } + try { + login.doLogout(); + } catch (Throwable alreadyGone) { + // Nothing was logged in, which is the outcome either way. + } + login.setAccessToken(null); + // And the persisted copy. getAccessToken() re-reads from Storage + // whenever its field is null, so clearing the field alone hands the + // next program the previous one's token back -- doLogout deletes the + // Preferences entry but not this one. + com.codename1.io.Storage.getInstance() + .deleteStorageFile(login.getClass().getName() + "AccessToken"); + login.setCallback(null); + } + + /** + * Which pushed program the current logins belong to. + * + *

A login completes on a later pass of the event thread, so a program + * can be replaced between asking and being answered. Without this the + * queued completion issues and stores a token *after* the reset, and the + * new program starts logged in as the old one -- the very thing the reset + * exists to prevent.

+ */ + private static int generation; + + /// A token that reads as fake, including in a log somebody pastes later. + static AccessToken token(String provider) { + return new AccessToken("mock-" + provider.toLowerCase() + "-token-not-valid-anywhere", + String.valueOf(System.currentTimeMillis() + 3600000L)); + } + + /** + * Completes a login on the event thread. + * + *

A real provider calls back there, and pushed code touches its UI from + * the callback -- delivering on the caller's thread is the sort of + * difference that works in a simulator and fails on a device.

+ */ + static void succeed(final Login login, final String provider) { + DeviceRuntimeMocks.warnOnce(provider + " login"); + final int asked = generation; + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (asked != generation) { + // The program that asked is gone. Answering it now would + // log the *next* program in, and call a callback the + // detached runtime owns. + return; + } + AccessToken issued = token(provider); + if (login instanceof Facebook) { + ((Facebook) login).remember(issued); + } else if (login instanceof Google) { + ((Google) login).remember(issued); + } + login.setAccessToken(issued); + login.callback.loginSuccessful(); + } + }); + } + + /** Facebook, answered locally. */ + public static final class Facebook extends FacebookConnect { + public boolean isFacebookSDKSupported() { + return true; + } + + public boolean isNativeLoginSupported() { + return true; + } + + public void login() { + succeed(this, "Facebook"); + } + + public void nativelogin() { + succeed(this, "Facebook"); + } + + /** + * What a provider mock has to answer once it claims native login. + * + *

Claiming it is not free: {@code FacebookConnect.getAccessToken()} falls + * through to {@code getToken()} when no token is stored, and the base + * class's {@code getToken()} and {@code nativeIsLoggedIn()} both throw. A + * mock that overrode only the login methods therefore worked until the + * first logged-out call and then failed inside the framework, which is a + * confusing place for a mock to fail.

+ * + *

The token is held here rather than read back through the provider, + * because reading it means calling the provider's own + * {@code getAccessToken()} -- the method this exists to keep honest.

+ */ + private AccessToken current; + + void remember(AccessToken token) { + current = token; + } + + public AccessToken getAccessToken() { + return current; + } + + public String getToken() { + return current == null ? null : current.getToken(); + } + + public boolean nativeIsLoggedIn() { + return current != null; + } + + public boolean isUserLoggedIn() { + return current != null; + } + + public void nativeLogout() { + current = null; + setAccessToken(null); + } + + protected boolean validateToken(String token) { + // Valid for this session and no longer: saying otherwise would send + // pushed code round a refresh loop it cannot win. + return token != null && token.startsWith("mock-"); + } + + } + + /** Google, the same way. */ + public static final class Google extends GoogleConnect { + public boolean isNativeLoginSupported() { + return true; + } + + public void login() { + succeed(this, "Google"); + } + + public void nativelogin() { + succeed(this, "Google"); + } + + /** + * What a provider mock has to answer once it claims native login. + * + *

Claiming it is not free: {@code FacebookConnect.getAccessToken()} falls + * through to {@code getToken()} when no token is stored, and the base + * class's {@code getToken()} and {@code nativeIsLoggedIn()} both throw. A + * mock that overrode only the login methods therefore worked until the + * first logged-out call and then failed inside the framework, which is a + * confusing place for a mock to fail.

+ * + *

The token is held here rather than read back through the provider, + * because reading it means calling the provider's own + * {@code getAccessToken()} -- the method this exists to keep honest.

+ */ + private AccessToken current; + + void remember(AccessToken token) { + current = token; + } + + public AccessToken getAccessToken() { + return current; + } + + public String getToken() { + return current == null ? null : current.getToken(); + } + + public boolean nativeIsLoggedIn() { + return current != null; + } + + public boolean isUserLoggedIn() { + return current != null; + } + + public void nativeLogout() { + current = null; + setAccessToken(null); + } + + protected boolean validateToken(String token) { + // Valid for this session and no longer: saying otherwise would send + // pushed code round a refresh loop it cannot win. + return token != null && token.startsWith("mock-"); + } + + } +} diff --git a/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeApp.java b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeApp.java new file mode 100644 index 00000000000..706b38e1869 --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeApp.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.devruntime; + +import com.codename1.io.NetworkEvent; +import com.codename1.system.Lifecycle; + +/** + * The device runtime: an app whose whole purpose is to run other people's apps. + * + *

Install it once. From then on a Codename One project pushed from a desktop + * runs here, interpreted, with no build and no reinstall. It is a debugging + * proxy for arbitrary applications, so it deliberately contains no application + * of its own -- one screen showing where it is dialling, what is loaded, and + * which computers may push to it.

+ * + *

The dialling direction is out, not in. A phone on a real network cannot + * accept an inbound connection, and over USB the device's loopback is what + * {@code adb reverse} maps. So the device asks the desktop, every couple of + * seconds, whether it has anything to run.

+ * + * @author Shai Almog + */ +public class DeviceRuntimeApp extends Lifecycle { + /** The port the desktop tooling listens on. */ + private static final int PORT = 18234; + + @Override + public void init(Object context) { + super.init(context); + // Before anything is pushed: a provider's implementation is chosen the + // first time getInstance() is called, and a pushed program calling it + // must find the mock rather than the real provider's "unsupported". + com.codename1.social.DeviceRuntimeSocialMocks.install(); + try { + DeviceRuntimeService svc = DeviceRuntimeService.getInstance(); + boolean started = svc.startDialer(PORT); + System.out.println("CN1SS:DEVRUNTIME started=" + started + " " + svc.getStatus()); + } catch (Throwable t) { + System.out.println("CN1SS:DEVRUNTIME:EXCEPTION " + + t.getClass().getName() + ": " + t.getMessage()); + } + } + + @Override + public void start() { + // Always the runtime screen. A pushed program replaces it while it runs + // and this comes back when the app is resumed, which is what you want + // from a debugging proxy: somewhere to see where it is dialling and what + // it last loaded. + DeviceRuntimeForm.showIt(); + } + + /** + * Report a network failure; do not try to phone home about it. + * + *

Lifecycle's default handler calls {@code Log.sendLogAsync()}, which + * makes another blocking request on the event thread -- and when the + * network is what is broken, that request fails too and re-enters this + * handler, leaving the event thread parked in nested {@code invokeAndBlock} + * calls. A pushed program pointed at a wrong URL hits it immediately. The + * modal dialog goes for the same reason: nobody is holding this device.

+ */ + @Override + protected void handleNetworkError(NetworkEvent err) { + err.consume(); + String url = err.getConnectionRequest() == null + ? "(no request)" : err.getConnectionRequest().getUrl(); + Object cause = err.getError() != null + ? err.getError() : ("http " + err.getResponseCode()); + System.out.println("CN1SS:DEVRUNTIME network error url=" + url + " error=" + cause); + } +} diff --git a/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeConnection.java b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeConnection.java new file mode 100644 index 00000000000..58ddbdd49e1 --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeConnection.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.devruntime; + +import com.codename1.io.SocketConnection; + +import java.io.InputStream; +import java.io.OutputStream; + +/** + * One accepted push connection. + * + *

Public, with a public no-argument constructor, because + * {@code Socket.listenLoopback} instantiates it reflectively per connection. + * That is one of the few reflective calls ParparVM does support -- the + * translator emits a default-constructor function pointer into every + * {@code struct clazz} -- so the framework's own listener API works unchanged on + * iOS.

+ * + * @author Shai Almog + */ +public class DeviceRuntimeConnection extends SocketConnection { + public void connectionEstablished(InputStream is, OutputStream os) { + // An accepted connection came in over the network, so it has to pair; + // only a connection this device dialled to loopback is trusted on the + // strength of the cable. + DeviceRuntimeService.getInstance().handleAccepted(is, os); + } + + public void connectionError(int errorCode, String message) { + // Not fatal: the listener stays bound and the next push is accepted. + com.codename1.io.Log.p("device runtime connection error " + errorCode + ": " + message); + } +} diff --git a/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeForm.java b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeForm.java new file mode 100644 index 00000000000..360e415151a --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeForm.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.devruntime; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Toolbar; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.util.UITimer; + +/** + * The screen you see when nothing is running. + * + *

Its job is to answer "what do I do now", so it says what to run on the + * computer rather than describing its own internals. The address is there + * because a network can always refuse to cooperate and somebody will need to + * see it, but nothing here has to be typed for the ordinary case.

+ * + * @author Shai Almog + */ +public class DeviceRuntimeForm extends Form { + private final Label heading = new Label(""); + private final SpanLabel instruction = new SpanLabel(""); + private final Label command = new Label(""); + private final Label address = new Label(""); + + public DeviceRuntimeForm() { + super("Device Runtime", new BorderLayout()); + + heading.getAllStyles().setAlignment(Label.CENTER); + heading.getAllStyles().setFont(Font.createSystemFont( + Font.FACE_SYSTEM, Font.STYLE_BOLD, Font.SIZE_LARGE)); + instruction.getTextAllStyles().setAlignment(Label.CENTER); + + // The command is the thing being copied by eye, so it gets a face that + // does not make l and 1 the same shape. + command.getAllStyles().setAlignment(Label.CENTER); + command.getAllStyles().setFont(Font.createSystemFont( + Font.FACE_MONOSPACE, Font.STYLE_BOLD, Font.SIZE_MEDIUM)); + command.getAllStyles().setPaddingUnit(Style.UNIT_TYPE_DIPS); + command.getAllStyles().setPadding(3, 3, 3, 3); + + address.getAllStyles().setAlignment(Label.CENTER); + address.getAllStyles().setFont(Font.createSystemFont( + Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_SMALL)); + + Button find = new Button("Look again"); + find.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent ev) { + DeviceRuntimeService.setHost(null); + heading.setText("Looking..."); + revalidate(); + } + }); + Button forget = new Button("Forget paired computers"); + forget.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent ev) { + DeviceRuntimePairing.forgetAll(); + heading.setText("Pairings forgotten"); + revalidate(); + } + }); + // Not decoration. The App Store's allowance for running downloaded code + // (2.5.2) is conditional on the person holding the device being able to + // see and edit what runs on it, so this screen is load-bearing for + // submission as well as useful for debugging. + Button source = new Button("View source"); + source.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent ev) { + DeviceRuntimeSourceForm.showIt(DeviceRuntimeForm.this); + } + }); + + Button stop = new Button("Stop the program"); + stop.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent ev) { + DeviceRuntimeService.getInstance().stopProgram(); + refresh(); + } + }); + + Container body = new Container(BoxLayout.y()); + body.add(heading); + body.add(instruction); + body.add(command); + body.add(address); + body.getAllStyles().setMarginUnit(Style.UNIT_TYPE_DIPS); + body.getAllStyles().setMargin(6, 2, 4, 4); + + Container buttons = new Container(BoxLayout.y()); + buttons.add(source).add(stop).add(find).add(forget); + buttons.getAllStyles().setMarginUnit(Style.UNIT_TYPE_DIPS); + buttons.getAllStyles().setMargin(2, 3, 4, 4); + // Otherwise the last button sits under the gesture bar / soft keys and + // cannot be pressed. Safe area is a property of the container, so the + // one holding the buttons is what has to opt in. + buttons.setSafeArea(true); + + Container south = new Container(new BorderLayout()); + south.add(BorderLayout.CENTER, buttons); + south.setSafeArea(true); + + add(BorderLayout.CENTER, body); + add(BorderLayout.SOUTH, south); + refresh(); + + // The state it reports is not driven by anything the user does here -- + // a computer appears when a computer appears -- so it refreshes itself. + UITimer.timer(1500, true, this, new Runnable() { + public void run() { + refresh(); + } + }); + } + + /** Pulls the current state into the view. */ + public void refresh() { + DeviceRuntimeService svc = DeviceRuntimeService.getInstance(); + String running = svc.getLoadedName(); + if (running != null && running.length() > 0) { + heading.setText("Running " + running); + instruction.setText("Push again from your IDE to replace it."); + command.setText(""); + } else { + heading.setText("Waiting for your computer"); + instruction.setText("In your project on the same network, run:"); + command.setText("mvn -Ppush-lan package"); + } + + String ip = DeviceRuntimeService.getLocalAddress(); + address.setText(ip == null + ? "This device has no network address." + : "This device is " + ip + (svc.isListening() ? "" : " (dial-out only)")); + revalidate(); + } + + /** Shows the runtime screen, from any thread. */ + public static void showIt() { + Display.getInstance().callSerially(new Runnable() { + public void run() { + Toolbar.setGlobalToolbar(true); + new DeviceRuntimeForm().show(); + } + }); + } +} diff --git a/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeMocks.java b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeMocks.java new file mode 100644 index 00000000000..7b522abf01a --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeMocks.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.devruntime; + +import com.codename1.impl.interp.InterpHostInterceptor; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; + +import java.util.Hashtable; + +/** + * Stands in for the subsystems this runtime cannot really provide. + * + *

Three of them, and each for a different reason. A purchase needs a store + * account and products in a console, and shipping a real billing flow inside a + * host that runs other people's code would be indefensible. A social login + * needs client ids bound to this app's bundle id and signing certificate, which + * a pushed program cannot have. Both are things developers debug constantly and + * would otherwise have to debug somewhere else.

+ * + *

The seam is the static factory. {@code Purchase.getInAppPurchase()} and + * {@code FacebookConnect.getInstance()} are how an application reaches these, + * so answering those calls hands pushed code a mock and every later call lands + * on it by ordinary dispatch. The runtime app itself is untouched.

+ * + *

Saying so

+ * + *

A mock that looks like the real thing is worse than no mock: somebody + * ships code that only ever succeeded because nothing was real. So the first + * time a pushed program touches one, the runtime says which subsystem it was, + * on screen and in the status line, and the mock objects say it again in the + * data they return -- a price of "$0.00 (mock)", a token that reads + * {@code mock-...-not-valid-anywhere}.

+ * + * @author Shai Almog + */ +public final class DeviceRuntimeMocks implements InterpHostInterceptor { + /// Subsystems already announced, so the warning appears once per program + /// rather than once per call. + private static final Hashtable ANNOUNCED = new Hashtable(); + + /// One purchase object for the life of a pushed program, so what it bought + /// is still bought on the next call. + private final MockPurchase purchase = new MockPurchase(); + + public Object interceptStatic(String owner, String name, String descriptor, Object[] args) { + if ("com/codename1/payment/Purchase".equals(owner) + && "getInAppPurchase".equals(name)) { + return purchase; + } + return NOT_INTERCEPTED; + } + + /// Forgets what the last program did, so the next one starts clean. + /// + /// The warnings, so it is told about the mocks too, and the social + /// providers, which are framework singletons that would otherwise carry a + /// token and a callback across from a program that is no longer running. + /// The purchase mock needs nothing: it belongs to the runtime that is being + /// replaced. + public static void reset() { + ANNOUNCED.clear(); + com.codename1.social.DeviceRuntimeSocialMocks.reset(); + } + + /** + * Says once, per subsystem, that what the program just used is a mock. + * + *

Called from the mocks rather than from the interception, because + * fetching {@code Purchase.getInAppPurchase()} is not the interesting + * moment -- completing a purchase is.

+ */ + public static void warnOnce(final String subsystem) { + if (ANNOUNCED.get(subsystem) != null) { + return; + } + ANNOUNCED.put(subsystem, Boolean.TRUE); + DeviceRuntimeService.getInstance().noteMockUsed(subsystem); + Display.getInstance().callSerially(new Runnable() { + public void run() { + Dialog.show("This is a mock", + "This program just used " + subsystem + ", which this runtime " + + "mocks. It always succeeds, no money moves and no real account " + + "is involved. Test the real thing in a build of your own app.", + "OK", null); + } + }); + } +} diff --git a/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimePairing.java b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimePairing.java new file mode 100644 index 00000000000..c29b87ae5a0 --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimePairing.java @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.devruntime; + +import com.codename1.impl.interp.InterpPairingSecret; +import com.codename1.io.Preferences; +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.Container; +import com.codename1.ui.Label; + +/** + * Decides whether a computer is allowed to push a program to this device. + * + *

Pairing

+ * + *

The IDE prints a six-digit code; the device asks the user to type it. Both + * ends then derive the same 256-bit secret from that code, the peer id and the + * device id -- the secret itself never crosses the wire -- and the device + * challenges the computer to prove it holds the same one. A computer that + * cannot see the IDE's terminal therefore cannot pair, which is the property + * worth having: the code proves a human is at both ends.

+ * + *

Every connection after that

+ * + *

Each connection begins with a fresh challenge, so a captured frame + * authenticates nothing the second time. Only after the computer answers it + * does the device raise "Approve connection from <name>?" with + * Once, Always and Deny. Only Always is + * remembered, and it is remembered per peer, so revoking one computer does not + * disturb another. "Forget all paired computers" clears the lot -- secrets + * included, so a forgotten computer has to be let back in by a human.

+ * + *

The order matters: authenticate, then ask. Prompting first would let + * anyone on the network raise a dialog on somebody's phone until they tapped + * Approve to make it stop.

+ * + * @author Shai Almog + */ +public final class DeviceRuntimePairing { + /** Peer id -> friendly name, for every computer that has ever paired. */ + private static final String PREF_PAIRED = "cn1devruntime.paired."; + + /** Peer id -> hex shared secret established at pairing. */ + private static final String PREF_SECRET = "cn1devruntime.secret."; + + /** Peer ids the user chose to stop being asked about. */ + private static final String PREF_ALWAYS = "cn1devruntime.always."; + + /** This device's own public identifier, bound into every secret. */ + private static final String PREF_DEVICE_ID = "cn1devruntime.deviceId"; + + /// Why the last pairing attempt failed, for the desktop to report. + private static volatile String lastFailure = "pairing declined on the device"; + + private DeviceRuntimePairing() { + } + + /** + * This device's identifier: public, stable, and bound into every derived + * secret so a code typed on one phone cannot pair another. + * + *

Random rather than a hardware id on purpose. A device id that followed + * the hardware would be a tracking identifier handed to every computer that + * ever pushed, and nothing here needs one -- reinstalling the app is + * supposed to invalidate the pairings it forgot anyway.

+ */ + static synchronized String deviceId() { + String id = Preferences.get(PREF_DEVICE_ID, null); + if (id == null || id.length() == 0) { + id = InterpPairingSecret.hex(com.codename1.security.SecureRandom.bytes(16)); + Preferences.set(PREF_DEVICE_ID, id); + } + return id; + } + + /** + * Asks the user for the code the IDE printed. + * + * @return what they typed, or null if they declined or typed nothing + */ + static String promptForCode(final String peerId, final String peerName) { + if (!isWellFormedPeerId(peerId) || peerName == null) { + return null; + } + final String[] result = new String[1]; + lastFailure = "pairing declined on the device"; + Display.getInstance().callSeriallyAndWait(new Runnable() { + public void run() { + final TextField code = new TextField("", "000000", 6, TextField.NUMERIC); + code.getAllStyles().setFont(com.codename1.ui.Font.createSystemFont( + com.codename1.ui.Font.FACE_MONOSPACE, + com.codename1.ui.Font.STYLE_BOLD, + com.codename1.ui.Font.SIZE_LARGE)); + Container body = new Container(BoxLayout.y()); + SpanLabel who = new SpanLabel("Pair with \"" + peerName + "\"?"); + body.add(who) + .add(new Label("Type the code shown in the IDE:")) + .add(code); + // Real buttons rather than dialog commands: a Command renders as + // a line of text at the dialog's edge, which on a phone is too + // small a target to hit reliably. + final Dialog prompt = new Dialog("Device runtime"); + prompt.setLayout(new BorderLayout()); + Button pairBtn = new Button("Pair"); + Button denyBtn = new Button("Deny"); + final boolean[] pairPressed = new boolean[1]; + pairBtn.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + pairPressed[0] = true; + prompt.dispose(); + } + }); + denyBtn.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + prompt.dispose(); + } + }); + Container actions = new Container(new com.codename1.ui.layouts.GridLayout(2)); + actions.add(pairBtn).add(denyBtn); + prompt.add(BorderLayout.CENTER, body); + prompt.add(BorderLayout.SOUTH, actions); + prompt.show(); + if (!pairPressed[0]) { + lastFailure = "you chose Deny on the device"; + return; + } + String typed = code.getText() == null ? "" : code.getText().trim(); + if (typed.length() == 0) { + lastFailure = "no code was typed on the device"; + Dialog.show("Device runtime", "Type the six digits shown in the IDE.", + "OK", null); + return; + } + result[0] = typed; + } + }); + return result[0]; + } + + /** + * Whether a peer id is safe to store and to index. + * + *

The removable index is a tab-separated list, so an id containing a tab + * would split into pieces on the way out and "forget all paired computers" + * would delete none of them -- leaving a computer authenticated, and + * silently approved if the user had chosen Always. Hex is what both push + * tools generate, so requiring it costs nothing and closes the question + * rather than escaping around it.

+ */ + static boolean isWellFormedPeerId(String peerId) { + if (peerId == null || peerId.length() == 0 || peerId.length() > 64) { + return false; + } + for (int i = 0; i < peerId.length(); i++) { + char c = peerId.charAt(i); + boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + return false; + } + } + return true; + } + + /** + * Guards the stored pairings and the index that makes them removable. + * + *

Two computers can pair at once -- each connection is served on its own + * thread -- and recording a pairing is a read-modify-write of one shared + * key. Interleaved, the later write drops the earlier peer from the index, + * and "forget all paired computers" then never finds it: its name, its + * secret and any Always approval stay behind, so a computer the user + * revoked is still authenticated.

+ */ + private static final Object PAIRING_LOCK = new Object(); + + /** Records a computer as paired, with the secret both ends derived. */ + static void completePairing(String peerId, String peerName, byte[] secret) { + synchronized (PAIRING_LOCK) { + Preferences.set(PREF_PAIRED + peerId, peerName); + Preferences.set(PREF_SECRET + peerId, InterpPairingSecret.hex(secret)); + remember(peerId); + } + } + + /** + * Tells the user their code was wrong, and says so differently from a + * denial: a wrong code is a typo to retry, a denial is a decision, and + * reporting both the same way sends people looking for the wrong problem. + */ + static void reportCodeMismatch() { + lastFailure = "the code typed on the device did not match"; + Display.getInstance().callSerially(new Runnable() { + public void run() { + Dialog.show("Device runtime", "That code did not match. " + + "Push again to get a new one.", "OK", null); + } + }); + } + + static String lastFailure() { + return lastFailure; + } + + /// Whether this computer has ever paired with this device. + /// + /// Distinct from approval: the desktop needs to tell "you have never paired + /// with me" apart from "the person said no", because the first is + /// recoverable by pairing again -- which is exactly what happens after the + /// runtime is reinstalled and the device forgets while the desktop does not. + static boolean isPaired(String peerId) { + return secretFor(peerId) != null; + } + + /// The secret established with a peer, or null if it has never paired. + static byte[] secretFor(String peerId) { + if (!isWellFormedPeerId(peerId)) { + return null; + } + String hex = Preferences.get(PREF_SECRET + peerId, null); + if (hex == null || hex.length() == 0) { + return null; + } + return InterpPairingSecret.unhex(hex); + } + + /** + * Whether an already-authenticated computer may push right now. + * + *

Returns false for an unknown peer without prompting: an unpaired + * computer asking for approval would train the user to approve dialogs they + * have no way to attribute.

+ */ + static boolean approve(final String peerId) { + final String name = peerId == null ? null : Preferences.get(PREF_PAIRED + peerId, null); + if (name == null) { + return false; + } + if (Preferences.get(PREF_ALWAYS + peerId, false)) { + return true; + } + final boolean[] allowed = new boolean[1]; + Display.getInstance().callSeriallyAndWait(new Runnable() { + public void run() { + // Buttons rather than commands, for the same reason as the + // pairing prompt: a command is a line of text at the dialog's + // edge and too small a target on a phone. + final Dialog prompt = new Dialog("Device runtime"); + prompt.setLayout(new BorderLayout()); + Container body = new Container(BoxLayout.y()); + body.add(new SpanLabel("Approve connection from \"" + name + "\"?")); + + Button once = new Button("Once"); + Button always = new Button("Always"); + Button deny = new Button("Deny"); + final int[] choice = new int[1]; + once.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + choice[0] = 1; + prompt.dispose(); + } + }); + always.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + choice[0] = 2; + prompt.dispose(); + } + }); + deny.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + prompt.dispose(); + } + }); + Container actions = new Container(new com.codename1.ui.layouts.GridLayout(3)); + actions.add(once).add(always).add(deny); + prompt.add(BorderLayout.CENTER, body); + prompt.add(BorderLayout.SOUTH, actions); + prompt.show(); + + if (choice[0] == 2) { + Preferences.set(PREF_ALWAYS + peerId, true); + } + allowed[0] = choice[0] != 0; + } + }); + return allowed[0]; + } + + /** Drops every pairing, so the next push has to go through pairing again. */ + public static void forgetAll() { + synchronized (PAIRING_LOCK) { + forgetAllLocked(); + } + } + + private static void forgetAllLocked() { + // Preferences has no key enumeration, so the peer ids have to be + // recorded to be removable. The index is a single key holding a + // tab-separated list, written whenever a peer pairs. + String index = Preferences.get(PREF_PAIRED + "index", ""); + int from = 0; + while (from < index.length()) { + int tab = index.indexOf('\t', from); + if (tab < 0) { + tab = index.length(); + } + String peerId = index.substring(from, tab); + if (peerId.length() > 0) { + Preferences.delete(PREF_PAIRED + peerId); + Preferences.delete(PREF_ALWAYS + peerId); + // The secret above all: leaving it behind would let a forgotten + // computer authenticate, and only the approval prompt would + // stand between it and running code here. + Preferences.delete(PREF_SECRET + peerId); + } + from = tab + 1; + } + Preferences.delete(PREF_PAIRED + "index"); + } + + /** Records a peer id in the removable index. Call with PAIRING_LOCK held. */ + static void remember(String peerId) { + String index = Preferences.get(PREF_PAIRED + "index", ""); + // Whole entries, not a substring search. Ids are variable-length hex, + // so a peer could choose one that is a substring of an id already in + // the index; it would then never be recorded, and "forget all paired + // computers" would leave its secret and its Always approval in place. + int from = 0; + while (from <= index.length()) { + int tab = index.indexOf('\t', from); + if (tab < 0) { + tab = index.length(); + } + if (peerId.equals(index.substring(from, tab))) { + return; + } + from = tab + 1; + } + Preferences.set(PREF_PAIRED + "index", index.length() == 0 + ? peerId : index + "\t" + peerId); + } +} diff --git a/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeService.java b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeService.java new file mode 100644 index 00000000000..d4c187cfb1d --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeService.java @@ -0,0 +1,1667 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.devruntime; + +import com.codename1.impl.interp.InterpBundle; +import com.codename1.impl.interp.InterpBundleReader; +import com.codename1.impl.interp.InterpPairingSecret; +import com.codename1.impl.interp.InterpPlatform; +import com.codename1.impl.interp.InterpRuntime; +import com.codename1.impl.CodenameOneImplementation; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import com.codename1.impl.interp.InterpThrowable; +import com.codename1.io.Preferences; +import com.codename1.io.Socket; +import com.codename1.io.SocketConnection; +import com.codename1.ui.Display; +import com.codename1.ui.Form; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Enumeration; +import java.util.Vector; + +/** + * Listens for a pushed program and runs it. + * + *

One implementation for both platforms. The transport is Codename One's own + * {@link Socket}, not {@code java.net.ServerSocket}, because ParparVM has no + * {@code java.net} server socket at all -- iOS server sockets exist only behind + * the port's {@code listenSocketLoopback}. Going through the framework's API + * means the same code binds a listener on Android and on iOS.

+ * + *

Loopback only, deliberately. Reaching it from a developer's machine goes + * through {@code adb forward} on Android or the simulator's shared loopback on + * iOS, both of which require possession of the device. That is the pairing + * story for the development build; a store build gets the code-and-approve + * handshake instead, and this listener is not what it will use.

+ * + * @author Shai Almog + */ +public class DeviceRuntimeService { + /** Wire magic: the ASCII bytes "CN1P". */ + static final int MAGIC = 0x434E3150; + + /** Unauthenticated push. Only meaningful over a loopback-bound listener. */ + static final int PROTOCOL_V1 = 1; + + /** + * Challenge-response push, subject to pairing and per-connection approval. + * + *

There was a v2 in which the peer id alone authorised a push. It was a + * bearer token in plaintext on a LAN -- capture one frame, push forever -- + * and it is gone rather than deprecated. Nothing has shipped that speaks + * it, and leaving it in would have made the fix optional for an attacker.

+ */ + static final int PROTOCOL_V3 = 3; + + /** + * "Are you a device runtime?" -- answered with this device's id, and + * nothing else happens on the connection. + * + *

The desktop finds a device by connecting to every address on the + * subnet, and a bare successful connect proves only that something on that + * address accepts TCP on this port. Without a frame to ask, the first + * unrelated service to answer won the race and the push then failed against + * it while the real device sat unqueried.

+ */ + static final int FRAME_PING = 0; + + static final int FRAME_PAIR = 1; + static final int FRAME_PUSH = 2; + + /** A bundle larger than this is a framing error, not a program. */ + private static final int MAX_BUNDLE = 64 * 1024 * 1024; + + /// Aggregate pre-authentication push memory cap, across all concurrent + /// connections. A LAN peer that knows a paired peer id (transmitted in + /// cleartext on ordinary pushes) could otherwise open many FRAME_PUSH + /// connections, advertise the {@code MAX_BUNDLE} on each, and force the + /// device to allocate 64 MiB per connection before proving possession of + /// the pairing secret -- enough concurrent connections wedge the device + /// heap. This budget caps the total unauthenticated allocation; a push + /// that would exceed it is refused with a message the desktop can show + /// rather than one that reads like the device is broken. Two full-sized + /// bundles simultaneously covers the ordinary "second push before the + /// first authenticated" race, and no more. + private static final long PRE_AUTH_MEMORY_CAP = 2L * MAX_BUNDLE; + + /// The number of concurrent pre-authentication reservations. The memory + /// cap alone still admits thousands of tiny-body pushes (128 MiB / 64 KiB + /// = 2048 reservations), each holding a socket and its framework + /// connection thread -- enough to exhaust the device's file descriptors + /// or thread limit without ever having proved possession of the secret. + /// Capping the count as well as the byte budget bounds those directly. + /// Four leaves room for the "second push during approval" race the + /// memory cap is sized for, plus two more. + private static final int PRE_AUTH_MAX_CONCURRENT = 4; + + /// Monitor + counter for {@link #PRE_AUTH_MEMORY_CAP} and + /// {@link #PRE_AUTH_MAX_CONCURRENT}. Held for a moment on reservation + /// and release; the actual body read happens without the lock so slow + /// senders do not block a legitimate second connection. + private static final Object PRE_AUTH_LOCK = new Object(); + private static long preAuthAllocated; + private static int preAuthConnections; + + private static final DeviceRuntimeService INSTANCE = new DeviceRuntimeService(); + + private Thread dialer; + private volatile InterpRuntime runtime; + private volatile String status = "idle"; + private volatile String loadedSource = ""; + + /// The entry class of whatever is running, for the screen to name. + private volatile String loadedName = ""; + + /// Mocked subsystems this program has used, for the screen to admit to. + private volatile String mocksUsed = ""; + + private DeviceRuntimeService() { + } + + public static DeviceRuntimeService getInstance() { + return INSTANCE; + } + + /** + * Whether this build can run pushed code at all. + * + *

False on an iOS build made without {@code ios.interpHost=true}: without + * the invoke thunks and the symbol table there is nothing for interpreted + * code to call, and saying so up front beats failing at the first + * {@code new Form()}.

+ */ + public boolean isSupported() { + return InterpPlatform.isAvailable() && Socket.isSupported(); + } + + /** + * Starts dialling the desktop. Idempotent. + * + *

The device connects out; it does not listen. That is not a stylistic + * choice -- a listening socket inside the iOS simulator is unreachable from + * the host. The app binds it and reports success, the desktop gets + * connection refused, and the two facts never meet. Outbound works on both + * platforms: the simulator shares the host's loopback for connections it + * makes, and on Android {@code adb reverse} maps the device's loopback onto + * the host's.

+ * + *

It also happens to be the shape a store build needs, where a phone on + * a real network cannot accept inbound connections at all.

+ */ + public boolean startDialer(final int port) { + return startDialer(getHost(), port); + } + + /// Where the device dials, remembered across launches. + /// + /// Loopback is the default and is what a USB session uses: `adb reverse` on + /// Android, the simulator's shared loopback on iOS. A phone on Wi-Fi has to + /// be told the desktop's address instead, because loopback on a phone is + /// the phone. + public static String getHost() { + return Preferences.get(PREF_HOST, "127.0.0.1"); + } + + /// Sets the desktop address and restarts the dialer against it. + public static void setHost(String host) { + Preferences.set(PREF_HOST, host == null || host.trim().length() == 0 + ? "127.0.0.1" : host.trim()); + } + + private static final String PREF_HOST = "cn1.devruntime.host"; + + /// Whether an address is this device rather than something on the network. + public static boolean isLoopback(String host) { + return "127.0.0.1".equals(host) || "localhost".equals(host) || "::1".equals(host); + } + + /** + * Also accept connections, so the desktop can find this device. + * + *

Having the phone hunt for the computer is the weaker half of the deal: + * it has no thread pool worth the name, and a sweep of 254 addresses from a + * phone is slow enough to look broken. A desktop scanning its own subnet + * does the same job in about a second. So the device listens as well as + * dials, and whichever side finds the other first wins.

+ * + *

Android only -- iOS has no server socket, which is why dialling + * exists at all and why it stays.

+ */ + private void startListener(int port) { + try { + if (!Socket.isServerSocketSupported()) { + return; + } + Socket.listen(port, DeviceRuntimeConnection.class); + listening = true; + } catch (Throwable t) { + // Another app may hold the port; dialling still works. + System.out.println("CN1SS:DEVRUNTIME listener unavailable: " + t); + } + } + + private volatile boolean listening; + + /// Whether this device can be found by a computer scanning the network. + public boolean isListening() { + return listening; + } + + /// This device's address on the network, or null. + public static String getLocalAddress() { + try { + String ip = Socket.getHostOrIP(); + return ip != null && ip.indexOf('.') > 0 && !isLoopback(ip) ? ip : null; + } catch (Throwable t) { + return null; + } + } + + public boolean startDialer(final String host, final int port) { + if (dialer != null) { + return true; + } + if (!isSupported()) { + status = InterpPlatform.isAvailable() + ? "sockets unavailable on this platform" + : "this build has no interpreter bindings; rebuild with interpHost=true"; + return false; + } + // Not a daemon thread: Thread.setDaemon is outside the Codename One + // API subset. The loop instead exits when the app does, which is the + // same outcome on both platforms. + // Pairing is not optional off loopback, and this is enforced rather + // than documented. On loopback the authentication is physical: the + // connection can only come from a USB-authorised host or the + // simulator. On a network any machine can answer, and the bundle + // carries the program's full source, so an unpaired push would hand + // that to whoever replied first. + + startListener(port); + Thread t = new Thread(new Runnable() { + public void run() { + dialLoop(host, port); + } + }, "cn1-device-runtime"); + dialer = t; + t.start(); + status = "dialling " + host + ":" + port + + (isLoopback(host) ? "" : " (pairing required)"); + return true; + } + + /** + * Retries forever, so the IDE can be started before or after the app. + * + *

Three places are tried, in the order that costs least: the computer + * this device last spoke to, loopback (which is a USB session, where + * {@code adb reverse} maps the desktop onto the device's own address), and + * failing both, every address on the local network.

+ * + *

A refused connection is the normal state -- nobody is pushing most of + * the time -- so it is not worth logging, only worth waiting between.

+ */ + private void dialLoop(String startingHost, int port) { + while (true) { + // Re-read every pass rather than trusting what was configured at + // startup: "Look for my computer" clears the remembered address, + // and a device carried to another network has to notice. + String host = getHost(); + boolean served = dial(host, port); + if (!served && !isLoopback(host)) { + served = dial("127.0.0.1", port); + } + if (!served) { + served = sweep(port); + } + try { + Thread.sleep(served ? 250 : 2000); + } catch (InterruptedException e) { + return; + } + } + } + + /// Dials one address and serves whatever it finds. Returns whether anybody + /// was there. + private boolean dial(final String host, final int port) { + // Three separate facts, and conflating any two of them is a bug this + // has already had. "Somebody answered" is what stops the timeout; + // "the exchange finished" is what ends the wait; and "the peer spoke + // our protocol" is the answer -- something else listening on 18234 + // would otherwise hold the dial loop and stop the sweep from ever + // looking for the real desktop. + // + // Volatile holders rather than one-element arrays: the socket callback + // runs on a thread of its own and this one only polls, and an array + // element carries no visibility guarantee at all -- the poll is + // entitled to never see the write, time out and close an exchange that + // was working. + final Progress progress = new Progress(); + final Flag finished = new Flag(); + final Flag spoke = new Flag(); + // The stream, so a connection that accepts and then says nothing can + // be closed rather than left behind. Socket.connect gives the callback + // its own thread and closes nothing when the caller gives up, so + // without this every dial past a silent listener leaks a thread parked + // in readInt and the socket under it, for the life of the app. + final StreamHolder open = new StreamHolder(); + SocketConnection sc = new SocketConnection() { + public void connectionEstablished(InputStream is, OutputStream os) { + open.set(is); + try { + if (handle(is, os, isLoopback(host), progress)) { + spoke.set(); + } + } finally { + open.set(null); + // Last, so a poll that sees "finished" also sees the rest. + finished.set(); + } + } + + public void connectionError(int errorCode, String message) { + // Nobody listening: the ordinary case between pushes. + finished.set(); + } + }; + sc.setConnectTimeout(CONNECT_TIMEOUT_MS); + Socket.connect(host, port, sc); + // Socket.connect runs on its own thread, so wait for the attempt rather + // than racing past it and calling everything unreachable. Once the + // exchange has begun -- the magic read, not merely a connection + // accepted -- wait for it however long it takes: a pairing code is + // typed by a human, a bundle takes as long as it takes, and a program's + // entry point runs before the handler returns. Timing out there would + // start the loopback fallback and the subnet sweep against a live + // connection, and let the sweep's status overwrite "running". + // + // The distinction matters on Android: `adb reverse` accepts a + // connection whether or not the push tool is listening behind it, and + // then says nothing. Waiting on *that* forever wedges the dial loop and + // the device never calls again. + long deadline = System.currentTimeMillis() + CONNECT_TIMEOUT_MS + 500; + while (!finished.isSet() + && (progress.deservesWaiting() || System.currentTimeMillis() < deadline)) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + return spoke.isSet(); + } + } + if (!finished.isSet()) { + // Gave up on a peer that never said anything. Closing the stream is + // what unblocks the read the handler is parked in, so its thread + // ends rather than accumulating one per dial. + closeQuietly(open.get()); + } + return spoke.isSet(); + } + + /** + * How far an exchange has got, for the thread deciding whether to wait. + * + *

Three states, because they license different waits. Nothing yet: the + * connect timeout applies, and an address that accepts and says nothing is + * closed. Identified: the peer spoke the protocol and named itself, which + * is worth a bounded grace -- a round trip and an HMAC, not a human and + * not a transfer. Open-ended: the exchange reached a phase that genuinely + * takes as long as it takes, a person typing a pairing code or a bundle + * crossing, and only then is waiting forever right.

+ * + *

The distinction is what stops an unauthenticated peer from wedging + * discovery: sending a header is cheap, so it may not buy an unbounded + * wait.

+ */ + private static final class Progress { + private volatile long identifiedAt; + private volatile boolean openEnded; + + void identify() { + if (identifiedAt == 0) { + identifiedAt = System.currentTimeMillis(); + } + } + + void allowLongWait() { + identify(); + openEnded = true; + } + + /// Restarts the bounded grace, without granting an unbounded wait. + /// + /// What a transfer needs: bytes arriving is progress, and a stalled + /// transfer should still be closed. Called as each chunk lands, so a + /// slow link is fine and a peer that stops sending is not. + void touch() { + identifiedAt = System.currentTimeMillis(); + } + + /// Ends the open-ended phase and restarts the bounded grace. + /// + /// The human part of pairing is over the moment the dialog closes, and + /// what follows -- the response, the verdict -- is a round trip like + /// any other. Leaving the exchange open-ended let a peer that never + /// answered hold the waiters forever. + void endLongWait() { + openEnded = false; + identifiedAt = System.currentTimeMillis(); + } + + boolean isIdentified() { + return identifiedAt != 0; + } + + boolean isOpenEnded() { + return openEnded; + } + + /// Whether this exchange still deserves to be waited on. + boolean deservesWaiting() { + return openEnded + || (identifiedAt != 0 + && System.currentTimeMillis() - identifiedAt < PREAUTH_TIMEOUT_MS); + } + } + + /// How long an identified but unauthenticated exchange may take. + /// + /// One round trip and one deliberately slow HMAC. Everything past that + /// point declares itself open-ended, so this only bounds the phase anything + /// on the network can reach. + private static final int PREAUTH_TIMEOUT_MS = 10000; + + /// A flag two threads share: set on the socket callback's thread, polled on + /// the dial or sweep thread. + /// + /// Volatile because that is the whole point -- an ordinary field (or an + /// array element, which is what this replaced) gives the polling thread no + /// guarantee it will ever observe the write. + private static final class Flag { + private volatile boolean value; + + boolean isSet() { + return value; + } + + void set() { + value = true; + } + } + + /// The stream a connection callback published, for the thread that may have + /// to close it. Volatile for the same reason [Flag] is. + private static final class StreamHolder { + private volatile InputStream stream; + + InputStream get() { + return stream; + } + + void set(InputStream stream) { + this.stream = stream; + } + } + + /// The address a sweep batch found, published across threads. + private static final class AddressHolder { + private volatile String address; + + String get() { + return address; + } + + void set(String address) { + this.address = address; + } + } + + /// One sweep connection, and whether the peer behind it has answered. + private static final class SweepConnection { + private final InputStream is; + + /// How far this exchange has got; shared with handle. + private final Progress progress = new Progress(); + + SweepConnection(InputStream is) { + this.is = is; + } + } + + /// Whether any connection in this batch is in the middle of an exchange. + private static boolean anyStarted(Vector connections) { + synchronized (connections) { + for (int i = 0; i < connections.size(); i++) { + if (((SweepConnection) connections.elementAt(i)).progress.deservesWaiting()) { + return true; + } + } + } + return false; + } + + /// Closes the connections a sweep batch left parked on a silent address, + /// unblocking their readers, and leaves an exchange in progress alone. + private static void closeSilent(Vector connections) { + synchronized (connections) { + for (int i = connections.size() - 1; i >= 0; i--) { + SweepConnection conn = (SweepConnection) connections.elementAt(i); + if (conn.progress.deservesWaiting()) { + continue; + } + closeQuietly(conn.is); + connections.removeElementAt(i); + } + } + } + + private static void closeQuietly(OutputStream os) { + if (os == null) { + return; + } + try { + os.close(); + } catch (Throwable alreadyGone) { + // Same as the InputStream overload: this is a refusal path, not + // an error report. + } + } + + private static void closeQuietly(InputStream is) { + if (is == null) { + return; + } + try { + is.close(); + } catch (Throwable alreadyGone) { + // Closing to unblock a reader; whether it was already shut is not + // something this can act on. + } + } + + /// How long to wait for one address. Short: most of the subnet is nothing. + private static final int CONNECT_TIMEOUT_MS = 1200; + + /// How many addresses to try at once. + private static final int SWEEP_BATCH = 24; + + /** + * Looks for the desktop on the local network. + * + *

There is no UDP in the Codename One API, so there is no broadcast to + * announce with; what there is, is this device's own address. Every address + * on its /24 gets a TCP connection attempt, and the tool answers with a + * frame that identifies itself -- so the sweep and the push are the same + * connection, and finding the computer costs nothing beyond the attempt.

+ * + *

The address that answers is remembered, so this happens once rather + * than every couple of seconds.

+ */ + private boolean sweep(final int port) { + String self = null; + try { + self = Socket.getHostOrIP(); + } catch (Throwable t) { + // Some platforms decline; there is nothing to sweep without it. + } + if (self == null || self.indexOf('.') < 0 || isLoopback(self)) { + return false; + } + String prefix = self.substring(0, self.lastIndexOf('.') + 1); + String selfSuffix = self.substring(self.lastIndexOf('.') + 1); + status = "looking for a computer on " + prefix + "*"; + + // Connections the sweep has open, so an address that accepts and then + // says nothing can be closed rather than left with a thread parked in + // readInt. A sweep is 254 addresses; leaking one thread each would end + // the app. Each carries whether it has spoken the magic, because only + // the silent ones may be closed: a peer that answered is pairing (a + // human is typing a code) or transferring a bundle, and neither + // finishes inside a batch deadline. + final Vector openStreams = new Vector(); + for (int base = 1; base <= 254; base += SWEEP_BATCH) { + final Flag found = new Flag(); + final AddressHolder foundAt = new AddressHolder(); + int last = Math.min(base + SWEEP_BATCH - 1, 254); + for (int i = base; i <= last; i++) { + final String candidate = prefix + i; + if (candidate.equals(self) || String.valueOf(i).equals(selfSuffix)) { + continue; + } + SocketConnection sc = new SocketConnection() { + public void connectionEstablished(InputStream is, OutputStream os) { + // Something is listening, which is not the same as it + // being the push tool. Remember the address only if the + // exchange actually spoke our protocol -- otherwise the + // first unrelated service on the subnet becomes "the + // desktop" and every later dial goes to it while the + // real one is never contacted. + // + // Held so the sweep can close it: an address that + // accepts and then says nothing parks this thread in + // readInt forever, and a sweep is 254 of them. The + // holder's flag goes true the moment this one answers, + // which is what takes it out of the batch's reach. + SweepConnection conn = new SweepConnection(is); + synchronized (openStreams) { + openStreams.addElement(conn); + } + boolean spoke; + try { + spoke = handle(is, os, false, conn.progress); + } finally { + synchronized (openStreams) { + openStreams.removeElement(conn); + } + } + if (spoke) { + synchronized (openStreams) { + // The address before the flag, so the sweeping + // thread that sees "found" also sees which + // address it was. Two candidates can answer at + // once; the first one to arrive is kept. + if (!found.isSet()) { + foundAt.set(candidate); + found.set(); + } + } + } + } + + public void connectionError(int errorCode, String message) { + // The overwhelmingly common answer while sweeping. + } + }; + sc.setConnectTimeout(CONNECT_TIMEOUT_MS); + Socket.connect(candidate, port, sc); + } + // Same rule as the dial: wait out the connect attempts, but once an + // address has spoken the magic wait for that exchange however long + // it takes. Ending the batch on the clock closed the one connection + // that was working -- pairing waits on a human, and a bundle takes + // as long as it takes -- so discovery could never complete on iOS, + // where the sweep is the only way in. + long deadline = System.currentTimeMillis() + CONNECT_TIMEOUT_MS + 800; + while (!found.isSet() + && (anyStarted(openStreams) || System.currentTimeMillis() < deadline)) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + closeSilent(openStreams); + return false; + } + } + // Whatever this batch left parked on a silent address. + closeSilent(openStreams); + if (found.isSet()) { + setHost(foundAt.get()); + status = "found " + foundAt.get() + ":" + port; + return true; + } + } + status = "no computer found on " + prefix + "*"; + return false; + } + + /// Whether a pairing prompt is on screen right now. + private final boolean[] pairingPromptOpen = new boolean[1]; + + /// When the next pairing prompt may be raised, as a wall clock. + private long pairingPromptNotBefore; + + /// How long the device ignores pairing frames after one has been answered. + /// + /// Long enough that repeating the frame cannot fill the screen with + /// dialogs, short enough that a person retyping a code they mistyped is not + /// kept waiting: pairing is a deliberate act taking several seconds anyway. + private static final long PAIRING_PROMPT_COOLDOWN_MS = 3000; + + /** + * Takes the right to raise a pairing prompt, or refuses. + * + *

Unauthenticated by definition -- pairing is what establishes the + * secret -- so this frame is the one thing anything on the network can make + * the device do. Serializing it and pausing between prompts is what keeps + * that from becoming a way to make the app unusable.

+ */ + private boolean claimPairingPrompt() { + synchronized (pairingPromptOpen) { + if (pairingPromptOpen[0] || System.currentTimeMillis() < pairingPromptNotBefore) { + return false; + } + pairingPromptOpen[0] = true; + return true; + } + } + + /// Gives the prompt slot back. Called when the dialog closes and again + /// from the frame's finally, so it has to be safe to call twice -- the + /// second call only pushes the cooldown out by a few milliseconds. + private void releasePairingPrompt() { + synchronized (pairingPromptOpen) { + pairingPromptOpen[0] = false; + pairingPromptNotBefore = System.currentTimeMillis() + PAIRING_PROMPT_COOLDOWN_MS; + } + } + + /** + * Asks the person to approve an authenticated push, without a clock on it. + * + *

Everything up to here was bounded because anything on the network can + * reach it. This is the other side of that line: the peer proved it holds + * the secret and the bundle matched, so what remains is a human deciding -- + * and closing the connection under them would run the program while the + * desktop was told the push failed.

+ */ + private boolean approveWhileWaiting(String peerId, Progress progress) { + progress.allowLongWait(); + return DeviceRuntimePairing.approve(peerId); + } + + /** + * Serves a connection this device accepted, under a deadline. + * + *

An accepted connection has no poller behind it -- the dial and the + * sweep watch their own -- and the framework gives every accepted + * connection a thread. A peer that connects and sends nothing would park + * one of those forever, and enough of them would take the app's threads and + * sockets with no authentication anywhere in sight. A watchdog closes the + * silent ones, which is what unblocks the read they are parked in.

+ */ + void handleAccepted(InputStream is, OutputStream os) { + Watched w = new Watched(is); + synchronized (accepted) { + if (accepted.size() >= MAX_ACCEPTED) { + // Refuse before spending a framework connection thread on + // reading the header. `reservePreAuth` alone was not enough: + // it only kicks in once the peer id and challenge response + // have been read, so a burst of drip-fed or silent sockets + // could still exhaust file descriptors and threads faster + // than the five-second watchdog closes them. + closeQuietly(is); + closeQuietly(os); + return; + } + accepted.addElement(w); + if (!watchdogRunning) { + watchdogRunning = true; + new Thread(new Runnable() { + public void run() { + watchAccepted(); + } + }, "cn1-devruntime-accept-watchdog").start(); + } + } + try { + handle(is, os, false, w.progress); + } finally { + synchronized (accepted) { + accepted.removeElement(w); + } + } + } + + /// One accepted connection and how far it has got. + private static final class Watched { + private final InputStream stream; + private final Progress progress = new Progress(); + private final long acceptedAt = System.currentTimeMillis(); + + Watched(InputStream stream) { + this.stream = stream; + } + + /// Whether this connection has outstayed what it has earned. + boolean expired() { + if (progress.isOpenEnded()) { + return false; + } + if (progress.isIdentified()) { + return !progress.deservesWaiting(); + } + return System.currentTimeMillis() - acceptedAt > SILENT_ACCEPT_TIMEOUT_MS; + } + } + + /// How long an accepted connection may stay silent before it is closed. + /// + /// Four bytes of magic is not much to ask for, and a peer that cannot send + /// them is not a push tool -- it is a port scanner, or a crash. + private static final int SILENT_ACCEPT_TIMEOUT_MS = 5000; + + /// The absolute number of accepted connections in flight, per device. + /// The pre-auth reservation cap protects the byte budget and only trips + /// after the header, challenge and length have been read; a burst of + /// silent or drip-fed sockets never reaches it. Refusing at accept keeps + /// the framework's connection threads and file descriptors bounded even + /// when nobody sends anything. Sized to comfortably cover a legitimate + /// desktop's few concurrent PING + PUSH probes while cutting off a + /// flood. + private static final int MAX_ACCEPTED = 16; + + private final Vector accepted = new Vector(); + + private boolean watchdogRunning; + + /// Closes accepted connections that stopped making progress, and stops when + /// there are none left to watch. + private void watchAccepted() { + while (true) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + return; + } + synchronized (accepted) { + for (int i = accepted.size() - 1; i >= 0; i--) { + Watched w = (Watched) accepted.elementAt(i); + if (w.expired()) { + // Closing is what ends the read its thread is parked in; + // the handler's own finally then unregisters it. + closeQuietly(w.stream); + } + } + if (accepted.isEmpty()) { + watchdogRunning = false; + return; + } + } + } + } + + /** + * Reads a bundle body, treating arrival as progress. + * + *

Not {@code readFully}: this is the one long read that happens before + * anything has authenticated -- the response covers the bundle, so it + * cannot be checked until the bundle is here -- and a peer that declares a + * plausible length and then stops sending would otherwise hold the dial and + * the sweep for good. Each chunk restarts the grace, so a slow link + * finishes and a stalled one is closed.

+ */ + private static void readBody(DataInputStream in, byte[] body, Progress progress) + throws IOException { + int off = 0; + while (off < body.length) { + int n = in.read(body, off, Math.min(BODY_CHUNK, body.length - off)); + if (n < 0) { + throw new java.io.EOFException("the bundle ended after " + off + " of " + + body.length + " bytes"); + } + off += n; + progress.touch(); + } + } + + /// How much of a bundle is read between heartbeats. + private static final int BODY_CHUNK = 64 * 1024; + + /// Reserves {@code length} bytes against the aggregate pre-authentication + /// memory budget. Returns false when the reservation would push the + /// running total past {@link #PRE_AUTH_MEMORY_CAP} or when the + /// concurrent-reservation count is already at + /// {@link #PRE_AUTH_MAX_CONCURRENT}; the caller then rejects rather + /// than allocating and holding a heap slot behind an unauthenticated + /// peer. The count cap matters even at trivial byte sizes because + /// each reservation is backed by a socket and a framework connection + /// thread, both of which are exhaustible. + private static boolean reservePreAuth(int length) { + synchronized (PRE_AUTH_LOCK) { + if (preAuthAllocated + length > PRE_AUTH_MEMORY_CAP + || preAuthConnections >= PRE_AUTH_MAX_CONCURRENT) { + return false; + } + preAuthAllocated += length; + preAuthConnections++; + return true; + } + } + + /// Releases a prior {@link #reservePreAuth} reservation. Called from the + /// caller's finally, and also as soon as authentication succeeds so a + /// legitimate second push during the approval dialog is not queued + /// behind the first. + private static void releasePreAuth(int length) { + synchronized (PRE_AUTH_LOCK) { + preAuthAllocated -= length; + preAuthConnections--; + if (preAuthAllocated < 0) { + // A double release is a caller bug, but do not let the + // counter drift negative -- a later legitimate push would + // then reserve more than the cap permits. + preAuthAllocated = 0; + } + if (preAuthConnections < 0) { + preAuthConnections = 0; + } + } + } + + /** + * Runs the pairing handshake on an open connection. + * + *

Two round trips, because the device cannot issue a challenge until a + * human has typed the code the challenge will be answered with. The secret + * derived here is never sent -- both ends compute it from the code, the peer + * id and the device id -- so what an eavesdropper sees is a nonce and an + * HMAC over it.

+ */ + private void handlePairing(String peerId, String peerName, Progress progress, + DataInputStream in, DataOutputStream out) throws IOException { + String code = DeviceRuntimePairing.promptForCode(peerId, peerName); + // The dialog is closed: the prompt slot goes back so the next computer + // can pair, and the connection goes back to a bounded wait -- what + // remains is a round trip, and a peer that stops answering here must + // not hold the waiters for good. + progress.endLongWait(); + releasePairingPrompt(); + if (code == null) { + out.writeByte(0); + out.writeUTF(DeviceRuntimePairing.lastFailure()); + out.flush(); + return; + } + String deviceId = DeviceRuntimePairing.deviceId(); + String challenge = InterpPairingSecret.challenge(); + out.writeByte(1); + out.writeUTF(deviceId); + out.writeUTF(challenge); + out.flush(); + + // Deliberately slow, and deliberately off the event thread: the whole + // point of the iteration count is that grinding the six-digit code costs + // an attacker real time. + byte[] secret = InterpPairingSecret.derive(code, peerId, deviceId); + String response = in.readUTF(); + if (!InterpPairingSecret.matches(response, + InterpPairingSecret.respond(secret, challenge))) { + DeviceRuntimePairing.reportCodeMismatch(); + out.writeByte(0); + out.writeUTF(DeviceRuntimePairing.lastFailure()); + out.flush(); + return; + } + DeviceRuntimePairing.completePairing(peerId, peerName, secret); + status = "paired with " + peerName; + out.writeByte(1); + out.writeUTF("paired with this device as \"" + peerName + "\""); + out.flush(); + } + + /** + * Reads one frame and replies. + * + *

Wire format: magic, protocol version, then a version-specific body.

+ * + *

v1 is {@code length, bundle} and is accepted only over loopback.

+ * + *

v3 opens with a frame type. {@code FRAME_PAIR} sends + * {@code peerId, peerName}; the device prompts for the code, replies + * {@code 1, deviceId, challenge}, reads the computer's response and replies + * again with the verdict. {@code FRAME_PUSH} sends + * {@code peerId, desktopChallenge}; the device replies + * {@code 1, deviceId, challenge, answerToDesktopChallenge}, then reads + * {@code response, length, bundle} and checks the response against the + * challenge and the bundle before anything is run.

+ * + *

Both ends prove possession of the secret, and the device proves it + * first: a device id is public, so an unauthenticated peer that answered + * the desktop's dial would otherwise be handed the bundle, which carries + * the program's whole source.

+ * + *

The reply is a status byte and a UTF message either way, so the desktop + * learns whether the program actually started rather than only that the + * bytes arrived.

+ */ + void handle(InputStream is, OutputStream os) { + handle(is, os, true); + } + + /// Whether the peer on this connection spoke the push protocol at all. + /// + /// Distinct from whether the push succeeded: a refused push is still a + /// desktop push tool at the other end, and a subnet sweep wants to know + /// which address that is. + + /** + * Serves one connection. + * + *

Whether pairing is required is a property of this connection, + * not of the app: a push arriving over loopback can only have come from a + * USB-authorised host or a simulator on the same machine, and possession is + * the authentication there. Treating it as a mode instead meant a device + * that had once seen a network address refused USB pushes for the rest of + * its life.

+ */ + boolean handle(InputStream is, OutputStream os, boolean loopback) { + return handle(is, os, loopback, new Progress()); + } + + /** + * Serves one connection, reporting when the exchange actually began. + * + *

{@code started} is set once the frame is identified -- the magic, the + * protocol version and, on v3, the frame type -- which is the moment a + * caller can stop applying a connect timeout: everything after it, a human + * typing a pairing code, a bundle crossing, an entry point running, takes + * as long as it takes. Not at the magic: four bytes are cheap to send, and + * a peer that sent them and then stalled would otherwise be waited on + * forever and exempted from the cleanup, which is all it takes to stop + * discovery for good. Before it, a connection that accepts and says nothing + * (an `adb reverse` with no listener behind it does exactly that) is closed + * when the batch deadline passes.

+ */ + boolean handle(InputStream is, OutputStream os, boolean loopback, Progress progress) { + firstContact(); + DataInputStream in = new DataInputStream(is); + DataOutputStream out = new DataOutputStream(os); + try { + String reject = null; + byte[] payload = null; + if (in.readInt() != MAGIC) { + reject = "bad magic"; + } else { + int version = in.readInt(); + if (version == PROTOCOL_V1) { + progress.identify(); + if (!loopback) { + reject = "this app requires a paired computer; upgrade the push tool"; + } else { + int length = in.readInt(); + if (length <= 0 || length > MAX_BUNDLE) { + reject = "implausible bundle length " + length; + } else { + payload = new byte[length]; + readBody(in, payload, progress); + } + } + } else if (version == PROTOCOL_V3) { + int frame = in.readInt(); + if (frame == FRAME_PING) { + progress.identify(); + out.writeByte(1); + out.writeUTF(DeviceRuntimePairing.deviceId()); + out.flush(); + return true; + } else if (frame == FRAME_PAIR) { + // The identity first, and only then the prompt. Both + // fields are bounded and are the peer's to send at once; + // claiming before reading them let anything that opened + // a connection and stalled hold the single prompt slot + // for the life of the app, refusing every real pairing. + String peerId = in.readUTF(); + String peerName = in.readUTF(); + progress.identify(); + // One prompt at a time, and a pause after a refusal. + // Nothing has authenticated yet at this point -- that is + // what pairing is for -- so anything on the network can + // reach here, and without this it could stack modal + // dialogs until the runtime's own UI was unusable. + if (!claimPairingPrompt()) { + out.writeByte(0); + out.writeUTF("a pairing prompt is already open on the device"); + out.flush(); + return true; + } + try { + // A person is about to be asked to type six digits, + // which is exactly the case an unbounded wait is + // for -- and it is reached only after the prompt + // slot was claimed, so it cannot be claimed by + // everything at once. + progress.allowLongWait(); + handlePairing(peerId, peerName, progress, in, out); + } finally { + releasePairingPrompt(); + } + return true; + } else if (frame == FRAME_PUSH) { + String peerId = in.readUTF(); + String desktopChallenge = in.readUTF(); + // Identified, which buys a bounded grace and no more: + // this much is cheap for anything on the network to + // send, and an unbounded wait here is a way to wedge + // discovery for good. + progress.identify(); + byte[] secret = DeviceRuntimePairing.secretFor(peerId); + if (secret == null) { + // Said precisely, so the push tool can offer to pair + // rather than leaving the user to guess. A device + // that was reinstalled has forgotten pairings the + // desktop still believes in. + reject = "this computer is not paired with this device"; + } else { + String challenge = InterpPairingSecret.challenge(); + out.writeByte(1); + out.writeUTF(DeviceRuntimePairing.deviceId()); + out.writeUTF(challenge); + // Authentication goes both ways. A device id is + // public, so without this any host on the LAN could + // answer the desktop's dial, claim to be a paired + // device and be handed the bundle -- which carries + // the program's whole source. + out.writeUTF(InterpPairingSecret.respond(secret, desktopChallenge)); + out.flush(); + + String response = in.readUTF(); + int length = in.readInt(); + if (length <= 0 || length > MAX_BUNDLE) { + reject = "implausible bundle length " + length; + } else if (!reservePreAuth(length)) { + // Aggregate pre-authentication push memory + // cap. Refusing early with a message the + // desktop can show is better than allocating + // and getting OOM after a slow read. + reject = "this device is busy with another push" + + " (pre-authentication buffer is full)"; + } else { + boolean released = false; + try { + // Read with a heartbeat rather than an + // unbounded wait: nothing has authenticated + // yet -- the answer is checked against the + // bundle itself, so it cannot be until the + // bytes are here -- and a peer that + // declares a length and then stops sending + // must not hold the waiters. + byte[] body = new byte[length]; + readBody(in, body, progress); + if (!InterpPairingSecret.matches(response, + InterpPairingSecret.respond(secret, challenge, body))) { + // Covers the bundle as well as the + // challenge, so this also rejects a program + // altered in flight behind a valid answer. + reject = "this connection did not authenticate"; + } else { + // Reservation was against the pre-auth + // budget; release it now that this + // peer has proven possession of the + // pairing secret, so a second legitimate + // push during approval does not have + // to queue behind this one. + releasePreAuth(length); + released = true; + if (!approveWhileWaiting(peerId, progress)) { + // Only now: prompting before + // authentication would let anyone + // on the network raise dialogs on + // this phone until somebody tapped + // Approve to make them stop. + reject = "this device did not approve the connection"; + } else { + payload = body; + } + } + } finally { + if (!released) { + releasePreAuth(length); + } + } + } + } + } else { + reject = "unknown frame type " + frame; + } + } else { + reject = "push protocol version " + version + ", this app speaks " + + PROTOCOL_V1 + " and " + PROTOCOL_V3; + } + } + if (reject != null) { + status = "refused: " + reject; + out.writeByte(0); + out.writeUTF(reject); + out.flush(); + // "bad magic" is the one refusal that says the peer is not a + // push tool at all; every other one is our protocol saying no. + return !"bad magic".equals(reject); + } + // Installing and starting a program takes as long as the program + // takes, and the desktop is waiting for the result on this same + // connection. Nothing unauthenticated reaches here: a v3 push + // answered the challenge and was approved, and v1 is loopback only. + progress.allowLongWait(); + try { + String result = loadAndRun(payload); + out.writeByte(1); + out.writeUTF(result); + } catch (Throwable t) { + String described = describe(runtime, t); + status = "error: " + described; + out.writeByte(0); + out.writeUTF(described); + } + out.flush(); + return true; + } catch (Throwable t) { + // The peer is gone or the framing is broken; there is nobody left to + // tell, so record it for the on-device status line and move on. + status = "push failed: " + describe(t); + return false; + } + } + + /// Run once, when the first program arrives. + /// + /// The host app registers this; the runtime does not know what it does. In + /// this repository's test host it stands down the screenshot suite, which + /// otherwise competes with pushed programs for the display and the event + /// thread. Reflection would have been the obvious way to reach back into + /// the host and is not available: ParparVM has none, and the bytecode + /// compliance gate rejects it outright. + private static Runnable onFirstPush; + + /// Registers work to run when the first program is pushed. + public static void setOnFirstPush(Runnable r) { + onFirstPush = r; + } + + /// Runs the host's stand-down hook, once, as soon as a desktop connects. + /// + /// On connection rather than on a served push: pairing happens first, and + /// its prompt is exactly what a busy host app steals. + private static void firstContact() { + Runnable r = onFirstPush; + if (r != null) { + onFirstPush = null; + r.run(); + } + } + + /** + * Applies the pushed program's own theme, or restores the runtime's. + * + *

Done here rather than left to {@code Lifecycle.init}: the pushed + * program may not reach that at all -- a program entered through + * {@code main} never does -- and a program wearing the host's theme is + * indistinguishable from the host, which is precisely the doubt a runtime + * has to avoid. Its own design travelling with it is the visible proof that + * what is on screen is the pushed code.

+ */ + private void applyPushedTheme(byte[] themeBytes) { + try { + if (themeBytes == null) { + if (hostTheme != null) { + Resources.setGlobalResources(hostTheme); + UIManager.getInstance().setThemeProps( + hostTheme.getTheme(hostTheme.getThemeResourceNames()[0])); + UIManager.getInstance().refreshTheme(); + } + return; + } + if (hostTheme == null) { + // Remembered once, so stopping a program can put the runtime's + // own look back. + hostTheme = Resources.getGlobalResources(); + } + Resources pushed = Resources.open(new java.io.ByteArrayInputStream(themeBytes)); + String[] names = pushed.getThemeResourceNames(); + System.out.println("CN1SS:DEVRUNTIME pushed theme has " + + (names == null ? 0 : names.length) + " theme(s)"); + if (names != null && names.length > 0) { + Resources.setGlobalResources(pushed); + UIManager.getInstance().setThemeProps(pushed.getTheme(names[0])); + // Styles are cached per UIID, and components already built hold + // theirs. Without this the new theme only reaches whatever is + // created next, which on a device that is already showing a + // form means it appears not to have worked at all. + UIManager.getInstance().refreshTheme(); + System.out.println("CN1SS:DEVRUNTIME applied theme " + names[0]); + } + } catch (Throwable t) { + System.out.println("CN1SS:DEVRUNTIME could not apply the pushed theme: " + t); + } + } + + private Resources hostTheme; + + /** + * Serializes installing a bundle. + * + *

Two pushes can arrive at once -- every accepted connection is served on + * a thread of its own, and the outbound dialer is a third -- and installing + * is not one step but several: clearing and republishing global resources, + * applying a theme, detaching the previous runtime and publishing the new + * one. Interleaved, one program starts with the other's resources, or is + * detached the moment it starts while its push reports success.

+ * + *

Held only by the threads that serve a push. The event thread must + * never take it: installing calls {@code callSeriallyAndWait}, so an event + * thread blocking here while the installer waits for the event thread is a + * deadlock. That is why stopping a program -- which runs from the UI -- + * does not.

+ */ + private final Object installLock = new Object(); + + private String loadAndRun(byte[] payload) throws Throwable { + synchronized (installLock) { + return install(payload); + } + } + + private String install(byte[] payload) throws Throwable { + // Before anything, parsing included: a stop pressed while the bundle is + // being read is a stop during this installation, and a snapshot taken + // later would record that stop as this install's own baseline. + final int generation = stopGeneration; + + InterpBundle bundle = InterpBundleReader.read(new ByteArrayInputStream(payload)); + + // Retire whatever is running before publishing anything of the new + // program's. Stop is on screen throughout an install -- a no-UI program + // leaves the runtime's own form up -- and stopping tears down exactly + // the resources and theme published here; doing it in this order means + // a stop that lands mid-install ends the old program, not the new one's + // assets, and the install then fails its own current-runtime check. + // A Stop arriving during an install tears down what is being + // published, and the counter above is how the installer notices rather + // than finishing on top of a teardown. + retirePrevious(); + requireNotStopped(generation); + + // The program's own theme, CSS and images, published where the + // framework looks for them. Cleared first so a program that ships no + // theme does not inherit the previous one's. + CodenameOneImplementation.clearLocalResources(); + java.util.Hashtable res = bundle.getResources(); + java.util.Enumeration paths = res.keys(); + while (paths.hasMoreElements()) { + String path = (String)paths.nextElement(); + CodenameOneImplementation.setLocalResource(path, (byte[])res.get(path)); + } + applyPushedTheme((byte[])res.get("/theme.res")); + + StringBuilder src = new StringBuilder(); + Enumeration e = bundle.getSourceFileNames(); + while (e.hasMoreElements()) { + String name = (String)e.nextElement(); + src.append("// ").append(name).append('\n') + .append(bundle.getSource(name)).append('\n'); + } + loadedSource = src.toString(); + String main = bundle.getMainClass(); + loadedName = main == null ? "" : main.replace('/', '.'); + + ShimObjectFactory factory = new ShimObjectFactory(); + final InterpRuntime rt = new InterpRuntime(bundle, InterpPlatform.getLinker(), factory); + factory.attach(rt); + // Purchases and social logins are answered by mocks: see + // DeviceRuntimeMocks for what this runtime cannot honestly provide and + // why standing in for it beats reporting it unsupported. + rt.setHostInterceptor(new DeviceRuntimeMocks()); + DeviceRuntimeMocks.reset(); + mocksUsed = ""; + runtime = rt; + if (stopGeneration != generation) { + // The stop landed between the guard above and this publication, so + // it tore down a runtime this one has just replaced. Undo the + // publication rather than leaving a stopped program reported as + // loaded with its callbacks live. + rollback(rt); + requireNotStopped(generation); + } + + final Throwable[] failure = new Throwable[1]; + final String[] outcome = new String[1]; + final boolean[] stopped = new boolean[1]; + // On the event thread: a pushed program builds UI, and Codename One + // requires that to happen there. + Display.getInstance().callSeriallyAndWait(new Runnable() { + public void run() { + // Stop can land between publishing this runtime and the event + // thread reaching here -- the wait above is exactly that + // window. Entering it anyway starts a program the user has + // already ended: runMain clears the cancel flag, so the entry + // point runs and shows a form while the service reports + // nothing loaded. + if (runtime != rt || rt.isDetached() || stopGeneration != generation) { //NOPMD CompareObjectsWithEquals - this runtime, not an equal one + stopped[0] = true; + return; + } + try { + outcome[0] = runProgram(rt); + } catch (Throwable t) { + failure[0] = t; + } + } + }); + if (stopped[0]) { + status = "stopped before it started"; + throw new IllegalStateException( + "the program was stopped before its entry point ran"); + } + if (failure[0] != null) { + // Its stop() first: start() may have acquired a recorder or a + // sensor before it threw, and stop is where a program releases + // those -- after detaching, that callback would be a no-op like + // every other. On the event thread, like every other lifecycle + // callback: this runs on the thread serving the push. + Display.getInstance().callSeriallyAndWait(new Runnable() { + public void run() { + stopLifecycleQuietly(rt); + } + }); + // Detach before reporting. The entry point may have shown a form or + // registered a listener before it threw, and those callbacks would + // go on running a program the desktop was just told had failed -- + // and isProgramLoaded() would agree that it is loaded. + rt.detach(); + if (runtime == rt) { //NOPMD CompareObjectsWithEquals - this runtime, not an equal one + runtime = null; + loadedName = ""; + loadedSource = ""; + CodenameOneImplementation.clearLocalResources(); + applyPushedTheme(null); + } + status = "failed to start"; + // And put the runtime's own screen back. The entry point may have + // shown a form before it threw, and leaving it up strands the user + // on a half-built screen whose callbacks are all detached -- + // exactly what stopProgram avoids by doing the same thing. + DeviceRuntimeForm.showIt(); + throw failure[0]; + } + // Form.show() queues the switch rather than performing it inline, so the + // current form has to be read on a later pass of the event thread. + // Reading it in the same pass reports the previous screen and makes a + // working push look like it did nothing. + final String[] shown = new String[1]; + Display.getInstance().callSeriallyAndWait(new Runnable() { + public void run() { + Form current = Display.getInstance().getCurrent(); + shown[0] = current == null ? null : current.getTitle(); + } + }); + // Stop can also land while the entry point was finishing -- a program + // that shows nothing leaves the runtime's own form and its Stop button + // on screen throughout. Reporting "running" then would overwrite the + // status the stop just set, and tell the desktop a push succeeded into + // a runtime that is no longer there. + if (runtime != rt || rt.isDetached() || stopGeneration != generation) { //NOPMD CompareObjectsWithEquals - this runtime, not an equal one + status = "stopped before it started"; + throw new IllegalStateException( + "the program was stopped while its entry point ran"); + } + status = "running " + rt.getBundle().getMainClass(); + if (shown[0] != null) { + return outcome[0] + "; showing \"" + shown[0] + "\""; + } + return outcome[0]; + } + + /** + * Runs the pushed program. A program whose main returns a {@code Form} -- or + * which leaves one current -- is shown; anything else simply runs. + */ + private String runProgram(InterpRuntime rt) throws Throwable { + Form before = Display.getInstance().getCurrent(); + Object result = rt.runMain(new String[0]); + if (result instanceof Form) { + ((Form)result).show(); + return "showed " + result.getClass().getName(); + } + Form after = Display.getInstance().getCurrent(); + if (after != before && after != null) { + return "showed " + after.getTitle(); + } + // A program with no UI is legitimate; say so rather than implying it + // failed. + return "ran " + rt.getBundle().getMainClass(); + } + + public boolean isProgramLoaded() { + return runtime != null; + } + + /// Counts stops, so an install can tell one happened while it was working. + /// + /// Comparing the running runtime is not enough on its own: a stop landing + /// between retiring the old program and publishing the new one clears the + /// resources just published, and the field would then agree that the new + /// runtime is the current one. + private volatile int stopGeneration; //NOPMD AvoidUsingVolatile - written from the UI, read on the install thread + + /// Retires a runtime this install published and cannot go on to start. + /// + /// The same teardown a failed entry point does: detach it, forget the + /// program, and put the resources and theme back the way a stop leaves + /// them -- otherwise a stopped program stays reported as loaded and its + /// callbacks keep running. + private void rollback(InterpRuntime rt) { + stopLifecycleQuietly(rt); + rt.detach(); + if (runtime == rt) { //NOPMD CompareObjectsWithEquals - this runtime, not an equal one + runtime = null; + } + loadedName = ""; + loadedSource = ""; + CodenameOneImplementation.clearLocalResources(); + applyPushedTheme(null); + DeviceRuntimeForm.showIt(); + } + + /// Fails the install when a stop happened since it started. + private void requireNotStopped(int generation) { + if (stopGeneration != generation) { + status = "stopped before it started"; + throw new IllegalStateException("the program was stopped while it was being installed"); + } + } + + /** + * Ends the program that is running, if any, before another is installed. + * + *

A replaced program's peers are still held by framework listeners and + * timers, and without detaching they go on dispatching into the old runtime + * alongside the new one -- and a later Stop would only detach the newest. + * Its stop() is delivered first, on the event thread, because that is where + * a Lifecycle's callbacks belong: releasing a recorder or a sensor from a + * socket thread is not something the framework expects.

+ */ + private void retirePrevious() { + final InterpRuntime previous = runtime; + if (previous == null) { + return; + } + Display.getInstance().callSeriallyAndWait(new Runnable() { + public void run() { + stopLifecycleQuietly(previous); + } + }); + previous.detach(); + } + + /** + * Delivers stop() to a pushed Lifecycle, reporting rather than propagating. + * + *

Stopping has to finish. A program whose stop() throws is a program + * with a bug, not a reason to leave the runtime half-detached with its + * screen still owned by the thing the user asked to end.

+ */ + private void stopLifecycleQuietly(InterpRuntime rt) { + try { + if (rt.stopLifecycle()) { + System.out.println("CN1SS:DEVRUNTIME delivered stop() to the pushed program"); + } + } catch (Throwable t) { + System.out.println("CN1SS:DEVRUNTIME the pushed program's stop() threw: " + t); + } + } + + public void stopProgram() { + // Counted whether or not something is running: an install in flight has + // to notice a stop that arrives before it published its runtime. + stopGeneration++; + InterpRuntime rt = runtime; + if (rt == null) { + return; + } + // stop() first, while the runtime still answers: a Lifecycle that + // opened a media player, a socket or a sensor releases it there, and + // detaching without delivering stop leaves those running against the + // runtime's own screen and against whatever is pushed next. + stopLifecycleQuietly(rt); + rt.detach(); + // Cancellation alone stops interpreted code that is *running*. A normal + // Lifecycle program is not running when Stop is pressed: its start() + // returned after showing a Form, and what remains is listeners the + // framework still holds -- each holding a peer that holds the runtime, + // so dropping this field alone would not stop them. detach() makes the + // runtime itself refuse every later callback; putting the runtime's own + // screen back is what tells the user it worked. + runtime = null; + loadedName = ""; + loadedSource = ""; + CodenameOneImplementation.clearLocalResources(); + // And the theme with them: a pushed program that shipped its own + // theme.res left the runtime's screen wearing it, until some later + // theme-less push happened to put it back. + applyPushedTheme(null); + status = "stopped"; + DeviceRuntimeForm.showIt(); + } + + public String getStatus() { + return status; + } + + /// Records that a pushed program used a mocked subsystem. + void noteMockUsed(String subsystem) { + if (mocksUsed.indexOf(subsystem) < 0) { + mocksUsed = mocksUsed.length() == 0 ? subsystem : mocksUsed + ", " + subsystem; + } + status = "running (mocked: " + mocksUsed + ")"; + } + + /// The mocked subsystems this program has used, empty when it has used none. + public String getMocksUsed() { + return mocksUsed; + } + + public String getLoadedSource() { + return loadedSource; + } + + /// The entry class of the running program, or "" when nothing is running. + public String getLoadedName() { + return loadedName; + } + + static String describe(Throwable t) { + return describe(null, t); + } + + /// A failure as the person who pushed the program needs to read it. + /// + /// The interpreted frames are the whole point: a host exception thrown by + /// pushed code carries a stack trace naming the interpreter, which says + /// nothing about the program. The runtime records where the program threw, + /// so that is what gets reported when it is available. + static String describe(InterpRuntime rt, Throwable t) { + if (t instanceof InterpThrowable) { + return ((InterpThrowable)t).getInterpretedStackTrace(); + } + String m = t.getMessage(); + String head = t.getClass().getName() + (m == null ? "" : ": " + m); + String hostCall = rt == null ? null : rt.hostCallFor(t); + if (hostCall != null) { + head = head + " (thrown by " + hostCall + ")"; + } + String[] frames = rt == null ? null : rt.interpretedStackFor(t); + if (frames == null || frames.length == 0) { + return head; + } + StringBuilder sb = new StringBuilder(head); + for (int i = 0; i < frames.length; i++) { + sb.append("\n\tat ").append(frames[i]); + } + return sb.toString(); + } +} diff --git a/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeSourceForm.java b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeSourceForm.java new file mode 100644 index 00000000000..3c796239128 --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/DeviceRuntimeSourceForm.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.devruntime; + +import com.codename1.ui.Display; +import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.layouts.BorderLayout; + +/** + * The source of whatever is loaded, shown and editable. + * + *

This exists for a rule as much as for a person. The App Store permits an + * app to run code it downloaded only in narrow circumstances, and one of the + * stated conditions is that the source is "completely viewable and editable by + * the user" -- which is also why the runtime refuses to load a bundle whose + * sources it does not have. Removing this screen would make the app + * unsubmittable, not merely less useful.

+ * + * @author Shai Almog + */ +public class DeviceRuntimeSourceForm extends Form { + public DeviceRuntimeSourceForm(final Form back) { + super("Source", new BorderLayout()); + String src = DeviceRuntimeService.getInstance().getLoadedSource(); + TextArea source = new TextArea(src == null || src.length() == 0 + ? "Nothing is loaded. Push a program and its source appears here." + : src, 40, 80); + source.setEditable(true); + source.setGrowByContent(true); + source.getAllStyles().setFont(Font.createSystemFont( + Font.FACE_MONOSPACE, Font.STYLE_PLAIN, Font.SIZE_SMALL)); + add(BorderLayout.CENTER, source); + getToolbar().setBackCommand("", e -> back.showBack()); + } + + /** Shows the source screen, from any thread. */ + public static void showIt(final Form back) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + new DeviceRuntimeSourceForm(back).show(); + } + }); + } +} diff --git a/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/MockPurchase.java b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/MockPurchase.java new file mode 100644 index 00000000000..ebe23c781c1 --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/MockPurchase.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.devruntime; + +import com.codename1.payment.Product; +import com.codename1.payment.Purchase; +import com.codename1.payment.PurchaseCallback; +import com.codename1.impl.CodenameOneImplementation; +import com.codename1.ui.Display; + +import java.util.Hashtable; +import java.util.Vector; + +/** + * A purchase API that completes every transaction and charges nobody. + * + *

The point is the edge cases. A store sandbox is slow to set up, needs + * products configured in a console and an account that is allowed to buy them, + * and none of that helps when the question is what your code does when a + * purchase succeeds twice, or when a restore returns an item you no longer sell. + * Those paths are the ones that ship broken.

+ * + *

Nothing here represents a real store. Prices are invented, every + * purchase succeeds, and no receipt is valid anywhere. The runtime says so on + * screen the first time a pushed program touches this, because a mock that + * looks like the real thing is worse than no mock at all.

+ * + * @author Shai Almog + */ +public class MockPurchase extends Purchase { + /** SKUs currently owned, in memory and for this session only. */ + private final Vector owned = new Vector(); + + /** SKU -> subscription flag, so unsubscribe can be exercised. */ + private final Hashtable subscriptions = new Hashtable(); + + public boolean isManagedPaymentSupported() { + return true; + } + + public boolean isManualPaymentSupported() { + return false; + } + + public boolean isItemListingSupported() { + return true; + } + + public boolean isSubscriptionSupported() { + return true; + } + + public boolean isUnsubscribeSupported() { + return true; + } + + public boolean isRestoreSupported() { + return true; + } + + public boolean isRefundable(String sku) { + return true; + } + + /** + * Invents a product per SKU. + * + *

A real store answers only for SKUs configured in its console; this + * answers for anything, which is the useful behaviour when the point is to + * exercise the code around the call.

+ */ + public Product[] getProducts(String[] skus) { + if (skus == null) { + return new Product[0]; + } + Product[] out = new Product[skus.length]; + for (int i = 0; i < skus.length; i++) { + Product p = new Product(); + p.setSku(skus[i]); + p.setDisplayName(skus[i]); + p.setDescription("Mock product. Not a real store listing."); + p.setLocalizedPrice("$0.00 (mock)"); + out[i] = p; + } + return out; + } + + public boolean wasPurchased(String sku) { + return owned.contains(sku); + } + + protected void purchaseImpl(String sku) { + complete(sku, false); + } + + public void subscribe(String sku) { + complete(sku, true); + } + + /** + * Refunds what was bought, because {@link #isRefundable(String)} says it + * can be. + * + *

{@code Purchase.refund} is a no-op in the base class, so inheriting it + * while advertising the capability would leave refund-handling code + * unexercised -- the SKU still owned, no callback delivered -- which is + * precisely the path this mock exists to let somebody test.

+ */ + public void refund(String sku) { + DeviceRuntimeMocks.warnOnce("in-app purchase"); + owned.removeElement(sku); + subscriptions.remove(sku); + deliverRefund(sku); + } + + public void unsubscribe(String sku) { + owned.removeElement(sku); + subscriptions.remove(sku); + deliverRefund(sku); + } + + /// On the event thread, for the same reason a purchase is. + private void deliverRefund(final String sku) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + PurchaseCallback c = callback(); + if (c != null) { + c.itemRefunded(sku); + } + } + }); + } + + /** + * Restores what this session bought. + * + *

Session-scoped on purpose: a restore that resurrected purchases from a + * previous run of a different pushed program would be a confusing lie.

+ */ + public void restore() { + PurchaseCallback c = callback(); + if (c == null) { + return; + } + for (int i = 0; i < owned.size(); i++) { + c.itemPurchased((String)owned.elementAt(i)); + } + } + + private void complete(final String sku, boolean subscription) { + DeviceRuntimeMocks.warnOnce("in-app purchase"); + if (!owned.contains(sku)) { + owned.addElement(sku); + } + if (subscription) { + subscriptions.put(sku, Boolean.TRUE); + } + // On the event thread, like a real store callback: pushed code updates + // its UI from here, and delivering on the caller's thread would work in + // the simulator and fail on a device. + Display.getInstance().callSerially(new Runnable() { + public void run() { + PurchaseCallback c = callback(); + if (c != null) { + c.itemPurchased(sku); + } + } + }); + } + + private PurchaseCallback callback() { + return CodenameOneImplementation.getPurchaseCallback(); + } +} diff --git a/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/ShimObjectFactory.java b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/ShimObjectFactory.java new file mode 100644 index 00000000000..b194f81a2c9 --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/java/com/codenameone/devruntime/ShimObjectFactory.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.devruntime; + +import com.codename1.impl.interp.InterpObject; +import com.codename1.impl.interp.InterpObjectFactory; +import com.codename1.impl.interp.InterpRuntime; +import com.codenameone.devruntime.gen.InterpShimRegistry; + +/** + * Produces the host-visible object for an interpreted class, from shims + * generated before the app shipped. + * + *

This is the same code on iOS and Android, which was not the original plan. + * Android could cover interfaces with {@link java.lang.reflect.Proxy} and needs + * generation only for classes; iOS has no {@code Proxy}, because {@code Proxy} + * is {@code defineClass} in a trenchcoat and ParparVM has no such thing. Rather + * than keep two factories that fail differently, both platforms use generated + * shims for both cases -- a pushed program that works on one now works on the + * other, and a program that does not gets the same error naming the same + * missing entry in the same curated list.

+ * + *

The cost is that a class implementing several host interfaces at once has + * no shim, since a shim covers one supertype. That is rare enough to be worth + * the symmetry, and it fails loudly.

+ * + * @author Shai Almog + */ +public class ShimObjectFactory implements InterpObjectFactory { + private InterpRuntime runtime; + + /** The runtime used to dispatch calls arriving on a peer. */ + public void attach(InterpRuntime runtime) { + this.runtime = runtime; + } + + /** + * The peer's class name, from the registry rather than from reflection. + * + *

{@code peer.getClass().getName()} is wrong on iOS for exactly the + * classes this factory produces: ParparVM reconstructs the name from the + * mangled C symbol, where the package separator and an underscore are the + * same character, so {@code Interp_Form} returns as {@code Interp/Form}. + * The registry knows the real name because it generated it.

+ */ + public String peerClassName(Object peer) { + return peer == null ? null : InterpShimRegistry.nameOf(peer); + } + + public boolean canExtend(String hostSuperclassName) { + if (hostSuperclassName == null || "java/lang/Object".equals(hostSuperclassName)) { + return true; + } + return InterpShimRegistry.canExtend(hostSuperclassName); + } + + public Object createPeer(InterpObject object, + String hostSuperclassName, + String[] hostInterfaceNames, + String superConstructorDescriptor, + Object[] superConstructorArgs) throws Throwable { + if (hostSuperclassName != null && !"java/lang/Object".equals(hostSuperclassName)) { + if (hostInterfaceNames != null && hostInterfaceNames.length > 0) { + // A shim extends one class and implements nothing else, so the + // interfaces would be silently dropped: the framework would + // accept the peer as a Form and then never recognise it as an + // ActionListener, and the missing callbacks would look like an + // interpreter bug rather than a missing shim. + throw new UnsupportedOperationException( + object.getType().getName().replace('/', '.') + " extends " + + hostSuperclassName.replace('/', '.') + + " and also implements a host interface; the device runtime " + + "generates a shim per supertype and has none for that combination"); + } + Object peer = InterpShimRegistry.create(hostSuperclassName, runtime, object, + superConstructorDescriptor, superConstructorArgs); + if (peer == null) { + throw new UnsupportedOperationException( + "no generated shim for " + hostSuperclassName.replace('/', '.') + + "; regenerate the shims with scripts/generate-interp-shims.sh and rebuild " + + "the device runtime app"); + } + return peer; + } + if (hostInterfaceNames == null || hostInterfaceNames.length == 0) { + return null; + } + if (hostInterfaceNames.length > 1) { + throw new UnsupportedOperationException( + object.getType().getName().replace('/', '.') + " implements " + + hostInterfaceNames.length + " host interfaces; the device runtime " + + "generates a shim per interface and has none for that combination"); + } + Object peer = InterpShimRegistry.createInterface(hostInterfaceNames[0], runtime, object); + if (peer == null) { + throw new UnsupportedOperationException( + "no generated shim implementing " + hostInterfaceNames[0].replace('/', '.') + + "; regenerate the shims with scripts/generate-interp-shims.sh and rebuild " + + "the device runtime app"); + } + return peer; + } +} diff --git a/scripts/cn1-device-runtime/common/src/main/kotlin/com/codenameone/devruntime/KotlinBridge.kt b/scripts/cn1-device-runtime/common/src/main/kotlin/com/codenameone/devruntime/KotlinBridge.kt new file mode 100644 index 00000000000..d8913f5ccd5 --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/kotlin/com/codenameone/devruntime/KotlinBridge.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.devruntime + +/** + * Presence of this file activates the `kotlin` Maven profile in + * `common/pom.xml`, which pulls in `kotlin-stdlib`. The runtime app itself + * has no Kotlin logic to run -- everything of substance is in Java -- but a + * *pushed* Kotlin bundle records ordinary `kotlin.jvm.internal.Intrinsics` + * calls (checkNotNullParameter at every method entry, checkNotNull for `!!`, + * lambda helpers) as host externs. Without the stdlib compiled into this + * app, the interpreter's linker cannot resolve those on the device and the + * first Kotlin push fails at its first stdlib call -- which is every method + * a Kotlin compiler emits. + * + * The file is deliberately trivial: the Maven profile's activation is + * conditioned on `src/main/kotlin` existing, not on which .kt files are + * inside it, so a marker is enough. Adding real Kotlin logic here would + * work, but is not necessary and would only mean an entry point that never + * runs. + */ +internal object KotlinBridge diff --git a/scripts/cn1-device-runtime/common/src/main/resources/surfaces.json b/scripts/cn1-device-runtime/common/src/main/resources/surfaces.json new file mode 100644 index 00000000000..072d4b0e79f --- /dev/null +++ b/scripts/cn1-device-runtime/common/src/main/resources/surfaces.json @@ -0,0 +1,15 @@ +{ + "liveActivities": true, + "kinds": [ + { + "id": "devruntime_status", + "name": "Status", + "description": "A pushed program's status, for trying the widget and live-activity APIs on a device" + }, + { + "id": "devruntime_progress", + "name": "Progress", + "description": "A pushed program's progress, for trying a second widget kind alongside the first" + } + ] +} diff --git a/scripts/cn1-device-runtime/common/src/main/resources/theme.res b/scripts/cn1-device-runtime/common/src/main/resources/theme.res new file mode 100644 index 00000000000..dc9bf64f231 Binary files /dev/null and b/scripts/cn1-device-runtime/common/src/main/resources/theme.res differ diff --git a/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/changelogs/default.txt b/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/changelogs/default.txt new file mode 100644 index 00000000000..5ac91138d55 --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/changelogs/default.txt @@ -0,0 +1 @@ +Weekly development build. diff --git a/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/full_description.txt b/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 00000000000..2dacb1a484e --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,23 @@ +A development tool for people building Codename One applications. + +Install it once on a device you are developing against. From then on, your +project runs here directly from your IDE: press run on your computer and the +application appears on the device in seconds, with no build, no signing and no +reinstall between edits. + +The device and your computer find each other on your local network. Pairing is +explicit -- your IDE shows a six digit code that you type on the device -- and +every later connection is approved on the device itself. You can revoke a +computer at any time. + +The source of whatever is running is always visible and editable on the device, +under "View source". + +This is a debugging tool, not a way to distribute software. It runs only what +you push to it from a computer you have paired, over your own network. It has +no catalogue, no store, no browsing and no download of code from anywhere else. +Interpreted code is slower than a compiled build, so it is for developing +application logic and user interface, not for measuring performance. + +Requires a Codename One project on your computer. See the documentation for the +push tooling. diff --git a/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/short_description.txt b/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 00000000000..fa04310f04d --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Run your Codename One project on this device, straight from your IDE. diff --git a/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/title.txt b/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 00000000000..38d7f7df9db --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +CN1 Device Runtime diff --git a/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/description.txt b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/description.txt new file mode 100644 index 00000000000..2dacb1a484e --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/description.txt @@ -0,0 +1,23 @@ +A development tool for people building Codename One applications. + +Install it once on a device you are developing against. From then on, your +project runs here directly from your IDE: press run on your computer and the +application appears on the device in seconds, with no build, no signing and no +reinstall between edits. + +The device and your computer find each other on your local network. Pairing is +explicit -- your IDE shows a six digit code that you type on the device -- and +every later connection is approved on the device itself. You can revoke a +computer at any time. + +The source of whatever is running is always visible and editable on the device, +under "View source". + +This is a debugging tool, not a way to distribute software. It runs only what +you push to it from a computer you have paired, over your own network. It has +no catalogue, no store, no browsing and no download of code from anywhere else. +Interpreted code is slower than a compiled build, so it is for developing +application logic and user interface, not for measuring performance. + +Requires a Codename One project on your computer. See the documentation for the +push tooling. diff --git a/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/keywords.txt b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/keywords.txt new file mode 100644 index 00000000000..f30aae0221f --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/keywords.txt @@ -0,0 +1 @@ +codename one,developer,debugging,ide,java,mobile development diff --git a/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/marketing_url.txt b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/marketing_url.txt new file mode 100644 index 00000000000..0887e2a02f8 --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://www.codenameone.com/ diff --git a/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/name.txt b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/name.txt new file mode 100644 index 00000000000..38d7f7df9db --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/name.txt @@ -0,0 +1 @@ +CN1 Device Runtime diff --git a/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/privacy_url.txt b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/privacy_url.txt new file mode 100644 index 00000000000..3790d1e32ec --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://www.codenameone.com/privacy-policy.html diff --git a/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/release_notes.txt b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/release_notes.txt new file mode 100644 index 00000000000..5ac91138d55 --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/release_notes.txt @@ -0,0 +1 @@ +Weekly development build. diff --git a/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/subtitle.txt b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/subtitle.txt new file mode 100644 index 00000000000..bb81ae3347e --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/subtitle.txt @@ -0,0 +1 @@ +Run your project from your IDE diff --git a/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/support_url.txt b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/support_url.txt new file mode 100644 index 00000000000..7b1894daa47 --- /dev/null +++ b/scripts/cn1-device-runtime/fastlane/metadata/ios/en-US/support_url.txt @@ -0,0 +1 @@ +https://www.codenameone.com/support.html diff --git a/scripts/cn1-device-runtime/ios/pom.xml b/scripts/cn1-device-runtime/ios/pom.xml new file mode 100644 index 00000000000..498504b1813 --- /dev/null +++ b/scripts/cn1-device-runtime/ios/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + com.codenameone.devruntime + cn1-device-runtime + 1.0-SNAPSHOT + + com.codenameone.devruntime + cn1-device-runtime-ios + 1.0-SNAPSHOT + + cn1-device-runtime-ios + + + UTF-8 + 17 + 17 + ios + ios + ios-device + + + + + src/main/objectivec + + + src/main/resources + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + build-ios + package + + build + + + + + + + + + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + tests + test + + + + + + + + + diff --git a/scripts/cn1-device-runtime/mvnw b/scripts/cn1-device-runtime/mvnw new file mode 100755 index 00000000000..19529ddf8c6 --- /dev/null +++ b/scripts/cn1-device-runtime/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/scripts/cn1-device-runtime/mvnw.cmd b/scripts/cn1-device-runtime/mvnw.cmd new file mode 100644 index 00000000000..b150b91ed50 --- /dev/null +++ b/scripts/cn1-device-runtime/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/scripts/cn1-device-runtime/pom.xml b/scripts/cn1-device-runtime/pom.xml new file mode 100644 index 00000000000..96e16a3c1dd --- /dev/null +++ b/scripts/cn1-device-runtime/pom.xml @@ -0,0 +1,200 @@ + + 4.0.0 + com.codenameone.devruntime + cn1-device-runtime + 1.0-SNAPSHOT + pom + Codename One Device Runtime + Runs pushed Codename One programs on a device + https://www.codenameone.com + + + GPL v2 With Classpath Exception + https://openjdk.java.net/legal/gplv2+ce.html + repo + A business-friendly OSS license + + + + tools + common + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + 17 + 17 + 1.7.11 + 3.8.0 + 17 + 17 + 17 + 17 + 17 + cn1-device-runtime + + + + + com.codenameone + java-runtime + ${cn1.version} + + + com.codenameone + codenameone-core + ${cn1.version} + + + com.codenameone + codenameone-javase + ${cn1.version} + + + com.codenameone + codenameone-buildclient + ${cn1.version} + system + ${user.home}/.codenameone/CodeNameOneBuildClient.jar + + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.codehaus.mojo + exec-maven-plugin + 3.0.0 + + + maven-antrun-plugin + org.apache.maven.plugins + 3.1.0 + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + + + + javascript + + + codename1.platform + javascript + + + + javascript + + + + ios + + + codename1.platform + ios + + + + ios + + + + win + + + codename1.platform + win + + + + win + + + + linux + + + codename1.platform + linux + + + + linux + + + + android + + + codename1.platform + android + + + + android + + + + javase + + + codename1.platform + javase + + true + + + javase + + + + cn1libs + + + ${basedir}/cn1libs/pom.xml + + + + cn1libs + + + + diff --git a/scripts/cn1-device-runtime/store/ExportOptions.plist b/scripts/cn1-device-runtime/store/ExportOptions.plist new file mode 100644 index 00000000000..d58458d952c --- /dev/null +++ b/scripts/cn1-device-runtime/store/ExportOptions.plist @@ -0,0 +1,20 @@ + + + + + + method + app-store + + uploadSymbols + + + destination + export + + diff --git a/scripts/cn1-device-runtime/store/README.md b/scripts/cn1-device-runtime/store/README.md new file mode 100644 index 00000000000..e0997408b31 --- /dev/null +++ b/scripts/cn1-device-runtime/store/README.md @@ -0,0 +1,84 @@ +# Shipping the device runtime + +Metadata and automation for putting this app on Google Play and the App Store, +and for pushing a build to testers every week. + +## What the weekly job does, and what it deliberately does not + +`.github/workflows/device-runtime-store.yml` runs every Monday and on demand. It +builds the app and uploads it to **Google Play's internal testing track** and to +**TestFlight**. It does not promote anything to production and does not submit +for App Store review. + +That is a decision, not an omission. A weekly automatic *release* would put an +unreviewed build in front of the public and, on iOS, would queue a review every +week whether or not anything changed. Internal testing and TestFlight are what +"ship weekly" usually means for a developer tool: the people who need the build +get it on Monday, and promoting it is a human decision. + +Promotion is one command when you want it -- `fastlane supply --track production` +or advancing the TestFlight build in App Store Connect. + +## Secrets the job needs + +It fails with a named error if any are missing, rather than half-publishing. + +| Secret | What it is | Where it comes from | +|---|---|---| +| `PLAY_SERVICE_ACCOUNT_JSON` | Google Play Developer API service account key | Play Console → Setup → API access | +| `ANDROID_KEYSTORE_BASE64` | Upload keystore, base64 | You generate it once; Play signs releases with its own key | +| `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Keystore credentials | With the keystore | +| `APPSTORE_ISSUER_ID`, `APPSTORE_KEY_ID`, `APPSTORE_PRIVATE_KEY` | App Store Connect API key | App Store Connect → Users and Access → Integrations | +| `IOS_DIST_CERT_P12`, `IOS_DIST_CERT_PASSWORD`, `IOS_PROVISIONING_PROFILE` | Distribution signing material | Apple Developer account | + +None of these exist yet. Until they do the job is a no-op that says so. + +## Before the first submission + +These are the things a human has to decide or supply; the automation cannot. + +1. **Bundle identifiers and accounts.** `com.codenameone.devruntime` has to be + registered in both consoles, and the App Store listing created once by hand. +2. **Screenshots.** Both stores require them per device class. Put PNGs in + `fastlane/metadata/android/en-US/images/` and + `fastlane/screenshots/ios/`. The fidelity harness in this repo can capture + them, but somebody has to choose which ones tell the story. +3. **Privacy.** The app makes no network connection except to a computer you + pair with, collects nothing and has no analytics. Play's Data Safety form and + Apple's Privacy Nutrition Label both still have to be filled in, and Apple + wants a Privacy Manifest declaring API reasons. See `privacy.md`. +4. **Content rating** questionnaire on Play. +5. **Export compliance** on Apple: the app uses no encryption beyond HTTPS, but + the question is asked every submission and can be answered once in the + listing. + +## The review risk, stated plainly + +This app runs code it did not ship with. That is squarely within App Store +Review Guideline 2.5.2, which permits it only for apps that "teach, develop, or +allow students to test executable code", and only when "the source code provided +by the app [is] completely viewable and editable by the user". + +The runtime is built around that: it refuses to load a bundle whose sources it +does not have, and the source of whatever is running is always on screen under +**View source**. Removing that screen would make the app unsubmittable. + +Guideline 4.7.2 is the sharper edge -- an app "may not extend or expose native +platform APIs or technologies to the software without prior permission from +Apple" -- and exposing the framework to pushed code is what this app is. The +argument to make is that this is a developer tool used point to point on the +developer's own network, with no distribution of anything to anyone, which is +the 2.5.2 case rather than the mini-apps case. Expect to make it explicitly, in +the review notes, and expect it to be a conversation. + +Google Play is the lower risk of the two. Its Device and Network Abuse policy +bans downloading executable code, "such as dex, JAR, .so files", but exempts +"code that runs in a virtual machine or an interpreter where either provides +indirect access to Android APIs". A `.cn1ip` bundle is none of those formats, +and the interpreter reaches Android only through the framework compiled into +the app. + +**If review says no:** TestFlight internal testing (100 users) needs no review +at all, and on Android a sideloaded APK has no gatekeeper. The weekly job +already targets exactly those two channels, so a rejection costs the public +listing and nothing else. diff --git a/scripts/cn1-device-runtime/store/privacy.md b/scripts/cn1-device-runtime/store/privacy.md new file mode 100644 index 00000000000..5bb8c62616f --- /dev/null +++ b/scripts/cn1-device-runtime/store/privacy.md @@ -0,0 +1,40 @@ +# CN1 Device Runtime — privacy + +The short version: this app collects nothing, sends nothing anywhere, and talks +only to a computer you have explicitly paired it with. + +## What it does on the network + +It looks for a computer running the Codename One push tool, on the local network +only, and accepts a connection from one you have paired with. It makes no other +connection of its own. + +A program you push may make its own network requests — those are your program's, +under your control, and are not made by the runtime. + +## What it stores on the device + +- The address of the computer it last spoke to, so it does not have to search again. +- The identity and friendly name of computers you have paired with, and whether + you chose "Always" for each. **Forget paired computers** deletes all of it. +- The program you pushed most recently, including its source, so it can be shown + and re-run. It is replaced by the next push. + +Nothing is written anywhere else and nothing leaves the device. + +## What it does not do + +- No analytics, telemetry, crash reporting or advertising identifiers. +- No accounts, no sign-in, no contacts, no location, no camera, no microphone. +- No download of code from any server. Code arrives only from a paired computer. + +## Permissions + +`INTERNET` and `ACCESS_NETWORK_STATE`, for the local connection to your computer. +On iOS, local network access, which the system prompts for on first use. + +## Data safety declarations + +Both stores ask; the answer is the same. No data collected, no data shared, no +data linked to the user. The pairing records and the last pushed program stay on +the device and are removable from within the app. diff --git a/scripts/cn1-device-runtime/tools/pom.xml b/scripts/cn1-device-runtime/tools/pom.xml new file mode 100644 index 00000000000..d4ef172f438 --- /dev/null +++ b/scripts/cn1-device-runtime/tools/pom.xml @@ -0,0 +1,49 @@ + + + + 4.0.0 + + com.codenameone.devruntime + cn1-device-runtime + 1.0-SNAPSHOT + + + cn1-device-runtime-tools + cn1-device-runtime-tools + + + 17 + 17 + + + + + + com.codenameone + codenameone-core + ${cn1.version} + provided + + + org.ow2.asm + asm + 9.8 + + + org.ow2.asm + asm-tree + 9.8 + + + diff --git a/scripts/cn1-device-runtime/tools/src/main/java/com/codenameone/devruntime/tools/GenerateInterpShims.java b/scripts/cn1-device-runtime/tools/src/main/java/com/codenameone/devruntime/tools/GenerateInterpShims.java new file mode 100644 index 00000000000..99ee3cd3d51 --- /dev/null +++ b/scripts/cn1-device-runtime/tools/src/main/java/com/codenameone/devruntime/tools/GenerateInterpShims.java @@ -0,0 +1,2042 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.devruntime.tools; + +import java.io.File; +import java.io.PrintWriter; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Generates the subclasses that let interpreted code extend a framework class. + * + *

Neither mobile platform lets you define a class at run time -- ParparVM has + * no {@code defineClass} and iOS forbids writing executable memory, while + * Android has both but Play forbids loading dex. So the subclass has to exist + * before the app ships, with each overridable method compiled to ask the + * interpreter whether the pushed class overrides it and otherwise call + * {@code super}.

+ * + *

Nothing here is curated

+ * + *

The set is every public, non-final, constructible class and every public + * interface under {@code com.codename1}, discovered by walking the framework + * jar. That is the only honest scope: an application may subclass anything the + * API exposes, and the failure mode of guessing wrong is not an error message + * but an override that is silently never called.

+ * + *

It is not free -- roughly 56,000 overridable methods, doubled by the + * {@code super_} bridges. Android carries that with multidex, which large + * applications do routinely, and this app is a development tool that is + * already built with the optimizer off and nothing culled. iOS does not pay it + * at all: there, runtime vtable synthesis subclasses any class with no + * generated code whatsoever.

+ * + * @author Shai Almog + */ +public final class GenerateInterpShims { + /** + * Packages whose classes an interpreted class may extend or implement. + * + *

A prefix, not a list of classes. The set is derived by walking the + * framework jar, because a curated list is a promise that every application + * only subclasses what somebody anticipated -- and the failure when it does + * not is a method that silently never gets called.

+ */ + private static final String API_PREFIX = "com/codename1/"; + + /** + * The one principled exclusion: the port implementation layer. + * + *

{@code com.codename1.impl} is not app-facing. Interpreted code reaches + * the platform through the framework, never by subclassing + * {@code CodenameOneImplementation} -- which alone declares 760 overridable + * methods. Excluding it is a statement about what the API is, not a guess + * about what applications need.

+ */ + /** + * Classes the language itself forbids naming as a superclass. Only the + * compiler may generate a subclass of these. + */ + private static final Set FORBIDDEN_SUPERTYPES = new LinkedHashSet( + Arrays.asList("java.lang.Enum", "java.lang.Record")); + + private static final String[] EXCLUDED_PREFIXES = { + "com/codename1/impl/", + // The interpreter's own types. A shim for InterpBacked would + // implement the interface the shims already implement. + "com/codename1/impl/interp/", + }; + + /** + * The one subsystem this runtime does not carry in any form. + * + *

Android Auto and CarPlay are a separate surface with their own + * manifest, templates and review process, and nothing about a car app can + * be driven from a pushed program -- so there is nothing to gain by + * carrying it and a large, awkward dependency to lose.

+ * + *

Everything else that cannot be provided honestly is *mocked* instead + * of excluded: see {@code DeviceRuntimeMocks} in the runtime app for + * purchases and social login, which are the two a developer most often + * needs to exercise and least often can. Excluding them left pushed code + * facing an {@code isSupported()} that answered false, which debugs + * nothing.

+ */ + private static final String[] NATIVE_HEAVY_PREFIXES = { + // Android Auto and CarPlay: a car app is a separate surface with + // its own manifest, templates and review process, and none of it + // can be driven from a pushed program. + "com/codename1/car/", + }; + + /** + * Subsystems the runtime carries the native half of, on purpose. + * + *

These are the reason to run on a device at all. A simulator can fake a + * layout; it cannot honestly imitate the camera, on-device inference, AR, + * the health store or a live activity, and a runtime that reported them + * unsupported would leave exactly the interesting half undebuggable.

+ * + *

Linking and subclassing are different needs and the shim set serves + * only the second. The build decides what native SDK to link by scanning + * the app for references to the API that fronts it, and most of these types + * are final or have no accessible constructor -- {@code TextRecognizer} is + * final -- so no shim mentions them and the SDK is left out. The generator + * therefore emits a class declaring a field of each type: a field + * descriptor is what that scan reads, where a class literal is invisible to + * it (an LDC, not a type instruction).

+ * + *

It is not free. ML Kit's bundled models and pipelines are 287MB of + * native libraries across four ABIs, which is what made an earlier build + * 323MB. The answer is an ABI split rather than dropping the feature: one + * ABI is 110MB, and a Play bundle delivers one ABI per device anyway.

+ */ + private static final String[] NATIVE_CAPABILITY_PREFIXES = { + "com/codename1/ai/", + "com/codename1/ar/", + "com/codename1/camera/", + "com/codename1/capture/", + "com/codename1/health/", + "com/codename1/media/", + "com/codename1/bluetooth/", + "com/codename1/vr/", + "com/codename1/surfaces/", + // Smart home: HomeKit on iOS, Matter and Google Home on Android. + // Debugging one of these on a device is the reason the runtime + // carries native capabilities at all -- a simulator cannot see an + // accessory. + "com/codename1/home/", + // The vector map, which needs no key and no native provider: a + // NativeMap with nothing wired in delegates to an embedded MapView, + // so a pushed program gets a real map rather than a blank one. + "com/codename1/maps/", + }; + + private GenerateInterpShims() { + } + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + System.err.println("usage: GenerateInterpShims " + + " [--exclude ]"); + System.exit(2); + } + // Shim names the caller has already established cannot be compiled. + // Supplied as a file rather than baked in, because the set is + // discovered by compiling and belongs to the framework version, not to + // this generator. + Set excludedShims = new LinkedHashSet(); + for (int i = 2; i + 1 < args.length; i++) { + if ("--exclude".equals(args[i])) { + File f = new File(args[i + 1]); + if (f.isFile()) { + for (String line : new String(java.nio.file.Files.readAllBytes(f.toPath()), + StandardCharsets.UTF_8).split("\n")) { + String t = line.trim(); + if (t.length() > 0 && !t.startsWith("#")) { + excludedShims.add(t); + } + } + } + } + } + File outDir = new File(args[0], "com/codenameone/devruntime/gen"); + if (!outDir.exists() && !outDir.mkdirs()) { + throw new IllegalStateException("cannot create " + outDir); + } + // Clear what a previous run wrote. Maven does not clean a generated + // source directory between builds, so a class the framework has since + // dropped leaves its shim behind -- and the next build fails compiling + // a shim for a type that no longer exists, naming a symbol nobody can + // find. Regenerating everything from the current jar is the only state + // that is ever correct. + File[] previous = outDir.listFiles(); + if (previous != null) { + for (File f : previous) { + if (f.getName().endsWith(".java") && !f.delete()) { + throw new IllegalStateException("cannot delete stale " + f); + } + } + } + for (int i = 2; i + 1 < args.length; i++) { + if ("--java-runtime".equals(args[i])) { + File jar = new File(args[i + 1]); + if (jar.isFile()) { + deviceJavaTypes = readTypeNames(jar); + deviceJavaMethods = readMethodTables(jar, false); + deviceFinalMethods = readMethodTables(jar, true); + } + } + } + File frameworkJar = new File(args[1]); + if (!frameworkJar.isFile()) { + throw new IllegalStateException("no framework jar at " + frameworkJar); + } + + List generated = new ArrayList(); + List ifaceShims = new ArrayList(); + List> classes = new ArrayList>(); + List> interfaces = new ArrayList>(); + scan(frameworkJar, classes, interfaces); + + int skipped = 0; + int skippedByExclusion = 0; + List> okClasses = new ArrayList>(); + List> okInterfaces = new ArrayList>(); + for (Class c : classes) { + String simple = shimName("Interp_", c); + if (excludedShims.contains(simple)) { + skippedByExclusion++; + continue; + } + try { + writeShim(outDir, simple, c); + generated.add(simple); + okClasses.add(c); + } catch (Exception e) { + // A class the shim cannot legally extend -- an inaccessible + // supertype, a name collision -- is dropped rather than + // emitted broken. Reported, so the count is never silently + // smaller than the API. + System.out.println("skipping " + c.getName() + ": " + e.getMessage()); + skipped++; + } + } + for (Class c : interfaces) { + String simple = shimName("Interp_I_", c); + if (excludedShims.contains(simple)) { + skippedByExclusion++; + continue; + } + try { + writeInterfaceShim(outDir, simple, c); + ifaceShims.add(simple); + okInterfaces.add(c); + } catch (Exception e) { + System.out.println("skipping " + c.getName() + ": " + e.getMessage()); + skipped++; + } + } + + writeNativeCapabilities(outDir, frameworkJar); + writeRegistry(outDir, generated, okClasses, ifaceShims, okInterfaces); + System.out.println("generated " + generated.size() + " class shims, " + + ifaceShims.size() + " interface shims, " + skipped + " skipped, " + + skippedByExclusion + " excluded, into " + outDir); + } + + /** + * Writes the class that names every native-backed capability. + * + *

Class literals and nothing else. Each is a constant-pool entry, which + * is exactly what the Codename One build scans for when deciding which + * native SDK an app needs -- and none of them runs, so the app pays the + * link cost and no behaviour.

+ * + *

Without this the runtime links a camera SDK only if some *shim* + * happens to mention the camera API, and most of these classes are final or + * have no accessible constructor, so they get no shim. The result was an + * app that reported "unsupported" for the very things a device is for.

+ */ + private static void writeNativeCapabilities(File dir, File jar) throws Exception { + List names = new ArrayList(); + java.util.jar.JarFile jf = new java.util.jar.JarFile(jar); + try { + java.util.Enumeration e = jf.entries(); + while (e.hasMoreElements()) { + String n = e.nextElement().getName(); + if (!n.endsWith(".class") || !isNativeCapability(n) || n.indexOf('$') >= 0) { + continue; + } + names.add(n.substring(0, n.length() - 6).replace('/', '.')); + } + } finally { + jf.close(); + } + java.util.Collections.sort(names); + + java.net.URLClassLoader loader = new java.net.URLClassLoader( + new java.net.URL[]{ jar.toURI().toURL() }, + GenerateInterpShims.class.getClassLoader()); + List referenced = new ArrayList(); + for (String name : names) { + try { + Class c = Class.forName(name, false, loader); + if (java.lang.reflect.Modifier.isPublic(c.getModifiers())) { + referenced.add(name); + } + } catch (Throwable notLoadable) { + // A class the framework jar names but this tool chain cannot + // load is not one the build will link either. + } + } + loader.close(); + + PrintWriter w = new PrintWriter(new File(dir, "InterpNativeCapabilities.java"), "UTF-8"); + try { + header(w); + w.println("package com.codenameone.devruntime.gen;"); + w.println(); + w.println("/**"); + w.println(" * The native capabilities this runtime carries, named so the build links"); + w.println(" * them."); + w.println(" *"); + w.println(" *

Generated, and nothing here runs: the class is never instantiated and"); + w.println(" * the fields are never read. A field's descriptor is the reference -- the"); + w.println(" * Codename One build decides which native SDK an app needs by scanning"); + w.println(" * field and method descriptors, and a class literal is invisible to that"); + w.println(" * scan because it is an LDC rather than a type instruction.

"); + w.println(" *"); + w.println(" *

Most of these types are final or have no accessible constructor, so no"); + w.println(" * shim mentions them and without this file the runtime would report the"); + w.println(" * camera, on-device inference, AR and the health store as unsupported --"); + w.println(" * on a device that supports them, which is the one place it matters.

"); + w.println(" */"); + w.println("public final class InterpNativeCapabilities {"); + int index = 0; + for (String name : referenced) { + w.println(" " + name + " c" + index + ";"); + index++; + } + w.println(); + w.println(" private InterpNativeCapabilities() {"); + w.println(" }"); + w.println("}"); + } finally { + w.close(); + } + System.out.println("native capabilities referenced: " + referenced.size()); + } + + private static boolean isNativeCapability(String entry) { + for (String p : NATIVE_CAPABILITY_PREFIXES) { + if (entry.startsWith(p)) { + return true; + } + } + return false; + } + + /** + * Every app-facing framework type an interpreted class could extend or + * implement. + * + *

Walking the jar rather than naming classes is the whole point: an + * application may subclass anything the API exposes, and a list maintained + * by hand is a list that is wrong the first time somebody subclasses + * something unusual.

+ */ + private static void scan(File jar, List> classes, List> interfaces) + throws Exception { + java.util.jar.JarFile jf = new java.util.jar.JarFile(jar); + java.net.URLClassLoader loader = new java.net.URLClassLoader( + new java.net.URL[]{ jar.toURI().toURL() }, + GenerateInterpShims.class.getClassLoader()); + List names = new ArrayList(); + try { + java.util.Enumeration e = jf.entries(); + while (e.hasMoreElements()) { + String n = e.nextElement().getName(); + if (!n.endsWith(".class") || !n.startsWith(API_PREFIX) || excluded(n)) { + continue; + } + names.add(n.substring(0, n.length() - 6).replace('/', '.')); + } + } finally { + jf.close(); + } + // The device's java.* subset is API too. An application that says + // `implements Runnable` -- which is most of them -- needs a shim for + // Runnable exactly as much as for ActionListener, and scanning only + // com.codename1 silently left it out. + names.addAll(javaApiTypeNames()); + java.util.Collections.sort(names); + for (String n : names) { + Class c; + try { + c = Class.forName(n, false, loader); + } catch (Throwable t) { + continue; // a class whose own dependencies are absent here + } + if (!isReachable(c) || c.isAnonymousClass() || c.isSynthetic()) { + continue; + } + int m = c.getModifiers(); + if (c.isInterface()) { + if (c.isAnnotation()) { + continue; + } + // An interface shim is concrete, so it must implement every + // method -- the same rule as a class, and it fails the same way + // when the JDK declares one the device does not have. + String missing = interfaceMethodNotOnDevice(c); + if (missing != null) { + System.out.println("not shimmable: " + c.getName() + " (" + missing + + " is not on the device or names a type that is not)"); + continue; + } + interfaces.add(c); + continue; + } + if (Modifier.isFinal(m) || c.isEnum()) { + continue; + } + if (!hasReachableConstructor(c)) { + continue; + } + if (c.isMemberClass() && !Modifier.isStatic(c.getModifiers())) { + continue; // needs an enclosing instance to construct + } + if (FORBIDDEN_SUPERTYPES.contains(c.getName())) { + continue; // the language forbids extending these directly + } + // A hierarchy difference is deliberately NOT a reason to skip. The + // JDK's Throwable implements Serializable and the device's does not, + // which rejected every exception class in the framework -- and + // `class MyException extends RuntimeException` is table stakes for a + // real application. Serializable and Cloneable declare no methods at + // all, so the difference is invisible to a subclass; where the extra + // interface does declare methods (Closeable.close, Readable.read), + // the shim implements them because javac here demands it and they + // are harmless there -- on the device they override nothing. + // + // What actually blocks a shim is a signature the device cannot + // express, which is the check below, applied per method. + String unrepresentable = abstractMethodNotOnDevice(c); + if (unrepresentable != null) { + // A concrete shim must implement every abstract method it + // inherits, and this one names a type the device does not have + // -- Format.parseObject(String,ParsePosition) when the subset + // has no ParsePosition. Writing it would not compile here and + // referencing it would not run there. + System.out.println("not shimmable: " + c.getName() + + " (" + unrepresentable + ")"); + continue; + } + String blocker = packagePrivateAbstract(c); + if (blocker != null) { + // Java forbids implementing a package-private abstract method + // from another package, so no subclass of this class can exist + // outside its own -- SurfaceNode.serializeContent is one. This + // is the framework's shape, not a generator limitation. + System.out.println("not subclassable outside its package: " + + c.getName() + " (" + blocker + " is package-private abstract)"); + continue; + } + classes.add(c); + } + } + + /** + * Type names of the device's {@code java.*} subset, from its source tree. + * + *

Taken from {@code vm/JavaAPI/src} rather than from the JDK, because + * what matters is what the device has. The classes are then loaded from the + * JDK -- they are the same types -- and any place the two disagree is + * caught by the compile and compliance gates rather than shipped.

+ */ + private static List javaApiTypeNames() { + return deviceJavaTypes == null + ? new ArrayList() + : new ArrayList(deviceJavaTypes); + } + + /** + * Whether the device's version of the class has this constructor. + * + *

Same skew as methods: the JDK's {@code Timer} has + * {@code Timer(String, boolean)} and the device's does not, and a peer + * constructor chaining to it fails the compliance gate.

+ */ + private static boolean declaredOnDevice(Constructor k) { + if (deviceJavaMethods == null) { + return true; + } + String owner = k.getDeclaringClass().getName(); + if (!owner.startsWith("java.") && !owner.startsWith("javax.")) { + return true; + } + Set declared = deviceJavaMethods.get(owner); + if (declared == null) { + return false; + } + StringBuilder sb = new StringBuilder("("); + for (Class p : k.getParameterTypes()) { + sb.append(descriptorOf(p)); + } + return declared.contains(sb.append(")V").toString()); + } + + /** Whether the device declares this method final, whatever the JDK says. */ + private static boolean finalOnDevice(Method m) { + if (deviceFinalMethods == null) { + return false; + } + String owner = m.getDeclaringClass().getName(); + if (!owner.startsWith("java.") && !owner.startsWith("javax.")) { + return false; + } + Set declared = deviceFinalMethods.get(owner); + return declared != null && declared.contains(m.getName() + descriptorOf(m)); + } + + /** + * Whether the device's own version of the declaring class has this method. + * + *

Only asked of {@code java.*}: the framework classes come from the very + * jar the app compiles against, so there is nothing to disagree with.

+ */ + private static boolean declaredOnDevice(Method m) { + if (deviceJavaMethods == null) { + return true; + } + String owner = m.getDeclaringClass().getName(); + if (!owner.startsWith("java.") && !owner.startsWith("javax.")) { + return true; + } + Set declared = deviceJavaMethods.get(owner); + return declared != null && declared.contains(m.getName() + descriptorOf(m)); + } + + /** Reads name+descriptor sets straight out of each class file's method table. */ + private static Map> readMethodTables(File jar, final boolean finalOnly) { + Map> out = new LinkedHashMap>(); + try { + java.util.jar.JarFile jf = new java.util.jar.JarFile(jar); + try { + java.util.Enumeration e = jf.entries(); + while (e.hasMoreElements()) { + java.util.jar.JarEntry entry = e.nextElement(); + if (!entry.getName().endsWith(".class")) { + continue; + } + java.io.InputStream in = jf.getInputStream(entry); + try { + final Set methods = new LinkedHashSet(); + new org.objectweb.asm.ClassReader(in).accept( + new org.objectweb.asm.ClassVisitor( + org.objectweb.asm.Opcodes.ASM9) { + public org.objectweb.asm.MethodVisitor visitMethod( + int access, String name, String desc, + String sig, String[] ex) { + boolean isFinal = (access + & org.objectweb.asm.Opcodes.ACC_FINAL) != 0; + if (!finalOnly || isFinal) { + methods.add(name + desc); + } + return null; + } + }, + org.objectweb.asm.ClassReader.SKIP_CODE + | org.objectweb.asm.ClassReader.SKIP_DEBUG + | org.objectweb.asm.ClassReader.SKIP_FRAMES); + out.put(entry.getName().substring(0, entry.getName().length() - 6) + .replace('/', '.'), methods); + } finally { + in.close(); + } + } + } finally { + jf.close(); + } + } catch (java.io.IOException ex) { + throw new IllegalStateException("cannot read " + jar, ex); + } + return out; + } + + /** Every class name in a jar. */ + private static Set readTypeNames(File jar) { + Set out = new LinkedHashSet(); + try { + java.util.jar.JarFile jf = new java.util.jar.JarFile(jar); + try { + java.util.Enumeration e = jf.entries(); + while (e.hasMoreElements()) { + String n = e.nextElement().getName(); + if (n.endsWith(".class")) { + out.add(n.substring(0, n.length() - 6).replace('/', '.')); + } + } + } finally { + jf.close(); + } + } catch (java.io.IOException ex) { + throw new IllegalStateException("cannot read " + jar, ex); + } + return out; + } + + private static boolean excluded(String entry) { + for (String p : NATIVE_HEAVY_PREFIXES) { + if (entry.startsWith(p)) { + return true; + } + } + for (String p : EXCLUDED_PREFIXES) { + if (entry.startsWith(p)) { + return true; + } + } + return false; + } + + /** + * Whether the type can be named from another package at all. + * + *

Public is not enough for a nested type: {@code GeneralPath.ShapeUtil} + * is a public class inside a package-private one, and naming it from + * outside does not compile.

+ */ + private static boolean isReachable(Class c) { + for (Class k = c; k != null; k = k.getEnclosingClass()) { + if (!Modifier.isPublic(k.getModifiers())) { + return false; + } + } + return true; + } + + /** + * The first interface method the device does not have, or null. + * + *

Walks the raw declarations rather than {@link #collectInterfaceMethods}, + * which already drops what the device lacks -- asking it would be asking + * whether anything it removed is missing.

+ */ + private static String interfaceMethodNotOnDevice(Class c) { + List all = new ArrayList(); + collectRawInterfaceMethods(c, all); + for (Method m : all) { + if (!declaredOnDevice(m)) { + return m.getDeclaringClass().getSimpleName() + "." + m.getName(); + } + if (!isUsable(m.getReturnType())) { + return m.getDeclaringClass().getSimpleName() + "." + m.getName(); + } + for (Class p : m.getParameterTypes()) { + if (!isUsable(p)) { + return m.getDeclaringClass().getSimpleName() + "." + m.getName(); + } + } + } + return null; + } + + private static void collectRawInterfaceMethods(Class c, List out) { + for (Method m : declaredMethodsSorted(c)) { + int mo = m.getModifiers(); + if (Modifier.isStatic(mo) || m.isSynthetic() || m.isBridge() + || !Modifier.isAbstract(mo)) { + continue; + } + out.add(m); + } + for (Class i : c.getInterfaces()) { + collectRawInterfaceMethods(i, out); + } + } + + /** + * The first abstract method whose signature the device cannot express, or + * null. Such a class cannot have a concrete subclass generated for it. + */ + private static String abstractMethodNotOnDevice(Class c) { + for (Class k = c; k != null && k != Object.class; k = k.getSuperclass()) { + for (Method m : declaredMethodsSorted(k)) { + if (!Modifier.isAbstract(m.getModifiers()) || m.isSynthetic() || m.isBridge()) { + continue; + } + // An abstract method the device's version does not declare is + // not a problem: javac here requires the shim to implement it, + // and there it is simply a method that overrides nothing. Only + // a signature the device cannot name rules the class out. + // + // Final on the device against abstract here does rule it out, + // and it is not hypothetical: Calendar.add is abstract on the + // JDK and final on the device, so a concrete subclass must + // declare it to compile here and must not declare it to compile + // there. No shim can satisfy both, so Calendar has none. + if (finalOnDevice(m)) { + return "abstract " + k.getSimpleName() + "." + m.getName() + + " is final on the device"; + } + if (!isUsable(m.getReturnType())) { + return "abstract " + k.getSimpleName() + "." + m.getName() + + " names a type the device does not have"; + } + for (Class p : m.getParameterTypes()) { + if (!isUsable(p)) { + return "abstract " + k.getSimpleName() + "." + m.getName() + + " names a type the device does not have"; + } + } + } + } + return null; + } + + /** + * The first package-private abstract method that makes this class + * unsubclassable from outside its package, or null. + */ + private static String packagePrivateAbstract(Class c) { + for (Class k = c; k != null && k != Object.class; k = k.getSuperclass()) { + for (Method m : declaredMethodsSorted(k)) { + int mo = m.getModifiers(); + if (!Modifier.isAbstract(mo)) { + continue; + } + if (!Modifier.isPublic(mo) && !Modifier.isProtected(mo)) { + return k.getSimpleName() + "." + m.getName(); + } + } + } + return null; + } + + /** Whether {@code super()} with no arguments is legal from another package. */ + private static boolean hasNoArgConstructor(Class c) { + for (Constructor k : declaredConstructorsSorted(c)) { + int km = k.getModifiers(); + if (k.getParameterTypes().length == 0 + && (Modifier.isPublic(km) || Modifier.isProtected(km)) + && declaredOnDevice(k)) { + return true; + } + } + return false; + } + + /** + * Whether a shim in another package could call {@code super(...)} at all. + * A class with only private or package-private constructors cannot be + * subclassed from outside its package, so no shim is possible or needed. + */ + private static boolean hasReachableConstructor(Class c) { + for (Constructor k : declaredConstructorsSorted(c)) { + int km = k.getModifiers(); + if ((Modifier.isPublic(km) || Modifier.isProtected(km)) && declaredOnDevice(k)) { + return true; + } + } + return false; + } + + /** + * A unique flat name for a shim. + * + *

Simple names collide across packages once the whole API is in scope -- + * there is more than one {@code Border}, more than one {@code Style} -- so + * the package rides along, flattened.

+ */ + private static String shimName(String prefix, Class c) { + String n = c.getName(); + if (n.startsWith("com.codename1.")) { + n = n.substring("com.codename1.".length()); + } + return prefix + n.replace('.', '_').replace('$', '_'); + } + + /** + * Emits a concrete class implementing a framework interface, forwarding + * every method to the interpreter. + * + *

Simpler than a class shim in the one way that matters: an interface has + * no implementation to fall back to, so there is no {@code super_} bridge + * and no "not overridden" case worth deferring. A pushed class that declares + * the interface and then fails to implement a method is a class that would + * not have compiled, so the unreachable branch throws rather than + * pretending.

+ */ + private static void writeInterfaceShim(File dir, String simpleName, Class target) + throws Exception { + PrintWriter w = new PrintWriter(new File(dir, simpleName + ".java"), "UTF-8"); + try { + header(w); + w.println("package com.codenameone.devruntime.gen;"); + w.println(); + w.println("import com.codename1.impl.interp.InterpBacked;"); + w.println("import com.codename1.impl.interp.InterpObject;"); + w.println("import com.codename1.impl.interp.InterpRuntime;"); + w.println(); + w.println("/** Lets an interpreted class implement {@link " + typeName(target) + + "}. */"); + w.println("public final class " + simpleName + " implements " + typeName(target) + + ", InterpBacked {"); + w.println(" private final InterpObject $interp;"); + w.println(" private final InterpRuntime $runtime;"); + w.println(); + w.println(" public " + simpleName + "(InterpRuntime runtime, InterpObject interp) {"); + w.println(" this.$runtime = runtime;"); + w.println(" this.$interp = interp;"); + w.println(" }"); + w.println(); + w.println(" public InterpObject getInterpObject() {"); + w.println(" return $interp;"); + w.println(" }"); + w.println(); + + Map> bindings = typeBindings(target); + Map ifaceMethods = collectInterfaceMethods(target); + for (Method m : ifaceMethods.values()) { + emitInterfaceMethod(w, m, target, bindings); + } + emitObjectMethods(w, ifaceMethods.keySet(), target); + w.println("}"); + } finally { + w.close(); + } + } + + /** Every method an implementation of the interface has to provide. */ + private static Map collectInterfaceMethods(Class target) { + Map out = new LinkedHashMap(); + collectInterfaceMethods(target, out); + return out; + } + + private static void collectInterfaceMethods(Class iface, Map out) { + for (Method m : declaredMethodsSorted(iface)) { + if (Modifier.isStatic(m.getModifiers()) || m.isSynthetic() || m.isBridge() + || !declaredOnDevice(m)) { + continue; + } + String key = m.getName() + paramDescriptorOf(m); + if (!out.containsKey(key)) { + out.put(key, m); + } + } + for (Class parent : iface.getInterfaces()) { + collectInterfaceMethods(parent, out); + } + } + + private static void emitInterfaceMethod(PrintWriter w, Method m, Class target, + Map> bindings) { + Class[] params = resolvedParams(m, bindings); + Class ret = resolved(m.getGenericReturnType(), m.getReturnType(), m, bindings); + StringBuilder sig = new StringBuilder(); + StringBuilder boxed = new StringBuilder(); + for (int i = 0; i < params.length; i++) { + if (i > 0) { + sig.append(", "); + boxed.append(", "); + } + sig.append(typeName(params[i])).append(" a").append(i); + boxed.append("a").append(i); + } + w.println(" public " + typeName(ret) + " " + m.getName() + "(" + sig + ")" + + throwsClause(m) + " {"); + emitDispatch(w, m, "$runtime.dispatch($interp, \"" + m.getName() + "\", \"" + + descriptorOf(params, ret) + "\", new Object[]{" + boxed + "})"); + if (!m.isDefault()) { + // A stopped program's peer is still held by whatever registered it. + // Answering nothing is the point of detaching; throwing here would + // turn an expected late callback into an event-thread failure. + w.println(" if ($r == InterpRuntime.DETACHED) {"); + w.println(ret == Void.TYPE ? " return;" + : " return " + zero(ret) + ";"); + w.println(" }"); + } + w.println(" if (" + (m.isDefault() ? MISS : "$r == InterpRuntime.NOT_OVERRIDDEN") + + ") {"); + if (m.isDefault()) { + // The interface's own default implementation. A pushed class that + // implements a host interface and does not override a default + // method is ordinary Java, and throwing here would make its peer + // fail on a method the interface plainly provides. + String call = target.getName() + ".super." + m.getName() + "(" + boxed + ")"; + if (ret == Void.TYPE) { + w.println(" " + call + ";"); + w.println(" return;"); + } else { + w.println(" return " + call + ";"); + } + } else { + w.println(" throw new AbstractMethodError(\"" + target.getName() + "." + + m.getName() + "\");"); + } + w.println(" }"); + if (ret != Void.TYPE) { + w.println(" return " + unbox(ret, "$r") + ";"); + } + w.println(" }"); + w.println(); + if (m.isDefault()) { + emitInterfaceSuperBridge(w, m, target, params, ret); + } + } + + /** + * The bridge {@code HostInterface.super.method(...)} needs. + * + *

Interpreted code writing that produces an invokespecial, which the + * runtime serves by calling {@code super_method} on the peer. Without a + * bridge the fallback calls the method itself, and on a reflective linker + * {@code Method.invoke} dispatches virtually -- straight back into the + * shim's override, which asks the interpreter, which calls super again, + * until the stack gives out.

+ */ + private static void emitInterfaceSuperBridge(PrintWriter w, Method m, Class target, + Class[] params, Class ret) { + StringBuilder sig = new StringBuilder(); + StringBuilder call = new StringBuilder(); + for (int i = 0; i < params.length; i++) { + if (i > 0) { + sig.append(", "); + call.append(", "); + } + sig.append(typeName(params[i])).append(" a").append(i); + call.append("a").append(i); + } + w.println(" public " + typeName(ret) + " super_" + m.getName() + "(" + sig + ")" + + throwsClause(m) + " {"); + String invocation = target.getName() + ".super." + m.getName() + "(" + call + ")"; + if (ret == Void.TYPE) { + w.println(" " + invocation + ";"); + } else { + w.println(" return " + invocation + ";"); + } + w.println(" }"); + w.println(); + } + + private static void writeShim(File dir, String simpleName, Class target) throws Exception { + PrintWriter w = new PrintWriter(new File(dir, simpleName + ".java"), "UTF-8"); + try { + header(w); + w.println("package com.codenameone.devruntime.gen;"); + w.println(); + w.println("import com.codename1.impl.interp.InterpBacked;"); + w.println("import com.codename1.impl.interp.InterpObject;"); + w.println("import com.codename1.impl.interp.InterpRuntime;"); + w.println(); + w.println("/**"); + w.println(" * Lets an interpreted class extend {@link " + typeName(target) + "}."); + w.println(" *"); + w.println(" *

Generated by GenerateInterpShims. Every override asks the runtime"); + w.println(" * whether the pushed class provides the method and otherwise defers to"); + w.println(" * super, so a class that overrides nothing behaves exactly like the"); + w.println(" * framework class it extends.

"); + w.println(" */"); + w.println("public final class " + simpleName + " extends " + typeName(target) + + " implements InterpBacked {"); + w.println(" private final InterpObject $interp;"); + w.println(" private final InterpRuntime $runtime;"); + w.println(); + // Only when super() is legal. Many framework classes have no + // accessible no-argument constructor, and an implicit super() call + // to one that does not exist does not compile. + if (hasNoArgConstructor(target)) { + w.println(" public " + simpleName + "(InterpRuntime runtime, InterpObject interp) {"); + w.println(" this.$runtime = runtime;"); + w.println(" this.$interp = interp;"); + w.println(" }"); + w.println(); + } + // One constructor per framework constructor, so `super("title")` in + // interpreted code reaches the real superclass constructor instead + // of silently collapsing to the no-arg one and losing its arguments. + emitConstructors(w, simpleName, target); + w.println(" public InterpObject getInterpObject() {"); + w.println(" return $interp;"); + w.println(" }"); + w.println(); + + Map> bindings = typeBindings(target); + Map methods = collectOverridable(target); + for (Map.Entry e : methods.entrySet()) { + emitOverride(w, e.getValue(), bindings); + } + emitObjectMethods(w, methods.keySet(), target); + w.println("}"); + } finally { + w.close(); + } + } + + /** + * Emits one constructor per accessible framework constructor, plus a + * descriptor-keyed factory the runtime uses to pick the right one. + * + *

The runtime learns which superclass constructor the interpreted class + * chained to only when it sees the {@code invokespecial }, so the + * choice has to be made by descriptor at that moment rather than baked in.

+ */ + private static void emitConstructors(PrintWriter w, String simpleName, Class target) { + List> ctors = new ArrayList>(); + for (Constructor c : declaredConstructorsSorted(target)) { + int mod = c.getModifiers(); + // The shim lives in its own package, so only public and protected + // constructors are reachable from its super(...) call. A + // package-private one compiles here and fails at the call site. + if (!Modifier.isPublic(mod) && !Modifier.isProtected(mod)) { + continue; + } + boolean usable = true; + for (Class p : c.getParameterTypes()) { + if (!isUsable(p)) { + usable = false; + break; + } + } + if (usable && declaredOnDevice(c)) { + ctors.add(c); + } + } + for (Constructor c : ctors) { + Class[] params = c.getParameterTypes(); + if (params.length == 0) { + // The (runtime, interp) constructor emitted above already + // chains to the no-arg superclass constructor; declaring it + // again here would be the same signature twice. + continue; + } + StringBuilder sig = new StringBuilder(); + StringBuilder call = new StringBuilder(); + for (int i = 0; i < params.length; i++) { + if (i > 0) { + sig.append(", "); + call.append(", "); + } + sig.append(typeName(params[i])).append(" a").append(i); + call.append("a").append(i); + } + String sep = params.length == 0 ? "" : ", "; + w.println(" public " + simpleName + "(InterpRuntime runtime, InterpObject interp" + + sep + sig + ")" + ctorThrows(c) + " {"); + w.println(" super(" + call + ");"); + w.println(" this.$runtime = runtime;"); + w.println(" this.$interp = interp;"); + w.println(" }"); + w.println(); + } + + w.println(" /** Builds the peer for the superclass constructor the pushed class used. */"); + w.println(" public static Object create(InterpRuntime rt, InterpObject o,"); + w.println(" String descriptor, Object[] args) throws Throwable {"); + for (Constructor c : ctors) { + Class[] params = c.getParameterTypes(); + StringBuilder desc = new StringBuilder("("); + for (Class p : params) { + desc.append(descriptorOf(p)); + } + desc.append(")V"); + StringBuilder cast = new StringBuilder(); + for (int i = 0; i < params.length; i++) { + cast.append(", ").append(castFromObject(params[i], "args[" + i + "]")); + } + w.println(" if (\"" + desc + "\".equals(descriptor)) {"); + w.println(" return new " + simpleName + "(rt, o" + cast + ");"); + w.println(" }"); + } + // Anything else is a constructor this shim does not have -- one the + // device's API lacks, or one the generator could not emit. Substituting + // the no-argument peer would run a different constructor than the + // program wrote, silently losing both its arguments and whatever that + // constructor does; saying so names the class and the descriptor. + w.println(" throw new UnsupportedOperationException(\"" + typeName(target) + + " has no constructor \" + descriptor + \" on this device\");"); + w.println(" }"); + w.println(); + } + + private static String castFromObject(Class t, String expr) { + if (t == Boolean.TYPE) return "((Boolean)" + expr + ").booleanValue()"; + if (t == Byte.TYPE) return "((Number)" + expr + ").byteValue()"; + if (t == Character.TYPE) return "((Character)" + expr + ").charValue()"; + if (t == Short.TYPE) return "((Number)" + expr + ").shortValue()"; + if (t == Integer.TYPE) return "((Number)" + expr + ").intValue()"; + if (t == Long.TYPE) return "((Number)" + expr + ").longValue()"; + if (t == Float.TYPE) return "((Number)" + expr + ").floatValue()"; + if (t == Double.TYPE) return "((Number)" + expr + ").doubleValue()"; + return "(" + typeName(t) + ")" + expr; + } + + /** Overridable methods of the target, keyed by erasure signature. */ + private static Map collectOverridable(Class target) { + Map out = new LinkedHashMap(); + Map> bindings = typeBindings(target); + // Signatures already emitted, keyed after type-variable resolution: a + // concrete compare(String,String) and Comparator's resolved compare(T,T) + // are one method, and emitting both is a duplicate definition. + Set seen = new LinkedHashSet(); + // A method that is final anywhere between the target and the class that + // declares it cannot be overridden, even though a superclass declares + // it non-final. Component.getComponentForm() is overridable; Form + // re-declares it final, so a subclass of Form may not touch it. Collect + // those first and let them block the inherited declaration. + Set blocked = new LinkedHashSet(); + for (Class c = target; c != null && c != Object.class; c = c.getSuperclass()) { + for (Method m : declaredMethodsSorted(c)) { + if (Modifier.isFinal(m.getModifiers()) || Modifier.isPrivate(m.getModifiers()) + || finalOnDevice(m)) { + blocked.add(m.getName() + paramDescriptorOf(m)); + blocked.add(m.getName() + + descriptorOfParams(resolvedParams(m, bindings))); + } + } + } + for (Class c = target; c != null && c != Object.class; c = c.getSuperclass()) { + for (Method m : declaredMethodsSorted(c)) { + int mod = m.getModifiers(); + if (Modifier.isStatic(mod) || Modifier.isFinal(mod) || Modifier.isPrivate(mod)) { + continue; + } + if (!Modifier.isPublic(mod) && !Modifier.isProtected(mod)) { + continue; + } + // A concrete method the device does not have is not worth + // overriding -- there is nothing there to call back into it. + // An abstract one has to be implemented anyway or the shim will + // not compile here, whatever the device thinks of it. + if (m.isSynthetic() || m.isBridge() + || (!declaredOnDevice(m) && !Modifier.isAbstract(mod))) { + continue; + } + if (m.getDeclaringClass() == Throwable.class) { + // printStackTrace and fillInStackTrace are Throwable's, not + // Codename One's, and the device API subset does not carry + // them -- the bytecode compliance gate rejects a shim that + // references them. An interpreted exception subclass has + // nothing to gain from overriding them anyway. + continue; + } + // A parameter or return type the app does not expose would not + // compile in the shim; skip rather than emit something broken. + if (!isUsable(m.getReturnType())) { + continue; + } + boolean usable = true; + for (Class p : m.getParameterTypes()) { + if (!isUsable(p)) { + usable = false; + break; + } + } + if (!usable) { + continue; + } + String key = m.getName() + paramDescriptorOf(m); + if (blocked.contains(key)) { + continue; + } + String resolvedKey = m.getName() + + descriptorOfParams(resolvedParams(m, bindings)); + if (!out.containsKey(key) && !seen.contains(resolvedKey)) { + out.put(key, m); + seen.add(resolvedKey); + } + } + } + // Interface abstracts last, and only for signatures no class in the + // chain implements. Collecting them first let StyleListener's abstract + // styleChanged beat Component's concrete one, so the shim threw + // AbstractMethodError where it should have called super. + collectInterfaceAbstracts(target, target, out, bindings, seen); + for (java.util.Iterator> it = out.entrySet().iterator(); + it.hasNext(); ) { + Map.Entry e = it.next(); + String resolvedKey = e.getValue().getName() + + descriptorOfParams(resolvedParams(e.getValue(), bindings)); + if (blocked.contains(e.getKey()) || blocked.contains(resolvedKey)) { + it.remove(); + } + } + return out; + } + + /** + * Whether some class between {@code from} and Object already provides a + * concrete implementation of this interface method's name and parameters. + * + *

Compared on name and parameters only, deliberately: a covariant + * override has a different return type and is still the implementation.

+ */ + private static boolean implementedByAClass(Class from, Method m) { + for (Class k = from; k != null; k = k.getSuperclass()) { + for (Method candidate : declaredMethodsSorted(k)) { + int cm = candidate.getModifiers(); + // Private and static methods do not implement an interface + // method however well their signatures match. + if (!candidate.getName().equals(m.getName()) + || Modifier.isAbstract(cm) || Modifier.isStatic(cm) + || Modifier.isPrivate(cm)) { + continue; + } + if (java.util.Arrays.equals(candidate.getParameterTypes(), + m.getParameterTypes())) { + return true; + } + } + } + return false; + } + + /** + * Abstract methods reachable only through an interface. + * + *

A concrete shim has to implement them or it will not compile, and the + * superclass walk does not see them: the class may declare the interface + * without declaring the method.

+ */ + private static void collectInterfaceAbstracts(Class target, Class c, + Map out, + Map> bindings, Set seen) { + if (c == null) { + return; + } + for (Class i : c.getInterfaces()) { + for (Method m : declaredMethodsSorted(i)) { + int mo = m.getModifiers(); + if (Modifier.isStatic(mo) || m.isSynthetic() || m.isBridge()) { + continue; + } + if (!Modifier.isAbstract(mo)) { + continue; // a default method already has an implementation + } + // Not filtered on declaredOnDevice: this is an abstract method + // of an interface the JDK's copy of the superclass declares and + // the device's does not -- Closeable.close on InputStream. The + // shim has to implement it to compile here; there it overrides + // nothing and costs a method. Its signature still has to be + // expressible, which is what isUsable enforces. + // + // Unless a class in the chain already implements it, in which + // case implementing it again is not merely redundant but wrong: + // Writer implements Appendable and narrows the return type to + // Writer, so a shim declaring Appendable append(CharSequence) + // does not override Writer's -- it clashes with it. + if (implementedByAClass(target, m)) { + continue; + } + if (!isUsable(m.getReturnType())) { + continue; + } + boolean expressible = true; + for (Class p : m.getParameterTypes()) { + if (!isUsable(p)) { + expressible = false; + break; + } + } + if (!expressible) { + continue; + } + String key = m.getName() + paramDescriptorOf(m); + String resolvedKey = m.getName() + + descriptorOfParams(resolvedParams(m, bindings)); + if (!out.containsKey(key) && !seen.contains(resolvedKey)) { + out.put(key, m); + seen.add(resolvedKey); + } + } + collectInterfaceAbstracts(target, i, out, bindings, seen); + } + collectInterfaceAbstracts(target, c.getSuperclass(), out, bindings, seen); + } + + /** + * Maps each type variable in a hierarchy to what the subtype binds it to. + * + *

{@code CaseInsensitiveOrder implements Comparator} binds + * {@code Comparator}'s {@code T} to {@code String}, so its + * {@code compare(T,T)} has to be written {@code compare(String,String)} -- + * the erasure {@code compare(Object,Object)} is a different method and + * clashes with the real one. Skipping such methods instead was worse: an + * abstract one then leaves the shim uncompilable, which is what put half + * the generic API on the unshimmable list.

+ * + *

A variable with no binding -- the target's own, as in + * {@code MutableStack} extended raw -- resolves to its erasure, which is + * what a raw supertype gives you anyway.

+ */ + private static Map> typeBindings(Class target) { + Map> out = new LinkedHashMap>(); + if (target.getTypeParameters().length > 0) { + // The shim extends this raw -- it has no type arguments to supply -- + // and a raw supertype erases every inherited member, binding or no + // binding. BooleanProperty extends Property, yet + // through the raw type super.get() is Object, not Boolean. So no + // substitution applies here; erasures are the truth. + return out; + } + collectBindings(target, out); + return out; + } + + private static void collectBindings(java.lang.reflect.Type t, Map> out) { + if (t instanceof Class) { + Class c = (Class) t; + if (c.getGenericSuperclass() != null) { + collectBindings(c.getGenericSuperclass(), out); + } + for (java.lang.reflect.Type i : c.getGenericInterfaces()) { + collectBindings(i, out); + } + return; + } + if (!(t instanceof java.lang.reflect.ParameterizedType)) { + return; + } + java.lang.reflect.ParameterizedType pt = (java.lang.reflect.ParameterizedType) t; + Class raw = (Class) pt.getRawType(); + java.lang.reflect.TypeVariable[] vars = raw.getTypeParameters(); + java.lang.reflect.Type[] args = pt.getActualTypeArguments(); + for (int i = 0; i < vars.length && i < args.length; i++) { + // Resolve through what is already known: descending from + // Set into Collection, that E is Set's variable and + // only the bindings collected so far say it means Component. + // Erasing it in place gave Object, so Collection.add(E) came out as + // add(Object) and clashed with the class's own add(Component). + Class bound = erase(substitute(args[i], out)); + if (bound != null) { + // Keyed by declaring class so two supertypes may both use "T". + out.put(raw.getName() + "#" + vars[i].getName(), bound); + } + } + collectBindings(raw, out); + } + + /** A type variable replaced by whatever an outer binding already fixed it to. */ + private static java.lang.reflect.Type substitute(java.lang.reflect.Type t, + Map> known) { + if (!(t instanceof java.lang.reflect.TypeVariable)) { + return t; + } + java.lang.reflect.TypeVariable v = (java.lang.reflect.TypeVariable) t; + Object owner = v.getGenericDeclaration(); + if (owner instanceof Class) { + Class bound = known.get(((Class) owner).getName() + "#" + v.getName()); + if (bound != null) { + return bound; + } + } + return t; + } + + /** The class a generic type erases to, or null if it cannot be named. */ + private static Class erase(java.lang.reflect.Type t) { + if (t instanceof Class) { + return (Class) t; + } + if (t instanceof java.lang.reflect.ParameterizedType) { + return erase(((java.lang.reflect.ParameterizedType) t).getRawType()); + } + if (t instanceof java.lang.reflect.TypeVariable) { + java.lang.reflect.Type[] bounds = + ((java.lang.reflect.TypeVariable) t).getBounds(); + return bounds.length > 0 ? erase(bounds[0]) : Object.class; + } + if (t instanceof java.lang.reflect.WildcardType) { + java.lang.reflect.Type[] upper = + ((java.lang.reflect.WildcardType) t).getUpperBounds(); + return upper.length > 0 ? erase(upper[0]) : Object.class; + } + return null; + } + + /** + * The type to write for one position of a method's signature, with the + * declaring class's variables resolved against what the shim's target + * binds them to. + */ + /** A method's parameter types with the declaring class's variables resolved. */ + private static Class[] resolvedParams(Method m, Map> bindings) { + Class[] erased = m.getParameterTypes(); + java.lang.reflect.Type[] generic = m.getGenericParameterTypes(); + Class[] out = new Class[erased.length]; + for (int i = 0; i < erased.length; i++) { + out[i] = i < generic.length + ? resolved(generic[i], erased[i], m, bindings) + : erased[i]; + } + return out; + } + + private static Class resolved(java.lang.reflect.Type generic, Class erased, + Method m, Map> bindings) { + if (!(generic instanceof java.lang.reflect.TypeVariable)) { + return erased; + } + String key = m.getDeclaringClass().getName() + "#" + + ((java.lang.reflect.TypeVariable) generic).getName(); + Class bound = bindings.get(key); + return bound != null ? bound : erased; + } + + /** + * Whether a method can be overridden by generated code at all. + * + *

Excludes anything whose signature mentions a type variable. Reflection + * reports the erasure -- {@code AsyncResource.get(T)} arrives as + * {@code get(Object)} -- while the compiler sees the specialised view + * through {@code BleScan extends AsyncResource<Boolean>}, and an + * override written against the erasure clashes with it instead of + * overriding it.

+ */ + private static boolean isShimmable(Method m) { + if (mentionsTypeVariable(m.getGenericReturnType())) { + return false; + } + for (java.lang.reflect.Type t : m.getGenericParameterTypes()) { + if (mentionsTypeVariable(t)) { + return false; + } + } + return true; + } + + private static boolean mentionsTypeVariable(java.lang.reflect.Type t) { + if (t instanceof java.lang.reflect.TypeVariable) { + return true; + } + if (t instanceof java.lang.reflect.GenericArrayType) { + return mentionsTypeVariable( + ((java.lang.reflect.GenericArrayType) t).getGenericComponentType()); + } + if (t instanceof java.lang.reflect.ParameterizedType) { + for (java.lang.reflect.Type a + : ((java.lang.reflect.ParameterizedType) t).getActualTypeArguments()) { + if (mentionsTypeVariable(a)) { + return true; + } + } + } + return false; + } + + private static boolean isUsable(Class c) { + if (c.isArray()) { + return isUsable(c.getComponentType()); + } + if (c.isPrimitive()) { + return true; + } + if (!Modifier.isPublic(c.getModifiers())) { + return false; + } + return onDevice(c); + } + + /** + * The device's {@code java.*} types, by name. + * + *

Read from the {@code codenameone-java-runtime} artifact, which is what + * the application tool chain actually compiles against -- 355 classes, not + * the JDK's thousands and not everything under {@code vm/JavaAPI} either. + * {@code ReentrantLock} exists in the VM's sources but is not on that + * classpath, and a shim for it fails to resolve.

+ */ + private static Set deviceJavaTypes; + + /** + * {@code class -> {name+descriptor}} for the device's {@code java.*}. + * + *

Type-level filtering is not enough. The device's {@code InputStream} + * is a real {@code java.io.InputStream}, but the JDK's has + * {@code readAllBytes()} and the device's does not -- reflection over the + * running JDK reports methods that will not exist on the phone, and a shim + * overriding one fails the bytecode compliance gate. So the jar's own + * method table decides.

+ */ + private static Map> deviceJavaMethods; + + /** + * The same, restricted to methods the device declares {@code final}. + * + *

Final-ness is skewed too, not just presence: {@code Writer.append} is + * overridable in the JDK and final on the device, so reflection says + * "override this" and javac says you may not.

+ */ + private static Map> deviceFinalMethods; + + /** + * Whether the device's {@code java.*} subset actually has this type. + * + *

The framework compiles against a full JDK; the device does not. + * {@code Reader.read(java.nio.CharBuffer)} is a real method of a real + * framework class and referencing it from a shim fails the bytecode + * compliance gate, because ParparVM has no {@code java.nio.CharBuffer}. + * Asking the subset directly is the general rule -- the alternative is + * discovering each absent type one build at a time.

+ */ + private static boolean onDevice(Class c) { + if (deviceJavaTypes == null) { + return true; // no runtime supplied; assume the caller knows + } + String n = c.getName(); + if (!n.startsWith("java.") && !n.startsWith("javax.")) { + return true; // framework and app types are not the subset's business + } + return deviceJavaTypes.contains(n); + } + + /** + * {@code toString}, {@code hashCode} and {@code equals}, routed to the + * interpreted class. + * + *

They are not reached by the ordinary walk, which stops below + * {@code Object}, and nothing in the framework declares them either -- so + * without this a shim keeps Object's versions. The effect is visible + * immediately: a list of interpreted objects prints as + * {@code Interp_I_java_lang_Comparable@df828bb} rather than by the class's + * own {@code toString}, and an interpreted {@code equals} is ignored by + * every collection that relies on it.

+ * + *

Skipped where the class already provides one, which is what the key + * set is for -- emitting it twice would not compile.

+ */ + private static void emitObjectMethods(PrintWriter w, Set alreadyEmitted, + Class target) { + if (!alreadyEmitted.contains("toString()") && !sealed(target, "toString")) { + w.println(" @Override"); + w.println(" public String toString() {"); + w.println(" Object $r = $runtime == null ? InterpRuntime.NOT_OVERRIDDEN"); + w.println(" : $runtime.dispatch($interp, \"toString\", " + + "\"()Ljava/lang/String;\", new Object[]{});"); + w.println(" if (" + MISS + ") {"); + w.println(" return super.toString();"); + w.println(" }"); + w.println(" return (String)$r;"); + w.println(" }"); + w.println(); + } + if (!alreadyEmitted.contains("hashCode()") && !sealed(target, "hashCode")) { + w.println(" @Override"); + w.println(" public int hashCode() {"); + w.println(" Object $r = $runtime == null ? InterpRuntime.NOT_OVERRIDDEN"); + w.println(" : $runtime.dispatch($interp, \"hashCode\", \"()I\", " + + "new Object[]{});"); + w.println(" if (" + MISS + ") {"); + w.println(" return super.hashCode();"); + w.println(" }"); + w.println(" return $r == null ? 0 : ((Number)$r).intValue();"); + w.println(" }"); + w.println(); + } + if (!alreadyEmitted.contains("equals(Ljava/lang/Object;)") + && !sealed(target, "equals")) { + w.println(" @Override"); + w.println(" public boolean equals(Object a0) {"); + w.println(" Object $r = $runtime == null ? InterpRuntime.NOT_OVERRIDDEN"); + w.println(" : $runtime.dispatch($interp, \"equals\", " + + "\"(Ljava/lang/Object;)Z\", new Object[]{a0});"); + w.println(" if (" + MISS + ") {"); + w.println(" return super.equals(a0);"); + w.println(" }"); + w.println(" return $r != null && ((Boolean)$r).booleanValue();"); + w.println(" }"); + w.println(); + } + } + + /** + * Whether some class in the chain declares this Object method final. + * + *

{@code Vec2.toString} and {@code BluetoothDevice.equals} are final, and + * a shim that redeclares them does not compile. Final on the device counts + * too, for the same reason it does anywhere else here.

+ */ + private static boolean sealed(Class target, String name) { + for (Class k = target; k != null && k != Object.class; k = k.getSuperclass()) { + for (Method m : declaredMethodsSorted(k)) { + if (!m.getName().equals(name) || m.getParameterTypes().length > 1) { + continue; + } + if (Modifier.isFinal(m.getModifiers()) || finalOnDevice(m)) { + return true; + } + } + } + return false; + } + + private static void emitOverride(PrintWriter w, Method m, Map> bindings) { + Class[] params = resolvedParams(m, bindings); + Class ret = resolved(m.getGenericReturnType(), m.getReturnType(), m, bindings); + StringBuilder sig = new StringBuilder(); + StringBuilder call = new StringBuilder(); + StringBuilder boxed = new StringBuilder(); + for (int i = 0; i < params.length; i++) { + if (i > 0) { + sig.append(", "); + call.append(", "); + boxed.append(", "); + } + sig.append(typeName(params[i])).append(" a").append(i); + call.append("a").append(i); + boxed.append("a").append(i); + } + + String visibility = Modifier.isPublic(m.getModifiers()) ? "public" : "protected"; + // An abstract method has no implementation to fall back to. The shim + // still has to declare it -- it is a concrete class -- but "not + // overridden" is then a program that would not have compiled, so it + // reports that rather than calling a super that does not exist. + boolean abstractMethod = Modifier.isAbstract(m.getModifiers()); + // @Override is a real check -- it is what catches a generic resolution + // that quietly produced a signature overriding nothing -- so it is kept + // wherever it can be. It is omitted for exactly the methods the device + // does not declare, because there the annotation would be a lie and + // javac would reject it. + if (declaredOnDevice(m)) { + w.println(" @Override"); + } + w.println(" " + visibility + " " + typeName(ret) + " " + m.getName() + + "(" + sig + ")" + throwsClause(m) + " {"); + // $runtime is null while the framework superclass constructor is still + // running: Java assigns a subclass's fields only after super() returns, + // and Form's constructor calls overridable methods. Deferring to super + // in that window is not a workaround -- the interpreted object genuinely + // has no state yet, so the base behaviour is the correct one. + emitDispatch(w, m, "$runtime == null ? InterpRuntime.NOT_OVERRIDDEN\n" + + " : $runtime.dispatch($interp, \"" + m.getName() + "\", \"" + + descriptorOf(params, ret) + "\", new Object[]{" + boxed + "})"); + if (abstractMethod) { + // See zero(): a callback for a program that has been stopped. + w.println(" if ($r == InterpRuntime.DETACHED) {"); + w.println(ret == Void.TYPE ? " return;" + : " return " + zero(ret) + ";"); + w.println(" }"); + } + w.println(" if (" + (abstractMethod ? "$r == InterpRuntime.NOT_OVERRIDDEN" : MISS) + + ") {"); + if (abstractMethod) { + w.println(" throw new AbstractMethodError(\"" + typeName(m.getDeclaringClass()) + + "." + m.getName() + "\");"); + w.println(" }"); + } else if (ret == Void.TYPE) { + w.println(" super." + m.getName() + "(" + call + ");"); + w.println(" return;"); + w.println(" }"); + } else { + w.println(" return super." + m.getName() + "(" + call + ");"); + w.println(" }"); + } + if (ret != Void.TYPE) { + w.println(" return " + unbox(ret, "$r") + ";"); + } + w.println(" }"); + w.println(); + // A super_ bridge is the only way interpreted code can reach super -- + // and only exists where there is a super to reach. + if (!abstractMethod) { + w.println(" public " + typeName(ret) + " super_" + m.getName() + "(" + sig + ")" + + throwsClause(m) + " {"); + if (ret == Void.TYPE) { + w.println(" super." + m.getName() + "(" + call + ");"); + } else { + w.println(" return super." + m.getName() + "(" + call + ");"); + } + w.println(" }"); + w.println(); + } + } + + /** + * The test a generated method uses for "the interpreter did not answer". + * + *

Two sentinels, one branch: NOT_OVERRIDDEN means the pushed class does + * not provide the method, DETACHED means the program that did has been + * stopped. Both are handled by doing what the framework class would do on + * its own, which for anything with a body is calling it.

+ */ + private static final String MISS = + "$r == InterpRuntime.NOT_OVERRIDDEN || $r == InterpRuntime.DETACHED"; + + /** + * The value a method returns when a callback arrives for a program that has + * been stopped, and there is no implementation to defer to. + * + *

A timer or a global listener still holds the old peer, and a late + * callback must not become an AbstractMethodError on the event thread: the + * program is gone, so the method quietly answers nothing.

+ */ + private static String zero(Class t) { + if (t == Boolean.TYPE) return "false"; + if (t == Byte.TYPE) return "(byte)0"; + if (t == Character.TYPE) return "(char)0"; + if (t == Short.TYPE) return "(short)0"; + if (t == Integer.TYPE) return "0"; + if (t == Long.TYPE) return "0L"; + if (t == Float.TYPE) return "0f"; + if (t == Double.TYPE) return "0d"; + return "null"; + } + + private static String unbox(Class t, String expr) { + if (t == Boolean.TYPE) return "$r == null ? false : ((Boolean)" + expr + ").booleanValue()"; + if (t == Byte.TYPE) return "$r == null ? (byte)0 : ((Number)" + expr + ").byteValue()"; + if (t == Character.TYPE) return "$r == null ? (char)0 : ((Character)" + expr + ").charValue()"; + if (t == Short.TYPE) return "$r == null ? (short)0 : ((Number)" + expr + ").shortValue()"; + if (t == Integer.TYPE) return "$r == null ? 0 : ((Number)" + expr + ").intValue()"; + if (t == Long.TYPE) return "$r == null ? 0L : ((Number)" + expr + ").longValue()"; + if (t == Float.TYPE) return "$r == null ? 0f : ((Number)" + expr + ").floatValue()"; + if (t == Double.TYPE) return "$r == null ? 0d : ((Number)" + expr + ").doubleValue()"; + return "(" + typeName(t) + ")" + expr; + } + + private static String typeName(Class c) { + if (c.isArray()) { + return typeName(c.getComponentType()) + "[]"; + } + return c.getName().replace('$', '.'); + } + + /** + * The {@code throws} clause a peer constructor has to repeat. + * + *

Its body calls {@code super(...)}, so every checked exception the + * framework constructor declares propagates. {@code URL(String)} throws + * {@code URISyntaxException}, which is why its shim would not compile.

+ */ + private static String ctorThrows(Constructor c) { + Class[] ex = c.getExceptionTypes(); + if (ex.length == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(" throws "); + for (int i = 0; i < ex.length; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(typeName(ex[i])); + } + return sb.toString(); + } + + /** + * The {@code throws} clause an override has to repeat. + * + *

Narrowing is legal, dropping a checked exception the body can still + * raise is not -- and the body here calls {@code super}.

+ */ + /** + * Emits the dispatch call, preserving the checked exceptions the method + * declares. + * + *

The interpreter cannot throw a checked exception through its own + * signature, so it wraps one in an InterpThrowable. The shim, on the other + * hand, declares exactly what the framework method declares -- so an + * interpreted implementation of {@code Row.getString()} that throws + * IOException should reach the caller's {@code catch (IOException)} rather + * than arriving as an unexpected runtime exception. Each declared type is + * unwrapped by an instanceof-guarded cast; anything else keeps travelling + * as it was.

+ */ + private static void emitDispatch(PrintWriter w, Method m, String call) { + Class[] declared = m.getExceptionTypes(); + java.util.List> checked = new java.util.ArrayList>(); + for (Class e : declared) { + if (!RuntimeException.class.isAssignableFrom(e) && !Error.class.isAssignableFrom(e)) { + checked.add(e); + } + } + w.println(" Object $r;"); + w.println(" try {"); + w.println(" $r = " + call + ";"); + w.println(" } catch (com.codename1.impl.interp.InterpThrowable $t) {"); + // hostThrowable, not getThrown: a pushed exception class arrives as an + // InterpObject whose peer is the host exception, and only the peer can + // match a catch clause. + w.println(" Throwable $thrown = $t.hostThrowable();"); + for (Class e : checked) { + w.println(" if ($thrown instanceof " + typeName(e) + ") {"); + w.println(" throw (" + typeName(e) + ") $thrown;"); + w.println(" }"); + } + // Unchecked ones need no throws clause to travel, and framework code + // around a callback catches them by their own type: a pushed + // `MyIllegalArgumentException extends IllegalArgumentException` has a + // peer that *is* an IllegalArgumentException, and letting the + // interpreter's carrier escape instead means that catch never runs. + w.println(" if ($thrown instanceof RuntimeException) {"); + w.println(" throw (RuntimeException) $thrown;"); + w.println(" }"); + w.println(" if ($thrown instanceof Error) {"); + w.println(" throw (Error) $thrown;"); + w.println(" }"); + w.println(" throw $t;"); + w.println(" }"); + } + + private static String throwsClause(Method m) { + Class[] ex = m.getExceptionTypes(); + if (ex.length == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(" throws "); + for (int i = 0; i < ex.length; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(typeName(ex[i])); + } + return sb.toString(); + } + + /** Parameter descriptor of an already-resolved signature. */ + private static String descriptorOfParams(Class[] params) { + StringBuilder sb = new StringBuilder("("); + for (Class p : params) { + sb.append(descriptorOf(p)); + } + return sb.append(')').toString(); + } + + /** Just the parameter part of the descriptor, for erasure-signature keys. */ + private static String paramDescriptorOf(Method m) { + StringBuilder sb = new StringBuilder("("); + for (Class p : m.getParameterTypes()) { + sb.append(descriptorOf(p)); + } + return sb.append(')').toString(); + } + + /** The JVM descriptor of a resolved signature. */ + private static String descriptorOf(Class[] params, Class ret) { + StringBuilder sb = new StringBuilder("("); + for (Class p : params) { + sb.append(descriptorOf(p)); + } + return sb.append(')').append(descriptorOf(ret)).toString(); + } + + /** + * Declared methods in a stable order: by name, then by descriptor. + * + *

{@code Class.getDeclaredMethods} makes no ordering promise and really + * does vary between runs of the same JVM. These shims are checked in, so an + * unstable order turns every regeneration into an 800,000-line diff that + * hides the change that actually matters. It also settles which of two + * methods that resolve to one signature is the one emitted, rather than + * leaving that to whatever order reflection happened to return.

+ */ + private static Method[] declaredMethodsSorted(Class c) { + Method[] all = c.getDeclaredMethods(); + java.util.Arrays.sort(all, new java.util.Comparator() { + public int compare(Method a, Method b) { + int byName = a.getName().compareTo(b.getName()); + return byName != 0 ? byName : descriptorOf(a).compareTo(descriptorOf(b)); + } + }); + return all; + } + + /** Declared constructors in a stable order, for the reason above. */ + private static Constructor[] declaredConstructorsSorted(Class c) { + Constructor[] all = c.getDeclaredConstructors(); + java.util.Arrays.sort(all, new java.util.Comparator>() { + public int compare(Constructor a, Constructor b) { + return descriptorOfParams(a.getParameterTypes()) + .compareTo(descriptorOfParams(b.getParameterTypes())); + } + }); + return all; + } + + private static String descriptorOf(Method m) { + StringBuilder sb = new StringBuilder("("); + for (Class p : m.getParameterTypes()) { + sb.append(descriptorOf(p)); + } + return sb.append(')').append(descriptorOf(m.getReturnType())).toString(); + } + + private static String descriptorOf(Class c) { + if (c == Void.TYPE) return "V"; + if (c == Boolean.TYPE) return "Z"; + if (c == Byte.TYPE) return "B"; + if (c == Character.TYPE) return "C"; + if (c == Short.TYPE) return "S"; + if (c == Integer.TYPE) return "I"; + if (c == Long.TYPE) return "J"; + if (c == Float.TYPE) return "F"; + if (c == Double.TYPE) return "D"; + if (c.isArray()) return c.getName().replace('.', '/'); + return "L" + c.getName().replace('.', '/') + ";"; + } + + /** A registry mapping a framework class to the shim that extends it. */ + /** + * A registry mapping a framework type to the shim that stands in for it. + * + *

A hash lookup to an index, then a switch -- not a chain of string + * comparisons. With the whole API in scope there are hundreds of entries, + * and every interpreted {@code new} and every host call through a peer goes + * through here.

+ */ + private static void writeRegistry(File dir, List shims, List> classes, + List ifaceShims, List> interfaces) + throws Exception { + PrintWriter w = new PrintWriter(new File(dir, "InterpShimRegistry.java"), "UTF-8"); + try { + header(w); + w.println("package com.codenameone.devruntime.gen;"); + w.println(); + w.println("import com.codename1.impl.interp.InterpObject;"); + w.println("import com.codename1.impl.interp.InterpRuntime;"); + w.println("import java.util.Hashtable;"); + w.println(); + w.println("/** Maps a framework type to the generated shim that stands in for it. */"); + w.println("public final class InterpShimRegistry {"); + w.println(" private static final Hashtable CLASS_IDS = new Hashtable();"); + w.println(" private static final Hashtable IFACE_IDS = new Hashtable();"); + w.println(" private static final Hashtable PEER_NAMES = new Hashtable();"); + w.println(); + w.println(" static {"); + for (int i = 0; i < classes.size(); i++) { + w.println(" CLASS_IDS.put(\"" + internal(classes.get(i)) + + "\", Integer.valueOf(" + i + "));"); + } + for (int i = 0; i < interfaces.size(); i++) { + w.println(" IFACE_IDS.put(\"" + internal(interfaces.get(i)) + + "\", Integer.valueOf(" + i + "));"); + } + w.println(" }"); + w.println(); + w.println(" private InterpShimRegistry() {"); + w.println(" }"); + w.println(); + w.println(" /** Whether a shim exists for the given JVM internal name. */"); + w.println(" public static boolean canExtend(String internalName) {"); + w.println(" return CLASS_IDS.containsKey(internalName);"); + w.println(" }"); + w.println(); + w.println(" /** Whether a shim exists implementing the given interface. */"); + w.println(" public static boolean canImplement(String internalName) {"); + w.println(" return IFACE_IDS.containsKey(internalName);"); + w.println(" }"); + w.println(); + w.println(" /**"); + w.println(" * Creates the shim for a framework class, choosing the constructor"); + w.println(" * the pushed class chained to."); + w.println(" */"); + w.println(" public static Object create(String internalName, InterpRuntime rt,"); + w.println(" InterpObject obj, String descriptor,"); + w.println(" Object[] args) throws Throwable {"); + w.println(" Integer id = (Integer)CLASS_IDS.get(internalName);"); + w.println(" if (id == null) {"); + w.println(" return null;"); + w.println(" }"); + w.println(" switch (id.intValue()) {"); + for (int i = 0; i < shims.size(); i++) { + w.println(" case " + i + ": return " + shims.get(i) + + ".create(rt, obj, descriptor, args);"); + } + w.println(" default: return null;"); + w.println(" }"); + w.println(" }"); + w.println(); + w.println(" /** Creates the shim implementing a framework interface. */"); + w.println(" public static Object createInterface(String internalName,"); + w.println(" InterpRuntime rt, InterpObject obj) {"); + w.println(" Integer id = (Integer)IFACE_IDS.get(internalName);"); + w.println(" if (id == null) {"); + w.println(" return null;"); + w.println(" }"); + w.println(" switch (id.intValue()) {"); + for (int i = 0; i < ifaceShims.size(); i++) { + w.println(" case " + i + ": return new " + ifaceShims.get(i) + + "(rt, obj);"); + } + w.println(" default: return null;"); + w.println(" }"); + w.println(" }"); + w.println(); + w.println(" /**"); + w.println(" * The JVM internal name of a shim instance."); + w.println(" *"); + w.println(" *

Registered by the shim itself rather than read from"); + w.println(" * getClass().getName(), which ParparVM derives from the mangled C"); + w.println(" * symbol -- where a package separator and an underscore are the"); + w.println(" * same character, so Interp_ui_Form comes back as Interp/ui/Form.

"); + w.println(" */"); + w.println(" public static String nameOf(Object peer) {"); + w.println(" return peer == null ? null"); + w.println(" : (String)PEER_NAMES.get(peer.getClass());"); + w.println(" }"); + w.println(); + w.println(" static {"); + for (int i = 0; i < shims.size(); i++) { + w.println(" PEER_NAMES.put(" + shims.get(i) + + ".class, \"com/codenameone/devruntime/gen/" + shims.get(i) + "\");"); + } + for (int i = 0; i < ifaceShims.size(); i++) { + w.println(" PEER_NAMES.put(" + ifaceShims.get(i) + + ".class, \"com/codenameone/devruntime/gen/" + ifaceShims.get(i) + "\");"); + } + w.println(" }"); + w.println("}"); + } finally { + w.close(); + } + } + + /** The JVM internal name of a class. */ + private static String internal(Class c) { + return c.getName().replace('.', '/'); + } + + private static void header(PrintWriter w) { + w.println("/*"); + w.println(" * Generated by GenerateInterpShims. Do not edit."); + w.println(" *"); + w.println(" * Lets interpreted code extend a framework class on a platform that cannot"); + w.println(" * define classes at run time. Regenerate after changing the curated list in"); + w.println(" * the generator."); + w.println(" */"); + } +} diff --git a/scripts/cn1-device-runtime/tools/unshimmable-by-contract.txt b/scripts/cn1-device-runtime/tools/unshimmable-by-contract.txt new file mode 100644 index 00000000000..f8bdcb56259 --- /dev/null +++ b/scripts/cn1-device-runtime/tools/unshimmable-by-contract.txt @@ -0,0 +1,18 @@ +# Types that cannot be subclassed by generated code for a reason no compiler +# reports. This file should stay one or two lines long. +# +# Anything javac can detect is NOT listed here -- a shim that fails to compile +# is a generator bug and the build fails so it gets fixed. Anything Java forbids +# structurally (a package-private abstract method, no reachable constructor) is +# detected by the generator, which names the blocking method when it skips. +# +# What is left is semantic contracts, which only a downstream gate sees. +# +# Format: one shim name per line, '#' comments ignored. + +# Simd.alloca* are stack-allocation intrinsics whose results must not escape the +# frame that allocated them. A shim's super_ bridge returns one to its caller, +# which the bytecode compliance gate rejects -- correctly, since escaping is +# precisely what the intrinsic forbids. Subclassing a bag of intrinsics is +# meaningless anyway. +Interp_util_Simd diff --git a/scripts/cn1-push.sh b/scripts/cn1-push.sh new file mode 100755 index 00000000000..dc3f6becbb7 --- /dev/null +++ b/scripts/cn1-push.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# +# Pushes a Java program to a running Codename One device runtime. +# +# scripts/cn1-push.sh [port] [--main ] +# +# Takes a single file or a whole source tree. A real application is a tree of +# packages whose entry point is a Lifecycle subclass rather than a main, and +# running one of those is the point of this runtime, so both shapes work. +# +# Compiles the sources, packages them as a .cn1ip bundle, and sends it to the +# listener on the device. The bundle carries the source as well as the code: +# the runtime refuses to execute anything whose source it cannot show, because +# that is the condition Apple attaches to running downloaded code at all +# (App Store Review Guideline 2.5.2). +# +# The listener binds loopback only. Reaching it means `adb reverse` on Android +# (which needs an authorised device) or the shared loopback of the iOS +# simulator. There is deliberately no discovery protocol: physical access to the +# device is the pairing. +# +# -XDstringConcat=inline is not optional -- see the note where it is used. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE="${1:?usage: cn1-push.sh [port] [--main ]}" +shift +PORT=18234 +MAIN_OVERRIDE="" +while [ $# -gt 0 ]; do + case "$1" in + --main) MAIN_OVERRIDE="$2"; shift 2 ;; + *) PORT="$1"; shift ;; + esac +done +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +JDK="${JAVA17_HOME:-$(/usr/libexec/java_home -v 17 2>/dev/null || true)}" +if [ -z "$JDK" ]; then + JDK="${JAVA_HOME:-}" +fi +if [ -z "$JDK" ]; then + echo "no JDK found; set JAVA17_HOME" >&2 + exit 1 +fi + +# `|| true` because a checkout without .m2-local is the normal case, and `ls` +# failing there would end the script under `set -e` before reaching the +# ~/.m2 fallback two lines down. +CORE_JAR="$(ls "$REPO_ROOT"/.m2-local/com/codenameone/codenameone-core/*/codenameone-core-*.jar 2>/dev/null | head -1 || true)" +if [ -z "$CORE_JAR" ]; then + CORE_JAR="$(ls "$HOME"/.m2/repository/com/codenameone/codenameone-core/*/codenameone-core-*.jar 2>/dev/null | head -1 || true)" +fi +PARPAR_JAR="$(ls "$REPO_ROOT"/.m2-local/com/codenameone/codenameone-parparvm/*/codenameone-parparvm-*.jar 2>/dev/null | grep -v -- '-sources\|-javadoc\|-bundle' | head -1 || true)" +if [ -z "$PARPAR_JAR" ]; then + echo "codenameone-parparvm not found in .m2-local; build it first:" >&2 + echo " mvn -pl parparvm install -f maven/pom.xml" >&2 + exit 1 +fi +# ASM classpath for the Pack.java compile and run. Any 9.x version is +# fine (BCT uses stable API), but the four artifacts have to be the same +# version -- ASM does not promise cross-minor compatibility between its +# own modules. Search .m2-local first, then ~/.m2, and pick the highest +# version present at which all four artifacts exist. Hard-coding 9.8 (as +# an earlier version of this script did) missed the 9.2 that the +# advertised `mvn -pl parparvm install` puts on disk, and the push then +# failed for missing ASM classes on a fresh checkout. +ASM_JARS="" +for asm_repo in "$REPO_ROOT/.m2-local/org/ow2/asm" "$HOME/.m2/repository/org/ow2/asm"; do + [ -d "$asm_repo/asm" ] || continue + for v in $(ls "$asm_repo/asm" 2>/dev/null | sort -V -r); do + if [ -f "$asm_repo/asm/$v/asm-$v.jar" ] \ + && [ -f "$asm_repo/asm-tree/$v/asm-tree-$v.jar" ] \ + && [ -f "$asm_repo/asm-commons/$v/asm-commons-$v.jar" ] \ + && [ -f "$asm_repo/asm-analysis/$v/asm-analysis-$v.jar" ]; then + ASM_JARS="$asm_repo/asm/$v/asm-$v.jar:$asm_repo/asm-tree/$v/asm-tree-$v.jar:$asm_repo/asm-commons/$v/asm-commons-$v.jar:$asm_repo/asm-analysis/$v/asm-analysis-$v.jar" + break 2 + fi + done +done +if [ -z "$ASM_JARS" ]; then + echo "no complete ASM install found (asm, asm-tree, asm-commons, asm-analysis at the same version)" >&2 + echo "build parparvm first: mvn -pl parparvm install -f maven/pom.xml" >&2 + exit 1 +fi + +echo "compiling $SOURCE" +mkdir -p "$WORK/classes" +if [ -d "$SOURCE" ]; then + SOURCE_ROOT="$SOURCE" + find "$SOURCE" -name '*.java' > "$WORK/sources.txt" + if [ ! -s "$WORK/sources.txt" ]; then + echo "no .java files under $SOURCE" >&2 + exit 1 + fi +else + # The file itself, not its directory: a directory would sweep in every + # other program sitting beside it. + SOURCE_ROOT="$SOURCE" + echo "$SOURCE" > "$WORK/sources.txt" +fi +# The device has no runtime invokedynamic: ParparVM desugars it at build time +# and a pushed bundle gets no such pass. From JDK 9 javac turns "a" + b into an +# indy against StringConcatFactory, so it has to be compiled the old way. +"$JDK/bin/javac" -g -nowarn -XDstringConcat=inline \ + -cp "$CORE_JAR" -d "$WORK/classes" @"$WORK/sources.txt" + +echo "building bundle" +cat > "$WORK/Pack.java" <<'JAVA' +import com.codename1.tools.translator.InterpBundleWriter; +import java.io.*; +import java.nio.file.*; +import java.util.*; + +public final class Pack { + public static void main(String[] a) throws Exception { + File classesDir = new File(a[0]); + String mainClass = a[1]; + File sourceRoot = new File(a[2]); + File out = new File(a[3]); + + InterpBundleWriter w = new InterpBundleWriter(); + List classes = new ArrayList(); + collect(classesDir, classes); + for (File f : classes) { + w.addClassFile(f); + } + // Every source in the tree, not just the entry point's: the runtime + // refuses to run a class whose source it cannot show, and a real + // application is many files. + if (sourceRoot.isDirectory()) { + w.addSourceTree(sourceRoot); + // Everything that is not source: theme.res, CSS, images. Keyed by + // path relative to the tree, which is how an application loads them. + w.addResourceTree(sourceRoot); + } else { + // Keyed by the declared package, exactly as addSourceTree does: + // the reader looks a class's source up as /, + // so a lone com.example.Main stored under "Main.java" is a bundle + // the device refuses as missing its own source. + String text = new String(Files.readAllBytes(sourceRoot.toPath()), "UTF-8"); + w.addSource(InterpBundleWriter.sourceKey( + InterpBundleWriter.packageOf(text), sourceRoot.getName()), text); + } + if (mainClass.length() == 0) { + mainClass = findEntryPoint(classesDir, classes); + System.out.println("entry point " + mainClass.replace('/', '.')); + } + // Dots to slashes: the documented override is `--main com.example.Other` + // and every name in the bundle is an internal name, so leaving it dotted + // builds a bundle whose main class the device cannot find. + w.setMainClass(mainClass.replace('.', '/')); + OutputStream os = new FileOutputStream(out); + try { + w.write(os); + } finally { + os.close(); + } + System.out.println("bundle " + out.length() + " bytes, " + classes.size() + " classes"); + } + + /// The class to enter: a main(String[]) if there is one, otherwise a + /// Lifecycle subclass, which is what a real application has. + private static String findEntryPoint(File root, List classes) throws Exception { + Map supers = new HashMap(); + Set abstracts = new HashSet(); + List mains = new ArrayList(); + for (File f : classes) { + org.objectweb.asm.tree.ClassNode cn = new org.objectweb.asm.tree.ClassNode(); + new org.objectweb.asm.ClassReader(Files.readAllBytes(f.toPath())) + .accept(cn, org.objectweb.asm.ClassReader.SKIP_CODE); + for (Object mo : cn.methods) { + org.objectweb.asm.tree.MethodNode m = (org.objectweb.asm.tree.MethodNode) mo; + // Java's entry-point rule is exactly `public static void + // main(String[])`. A package-private or private helper of the + // same signature is not an entry point, and picking one up + // here preferred an inaccessible method over a valid Lifecycle + // subclass -- matches the DevicePush filter for the same + // reason. + if ("main".equals(m.name) && "([Ljava/lang/String;)V".equals(m.desc) + && (m.access & org.objectweb.asm.Opcodes.ACC_STATIC) != 0 + && (m.access & org.objectweb.asm.Opcodes.ACC_PUBLIC) != 0) { + mains.add(cn.name); + } + } + supers.put(cn.name, cn.superName); + if ((cn.access & (org.objectweb.asm.Opcodes.ACC_ABSTRACT + | org.objectweb.asm.Opcodes.ACC_INTERFACE)) != 0) { + abstracts.add(cn.name); + } + } + if (!mains.isEmpty()) { + // Sorted: listFiles() has no defined order, so a tree with a second + // main would otherwise push one program today and the other + // tomorrow from the same sources. As DevicePush does. + Collections.sort(mains); + if (mains.size() > 1) { + System.out.println("more than one main(String[]): " + mains + + " -- entering " + mains.get(0)); + } + return mains.get(0); + } + // Transitively, skipping the abstract ones and taking the deepest, as + // DevicePush does: a project whose app extends its own BaseApp extends + // Lifecycle has two descendants, and entering the wrong one runs a class + // that was never meant to be instantiated. + String lifecycle = null; + for (Map.Entry e : supers.entrySet()) { + if (abstracts.contains(e.getKey()) || !descendsFromLifecycle(e.getKey(), supers)) continue; + if (lifecycle == null) { lifecycle = e.getKey(); continue; } + // Deepest wins, and a genuine tie is broken by name: entries arrive + // in hash order, and an entry point that changes between two + // identical pushes is worse than either answer. + int mine = depthOf(e.getKey(), supers); + int best = depthOf(lifecycle, supers); + if (mine > best || (mine == best && e.getKey().compareTo(lifecycle) < 0)) { + lifecycle = e.getKey(); + } + } + if (lifecycle != null) { + return lifecycle; + } + throw new IllegalStateException( + "no entry point: expected a main(String[]) or a Lifecycle subclass"); + } + + // Bounded by what has been seen rather than by a count, as DevicePush is: + // a superclass chain is acyclic, and a count refused a hierarchy for being + // deep -- reporting no entry point for a project that has one. + private static boolean descendsFromLifecycle(String name, Map supers) { + Set seen = new HashSet(); + String parent = supers.get(name); + while (parent != null && seen.add(parent)) { + if ("com/codename1/system/Lifecycle".equals(parent)) return true; + parent = supers.get(parent); + } + return false; + } + + private static int depthOf(String name, Map supers) { + Set seen = new HashSet(); + int depth = 0; + String at = supers.get(name); + while (at != null && seen.add(at)) { depth++; at = supers.get(at); } + return depth; + } + + private static void collect(File dir, List out) { + File[] kids = dir.listFiles(); + if (kids == null) return; + for (File f : kids) { + if (f.isDirectory()) collect(f, out); + else if (f.getName().endsWith(".class")) out.add(f); + } + } +} +JAVA +"$JDK/bin/javac" -nowarn -cp "$PARPAR_JAR:$ASM_JARS" -d "$WORK" "$WORK/Pack.java" +"$JDK/bin/java" -cp "$WORK:$PARPAR_JAR:$ASM_JARS" Pack \ + "$WORK/classes" "$MAIN_OVERRIDE" "$SOURCE_ROOT" "$WORK/program.cn1ip" + +echo "awaiting the device on 127.0.0.1:$PORT" +cat > "$WORK/Push.java" <<'JAVA' +import java.io.*; +import java.net.*; +import java.nio.file.*; + +/** + * Sends a bundle to a device runtime over loopback. + * + * Unauthenticated on purpose, and only over loopback: reaching this listener + * means `adb reverse` on a USB-authorised device or the iOS simulator's own + * loopback, so possession of the device is the authentication, and going + * through a pairing dialog for every push during framework work would be pure + * friction. The device refuses this protocol on any connection that did not + * arrive over loopback. + * + * A push to a phone over Wi-Fi is a different thing and belongs to a different + * tool: DevicePush pairs, derives a shared secret and answers a challenge on + * every connection. This helper deliberately does not, rather than carrying a + * third copy of the crypto that would drift from the other two. + */ +public final class Push { + private static final int MAGIC = 0x434E3150; // "CN1P" + private static final int V1 = 1; + + public static void main(String[] a) throws Exception { + byte[] payload = Files.readAllBytes(Paths.get(a[0])); + send(Integer.parseInt(a[1]), payload); + } + + /** + * Waits for the device to dial in, then sends the frame. + * + * The desktop listens and the device connects out, not the other way round. + * A socket the device listens on is unreachable from the host inside the + * iOS simulator -- the app binds it and reports success while every + * connection attempt is refused -- and a phone on a real network cannot + * accept inbound connections at all. The device retries every couple of + * seconds, so starting this first is all the synchronisation needed. + */ + private static void send(int port, byte[] payload) throws Exception { + ServerSocket server = new ServerSocket(); + server.setReuseAddress(true); + server.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port)); + server.setSoTimeout(120000); + Socket s; + try { + System.out.println("waiting for the device to connect on 127.0.0.1:" + port); + s = server.accept(); + } catch (java.net.SocketTimeoutException e) { + System.out.println("FAILED: the device never connected. Is the app running, " + + "and is `adb reverse tcp:" + port + " tcp:" + port + "` set on Android?"); + System.exit(1); + return; + } finally { + server.close(); + } + try { + s.setSoTimeout(120000); + DataOutputStream out = new DataOutputStream(s.getOutputStream()); + out.writeInt(MAGIC); + out.writeInt(V1); + out.writeInt(payload.length); + out.write(payload); + out.flush(); + if (!report(s)) { + System.exit(1); + } + } finally { + s.close(); + } + } + + private static boolean report(Socket s) throws IOException { + DataInputStream in = new DataInputStream(s.getInputStream()); + int ok = in.readByte(); + String message = in.readUTF(); + System.out.println((ok == 1 ? "OK: " : "FAILED: ") + message); + return ok == 1; + } +} +JAVA +"$JDK/bin/javac" -nowarn -d "$WORK" "$WORK/Push.java" +"$JDK/bin/java" -cp "$WORK" Push "$WORK/program.cn1ip" "$PORT" diff --git a/scripts/devruntime-ide-project/README.md b/scripts/devruntime-ide-project/README.md new file mode 100644 index 00000000000..b85dd8c37d1 --- /dev/null +++ b/scripts/devruntime-ide-project/README.md @@ -0,0 +1,102 @@ +# Run a Codename One app on your phone, from your IDE + +Open this project, edit `MyApp.java`, run the push. The app appears on a phone +that is already holding the runtime, in seconds. Nothing is compiled for the +phone and nothing is installed: your classes are bundled and interpreted by the +runtime app. + +## 1. Install the runtime on the phone + +``` +~/cn1-device-runtime.apk +``` + +Copy it to the phone and open it; Android will ask you to allow installs from +this source. It is debug-signed, which is what makes it installable without a Play listing +and also what makes it unfit for anything but development. + +It is 11MB and contains no native libraries at all, so it runs on any phone +regardless of architecture. It is built from `scripts/cn1-device-runtime/`, +whose only application code is the runtime itself. + +## 2. Nothing to configure + +Put the phone on the same network as your machine and open the app. There is no +address to type and no port to set. + +The phone looks for you: the computer it last spoke to, then loopback (which is +a USB session, where `adb reverse tcp:18234 tcp:18234` maps your machine onto +the phone's own address), then every address on its own subnet. The push tool +answers with a frame that identifies itself, so the search and the push are the +same connection. + +There is no UDP in the Codename One API and therefore no broadcast to announce +with, which is why this is a sweep rather than the discovery protocol you might +expect. It costs one TCP attempt per address, in batches, and the address that +answers is remembered -- so it happens once, not every couple of seconds. + +**Look for my computer** on the runtime screen forgets that address, which is +what you want after moving to a different network. + +## 3. Push + +```bash +mvn -Ppush package # USB +mvn -Ppush-lan package # Wi-Fi +``` + +In an IDE, add those as run configurations, or right-click the profile in the +Maven panel. Every run recompiles, rebundles and pushes, so the loop is: edit, +run, look at the phone. + +Over Wi-Fi the first push pairs. The terminal shows a six-digit code and the +phone asks for it. The phone stores your computer only if the code matches, and +every later connection is still approved on the phone unless you choose +*Always*. **Forget paired computers** on the runtime screen undoes it. + +Pairing is not optional off loopback, and the runtime enforces that rather than +trusting the caller: over USB the connection can only come from a machine you +have authorised with a cable, while on a network anything can answer, and the +bundle carries your program's whole source. + +## What you can write + +Ordinary Codename One. The entry point is a `Lifecycle` subclass, as in any +application -- `MyApp` is one -- and a `main(String[])` works too if you would +rather have one. Lambdas, method references, enums, inner classes, generics, +collections, threads, `synchronized`, networking, `Storage`, `Preferences`, and +subclassing framework classes such as `Form` all work. + +Put `theme.res`, CSS and images under `src/main/resources`; they travel with the +bundle and the framework loads them the usual way, so your app wears its own +design rather than the runtime host's. + +## What you cannot + +- **Native code.** A `cn1lib`'s Java half is interpreted like the rest of your + program; its native half reports `isSupported() == false` rather than failing. +- **The app's identity.** Bundle id, icon, permissions, push certificates and + URL schemes belong to the runtime app and are fixed at its build. +- **Performance conclusions.** Your code is interpreted and the runtime app is + built with the optimizer off. Tight loops and per-pixel work in `paint` say + nothing about a real build. +- **A program that never yields.** The event thread has a budget; a runaway loop + is stopped and reported rather than freezing the phone. + +## When something fails + +The phone reports back through the terminal, with your file names and line +numbers rather than the interpreter's: + +``` +FAILED: java.lang.IllegalStateException: from depth 0 + at TraceProbe.deep(TraceProbe.java:3) + at TraceProbe.main(TraceProbe.java:5) +``` + +If a framework method threw, the report names it: `(thrown by +java.util.List.add(Ljava/lang/Object;)Z)`. + +*"the device never connected"* means the phone is not dialling this machine: +either the app is not running, or `adb reverse` is not set (USB), or the address +under **Desktop** is not this computer (Wi-Fi). diff --git a/scripts/devruntime-ide-project/pom.xml b/scripts/devruntime-ide-project/pom.xml new file mode 100644 index 00000000000..9f22b54471f --- /dev/null +++ b/scripts/devruntime-ide-project/pom.xml @@ -0,0 +1,199 @@ + + + + 4.0.0 + + com.example + myapp-devruntime + 1.0-SNAPSHOT + + + 8.0-SNAPSHOT + 8 + 8 + UTF-8 + + 18234 + + + + + + cn1-local-build + file://${project.basedir}/../../.m2-local + true + truealways + + + + + + + com.codenameone + codenameone-core + ${cn1.version} + provided + + + + com.codenameone + codenameone-parparvm + ${cn1.version} + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + + + -XDstringConcat=inline + + + + + + + + + + push + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + package + java + + + + com.codename1.tools.translator.DevicePush + true + false + compile + + --classes + ${project.build.outputDirectory} + + --source + ${project.basedir}/src/main/java + --source + ${project.basedir}/src/main/kotlin + --source + ${project.build.directory}/generated-sources/annotations + --source + ${project.build.directory}/generated-sources/kapt/main + --port + ${devruntime.port} + + + + + + + + + + push-lan + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + package + java + + + + com.codename1.tools.translator.DevicePush + true + false + compile + + --classes + ${project.build.outputDirectory} + + --source + ${project.basedir}/src/main/java + --source + ${project.basedir}/src/main/kotlin + --source + ${project.build.directory}/generated-sources/annotations + --source + ${project.build.directory}/generated-sources/kapt/main + --port + ${devruntime.port} + --lan + + + + + + + + diff --git a/scripts/devruntime-ide-project/src/main/java/com/example/myapp/MyApp.java b/scripts/devruntime-ide-project/src/main/java/com/example/myapp/MyApp.java new file mode 100644 index 00000000000..fd4f5e199d7 --- /dev/null +++ b/scripts/devruntime-ide-project/src/main/java/com/example/myapp/MyApp.java @@ -0,0 +1,140 @@ +/* + * 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.example.myapp; + +import com.codename1.system.Lifecycle; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Slider; +import com.codename1.ui.TextField; +import com.codename1.ui.Toolbar; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.util.UITimer; + +import java.util.Date; + +/** + * Your app. Edit it, run the push, watch it change on the phone. + * + *

It ships its own theme in {@code src/main/resources/theme.res}, which is + * why it looks nothing like the runtime that hosts it -- the theme travels with + * the bundle and the framework loads it the usual way.

+ */ +public class MyApp extends Lifecycle { + private int taps; + + /// A label that stays readable on the dark background set above. + private static Label whiteText(String text) { + Label l = new Label(text); + l.getAllStyles().setFgColor(0xffffff); + return l; + } + + @Override + public void start() { + // No "already showing" guard. Every push runs start() again, and a + // guard that sees the previous push's form still on screen makes the + // next one a silent no-op -- the form keeps ticking and it looks like + // the edit never arrived. + Form f = new Form("Pushed App", BoxLayout.y()); + Toolbar.setGlobalToolbar(true); + + // Colours set in code rather than left to a theme. On Android the + // platform's own Material palette paints standard widgets, so two apps + // with different theme.res files can still look identical -- which is + // exactly the doubt worth removing here. Nothing below can come from + // anywhere but this file. + + + // Live, so it is obvious this is executing rather than a screenshot. + final Label clock = new Label(""); + clock.getAllStyles().setAlignment(Label.CENTER); + UITimer.timer(1000, true, f, new Runnable() { + public void run() { + clock.setText(new Date().toString()); + clock.getParent().revalidate(); + } + }); + + final Label counter = whiteText("tapped 0 times"); + Button tap = new Button("Tap me"); + tap.addActionListener(e -> counter.setText("tapped " + (++taps) + " times")); + + final Label echo = whiteText("type above and it echoes here"); + TextField field = new TextField("", "type something", 20, TextField.ANY); + field.addDataChangeListener((type, index) -> + echo.setText(field.getText().length() == 0 + ? "type above and it echoes here" + : field.getText().toUpperCase())); + + final Label slid = whiteText("slider: 0"); + Slider s = new Slider(); + s.setEditable(true); + s.addDataChangedListener((type, index) -> slid.setText("slider: " + s.getProgress())); + + // A grid of fixed rows squashes its cells when the form runs out of + // room; one label per line simply wraps. + Container facts = new Container(BoxLayout.y()); + Display d = Display.getInstance(); + facts.add(whiteText("Platform: " + d.getPlatformName())); + facts.add(whiteText("Screen: " + d.getDisplayWidth() + "x" + d.getDisplayHeight())); + facts.add(whiteText("Density: " + d.getDeviceDensity())); + facts.add(whiteText("This class was interpreted, not compiled in.")); + + Label banner = new Label("This is your code, running on the device."); + f.add(banner); + f.add(clock); + f.add(tap).add(counter); + f.add(field).add(echo); + f.add(s).add(slid); + f.add(facts); + f.getContentPane().setScrollableY(true); + f.show(); + // After show(), not before: the theme is applied to a component when it + // is laid out, which overwrites anything set at construction. This is + // also the answer to "is that really my code" -- these colours exist + // nowhere but this file. + f.getContentPane().getAllStyles().setBgColor(0x102027); + f.getContentPane().getAllStyles().setBgTransparency(255); + f.getToolbar().getAllStyles().setBgColor(0xff6d00); + f.getToolbar().getAllStyles().setBgTransparency(255); + f.getTitleComponent().getAllStyles().setFgColor(0xffffff); + tap.getAllStyles().setBgColor(0xff6d00); + tap.getAllStyles().setBgTransparency(255); + tap.getAllStyles().setFgColor(0xffffff); + banner.getAllStyles().setFgColor(0xffffff); + clock.getAllStyles().setFgColor(0x80ff80); + counter.getAllStyles().setFgColor(0xffffff); + echo.getAllStyles().setFgColor(0xffffff); + slid.getAllStyles().setFgColor(0xffffff); + for (int i = 0; i < facts.getComponentCount(); i++) { + facts.getComponentAt(i).getAllStyles().setFgColor(0xffffff); + } + f.revalidate(); + } +} diff --git a/scripts/devruntime-ide-project/src/main/resources/theme.res b/scripts/devruntime-ide-project/src/main/resources/theme.res new file mode 100644 index 00000000000..e5fc59f3de3 Binary files /dev/null and b/scripts/devruntime-ide-project/src/main/resources/theme.res differ diff --git a/scripts/devruntime-probes/AbstractProbe.java b/scripts/devruntime-probes/AbstractProbe.java new file mode 100644 index 00000000000..f9e2d477ca6 --- /dev/null +++ b/scripts/devruntime-probes/AbstractProbe.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import java.util.*; +public abstract class AbstractProbe { + abstract String kind(); + String describe() { return "a " + kind(); } + static class Dog extends AbstractProbe { String kind() { return "dog"; } } + static class Cat extends AbstractProbe { + String kind() { return "cat"; } + String describe() { return "definitely " + super.describe(); } + } + interface Shape { T area(); } + static class Sq implements Shape { + public Integer area() { return 4; } + } + public static void main(String[] a) { + java.util.List l = new ArrayList(); + l.add(new Dog()); l.add(new Cat()); + StringBuilder r = new StringBuilder(); + for (AbstractProbe p : l) r.append(p.describe()).append("; "); + Shape s = new Sq(); + r.append("area=").append(s.area()); + System.out.println("PROBE AbstractProbe: " + r); + new Form("Abstract").show(); + } +} diff --git a/scripts/devruntime-probes/CollectionProbe.java b/scripts/devruntime-probes/CollectionProbe.java new file mode 100644 index 00000000000..78d658eb514 --- /dev/null +++ b/scripts/devruntime-probes/CollectionProbe.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import java.util.*; + +/** + * Collections reached through interface-typed references. + * + * The declared type at the call site is java.util.List, not ArrayList, so a + * linker that resolves against the declared owner rather than the receiver runs + * AbstractList's method -- which throws. Every real application does this in + * its first ten lines, and it is invisible to a probe that declares the + * concrete type. + */ +public class CollectionProbe { + public static void main(String[] a) { + java.util.List l = new ArrayList(); + l.add("b"); l.add("a"); l.add("c"); + Collections.sort(l); + Map m = new HashMap(); + m.put("k", 7); + Set s = new HashSet(); + s.add("x"); s.add("x"); + Iterator it = l.iterator(); + StringBuilder walked = new StringBuilder(); + while (it.hasNext()) { walked.append(it.next()); } + Collection c = l; + System.out.println("PROBE CollectionProbe: list=" + l + " size=" + l.size() + + " map=" + m.get("k") + " set=" + s.size() + " walked=" + walked + + " contains=" + c.contains("a") + " removed=" + l.remove("a") + " now=" + l); + new Form("Collection").show(); + } +} diff --git a/scripts/devruntime-probes/EnumProbe.java b/scripts/devruntime-probes/EnumProbe.java new file mode 100644 index 00000000000..c1d256d2f6b --- /dev/null +++ b/scripts/devruntime-probes/EnumProbe.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +public class EnumProbe { + enum Color { RED, GREEN, BLUE; + String low() { return name().toLowerCase(); } } + public static void main(String[] a) { + StringBuilder r = new StringBuilder(); + for (Color c : Color.values()) r.append(c).append(":").append(c.ordinal()).append(" "); + Color c = Color.GREEN; + switch (c) { case GREEN: r.append("switch=green"); break; default: r.append("switch=?"); } + r.append(" valueOf=").append(Color.valueOf("BLUE")).append(" low=").append(c.low()); + System.out.println("PROBE EnumProbe: " + r); + new Form("Enum").show(); + } +} diff --git a/scripts/devruntime-probes/HostFieldProbe.java b/scripts/devruntime-probes/HostFieldProbe.java new file mode 100644 index 00000000000..a96a1d7707f --- /dev/null +++ b/scripts/devruntime-probes/HostFieldProbe.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ai.ChatMessage; +import com.codename1.ai.SafetyFilter; +import com.codename1.ui.*; + +import java.util.List; + +/** + * A field a pushed class inherits from its host superclass. + * + * javac records the *pushed* class as the owner -- `HostFieldProbe.focusScrolling`, + * not `Form.focusScrolling` -- and the installed app has never heard of that + * name, so the read reached the linker with an owner it could not resolve. + */ +public class HostFieldProbe extends Form { + String readAndWrite() { + boolean before = focusScrolling; + focusScrolling = !before; + boolean after = focusScrolling; + return "read=" + before + " wrote=" + after; + } + + /** A pushed interface over a host one, so the host static is two hops away. */ + interface Guarded extends SafetyFilter { + } + + /** + * The owner javac records for `ALLOW_ALL` here is `Allowing` -- a class the + * app does not have -- and the host interface that declares it is reached + * only through `Guarded`, which the app does not have either. Following + * just the superclass chain answered java/lang/Object and reported a + * NoSuchFieldError for a constant that plainly exists. + */ + static final class Allowing implements Guarded { + public String check(List messages) { + return null; + } + + String inherited() { + return ALLOW_ALL == null ? "null" : "found " + (ALLOW_ALL.check(null) == null); + } + } + + public static void main(String[] a) { + String result; + try { + result = new HostFieldProbe().readAndWrite(); + } catch (Throwable t) { + result = "threw " + t.getClass().getName() + ": " + t.getMessage(); + } + System.out.println("PROBE HostFieldProbe: " + result); + + String throughInterface; + try { + throughInterface = new Allowing().inherited(); + } catch (Throwable t) { + throughInterface = "threw " + t.getClass().getName() + ": " + t.getMessage(); + } + System.out.println("PROBE HostFieldProbe iface: " + throughInterface); + new Form("HostFields").show(); + } +} diff --git a/scripts/devruntime-probes/InnerProbe.java b/scripts/devruntime-probes/InnerProbe.java new file mode 100644 index 00000000000..c929fa77ec7 --- /dev/null +++ b/scripts/devruntime-probes/InnerProbe.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +public class InnerProbe { + private int field = 5; + class Inner { int get() { return field * 2; } } + static class Nested { int get() { return 3; } } + interface Cb { int call(); } + Cb closure(final int base) { return new Cb() { public int call() { return base + field; } }; } + public static void main(String[] a) { + InnerProbe p = new InnerProbe(); + InnerProbe.Inner in = p.new Inner(); + System.out.println("PROBE InnerProbe: inner=" + in.get() + + " nested=" + new Nested().get() + " closure=" + p.closure(10).call()); + new Form("Inner").show(); + } +} diff --git a/scripts/devruntime-probes/LambdaProbe.java b/scripts/devruntime-probes/LambdaProbe.java new file mode 100644 index 00000000000..36491d415e2 --- /dev/null +++ b/scripts/devruntime-probes/LambdaProbe.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import com.codename1.ui.events.*; +import java.util.*; +public class LambdaProbe { + interface Op { int apply(int v); default Op twice() { return v -> apply(apply(v)); } } + public static void main(String[] a) { + Op inc = v -> v + 1; + Runnable r = () -> System.out.println("PROBE LambdaProbe: lambda-runnable ran"); + r.run(); + java.util.List l = new ArrayList(); + l.add("b"); l.add("a"); + Collections.sort(l, (p, q) -> p.compareTo(q)); + ActionListener al = evt -> System.out.println("PROBE LambdaProbe: listener fired"); + al.actionPerformed(new ActionEvent(null)); + System.out.println("PROBE LambdaProbe: inc=" + inc.apply(1) + + " twice=" + inc.twice().apply(1) + " sorted=" + l); + new Form("Lambda").show(); + } +} diff --git a/scripts/devruntime-probes/LangProbe.java b/scripts/devruntime-probes/LangProbe.java new file mode 100644 index 00000000000..13d6338ad24 --- /dev/null +++ b/scripts/devruntime-probes/LangProbe.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import java.util.*; +public class LangProbe { + static String sw(String s) { switch (s) { case "a": return "A"; case "b": return "B"; default: return "?"; } } + static int sum(int... xs) { int t = 0; for (int x : xs) t += x; return t; } + public static void main(String[] a) { + StringBuilder r = new StringBuilder(); + java.util.List l = new ArrayList(); + l.add("x"); l.add("y"); + Map m = new HashMap(); + m.put("k", 7); + int[][] grid = new int[2][3]; + grid[1][2] = 9; + r.append("list=").append(l).append(" map=").append(m.get("k")); + r.append(" grid=").append(grid[1][2]).append(" sw=").append(sw("b")); + r.append(" var=").append(sum(1,2,3)); + Object o = l; + r.append(" inst=").append(o instanceof java.util.List); + long big = 1L << 40; double d = 3.5; + r.append(" long=").append(big).append(" d=").append(d); + char c = "hello".charAt(1); + r.append(" ch=").append(c).append(" sub=").append("hello".substring(1,3)); + Collections.sort(l, new Comparator() { + public int compare(String p, String q) { return q.compareTo(p); } + }); + r.append(" sorted=").append(l); + System.out.println("PROBE LangProbe: " + r); + new Form("Lang").show(); + } +} diff --git a/scripts/devruntime-probes/LateCallbackProbe.java b/scripts/devruntime-probes/LateCallbackProbe.java new file mode 100644 index 00000000000..5569dd47e95 --- /dev/null +++ b/scripts/devruntime-probes/LateCallbackProbe.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; + +/** + * A callback that arrives well after the program started, doing enough work to + * reach a watchdog checkpoint. + * + * This is what every button press in a real application looks like: the program + * ran once, and the framework calls back into it minutes later. The EDT budget + * must apply to that one callback, not to the age of the session. + */ +public class LateCallbackProbe { + public static void main(String[] a) { + new Form("LateCallback").show(); + System.out.println("PROBE LateCallbackProbe: started, callback scheduled"); + new Thread(new Runnable() { + public void run() { + try { Thread.sleep(5000); } catch (InterruptedException e) { } + Display.getInstance().callSerially(new Runnable() { + public void run() { + long n = 0; + for (int i = 0; i < 3000000; i++) { n += i; } + System.out.println("PROBE LateCallbackProbe: late callback ran, n=" + n); + } + }); + } + }).start(); + } +} diff --git a/scripts/devruntime-probes/LifecycleStopProbe.java b/scripts/devruntime-probes/LifecycleStopProbe.java new file mode 100644 index 00000000000..d3b8eb3b462 --- /dev/null +++ b/scripts/devruntime-probes/LifecycleStopProbe.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.system.Lifecycle; +import com.codename1.ui.*; + +/** + * A pushed application, entered the way the platform enters one. + * + * There is no main here: a real Codename One application is a Lifecycle, and + * the runtime constructs it, calls init and start, and -- when the program is + * stopped or replaced -- calls stop. That last one is what a program releasing + * a recorder, a socket or a sensor depends on, and a runtime that only detached + * its callbacks left those running against the next program. + */ +public class LifecycleStopProbe extends Lifecycle { + @Override + public void start() { + Form f = new Form("Lifecycle"); + f.add(new Label("Press Stop, then read the log")); + f.show(); + System.out.println("PROBE LifecycleStopProbe: start() ran"); + } + + @Override + public void stop() { + // Printed rather than shown: by the time this runs the runtime is + // putting its own screen back, and a dialog here would fight it. + System.out.println("PROBE LifecycleStopProbe: stop() ran"); + } +} diff --git a/scripts/devruntime-probes/LoopProbe.java b/scripts/devruntime-probes/LoopProbe.java new file mode 100644 index 00000000000..d1cf20f2bde --- /dev/null +++ b/scripts/devruntime-probes/LoopProbe.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +public class LoopProbe { + public static void main(String[] a) { + System.out.println("PROBE LoopProbe: entering an infinite loop on purpose"); + long n = 0; + while (true) { n++; } + } +} diff --git a/scripts/devruntime-probes/MockProbe.java b/scripts/devruntime-probes/MockProbe.java new file mode 100644 index 00000000000..addd3ce7c3b --- /dev/null +++ b/scripts/devruntime-probes/MockProbe.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import com.codename1.payment.*; +import com.codename1.social.*; + +/** + * The subsystems the runtime mocks: a purchase and a social login. + * + * Written against the ordinary APIs, exactly as an application would, because + * the point of the mocks is that application code needs no knowledge of them. + */ +public class MockProbe { + public static void main(String[] a) { + String purchaseResult; + try { + Purchase p = Purchase.getInAppPurchase(); + if (p == null) { + purchaseResult = "getInAppPurchase returned null"; + } else { + Product[] products = p.getProducts(new String[]{"com.example.pro"}); + p.purchase("com.example.pro"); + purchaseResult = "managed=" + p.isManagedPaymentSupported() + + " listing=" + p.isItemListingSupported() + + " price=" + (products.length > 0 ? products[0].getLocalizedPrice() : "none") + + " owned=" + p.wasPurchased("com.example.pro"); + p.refund("com.example.pro"); + purchaseResult = purchaseResult + " afterRefund=" + p.wasPurchased("com.example.pro"); + } + } catch (Throwable t) { + purchaseResult = "threw " + t.getClass().getName() + ": " + t.getMessage(); + } + System.out.println("PROBE MockProbe purchase: " + purchaseResult); + + String loginResult; + try { + FacebookConnect fb = FacebookConnect.getInstance(); + loginResult = "instance=" + fb.getClass().getName() + + " native=" + fb.isNativeLoginSupported(); + fb.doLogin(); + } catch (Throwable t) { + loginResult = "threw " + t.getClass().getName() + ": " + t.getMessage(); + } + System.out.println("PROBE MockProbe login: " + loginResult); + + new Form("Mocks").show(); + } +} diff --git a/scripts/devruntime-probes/MockResetProbe.java b/scripts/devruntime-probes/MockResetProbe.java new file mode 100644 index 00000000000..e821e7f9af4 --- /dev/null +++ b/scripts/devruntime-probes/MockResetProbe.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import com.codename1.social.*; + +/** + * Whether a second pushed program inherits the first one's login. + * + * A provider is a framework singleton, so the mock survives the program that + * used it. Push MockProbe (which logs in) and then this: a fresh program must + * start logged out. + */ +public class MockResetProbe { + public static void main(String[] a) { + FacebookConnect fb = FacebookConnect.getInstance(); + System.out.println("PROBE MockResetProbe: instance=" + fb.getClass().getName()); + System.out.println("PROBE MockResetProbe: token=" + + (fb.getAccessToken() == null ? "none" : "carried over")); + System.out.println("PROBE MockResetProbe: loggedIn=" + fb.isUserLoggedIn()); + new Form("Reset").show(); + } +} diff --git a/scripts/devruntime-probes/NativeProbe.java b/scripts/devruntime-probes/NativeProbe.java new file mode 100644 index 00000000000..d9622de1dee --- /dev/null +++ b/scripts/devruntime-probes/NativeProbe.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import com.codename1.system.*; +public class NativeProbe { + public interface MyNative extends NativeInterface { String hello(); } + public static void main(String[] a) { + String s; + try { + MyNative n = (MyNative)NativeLookup.create(MyNative.class); + s = (n == null) ? "create returned null" : ("isSupported=" + n.isSupported()); + } catch (Throwable t) { + s = "threw " + t.getClass().getName() + ": " + t.getMessage(); + } + System.out.println("PROBE NativeProbe: " + s); + new Form("Native").show(); + } +} diff --git a/scripts/devruntime-probes/NavProbe.java b/scripts/devruntime-probes/NavProbe.java new file mode 100644 index 00000000000..2f0c9b0869e --- /dev/null +++ b/scripts/devruntime-probes/NavProbe.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import com.codename1.ui.events.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.plaf.*; +public class NavProbe { + static Form home; + static Form detail(String item) { + Form f = new Form(item, BoxLayout.y()); + f.add(new Label("detail for " + item)); + f.getToolbar().setBackCommand("Back", e -> home.showBack()); + return f; + } + public static void main(String[] a) { + home = new Form("Items", BoxLayout.y()); + for (final String s : new String[]{"alpha","beta"}) { + Button b = new Button(s); + b.addActionListener(e -> detail(s).show()); + home.add(b); + } + home.add(new Label(UIManager.getInstance().getThemeConstant("x", "themed-ok"))); + home.show(); + System.out.println("PROBE NavProbe: kids=" + home.getContentPane().getComponentCount() + + " current=" + Display.getInstance().getCurrent().getTitle()); + } +} diff --git a/scripts/devruntime-probes/NetProbe.java b/scripts/devruntime-probes/NetProbe.java new file mode 100644 index 00000000000..2101b4db272 --- /dev/null +++ b/scripts/devruntime-probes/NetProbe.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import com.codename1.io.*; + +/** + * A pushed program doing real networking, against a host-side endpoint reached + * over `adb reverse` -- the emulator has no DNS, and a hermetic endpoint is a + * better test anyway. + * + * The interesting part is not the request: it is that ConnectionRequest is a + * framework class being subclassed by interpreted code, so readResponse is an + * interpreted override the framework calls back into on its own network thread. + */ +public class NetProbe { + public static void main(String[] a) { + final StringBuilder r = new StringBuilder(); + ConnectionRequest cr = new ConnectionRequest() { + protected void readResponse(java.io.InputStream in) throws java.io.IOException { + r.append("body=").append(Util.readToString(in).trim()); + } + protected void handleErrorResponseCode(int code, String message) { + r.append("http ").append(code); + } + }; + cr.setUrl("http://127.0.0.1:18080/hello.txt"); + cr.setPost(false); + NetworkManager.getInstance().addToQueueAndWait(cr); + System.out.println("PROBE NetProbe: " + (r.length() == 0 ? "no callback" : r.toString()) + + " status=" + cr.getResponseCode()); + new Form("Net").show(); + } +} diff --git a/scripts/devruntime-probes/README.md b/scripts/devruntime-probes/README.md new file mode 100644 index 00000000000..a20b5a7545b --- /dev/null +++ b/scripts/devruntime-probes/README.md @@ -0,0 +1,56 @@ +# Device runtime probes + +Programs to push at a device runtime to find out what it cannot do yet. + +Each one is small, prints a single `PROBE : ...` line whose contents are +checkable by eye, and shows a form so the device is visibly doing something. +`notes-app/` and `resource-app/` are not probes but ordinary applications -- +the first is four files in three packages entered through a `Lifecycle` rather +than a `main`, the second ships its own `.res` and a plain file alongside its +source. The shape of a real application is itself a thing that has to work. + +Run them against a device runtime with the app already installed: + +```bash +scripts/run-device-runtime-android.sh # build, install, launch +for p in scripts/devruntime-probes/*.java; do + scripts/cn1-push.sh "$p" 18234 +done +scripts/cn1-push.sh scripts/devruntime-probes/notes-app 18234 + +scripts/run-device-runtime-ios.sh # same, for the simulator +scripts/run-device-runtime-ios.sh --skip-build scripts/devruntime-probes/EnumProbe.java +``` + +`NetProbe` needs an endpoint on the host, reached over loopback (`adb reverse +tcp:18080 tcp:18080` on Android; the simulator shares the host's loopback): + +```bash +mkdir -p /tmp/www && echo hello-from-host > /tmp/www/hello.txt +(cd /tmp/www && python3 -m http.server 18080) +``` + +Two probes fail on purpose: `TraceProbe` throws, to show that the reported +stack names your source and your line numbers, and `LoopProbe` spins forever, +to show that the watchdog stops it rather than freezing the app. + +## Why these exist + +Every one of them was written because something plausible turned out not to +work, and each covers a defect that was invisible to everything written before +it. They are kept because that is the pattern: what breaks a device runtime is +never the thing being tested at the time. + +| Probe | The defect it was written for | +|---|---| +| `LambdaProbe` | `invokedynamic` was rejected outright -- no lambdas or method references | +| `EnumProbe` | `java.lang.Enum` cannot be shimmed, so enums did not run at all | +| `CollectionProbe` | iOS dispatched on the call site's declared type, so `List.add` reached `AbstractList.add`, which throws. Probes that declared `ArrayList` could not see it | +| `LateCallbackProbe` | the event-thread budget was measured per session, so every callback later than two seconds failed having run nothing | +| `NativeProbe` | a `cn1lib`'s native half has to degrade to `isSupported() == false`, not fail | +| `TraceProbe` | a host exception thrown by pushed code carried the interpreter's stack, not the program's | +| `NetProbe` | the default network-error handler answers a failure with another blocking request, and wedges the event thread | +| `SyncProbe` | `monitorenter`/`monitorexit` were accepted and ignored, so `synchronized` guaranteed nothing | +| `WaitNotifyProbe` | why the monitors are the objects' own: a `ReentrantLock` per object gives mutual exclusion and nothing else, and `wait()` would throw `IllegalMonitorStateException` | +| `resource-app` | a pushed program had no way to bring its own `theme.res`, so it wore the host's design | +| `notes-app` | `ireturn` was boxed as `Integer`, so every `boolean` method crashed; peers were never mapped back to their interpreted objects, so `Collections.sort` failed; shims did not override `Object`'s methods, so model objects printed as `Interp_I_java_lang_Comparable@df828bb` | diff --git a/scripts/devruntime-probes/StaticInitProbe.java b/scripts/devruntime-probes/StaticInitProbe.java new file mode 100644 index 00000000000..cabba330efe --- /dev/null +++ b/scripts/devruntime-probes/StaticInitProbe.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import java.util.*; +public class StaticInitProbe { + static final java.util.List LOG = new ArrayList(); + static int counter; + static { LOG.add("clinit"); counter = 42; } + static class Late { static final String V; static { V = "late-init"; } } + public static void main(String[] a) { + System.out.println("PROBE StaticInitProbe: log=" + LOG + " counter=" + counter + + " late=" + Late.V); + new Form("StaticInit").show(); + } +} diff --git a/scripts/devruntime-probes/StorageProbe.java b/scripts/devruntime-probes/StorageProbe.java new file mode 100644 index 00000000000..45622b822b6 --- /dev/null +++ b/scripts/devruntime-probes/StorageProbe.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import com.codename1.io.*; +import java.util.*; +public class StorageProbe { + public static void main(String[] a) { + StringBuilder r = new StringBuilder(); + Storage.getInstance().writeObject("probe-key", "stored-value"); + r.append("storage=").append(Storage.getInstance().readObject("probe-key")); + Preferences.set("probe-pref", 99); + r.append(" pref=").append(Preferences.get("probe-pref", 0)); + java.util.List l = new ArrayList(); + l.add("a"); l.add("b"); + Storage.getInstance().writeObject("probe-list", l); + r.append(" list=").append(Storage.getInstance().readObject("probe-list")); + System.out.println("PROBE StorageProbe: " + r); + new Form("Storage").show(); + } +} diff --git a/scripts/devruntime-probes/SyncProbe.java b/scripts/devruntime-probes/SyncProbe.java new file mode 100644 index 00000000000..1290d0f38c8 --- /dev/null +++ b/scripts/devruntime-probes/SyncProbe.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; + +/** + * Monitors under contention, on the device. + * + * Each counter is guarded by exactly one monitor -- a synchronized method takes + * the class's or the receiver's, a block takes whatever it names -- so the + * totals are exact, and an interpreter that treats monitorenter as a no-op + * loses increments rather than merely reordering them. + */ +public class SyncProbe { + static int byLock; + static int byMethod; + int byInstance; + static final Object LOCK = new Object(); + + static synchronized void bumpStatic() { byMethod++; } + synchronized void bumpInstance() { byInstance++; } + static void guarded() { synchronized (LOCK) { byLock++; } } + + public static void main(String[] a) throws Exception { + final SyncProbe shared = new SyncProbe(); + Thread[] t = new Thread[4]; + for (int i = 0; i < t.length; i++) { + t[i] = new Thread(new Runnable() { + public void run() { + for (int j = 0; j < 1000; j++) { + bumpStatic(); shared.bumpInstance(); guarded(); + } + } + }); + } + for (int i = 0; i < t.length; i++) { t[i].start(); } + for (int i = 0; i < t.length; i++) { t[i].join(); } + System.out.println("PROBE SyncProbe: method=" + byMethod + " instance=" + shared.byInstance + + " lock=" + byLock + " expected=4000 each"); + new Form("Sync").show(); + } +} diff --git a/scripts/devruntime-probes/ThreadProbe.java b/scripts/devruntime-probes/ThreadProbe.java new file mode 100644 index 00000000000..97b1392710f --- /dev/null +++ b/scripts/devruntime-probes/ThreadProbe.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +public class ThreadProbe { + static final StringBuilder r = new StringBuilder(); + static synchronized void add(String s) { r.append(s); } + public static void main(String[] a) throws Exception { + Thread t = new Thread(new Runnable() { + public void run() { add("worker "); } + }); + t.start(); + t.join(); + Object lock = new Object(); + synchronized (lock) { add("sync "); } + System.out.println("PROBE ThreadProbe: " + r); + new Form("Thread").show(); + } +} diff --git a/scripts/devruntime-probes/TraceProbe.java b/scripts/devruntime-probes/TraceProbe.java new file mode 100644 index 00000000000..9ef5f3db8f0 --- /dev/null +++ b/scripts/devruntime-probes/TraceProbe.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +public class TraceProbe { + static void deep(int n) { if (n == 0) { throw new IllegalStateException("from depth 0"); } deep(n - 1); } + public static void main(String[] a) { + deep(3); + } +} diff --git a/scripts/devruntime-probes/TryFinallyProbe.java b/scripts/devruntime-probes/TryFinallyProbe.java new file mode 100644 index 00000000000..448c0feada2 --- /dev/null +++ b/scripts/devruntime-probes/TryFinallyProbe.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +public class TryFinallyProbe { + static StringBuilder r = new StringBuilder(); + static int f() { try { r.append("t"); return 1; } finally { r.append("f"); } } + static void nested() { + try { try { throw new IllegalStateException("inner"); } finally { r.append("F1"); } } + catch (IllegalStateException e) { r.append("C:").append(e.getMessage()); } + } + public static void main(String[] a) { + r.append(" f=").append(f()); + nested(); + try { Object o = null; o.toString(); } catch (NullPointerException e) { r.append(" npe"); } + try { int[] x = new int[1]; int y = x[3]; r.append(y); } + catch (ArrayIndexOutOfBoundsException e) { r.append(" aioobe"); } + try { int z = 1 / Integer.parseInt("0"); r.append(z); } + catch (ArithmeticException e) { r.append(" arith"); } + System.out.println("PROBE TryFinallyProbe: " + r); + new Form("TryFinally").show(); + } +} diff --git a/scripts/devruntime-probes/UiProbe.java b/scripts/devruntime-probes/UiProbe.java new file mode 100644 index 00000000000..154357f29b6 --- /dev/null +++ b/scripts/devruntime-probes/UiProbe.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import com.codename1.ui.events.*; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.list.*; +public class UiProbe { + public static void main(String[] a) { + Form f = new Form("Ui", BoxLayout.y()); + final Label out = new Label("idle"); + Button b = new Button("press"); + b.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { out.setText("pressed"); } + }); + f.add(out).add(b); + DefaultListModel model = new DefaultListModel(new String[]{"one","two"}); + f.add(new com.codename1.ui.List(model)); + f.getToolbar().addCommandToRightBar("Cmd", null, e -> out.setText("cmd")); + f.show(); + b.pressed(); b.released(); + System.out.println("PROBE UiProbe: after=" + out.getText() + " model=" + model.getSize() + + " title=" + f.getTitle()); + } +} diff --git a/scripts/devruntime-probes/WaitNotifyProbe.java b/scripts/devruntime-probes/WaitNotifyProbe.java new file mode 100644 index 00000000000..d43e797aa9f --- /dev/null +++ b/scripts/devruntime-probes/WaitNotifyProbe.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; + +/** + * Producer/consumer across two threads, through wait/notify. + * + * This is the case that decides how monitors are implemented. A private lock + * table keyed by object identity -- a ReentrantLock per object -- gives mutual + * exclusion and nothing else: wait() requires the caller to own that object's + * monitor, and would throw IllegalMonitorStateException here. Using the + * object's own monitor costs a nested interpreter frame per guarded region and + * buys this, plus exclusion against framework code locking the same object. + */ +public class WaitNotifyProbe { + static final Object LOCK = new Object(); + static int value = -1; + static boolean ready; + + public static void main(String[] a) throws Exception { + Thread consumer = new Thread(new Runnable() { + public void run() { + synchronized (LOCK) { + while (!ready) { + try { LOCK.wait(); } catch (InterruptedException e) { } + } + System.out.println("PROBE WaitNotifyProbe: consumer got " + value); + } + } + }); + Thread producer = new Thread(new Runnable() { + public void run() { + synchronized (LOCK) { value = 42; ready = true; LOCK.notifyAll(); } + } + }); + consumer.start(); + Thread.sleep(100); + producer.start(); + consumer.join(); + producer.join(); + System.out.println("PROBE WaitNotifyProbe: done ready=" + ready); + new Form("WaitNotify").show(); + } +} diff --git a/scripts/devruntime-probes/notes-app/com/example/notes/NotesApp.java b/scripts/devruntime-probes/notes-app/com/example/notes/NotesApp.java new file mode 100644 index 00000000000..380121bcc58 --- /dev/null +++ b/scripts/devruntime-probes/notes-app/com/example/notes/NotesApp.java @@ -0,0 +1,45 @@ +/* + * 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.example.notes; + +import com.codename1.system.Lifecycle; +import com.example.notes.ui.NotesForm; + +/** A real application shape: a Lifecycle entry point, no main anywhere. */ +public class NotesApp extends Lifecycle { + @Override + public void start() { + if (isStartedBefore()) { + return; + } + new NotesForm().show(); + System.out.println("REALAPP: started, form shown"); + } + + private boolean started; + private boolean isStartedBefore() { + boolean was = started; + started = true; + return was; + } +} diff --git a/scripts/devruntime-probes/notes-app/com/example/notes/model/Note.java b/scripts/devruntime-probes/notes-app/com/example/notes/model/Note.java new file mode 100644 index 00000000000..bdd843ec79a --- /dev/null +++ b/scripts/devruntime-probes/notes-app/com/example/notes/model/Note.java @@ -0,0 +1,43 @@ +/* + * 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.example.notes.model; + +public class Note implements Comparable { + public enum Priority { LOW, NORMAL, HIGH } + + private final String title; + private final Priority priority; + + public Note(String title, Priority priority) { + this.title = title; + this.priority = priority; + } + + public String getTitle() { return title; } + public Priority getPriority() { return priority; } + + public int compareTo(Note o) { return o.priority.ordinal() - priority.ordinal(); } + + @Override + public String toString() { return title + " [" + priority + "]"; } +} diff --git a/scripts/devruntime-probes/notes-app/com/example/notes/model/NoteStore.java b/scripts/devruntime-probes/notes-app/com/example/notes/model/NoteStore.java new file mode 100644 index 00000000000..7f41ef47516 --- /dev/null +++ b/scripts/devruntime-probes/notes-app/com/example/notes/model/NoteStore.java @@ -0,0 +1,46 @@ +/* + * 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.example.notes.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class NoteStore { + private final List notes = new ArrayList(); + + public NoteStore seed() { + notes.add(new Note("buy milk", Note.Priority.LOW)); + notes.add(new Note("ship the release", Note.Priority.HIGH)); + notes.add(new Note("write tests", Note.Priority.NORMAL)); + return this; + } + + public List sorted() { + List copy = new ArrayList(notes); + Collections.sort(copy); + return copy; + } + + public int count() { return notes.size(); } +} diff --git a/scripts/devruntime-probes/notes-app/com/example/notes/ui/NotesForm.java b/scripts/devruntime-probes/notes-app/com/example/notes/ui/NotesForm.java new file mode 100644 index 00000000000..3a202df14ea --- /dev/null +++ b/scripts/devruntime-probes/notes-app/com/example/notes/ui/NotesForm.java @@ -0,0 +1,56 @@ +/* + * 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.example.notes.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.example.notes.model.Note; +import com.example.notes.model.NoteStore; + +/** A Form subclass, which is the case that needs a generated shim. */ +public class NotesForm extends Form { + private final NoteStore store = new NoteStore().seed(); + private final Label status = new Label("ready"); + + public NotesForm() { + super("Notes", BoxLayout.y()); + for (Note n : store.sorted()) { + add(new Label(n.toString())); + } + Button b = new Button("count"); + b.addActionListener(e -> status.setText("notes=" + store.count())); + add(b).add(status); + b.pressed(); + b.released(); + System.out.println("REALAPP: sorted=" + store.sorted() + + " status=" + status.getText()); + } + + @Override + protected void initComponent() { + super.initComponent(); + System.out.println("REALAPP: initComponent reached interpreted override"); + } +} diff --git a/scripts/devruntime-probes/resource-app/ResourceProbe.java b/scripts/devruntime-probes/resource-app/ResourceProbe.java new file mode 100644 index 00000000000..f281fb12168 --- /dev/null +++ b/scripts/devruntime-probes/resource-app/ResourceProbe.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import com.codename1.ui.*; +import com.codename1.io.Util; +import com.codename1.ui.util.Resources; +import java.io.InputStream; + +/** + * A program that brings its own resources. + * + * The plain stream proves the implementation layer serves them. Resources.open + * is the one that matters: it resolves inside the framework, which asks the + * implementation directly and never passes through anything the interpreter + * sees -- so if the hook were on Display instead, this line would still load + * the host app's file. + */ +public class ResourceProbe { + public static void main(String[] a) { + String text = "?"; + String res = "?"; + try { + InputStream in = Display.getInstance().getResourceAsStream(null, "/pushed.txt"); + text = in == null ? "missing" : Util.readToString(in).trim(); + } catch (Exception e) { + text = "threw " + e; + } + try { + Resources r = Resources.open("/pushed.res"); + res = "opened themes=" + r.getThemeResourceNames().length; + } catch (Exception e) { + res = "threw " + e; + } + System.out.println("PROBE ResourceProbe: text=" + text + " res=" + res); + new Form("Resource").show(); + } +} diff --git a/scripts/devruntime-probes/resource-app/pushed.res b/scripts/devruntime-probes/resource-app/pushed.res new file mode 100644 index 00000000000..a4f1800498c Binary files /dev/null and b/scripts/devruntime-probes/resource-app/pushed.res differ diff --git a/scripts/devruntime-probes/resource-app/pushed.txt b/scripts/devruntime-probes/resource-app/pushed.txt new file mode 100644 index 00000000000..e93dfe2c16a --- /dev/null +++ b/scripts/devruntime-probes/resource-app/pushed.txt @@ -0,0 +1 @@ +pushed-resource-body diff --git a/scripts/generate-interp-shims.sh b/scripts/generate-interp-shims.sh new file mode 100755 index 00000000000..303c30ab745 --- /dev/null +++ b/scripts/generate-interp-shims.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# Verifies the device runtime's shim generator. +# +# The shims themselves are generated by the app build (see the exec-maven-plugin +# execution in scripts/cn1-device-runtime/common/pom.xml) into target/, so there +# is nothing here to commit. What this script checks is the three properties the +# build takes on faith: that every shim compiles, that the ones the runtime +# cannot work without exist, and that generating twice produces the same source. +# +# The set is not curated. It is every public, non-final, constructible class and +# every public interface under com.codename1, because an application may +# subclass anything the API exposes and a hand-maintained list is wrong the +# first time somebody subclasses something unusual. +# +# Every shim that can exist is generated, and every one of them compiles. The +# only classes skipped are those Java itself forbids subclassing from another +# package -- a package-private abstract method leaves no legal subclass outside +# its own package -- and the generator names the method when it skips one. +# +# There is deliberately no drop-what-fails fallback. A shim that will not +# compile is a generator bug; hiding it behind a prune loop is how Interp_ui_Form +# went missing once already. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$ROOT/scripts/cn1-device-runtime" +CORE_JAR="$ROOT/maven/core/target/codenameone-core-8.0-SNAPSHOT.jar" +# The device's java.* API, as the application tool chain sees it. Not the JDK +# (thousands of types the device lacks) and not vm/JavaAPI (which has types the +# app classpath does not expose, ReentrantLock among them). +JAVA_RUNTIME="$(ls "$ROOT"/.m2-local/com/codenameone/java-runtime/*/java-runtime-*.jar 2>/dev/null \ + | grep -vE 'sources|javadoc' | head -1)" +if [ -z "$JAVA_RUNTIME" ]; then + JAVA_RUNTIME="$(ls "$HOME"/.m2/repository/com/codenameone/java-runtime/*/java-runtime-*.jar 2>/dev/null \ + | grep -vE 'sources|javadoc' | head -1)" +fi +if [ -z "$JAVA_RUNTIME" ]; then + echo "codenameone-java-runtime not found; build it first" >&2 + exit 1 +fi + +if [ ! -f "$CORE_JAR" ]; then + echo "core jar not built: $CORE_JAR" >&2 + echo "build it first: mvn -f maven/pom.xml -pl core install -DskipTests" >&2 + exit 1 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Generate with the JDK the app is compiled with, not whichever one happens to +# be on PATH. The java.* shims are derived from reflection over the running +# JDK's classes, and the JDKs disagree: LambdaMetafactory is non-final on 8 and +# final on 17, Thread's methods differ. Generating on one and compiling on +# another produces shims that cannot exist. +if [ -n "${JAVA17_HOME:-}" ]; then + export JAVA_HOME="$JAVA17_HOME" + export PATH="$JAVA_HOME/bin:$PATH" +fi + +# ASM reads the device runtime's method tables; reflection over the running JDK +# reports methods the phone does not have. +ASM="$(find "$ROOT/.m2-local/org/ow2/asm" "$HOME/.m2/repository/org/ow2/asm" \ + -name 'asm-9.8.jar' 2>/dev/null | head -1)" +if [ -z "$ASM" ]; then + echo "asm not found in the local repositories" >&2 + exit 1 +fi +javac -nowarn -d "$WORK" -cp "$CORE_JAR:$ASM" \ + "$APP/tools/src/main/java/com/codenameone/devruntime/tools/GenerateInterpShims.java" + +OUT="$WORK/first" +GEN="$OUT/com/codenameone/devruntime/gen" +mkdir -p "$OUT" +SEED="$APP/tools/unshimmable-by-contract.txt" +SEED_ARG=() +[ -f "$SEED" ] && SEED_ARG=(--exclude "$SEED") +java -cp "$WORK:$CORE_JAR:$ASM" com.codenameone.devruntime.tools.GenerateInterpShims \ + "$OUT" "$CORE_JAR" --java-runtime "$JAVA_RUNTIME" "${SEED_ARG[@]}" + +# Every generated shim must compile: a shim that does not is a bug in the +# generator, not a property of the framework. That is not a theoretical +# distinction -- an earlier compile-and-drop loop silently ate Interp_ui_Form +# because Form re-declares getComponentForm() final, and a device runtime that +# cannot subclass Form is useless. +# +# Classes Java genuinely forbids subclassing across packages (a package-private +# abstract method) are excluded by the generator, up front, naming the method. +# Anything else reaching javac is a defect and fails the build here. +CLASSES="$WORK/classes" +mkdir -p "$CLASSES" +if ! javac -nowarn -proc:none -d "$CLASSES" -cp "$CORE_JAR" "$GEN"/*.java 2> "$WORK/errors.txt"; then + echo "generated shims do not compile -- this is a generator bug, not a limitation" >&2 + grep -E "error:" "$WORK/errors.txt" | head -20 >&2 + echo >&2 + echo "offending shims:" >&2 + grep -oE "Interp_[A-Za-z0-9_]+\.java" "$WORK/errors.txt" | sort -u | head -20 >&2 + exit 1 +fi + +# Pruning is gone, so the core-type guard is belt and braces rather than the +# thing standing between us and a useless build. Keep it: it costs nothing and +# it is the assertion that would have caught the Form regression immediately. +# +# Each java.* entry below is a gap that actually shipped, not a hypothetical. +# Runnable went missing when the curated list was replaced by a scan of +# com.codename1 alone, and every exception type went missing while a difference +# between the JDK's Throwable and the device's was treated as a reason to skip +# the class. Both took a device run to notice. This is the check that makes the +# next one cost a second instead. +for required in Interp_ui_Component Interp_ui_Container Interp_ui_Form \ + Interp_ui_Label Interp_ui_Button Interp_ui_Dialog \ + Interp_I_ui_events_ActionListener \ + Interp_I_java_lang_Runnable \ + Interp_java_lang_Exception Interp_java_lang_RuntimeException \ + Interp_java_lang_Thread Interp_java_util_ArrayList; do + if [ ! -f "$GEN/$required.java" ]; then + echo "$required was not generated -- that is a generator bug" >&2 + exit 1 + fi +done + +# Generating twice has to produce the same source. Reflection does not promise +# an order for declared methods and really does vary run to run, which is why +# the generator sorts them; without that, two builds of the same commit would +# ship shims that differ, and a bundle linked against one would miss on the +# other. +SECOND="$WORK/second" +mkdir -p "$SECOND" +java -cp "$WORK:$CORE_JAR:$ASM" com.codenameone.devruntime.tools.GenerateInterpShims \ + "$SECOND" "$CORE_JAR" --java-runtime "$JAVA_RUNTIME" "${SEED_ARG[@]}" > /dev/null +if ! diff -rq "$SECOND/com/codenameone/devruntime/gen" "$GEN" > "$WORK/unstable.txt"; then + echo "generation is not reproducible -- the same inputs produced different shims:" >&2 + head -5 "$WORK/unstable.txt" >&2 + exit 1 +fi + +ls "$GEN"/Interp_*.java | wc -l | xargs echo "shims:" diff --git a/scripts/hellocodenameone/common/codenameone_settings.properties b/scripts/hellocodenameone/common/codenameone_settings.properties index 1fbf5370aeb..54bb379ecb0 100644 --- a/scripts/hellocodenameone/common/codenameone_settings.properties +++ b/scripts/hellocodenameone/common/codenameone_settings.properties @@ -7,6 +7,13 @@ codename1.arg.android.androidAuto.poi=true codename1.arg.android.health.privacyPolicyUrl=https\://www.codenameone.com/privacy-policy.html codename1.arg.android.health.read=steps,heart_rate codename1.arg.android.health.write=steps +# Shimming the whole Codename One API makes the build's bytecode scanner see +# every feature, so every credential-gated one has to be configured. A shipping +# device-runtime app supplies real values -- it wants those natives compiled in, +# because pushed code may use them. These placeholders exist so the sample app +# builds; nothing here calls Play billing or FCM at runtime. +codename1.arg.android.licenseKey=PLACEHOLDER_NOT_A_REAL_LVL_KEY +codename1.arg.android.messagingService=auto codename1.arg.android.useAndroidX=true codename1.arg.ios.applicationQueriesSchemes=cydia codename1.arg.ios.carplay.audio=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 5c703262358..9496cea3166 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 @@ -558,6 +558,7 @@ public void runSuite() { /// 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( diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt index 5855a688185..deaaeda0ab9 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt @@ -32,6 +32,7 @@ import com.codename1.car.CarRow import com.codename1.car.CarScreen import com.codename1.car.CarTemplate import com.codename1.system.Lifecycle +import com.codename1.system.NativeLookup import com.codename1.testing.TestReporting import com.codename1.ui.CN import com.codename1.ui.Display @@ -114,6 +115,7 @@ open class HelloCodenameOne : Lifecycle() { TestReporting.setInstance(Cn1ssDeviceRunnerReporter()) } + override fun runApp() { // HTML5 runs inside a Web Worker whose single thread hosts the EDT — // starting a java.lang.Thread there would never get to execute, so diff --git a/scripts/run-device-runtime-android.sh b/scripts/run-device-runtime-android.sh new file mode 100755 index 00000000000..03e2fecdbde --- /dev/null +++ b/scripts/run-device-runtime-android.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# Builds the device runtime app for an Android emulator or device, installs it, +# and pushes a program to it. +# +# Android needs no special translator mode: it has reflection, so the linker is +# registered on every build and the shims are ordinary compiled classes. That +# asymmetry with iOS is the whole reason both platforms are tested. +# +# Usage: run-device-runtime-android.sh [--skip-build] [program.java] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$ROOT/scripts/cn1-device-runtime" +WORK="${CN1_DEVRUNTIME_WORK:-${TMPDIR:-/tmp}/cn1-devruntime-android}" +mkdir -p "$WORK" + +SKIP_BUILD=0 +PROGRAM="" +while [ $# -gt 0 ]; do + case "$1" in + --skip-build) SKIP_BUILD=1 ;; + *) PROGRAM="$1" ;; + esac + shift +done + +# adb picks a device implicitly only when exactly one is attached, and it will +# happily choose a Wear emulator over the phone. Pin the serial. +SERIAL="${CN1_ADB_SERIAL:-$(adb devices | awk '/\tdevice$/ {print $1; exit}')}" +if [ -z "$SERIAL" ]; then + echo "no Android device or emulator attached" >&2 + exit 1 +fi +echo "device: $SERIAL" +ADB=(adb -s "$SERIAL") + +MVN_ARGS=() +[ -n "${SETTINGS_LOCAL:-}" ] && MVN_ARGS+=(-s "$SETTINGS_LOCAL") +[ -n "${M2_LOCAL:-}" ] && MVN_ARGS+=(-Dmaven.repo.local="$M2_LOCAL") + +if [ "$SKIP_BUILD" = 0 ]; then + # Same trap the iOS script guards: the app resolves the framework from the + # local repository, so an edit to com.codename1.impl.interp is invisible here + # until core is installed, and the Android port jar carries its own copy of + # those classes on top of that. Rebuilding both costs a minute and removes + # a failure that reads as "my change did nothing". + echo "=== refreshing core and the Android port ===" + for module in core android; do + (cd "$ROOT/maven" && mvn -q ${MVN_ARGS[@]+"${MVN_ARGS[@]}"} \ + -Pcompile-android -f "$module/pom.xml" install -DskipTests) || { + echo "could not rebuild $module" >&2 + exit 1 + } + done + + echo "=== building ===" + # The generated Gradle project is copied into, not regenerated: a framework + # jar that changed since the last run keeps its old copy there and the app + # silently runs yesterday's core. That failure reads as a protocol bug -- + # the device rejecting a bundle the current writer plainly produced. + # + # target/classes goes too. The build copies .java files there as resources + # so the generated Gradle project can carry them, and a class deleted from + # src/ keeps its copy in target/classes indefinitely -- which is how a file + # that no longer exists ends up failing the compile. + rm -rf "$APP/android/target/codenameone" \ + "$APP/android/target/classes" \ + "$APP"/android/target/*-android-source + (cd "$APP" && JAVA_HOME="${JAVA17_HOME:-$JAVA_HOME}" \ + PATH="${JAVA17_HOME:-$JAVA_HOME}/bin:$PATH" \ + mvn -o ${MVN_ARGS[@]+"${MVN_ARGS[@]}"} package -DskipTests \ + -Dcodename1.platform=android \ + -Dcodename1.buildTarget=android-source \ + -Dcodename1.arg.android.xapplication_attr='android:usesCleartextTraffic="true"' \ + -Dmaven.compiler.fork=true \ + -Dmaven.compiler.executable="${JAVA17_HOME:-$JAVA_HOME}/bin/javac" \ + -Dopen=false 2>&1 | tee "$WORK/build.log" | grep -E "BUILD|ERROR" | head -20) +fi + +GRADLE_DIR="$(find "$APP/android/target" -maxdepth 1 -name '*-android-source' -type d | head -1)" +[ -n "$GRADLE_DIR" ] || { echo "no gradle project generated; see $WORK/build.log" >&2; exit 1; } + +if [ "$SKIP_BUILD" = 0 ]; then + # android-source stops at generating the Gradle project; the cloud build + # server is what normally compiles it. Locally that step is ours. + # + # The retarget is a workaround for a malformed local SDK package: the + # installed android-37.0 declares AndroidVersion.ApiLevel=37.0 with + # Platform.Version=17, which AGP rejects. 36 is the newest coherent one + # here. Drop this once the SDK package is fixed. + API="${CN1_ANDROID_API:-36}" + /usr/bin/sed -i '' -e "s/compileSdkVersion 37/compileSdkVersion $API/" \ + -e "s/targetSdkVersion 37/targetSdkVersion $API/" \ + "$GRADLE_DIR/app/build.gradle" + # One ABI for a sideloadable APK. The runtime carries ML Kit, CameraX and + # ARCore on purpose -- they are the reason to debug on a device rather than + # in the simulator -- and ML Kit's bundled models are ~287MB of native + # libraries across four ABIs, which makes a universal APK 323MB. arm64 alone + # is 110MB, and arm64 is every device worth testing on and the only emulator + # image that runs at speed on an Apple-silicon Mac. + # + # The store build does not do this: it ships an app bundle, and Play + # delivers one ABI per device by itself. + ABI="${CN1_ANDROID_ABI:-arm64-v8a}" + if ! grep -q abiFilters "$GRADLE_DIR/app/build.gradle"; then + /usr/bin/sed -i '' -e "s/ defaultConfig {/ defaultConfig {\ + ndk { abiFilters '$ABI' }/" "$GRADLE_DIR/app/build.gradle" + fi + # The generated project has no local.properties -- the cloud build server + # supplies the SDK location. Locally it has to be written. + SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}" + [ -d "$SDK" ] || { echo "no Android SDK at $SDK; set ANDROID_HOME" >&2; exit 1; } + echo "sdk.dir=$SDK" > "$GRADLE_DIR/local.properties" + + echo "=== gradle assembleDebug (api $API) ===" + (cd "$GRADLE_DIR" && JAVA_HOME="${JAVA17_HOME:-$JAVA_HOME}" ANDROID_HOME="$SDK" \ + ./gradlew --no-daemon assembleDebug > "$WORK/gradle.log" 2>&1) || { + echo "gradle failed; last errors:" >&2 + grep -E "error:|FAILURE|What went wrong" -A3 "$WORK/gradle.log" | head -30 >&2 + exit 1 + } +fi + +APK="$(find "$APP/android/target" -name '*.apk' -print 2>/dev/null | head -1 || true)" +[ -n "$APK" ] || { echo "no apk produced; see $WORK/gradle.log" >&2; exit 1; } + +echo "=== installing $APK ===" +"${ADB[@]}" install -r "$APK" >/dev/null + +echo "=== launching ===" +"${ADB[@]}" logcat -c +# `monkey` sends a LAUNCH_SINGLE_TOP intent, which a just-replaced install can +# answer with "already running" against a window that is on its way out -- the +# app never actually starts and nothing says so. am start is explicit. +"${ADB[@]}" shell am force-stop com.codenameone.devruntime >/dev/null 2>&1 || true +"${ADB[@]}" shell am start -n \ + com.codenameone.devruntime/.DeviceRuntimeAppStub >/dev/null 2>&1 + +for _ in $(seq 1 40); do + if "${ADB[@]}" logcat -d | grep -q "CN1SS:DEVRUNTIME"; then + break + fi + sleep 1 +done +"${ADB[@]}" logcat -d | grep "CN1SS:DEVRUNTIME" | tail -3 || { + echo "the app never reported its device runtime status; last crash output:" >&2 + "${ADB[@]}" logcat -d | grep -E "AndroidRuntime|FATAL|System.err" | tail -20 >&2 + exit 1 +} + +if [ -n "$PROGRAM" ]; then + # The device dials out and the desktop listens, so this is `adb reverse`: + # it maps the *device's* 127.0.0.1:18234 onto the host's. `adb forward` is + # the opposite direction and was what the old listening design needed. + "${ADB[@]}" reverse --remove tcp:18234 >/dev/null 2>&1 || true + "${ADB[@]}" reverse tcp:18234 tcp:18234 >/dev/null + echo "=== pushing $PROGRAM ===" + "$ROOT/scripts/cn1-push.sh" "$PROGRAM" 18234 + sleep 2 + "${ADB[@]}" logcat -d | grep -E "CN1SS:|devruntime" | tail -10 || true +fi diff --git a/scripts/run-device-runtime-ios.sh b/scripts/run-device-runtime-ios.sh new file mode 100755 index 00000000000..2f75d623b88 --- /dev/null +++ b/scripts/run-device-runtime-ios.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# Builds the device runtime app for the iOS simulator, installs it, and pushes a +# program to it. +# +# Everything here is local: an interp-host build is an ordinary ios-source build +# with cn1.interpHost=true, compiled by the local Xcode for iphonesimulator with +# signing off. No build server and no certificate is involved, which is the +# point -- this is the loop a framework developer iterates in. +# +# Usage: run-device-runtime-ios.sh [--skip-build] [program.java] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$ROOT/scripts/cn1-device-runtime" +WORK="${CN1_DEVRUNTIME_WORK:-${TMPDIR:-/tmp}/cn1-devruntime-ios}" +mkdir -p "$WORK" + +SKIP_BUILD=0 +PROGRAM="" +while [ $# -gt 0 ]; do + case "$1" in + --skip-build) SKIP_BUILD=1 ;; + *) PROGRAM="$1" ;; + esac + shift +done + +# A booted simulator is not enough to identify one: `booted` resolves to +# whichever device simctl lists first, and a booted Apple Watch will happily +# take an install that then never appears on the phone. Pin the UDID. +SIM_UDID="${CN1_SIM_UDID:-}" +if [ -z "$SIM_UDID" ]; then + SIM_UDID="$(xcrun simctl list devices available -j \ + | python3 -c ' +import json,sys +d=json.load(sys.stdin)["devices"] +for runtime, devices in d.items(): + if "iOS" not in runtime: + continue + for dev in devices: + if dev.get("state") == "Booted" and "iPhone" in dev["name"]: + print(dev["udid"]); raise SystemExit +for runtime, devices in d.items(): + if "iOS" not in runtime: + continue + for dev in devices: + if "iPhone" in dev["name"]: + print(dev["udid"]); raise SystemExit +')" +fi +if [ -z "$SIM_UDID" ]; then + echo "no iPhone simulator available; install one in Xcode > Settings > Components" >&2 + exit 1 +fi +echo "simulator: $SIM_UDID" + +# The simulator shares the host's loopback, so an `adb forward tcp:18234` left +# over from an Android run owns the port the iOS app wants and quietly wins the +# race. The push then reaches the Android emulator and fails with whatever that +# app makes of the bundle -- an error that says nothing about the real problem. +if command -v adb >/dev/null 2>&1 && adb forward --list 2>/dev/null | grep -q "tcp:18234"; then + echo "clearing an adb forward that holds tcp:18234" + adb forward --remove tcp:18234 >/dev/null 2>&1 || true +fi +# Both runtimes dial out to the same host port, so a device runtime still +# running on the emulator answers the push meant for the simulator and reports +# a perfectly good result for the wrong device. That mistake has been made here +# twice; stopping the Android app costs nothing and makes it impossible. +if command -v adb >/dev/null 2>&1 && [ -n "$(adb devices | sed -n '2p')" ]; then + adb shell am force-stop com.codenameone.devruntime >/dev/null 2>&1 || true +fi +xcrun simctl bootstatus "$SIM_UDID" -b >/dev/null 2>&1 || xcrun simctl boot "$SIM_UDID" || true + +MVN_ARGS=() +[ -n "${SETTINGS_LOCAL:-}" ] && MVN_ARGS+=(-s "$SETTINGS_LOCAL") +[ -n "${M2_LOCAL:-}" ] && MVN_ARGS+=(-Dmaven.repo.local="$M2_LOCAL") + +SRC_DIR="$APP/ios/target/cn1-device-runtime-ios-1.0-SNAPSHOT-ios-source" + +if [ "$SKIP_BUILD" = 0 ]; then + # Two artifacts repackage things you might reasonably think live elsewhere, + # and both have cost a full hour-long cycle to a change that "did nothing": + # + # parparvm carries the translator the build actually runs, so installing + # vm/ByteCodeTranslator alone leaves the old one in place. + # ios bundles iOSPort.jar, which embeds a copy of the core classes, + # so a change to com.codename1.impl.interp is invisible until this is + # rebuilt -- core alone is not enough. + # + # Rebuilding all three here costs a minute and removes the whole category. + echo "=== refreshing core, translator and iOS port ===" + # Built one POM at a time rather than as `-pl core,parparvm,ios` from the + # aggregator: the aggregator reactor reads every module, and the archetype + # modules need archetype-packaging, which an offline build against a local + # repository does not have. Pointing at a module reads only its parent chain. + for module in core parparvm ios; do + (cd "$ROOT/maven" && mvn -q ${MVN_ARGS[@]+"${MVN_ARGS[@]}"} \ + -f "$module/pom.xml" install -DskipTests) || { + echo "could not rebuild $module" >&2 + exit 1 + } + done + + echo "=== translating (interp host) ===" + # The generated Xcode project is not regenerated in place: the build copies + # into it and leaves whatever is already there. A native source or a + # translator header that changed since the last run would silently keep its + # old contents and fail to compile against the new Java side, which reads as + # "my edit had no effect" rather than as a stale copy. + rm -rf "$SRC_DIR" "$APP/ios/target/codenameone" + # The ios module lives behind a profile keyed on codename1.platform, and the + # translator needs JDK 17 for javac while the framework itself was built + # with 8; both are the project's normal arrangement, not something this + # script invents. + (cd "$APP" && JAVA_HOME="${JAVA17_HOME:-$JAVA_HOME}" \ + PATH="${JAVA17_HOME:-$JAVA_HOME}/bin:$PATH" \ + mvn -o ${MVN_ARGS[@]+"${MVN_ARGS[@]}"} package -DskipTests \ + -Dcodename1.platform=ios \ + -Dcodename1.buildTarget=ios-source \ + -Dcodename1.arg.ios.interpHost=true \ + -Dmaven.compiler.fork=true \ + -Dmaven.compiler.executable="${JAVA17_HOME:-$JAVA_HOME}/bin/javac" \ + -Dopen=false 2>&1 | tee "$WORK/translate.log" | grep -E "BUILD|ERROR" | head -20) + + echo "=== xcodebuild ===" + (cd "$SRC_DIR" && xcodebuild -workspace CN1DeviceRuntime.xcworkspace \ + -scheme CN1DeviceRuntime -sdk iphonesimulator -configuration Debug \ + -destination "platform=iOS Simulator,id=$SIM_UDID" \ + -derivedDataPath "$WORK/dd" \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build \ + > "$WORK/xcodebuild.log" 2>&1) || { + echo "xcodebuild failed; last errors:" >&2 + grep -E "error:" "$WORK/xcodebuild.log" | head -20 >&2 + exit 1 + } + echo "BUILD SUCCEEDED" +fi + +APP_BUNDLE="$WORK/dd/Build/Products/Debug-iphonesimulator/CN1DeviceRuntime.app" +[ -d "$APP_BUNDLE" ] || { echo "no app bundle at $APP_BUNDLE" >&2; exit 1; } + +echo "=== installing ===" +xcrun simctl uninstall "$SIM_UDID" com.codenameone.devruntime >/dev/null 2>&1 || true +xcrun simctl install "$SIM_UDID" "$APP_BUNDLE" + +echo "=== launching ===" +xcrun simctl launch --console-pty "$SIM_UDID" com.codenameone.devruntime \ + > "$WORK/console.log" 2>&1 & +CONSOLE_PID=$! +trap 'kill $CONSOLE_PID 2>/dev/null || true' EXIT + +# The listener binds during Lifecycle.init, after the framework has started; ten +# seconds is generous on a warm simulator and still fails fast on a cold one. +for _ in $(seq 1 20); do + if grep -q "CN1SS:DEVRUNTIME" "$WORK/console.log" 2>/dev/null; then + break + fi + sleep 1 +done +if ! grep "CN1SS:DEVRUNTIME" "$WORK/console.log"; then + # --console-pty wants a terminal and gives nothing without one, so fall back + # to the unified log, where NSLog output lands either way. + xcrun simctl spawn "$SIM_UDID" log show --last 3m --style compact \ + --predicate 'eventMessage CONTAINS "CN1SS:"' > "$WORK/unified.log" 2>/dev/null || true + grep "CN1SS:DEVRUNTIME" "$WORK/unified.log" || { + echo "the app never reported its device runtime status:" >&2 + tail -30 "$WORK/console.log" >&2 + exit 1 + } +fi + +if [ -n "$PROGRAM" ]; then + echo "=== pushing $PROGRAM ===" + # The simulator shares the host's loopback, so the app's outbound dial + # reaches this listener directly -- no port forwarding, unlike Android, + # which needs `adb reverse` to see the host at 127.0.0.1. + "$ROOT/scripts/cn1-push.sh" "$PROGRAM" 18234 + sleep 2 + tail -20 "$WORK/console.log" +fi diff --git a/scripts/run-interp-spike.sh b/scripts/run-interp-spike.sh new file mode 100755 index 00000000000..db91e1ae9d3 --- /dev/null +++ b/scripts/run-interp-spike.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# +# Phase 0 spike for the CN1 device runtime: proves that a class which exists +# only at runtime can be dispatched to, type-checked, and survive collection -- +# on a real iOS simulator and a real Android emulator. +# +# The two platforms need different mechanisms and this runs both: +# +# iOS / ParparVM runtime clazz synthesis. There is no defineClass and iOS +# forbids writing executable memory, but each class's vtable +# is heap-allocated and slot-indexed, so a subclass is built +# by copying the parent's clazz and repointing the overridden +# slots at an interpreter trampoline. +# Driver: vm/tests/src/test/resources/interp/cn1_interp_spike.c +# +# Android / ART build-time generated subclass plus real reflection. Dalvik +# has no patchable vtable and Play forbids loading dex at +# runtime, so the override guard is compiled in ahead of time. +# Driver: vm/tests/src/test/resources/interp/AndroidInterpSpike.java +# +# Both must print the same verdicts. Usage: +# scripts/run-interp-spike.sh [ios|android|all] +# +# The iOS leg builds through the JUnit integration test, which translates with +# cn1.interpHost=true and links the spike into the generated sources; the +# Android leg compiles a dex and runs it with app_process. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TARGET="${1:-all}" + +# Both `simctl ... booted` and a bare `adb shell` pick a device for you when +# more than one is running, and will happily pick the wrong one -- an iOS +# binary spawned into a booted watchOS simulator fails with a dyld_sim platform +# error that reads like a version mismatch. Always resolve a device explicitly. +pick_iphone() { + xcrun simctl list devices booted \ + | grep -E "iPhone|iPad" \ + | head -1 \ + | sed -E 's/.*\(([0-9A-F-]{36})\).*/\1/' +} + +pick_android() { + adb devices | awk '/\tdevice$/ {print $1; exit}' +} + +run_ios() { + echo "== iOS simulator ==" + local udid + udid="$(pick_iphone)" + if [ -z "$udid" ]; then + echo "no booted iPhone/iPad simulator; boot one with:" >&2 + echo " xcrun simctl boot 'iPhone 17 Pro'" >&2 + return 1 + fi + echo "device: $udid" + + # Translate + build + run on the host, which is also where the assertions + # live. This leaves the generated C in a temp dir we then rebuild for the + # simulator, so the simulator run uses exactly the code the test verified. + ( cd "$REPO_ROOT/vm" && mvn -q -B \ + -Dmaven.repo.local="$REPO_ROOT/.m2-local" \ + -pl tests -am \ + -Dtest=InterpHostVtableSynthesisIntegrationTest \ + -Dsurefire.failIfNoSpecifiedTests=false test ) + + local src + src="$(ls -td "${TMPDIR:-/tmp}"/interp-vt-run*/dist/*-src 2>/dev/null | head -1)" + if [ -z "$src" ]; then + echo "no generated sources found; did the integration test run?" >&2 + return 1 + fi + + local out="${TMPDIR:-/tmp}/interp-spike-ios" + mkdir -p "$out" + local sdk + sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" + xcrun --sdk iphonesimulator clang \ + -target arm64-apple-ios17.0-simulator -isysroot "$sdk" \ + -I"$src" -o "$out/InterpVtApp" "$src"/*.c + xcrun simctl spawn "$udid" "$out/InterpVtApp" +} + +run_android() { + echo "== Android emulator ==" + local serial + serial="$(pick_android)" + if [ -z "$serial" ]; then + echo "no attached device/emulator; boot one with:" >&2 + echo " \$ANDROID_HOME/emulator/emulator -avd -no-window" >&2 + return 1 + fi + echo "device: $serial" + + local d8 + d8="$(ls -d "$HOME"/Library/Android/sdk/build-tools/*/d8 2>/dev/null | tail -1)" + if [ -z "$d8" ]; then + echo "d8 not found under \$HOME/Library/Android/sdk/build-tools" >&2 + return 1 + fi + + # javac and d8 need different JDKs: the spike targets bytecode 8 (what the + # CN1 Android port is built against), while d8 itself is compiled for 11+. + # A single JAVA_HOME cannot satisfy both, and the failure mode is an + # UnsupportedClassVersionError from inside d8 rather than anything about + # the code being built. + local d8_java="" + for candidate in "${JAVA17_HOME:-}" \ + "$(/usr/libexec/java_home -v 17 2>/dev/null || true)" \ + "$(/usr/libexec/java_home -v 21 2>/dev/null || true)" \ + "$(/usr/libexec/java_home 2>/dev/null || true)"; do + if [ -n "$candidate" ] && [ -x "$candidate/bin/java" ]; then + d8_java="$candidate" + break + fi + done + if [ -z "$d8_java" ]; then + echo "no JDK 11+ found for d8; set JAVA17_HOME" >&2 + return 1 + fi + + local work="${TMPDIR:-/tmp}/interp-spike-android" + rm -rf "$work" && mkdir -p "$work/classes" + javac -source 8 -target 8 -nowarn -d "$work/classes" \ + "$REPO_ROOT/vm/tests/src/test/resources/interp/AndroidInterpSpike.java" 2>/dev/null + JAVA_HOME="$d8_java" "$d8" --output "$work" "$work"/classes/*.class + + adb -s "$serial" push "$work/classes.dex" /data/local/tmp/interpspike.dex >/dev/null + adb -s "$serial" shell \ + "CLASSPATH=/data/local/tmp/interpspike.dex app_process / AndroidInterpSpike" \ + | tr -d '\r' +} + +case "$TARGET" in + ios) run_ios ;; + android) run_android ;; + all) run_ios; echo; run_android ;; + *) echo "usage: $0 [ios|android|all]" >&2; exit 2 ;; +esac diff --git a/vm/ByteCodeTranslator/spotbugs-exclude.xml b/vm/ByteCodeTranslator/spotbugs-exclude.xml index 279a6032327..c2d00eeb641 100644 --- a/vm/ByteCodeTranslator/spotbugs-exclude.xml +++ b/vm/ByteCodeTranslator/spotbugs-exclude.xml @@ -191,4 +191,22 @@ + + + + + + + + diff --git a/vm/ByteCodeTranslator/src/cn1_globals.h b/vm/ByteCodeTranslator/src/cn1_globals.h index 5c9278af9d1..039ba8f5a4c 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.h +++ b/vm/ByteCodeTranslator/src/cn1_globals.h @@ -96,6 +96,16 @@ // this off and pay no overhead. //#define CN1_ON_DEVICE_DEBUG +// Uncommented by the translator (driven by the cn1.interpHost system property) +// when building the CN1 device runtime host app -- the app that loads and +// interprets bytecode bundles pushed from a developer's machine. Implies +// CN1_ON_DEVICE_DEBUG, whose invoke thunks, field tables and symbol table the +// interpreter binds against; adds constructor thunks and the vtable layout rows +// that runtime clazz synthesis needs. An interp-host build is also translated +// with the optimizer off and dead code elimination disabled, so it is much +// larger and slower than a normal app build. Never set for a shipping app. +//#define CN1_INTERP_HOST + #ifdef DEBUG_GC_ALLOCATIONS #define DEBUG_GC_VARIABLES int line; int className; #define DEBUG_GC_INIT 0, 0, @@ -533,6 +543,18 @@ typedef struct clazz* JAVA_CLASS; (*SP).data.f = pFlo; \ SP++; } +// The POP_MANY_AND_PUSH_* macros below use MAX. On iOS/macOS it arrives via +// Foundation (NSObjCRuntime.h); a plain C target has no such definition, and +// the omission went unnoticed for years because the only callers are bytecode +// shapes that dead code elimination removed before they reached the compiler. +// An interp-host build keeps everything, so they now get emitted for real. +#ifndef MAX +#define MAX(a, b) (((a) > (b)) ? (a) : (b)) +#endif +#ifndef MIN +#define MIN(a, b) (((a) < (b)) ? (a) : (b)) +#endif + #define POP_MANY_AND_PUSH_OBJ(value, offset) { \ JAVA_OBJECT pObj = value; SP[-offset].type = CN1_TYPE_INVALID; \ SP[-offset].data.o = pObj; SP[-offset].type = CN1_TYPE_OBJECT; \ diff --git a/vm/ByteCodeTranslator/src/cn1_reflect.h b/vm/ByteCodeTranslator/src/cn1_reflect.h new file mode 100644 index 00000000000..676a34bf4f7 --- /dev/null +++ b/vm/ByteCodeTranslator/src/cn1_reflect.h @@ -0,0 +1,177 @@ +/* + * 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. + */ +#ifndef __CN1_REFLECT_H__ +#define __CN1_REFLECT_H__ + +/** + * The ABI between translator-generated metadata and whatever consumes it. + * + * ParparVM has no reflection: struct clazz carries no name-to-method table, so + * nothing can call a method it did not name at compile time. What fills that + * gap is generated metadata -- a per-method invoke thunk and a per-class field + * offset table, both emitted under CN1_ON_DEVICE_DEBUG (see + * ByteCodeClass#appendOnDeviceDebugInvokeThunks and + * #appendOnDeviceDebugFieldTable) and registered at process load. + * + * These declarations used to live in the iOS port's cn1_debugger.h, which made + * the generated metadata unbuildable on any other target and tied it to a + * debugger session existing. They are not debugger-specific: the on-device + * interpreter binds framework calls through exactly the same thunks, with no + * proxy attached. They live here so the translator owns them and every target + * can compile what it generates. + * + * cn1_debugger.h includes this header rather than redeclaring these types. + */ + +#include "cn1_globals.h" + +/** + * One instance field of one class. Emitted per class as a static table and + * published by a __attribute__((constructor)) shim the translator also emits. + * + * offset is from the start of the object struct (i.e. offsetof). type is a JVM + * type-char ('I','J','F','D','Z','B','S','C','L' -- 'L' covers arrays too, + * since an array is a JAVA_OBJECT in the struct). + */ +typedef struct cn1_field_entry { + int fieldId; + int offset; + char type; + const char* name; +} cn1_field_entry; + +/** + * Argument or scratch slot for a generically dispatched call. All arguments + * travel as a flat array of these and the thunk reads the field matching each + * declared parameter. Floats and doubles round-trip through the bit width of + * their integer counterparts, since callers pass them as raw 32/64-bit values. + */ +typedef union cn1_invoke_arg { + JAVA_INT i; + JAVA_LONG j; + JAVA_FLOAT f; + JAVA_DOUBLE d; + JAVA_OBJECT o; +} cn1_invoke_arg; + +/** + * Result of a generically dispatched call. {@code type} is a JVM type-char + * ('V','I','J','F','D','L','Z','B','S','C'), or 'X' if the call threw -- in + * which case {@code value.o} carries the Throwable. + * + * A constructor thunk reports 'L' and returns the constructed object, even + * though a constructor's Java return type is void. + */ +typedef struct cn1_invoke_result { + char type; + cn1_invoke_arg value; +} cn1_invoke_result; + +/** + * Translator-emitted per-method shim. Unpacks {@code args} into the typed C + * parameters the translated function expects, dispatches through + * {@code virtual_} (instance), the plain symbol (static or constructor), + * and packs the return into {@code result}. Exceptions are caught and surfaced + * as result.type=='X' rather than unwinding past the caller. + * + * For a constructor thunk {@code thisObj} is ignored: the thunk allocates its + * own receiver and hands it back through {@code result}. + */ +typedef void (*cn1_invoke_thunk_t)(struct ThreadLocalData* threadStateData, + JAVA_OBJECT thisObj, + const cn1_invoke_arg* args, + cn1_invoke_result* result); + +/** Publishes a class's field table. Called from generated constructors. */ +extern void cn1_debugger_register_fields(int classId, + const cn1_field_entry* table, + int count); + +/** + * Publishes a class's {@code clazz} address under its classId, so a consumer + * can verify that something handed to it as an object reference really points + * at an object of a known class rather than at arbitrary memory. + */ +extern void cn1_debugger_register_class(int classId, struct clazz* cls); + +/** Publishes one method's invoke thunk under its methodId. */ +extern void cn1_debugger_register_invoke_thunk(int methodId, cn1_invoke_thunk_t thunk); + +/** A class's generated static initializer, which is idempotent. */ +typedef void (*cn1_class_init_t)(struct ThreadLocalData* threadStateData); + +/** + * Publishes a class's static initializer under its classId. + * + * The device runtime initializes a host superclass before an interpreted + * subclass's own initializer runs, and reading a static field -- the other way + * to reach an initializer -- does not exist for a class that declares none. + */ +extern void cn1_register_class_initializer(int classId, cn1_class_init_t fn); + +/** + * Translator-emitted accessor for one static field. + * + * A static field has no receiver, so the offsetof trick that covers instance + * fields does not apply: the translator emits a named C global per field plus + * typed {@code get_static_}/{@code set_static_} functions around it, and there + * is no table to index. This wraps that pair in one uniform signature so a + * caller holding only a fieldId can read or write it. + * + * When {@code write} is zero the accessor fills {@code value} and sets + * {@code *type} to the JVM type-char; otherwise it stores {@code value}. Going + * through the generated getter rather than the global directly is what runs the + * class's static initializer first, which is the whole reason the getter exists. + */ +typedef void (*cn1_static_accessor_t)(struct ThreadLocalData* threadStateData, + int write, + cn1_invoke_arg* value, + char* type); + +/** Publishes one static field's accessor under its fieldId. */ +extern void cn1_debugger_register_static_accessor(int fieldId, cn1_static_accessor_t accessor); + +/** The accessor registered for a static fieldId, or null. */ +extern cn1_static_accessor_t cn1_reflect_static_accessor_for(int fieldId); + +/* + * Lookups over the tables above. + * + * The debugger keeps these registries for its own use and reaches them through + * file-static helpers. The on-device interpreter needs the same lookups from + * another translation unit and with no debugger session attached, so they are + * exported here. A target without the registries links the weak no-op + * definitions in cn1_reflect and gets nulls, which every caller already has to + * handle -- a pushed program may legitimately name a symbol this build lacks. + */ + +/** The invoke thunk registered for a methodId, or null. */ +extern cn1_invoke_thunk_t cn1_reflect_thunk_for_method(int methodId); + +/** The field entry for (classId, fieldId), or null. */ +extern const cn1_field_entry* cn1_reflect_field_for(int classId, int fieldId); + +/** The clazz registered under a classId, or null. */ +extern struct clazz* cn1_reflect_clazz_for(int classId); + +#endif // __CN1_REFLECT_H__ diff --git a/vm/ByteCodeTranslator/src/cn1_reflect.m b/vm/ByteCodeTranslator/src/cn1_reflect.m new file mode 100644 index 00000000000..47c8407cedb --- /dev/null +++ b/vm/ByteCodeTranslator/src/cn1_reflect.m @@ -0,0 +1,106 @@ +/* + * 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. + */ + +/* + * Fallback sinks for the generated-metadata registries declared in + * cn1_reflect.h. + * + * Generated code registers its field tables, clazz addresses and invoke thunks + * from __attribute__((constructor)) shims, unconditionally, whenever the + * metadata is emitted. Whether anything is listening is a property of the + * target: the iOS port links cn1_debugger.m, which defines these for real and + * — being strong definitions — overrides the weak ones here at link time. + * + * Every other target (the clean/C target, and a host-side build of generated + * sources) has no such runtime. Without these it would not link at all, which + * is what stopped an interp-host build from being testable anywhere but iOS. + * + * These are deliberately sinks rather than a second real registry: two + * implementations of the same table is exactly the drift the generated-metadata + * design exists to avoid. A non-iOS device runtime that needs to *call* thunks + * will want a real registry, and that belongs in one place shared with the + * debugger's, not duplicated here. + */ + +#include "cn1_reflect.h" + +__attribute__((weak)) +void cn1_debugger_register_fields(int classId, const cn1_field_entry* table, int count) { + (void)classId; (void)table; (void)count; +} + +__attribute__((weak)) +void cn1_debugger_register_class(int classId, struct clazz* cls) { + (void)classId; (void)cls; +} + +__attribute__((weak)) +void cn1_debugger_register_invoke_thunk(int methodId, cn1_invoke_thunk_t thunk) { + (void)methodId; (void)thunk; +} + +/* + * The device runtime's class-initializer registry. Weak like the rest: a build + * without the interpreter's native half registers into a sink, and the iOS + * port's strong definition takes over when it is present. + */ +__attribute__((weak)) +void cn1_register_class_initializer(int classId, cn1_class_init_t fn) { + (void)classId; + (void)fn; +} + +__attribute__((weak)) +void cn1_debugger_register_static_accessor(int fieldId, cn1_static_accessor_t accessor) { + (void)fieldId; (void)accessor; +} + +/* + * Lookups. A target that registered nothing has nothing to find, so these + * answer null -- which is the same answer the real registries give for an + * unknown id, and which every caller already handles. + */ + +__attribute__((weak)) +cn1_invoke_thunk_t cn1_reflect_thunk_for_method(int methodId) { + (void)methodId; + return 0; +} + +__attribute__((weak)) +const cn1_field_entry* cn1_reflect_field_for(int classId, int fieldId) { + (void)classId; (void)fieldId; + return 0; +} + +__attribute__((weak)) +struct clazz* cn1_reflect_clazz_for(int classId) { + (void)classId; + return 0; +} + +__attribute__((weak)) +cn1_static_accessor_t cn1_reflect_static_accessor_for(int fieldId) { + (void)fieldId; + return 0; +} diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java index 89faf366608..bfaf7a07c10 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java @@ -297,34 +297,55 @@ public static void markDependencies(List lst, String[] nativeSour } } - // mark all non-final classes that aren't inherited as final for use in - // additional optimizations - for(ByteCodeClass bc : lst) { - if(bc.isFinalClass() || bc.isInterface || bc.isIsAbstract()) { - continue; - } - boolean found = false; - for(ByteCodeClass bk : lst) { - if(bk.baseClassObject == bc) { - found = true; - break; + // Both passes below reason from "this is the whole program": a class + // nothing extends can be treated as final, and a final class's methods + // need no virtual dispatch. An interp-host build breaks that premise by + // construction -- interpreted subclasses are synthesized at runtime, so + // any class may be extended by code the translator never saw. + // + // The consequences are not subtle. setVirtualOverriden suppresses the + // virtual_ wrapper AND stops __INIT_VTABLE_ from filling the method's + // slot, while call sites become direct calls. A synthesized subclass + // would then patch a slot nothing dispatches through, and every call + // would silently reach the parent implementation. + { + // mark all non-final classes that aren't inherited as final for use in + // additional optimizations + for(ByteCodeClass bc : lst) { + if(BytecodeMethod.isInterpHost() + && !BytecodeMethod.isInterpOpaqueClass(bc.getClsName())) { + continue; + } + if(bc.isFinalClass() || bc.isInterface || bc.isIsAbstract()) { + continue; + } + boolean found = false; + for(ByteCodeClass bk : lst) { + if(bk.baseClassObject == bc) { + found = true; + break; + } + } + if(!found) { + bc.setFinalClass(true); } } - if(!found) { - bc.setFinalClass(true); + + // we try to disable the "virtual" aspect of methods where possible + for(ByteCodeClass bc : lst) { + if(BytecodeMethod.isInterpHost() + && !BytecodeMethod.isInterpOpaqueClass(bc.getClsName())) { + continue; + } + if(bc.isFinalClass()) { + for(BytecodeMethod meth : bc.methods) { + if(meth.canBeVirtual() && !bc.isMethodFromBaseOrInterface(meth)) { + meth.setVirtualOverriden(true); + } + } + } } } - - // we try to disable the "virtual" aspect of methods where possible - for(ByteCodeClass bc : lst) { - if(bc.isFinalClass()) { - for(BytecodeMethod meth : bc.methods) { - if(meth.canBeVirtual() && !bc.isMethodFromBaseOrInterface(meth)) { - meth.setVirtualOverriden(true); - } - } - } - } } @@ -366,7 +387,7 @@ private ByteCodeClass findMethodOwner(String name, String desc, Set lst) { } } + /** + * Marks every class in the list, reachable or not. Used only by an + * interp-host build, which retains the whole API because pushed code may + * call any part of it. + * + *

Marking is not just bookkeeping for the cull: {@link #markDependent} + * is also what puts a class's name, its method names and its string + * literals into the constant pool. The pool's size is baked into + * cn1_class_method_index.h, which is written before any class is emitted, + * so a class that is emitted without having been marked appends its + * literals to the pool afterwards and its LDCs index past the declared + * size — reading whatever follows the array. That shows up as a string + * literal arriving as null, then a segfault, with nothing to connect it + * back to reachability.

+ */ + public static void markAll(List lst, String[] nativeSources) { + // Seed from the real roots first so anything genuinely reachable is + // marked through the normal path, then sweep up the remainder. + markDependencies(lst, nativeSources); + for (ByteCodeClass bc : lst) { + if (!bc.marked) { + bc.markDependent(lst); + } + } + } + public static List clearUnmarked(List lst) { List response = new ArrayList(); for(ByteCodeClass bc : lst) { @@ -583,6 +630,40 @@ public static void addArrayType(String type, int dimenstions) { } } + /** + * Advertises rank 1--3 array classes for every host class in an interp-host + * build. + * + *

The symbol sidecar advertises a class row (and the class-id header + * carries a {@code cn1_array_N_id_} constant) for every rank of every host + * class, whether or not the closed world used it -- that is what lets a + * pushed program write {@code new HostType[1]} at all. Emission of the + * matching {@code class_arrayN__X} struct is otherwise gated by + * {@code arrayTypes}, so a host type the AOT build never happens to use as + * an array has an advertised id whose registry entry is absent: + * {@code classObjectById()} returns null and {@code newObjectArray()} + * silently falls back to {@code Object[]}, breaking class literals, casts + * and host calls expecting {@code HostType[]}.

+ * + *

Called once after dependencies have been recomputed and before any + * class is emitted; the added entries are visible to every gate -- + * {@link #getArrayClazz}, the array-struct emission loop, the header + * externs, the array vtable initializer and the id-to-clazz registration + * below -- so declared and defined array clazzes match and the registry + * has one entry per advertised id.

+ */ + static void seedInterpHostArrayTypes(List allClasses) { + if (!BytecodeMethod.isInterpHost()) { + return; + } + for (ByteCodeClass bc : allClasses) { + String name = bc.getClsName(); + for (int rank = 1; rank <= 3; rank++) { + addArrayType(name, rank); + } + } + } + public String generateCCode(List allClasses) { @@ -597,6 +678,9 @@ public String generateCCode(List allClasses) { if (exportsClassesInterfaces.contains(s)) { continue; } + if (skipAbsentInclude(s)) { + continue; + } b.append("#include \""); b.append(s); b.append(".h\"\n"); @@ -1401,18 +1485,43 @@ private void appendOnDeviceDebugInvokeThunks(StringBuilder b) { // where this is known to bite. java.lang / java.util etc. are // fine and worth keeping (jdb leans on Object.toString for // "print" output, and we want lists/strings to round-trip too). - if (clsName.startsWith("java_io_") || clsName.startsWith("java_net_") - || clsName.startsWith("java_nio_") - || clsName.startsWith("com_codename1_impl_")) { + // + // The java.* half of that list comes off under interp-host, where it is + // actively harmful: a pushed program calling File.getPath() or + // URL.getHost() would be told the method is "not present in the + // installed app" when it plainly is. Dropping it is safe there because + // interp-host disables dead-code elimination, so those wrappers are + // already forced live by markAll whether or not a thunk points at them. + // + // com.codename1.impl stays out in every mode. It is the port's own + // native surface, where the drift is real -- IOSNative declares natives + // (createVideoComponentNSData, fillRadialGradientMutable) that no + // hand-written implementation defines, and a thunk turns that into an + // undefined symbol at link time. Interpreted code has no business + // calling the implementation layer directly in any case; it calls the + // framework, and the framework calls the port. + boolean portInternal = clsName.startsWith("com_codename1_impl_"); + boolean nativeSidecar = clsName.startsWith("java_io_") || clsName.startsWith("java_net_") + || clsName.startsWith("java_nio_"); + if (portInternal || (nativeSidecar && !BytecodeMethod.isInterpHost())) { return; } // We emit a constructor PER class that registers all of that // class's invoke thunks in one go. The thunks themselves are // file-static so they don't leak symbols. + // Constructor thunks exist only for the device runtime host build, + // where the interpreter has to be able to evaluate `new Foo(args)` for + // an arbitrary framework class. A plain on-device-debug build has no + // use for them (jdb never constructs), so it keeps the smaller output. + // + // __NEW_ -- which the ctor thunk calls -- is only emitted for + // concrete classes, so asking for one on an interface or abstract class + // would not compile. + boolean ctorThunks = BytecodeMethod.isInterpHost() && !isInterface && !isAbstract; List eligible = new ArrayList<>(); for (BytecodeMethod m : methods) { if (m.isEliminated()) continue; - if (m.isConstructor()) continue; + if (m.isConstructor() && !ctorThunks) continue; String name = m.getMethodName(); if ("__CLINIT__".equals(name) || "".equals(name)) continue; // Abstract methods have no body to call. Native methods are @@ -1452,7 +1561,10 @@ private void appendOnDeviceDebugFieldTable(StringBuilder b) { // we don't have, but this should never happen — translator pulls in // parents transitively. b.append("\n#ifdef CN1_ON_DEVICE_DEBUG\n"); - b.append("#import \"cn1_debugger.h\"\n"); + // cn1_reflect.h, not cn1_debugger.h: the metadata ABI is translator-owned + // and every target has to be able to compile what it generates. The iOS + // port's debugger header includes this one, so an iOS build is unchanged. + b.append("#include \"cn1_reflect.h\"\n"); b.append("static const cn1_field_entry __cn1_dbg_fields_").append(clsName).append("[] = {\n"); for (ByteCodeField bf : instance) { String declCls = bf.getClsName().replace('/', '_').replace('$', '_'); @@ -1465,6 +1577,7 @@ private void appendOnDeviceDebugFieldTable(StringBuilder b) { .append(bf.getFieldName()).append("\" },\n"); } b.append("};\n"); + appendInterpStaticAccessors(b); b.append("__attribute__((constructor)) static void __cn1_dbg_register_").append(clsName).append("(void) {\n"); b.append(" cn1_debugger_register_fields(cn1_class_id_").append(clsName).append(",\n"); b.append(" __cn1_dbg_fields_").append(clsName).append(",\n"); @@ -1475,10 +1588,181 @@ private void appendOnDeviceDebugFieldTable(StringBuilder b) { // this constructor, so the registry is complete before main(). b.append(" cn1_debugger_register_class(cn1_class_id_").append(clsName) .append(", &class__").append(clsName).append(");\n"); + appendInterpStaticAccessorRegistrations(b); + appendInterpClassInitializerRegistration(b); + appendInterpArrayClassRegistrations(b); b.append("}\n"); b.append("#endif // CN1_ON_DEVICE_DEBUG\n"); } + /** + * Emits an accessor per static field, and registers each by fieldId. + * + *

Only under interp-host. The debugger reads statics through its own + * command path; the interpreter cannot, because a pushed program's + * {@code GETSTATIC java/lang/System.out} arrives as a name and there is + * nothing to resolve it against. Without this, every host static read + * failed -- which is most programs, since {@code System.out.println} is + * one.

+ * + *

An instance field needs no accessor: it is an offset from a receiver + * and the table above covers it. A static field has no receiver, and the + * translator gives it a named C global plus typed + * {@code get_static_}/{@code set_static_} functions rather than a table. + * Going through those functions rather than the global matters: the getter + * runs {@code __STATIC_INITIALIZER_} first, so reading a static from + * interpreted code initialises the class exactly as compiled code would.

+ */ + private void appendInterpStaticAccessorRegistrations(StringBuilder b) { + if (!BytecodeMethod.isInterpHost() || staticFieldList == null) { + return; + } + for (ByteCodeField bf : staticFieldList) { + if (!isOwnStatic(bf)) { + continue; + } + int fid = Parser.getOrAssignFieldId(clsName, bf.getFieldName()); + b.append(" cn1_debugger_register_static_accessor(").append(fid) + .append(", &__cn1_sacc_").append(fid).append(");\n"); + } + } + + /** + * Publishes this class's array clazz objects under their class ids. + * + *

`String[].class` in a pushed program resolves to an array class id, + * and turning that id back into a class object needs the clazz registered + * -- the same registry that answers for ordinary classes.

+ */ + private void appendInterpArrayClassRegistrations(StringBuilder b) { + if (!BytecodeMethod.isInterpHost()) { + return; + } + // Every rank the sidecar advertises. `seedInterpHostArrayTypes` puts + // ranks 1--3 into `arrayTypes` for every host class in an interp-host + // build, which is what the symbol sidecar advertises class rows for and + // what the id-to-clazz registry has to answer for. Gating this on prior + // AOT array use would leave `classObjectById()` returning null for a + // valid advertised id -- observed as `newObjectArray()` falling back to + // `Object[]` for `HostType[]` and breaking casts, class literals and + // host calls expecting the array type. + for (int rank = 1; rank <= 3; rank++) { + if (!arrayTypes.contains(rank + "_" + clsName)) { + continue; + } + b.append(" cn1_debugger_register_class(cn1_array_").append(rank) + .append("_id_").append(clsName).append(", (struct clazz*)&class_array") + .append(rank).append("__").append(clsName).append(");\n"); + } + } + + /** + * Publishes this class's or interface's static initializer under its + * class id. + * + *

Reading a static field runs it, which covers most classes -- but a + * class can have an observable static block and declare no static field at + * all, and then there is nothing to read. The device runtime has to + * initialize a host superclass before an interpreted subclass's own + * initializer runs, so it needs to name one directly.

+ * + *

Interfaces need the same handle, for the same reason: JLS 12.4.1 + * requires a default-bearing superinterface to initialize before its + * implementor's initializer runs, and the linker's traversal calls + * {@code initializeClassById} on each such interface. Without a registered + * initializer that call is a no-op, and a host interface with a + * nonconstant {@code } stays uninitialized until its methods are + * first entered -- which may be never, and is always after the + * implementor's constructor. {@code __STATIC_INITIALIZER_} is emitted for + * interfaces too, so registering it costs one row per interface.

+ * + *

{@code __STATIC_INITIALIZER_} is idempotent (it returns immediately + * once the class is loaded), so calling it when the class is already + * initialized costs a comparison.

+ */ + private void appendInterpClassInitializerRegistration(StringBuilder b) { + if (!BytecodeMethod.isInterpHost()) { + return; + } + b.append(" cn1_register_class_initializer(cn1_class_id_").append(clsName) + .append(", &__STATIC_INITIALIZER_").append(clsName).append(");\n"); + } + + /** + * Whether this class physically stores the static field. + * + *

{@code staticFieldList} walks the hierarchy, but only the declaring + * class emits storage and accessors for a static -- so registering from any + * other class would name a C symbol that translation unit does not have.

+ */ + private boolean isOwnStatic(ByteCodeField bf) { + return bf.isStaticField() && bf.getClsName().equals(clsName); + } + + /** + * Whether this static is emitted as an inlined constant getter rather than + * as storage. Mirrors the condition in the emission loop above; a constant + * has a getter and no setter, so its accessor must be read-only. + */ + private static boolean isInlinedConstant(ByteCodeField bf) { + return bf.isFinal() && bf.getValue() != null + && !writableFields.contains(bf.getFieldName()); + } + + /** + * Emits the accessor bodies. Separate from the registration above only + * because C wants them defined before the constructor that takes their + * address. + */ + private void appendInterpStaticAccessors(StringBuilder b) { + if (!BytecodeMethod.isInterpHost() || staticFieldList == null) { + return; + } + for (ByteCodeField bf : staticFieldList) { + if (!isOwnStatic(bf)) { + continue; + } + int fid = Parser.getOrAssignFieldId(clsName, bf.getFieldName()); + char tc = onDeviceDebugTypeCharFor(bf); + String slot = interpArgSlotFor(tc); + String suffix = clsName + "_" + bf.getFieldName().replace('$', '_'); + b.append("static void __cn1_sacc_").append(fid) + .append("(CODENAME_ONE_THREAD_STATE, int write, cn1_invoke_arg* value, char* type) {\n"); + b.append(" *type = '").append(tc).append("';\n"); + if (isInlinedConstant(bf)) { + // Emitted as a constant getter with no storage and no setter, + // so a write has nowhere to go. Java would not compile one + // either -- the field is final. + b.append(" if (write) { return; }\n"); + } else { + // The generated setter takes thread state only for object + // fields -- those touch the write barrier and the heap + // collection, primitives do not. Passing it unconditionally + // fails to compile on every primitive static in the app. + b.append(" if (write) {\n"); + b.append(" set_static_").append(suffix).append("(") + .append(bf.isObjectType() ? "threadStateData, (" : "(") + .append(bf.getCDefinition()).append(")value->").append(slot).append(");\n"); + b.append(" return;\n"); + b.append(" }\n"); + } + b.append(" value->").append(slot).append(" = get_static_") + .append(suffix).append("();\n"); + b.append("}\n"); + } + } + + /** The {@code cn1_invoke_arg} member a JVM type-char travels in. */ + private static String interpArgSlotFor(char typeChar) { + switch (typeChar) { + case 'J': return "j"; + case 'F': return "f"; + case 'D': return "d"; + case 'L': return "o"; + default: return "i"; // I, Z, B, S, C all ride in the int slot + } + } + private static char onDeviceDebugTypeCharFor(ByteCodeField bf) { // Object and arrays — both stored as JAVA_OBJECT in the C struct. if (bf.isObjectType()) return 'L'; @@ -1660,6 +1944,29 @@ private List buildStaticFieldList(List fieldList, return fieldList; } + /** + * Whether an {@code #include} of the given class should be dropped because + * no such class was translated. Only ever true for an interp-host build. + * + *

A dependency is recorded for a field's declared type as well as for + * anything the code calls, and ParparVM's JavaAPI simply does not contain + * some types the wider ecosystem references -- Kotlin's stdlib has fields + * typed {@code java.io.BufferedReader} and {@code java.nio.ByteBuffer}, + * neither of which exists here. Reachability normally removes the whole + * class before this matters; an interp-host build keeps it, and the include + * then names a header that was never generated.

+ * + *

Dropping the include is safe: every object field is a + * {@code JAVA_OBJECT} in the emitted struct regardless of its Java type, so + * the header is only needed by code that calls into the class -- and any + * method that did was already eliminated for referencing an absent + * class.

+ */ + private static boolean skipAbsentInclude(String mangledClassName) { + return BytecodeMethod.isInterpHost() + && Parser.getClassObject(mangledClassName) == null; + } + private void addFields(StringBuilder b) { if(baseClassObject != null) { baseClassObject.addFields(b); @@ -1698,6 +2005,9 @@ public String generateCHeader() { //if (isAnnotation) { // continue; //} + if (skipAbsentInclude(s)) { + continue; + } b.append("#include \""); b.append(s); b.append(".h\"\n"); @@ -2096,7 +2406,20 @@ private void fillVirtualMethodTable(List virtualMethods, boolean } } for(BytecodeMethod bm : methods) { - if (bm.isEliminated()) continue; + // An eliminated method still gets a stub body emitted ("return 0;"), + // and callers that survived still reference it. Dropping it from the + // table removes its virtual_ wrapper and its header declaration + // while those call sites remain -- an undeclared-function error in + // the class's own generated C. + // + // A normal build never hits this because elimination is driven by + // reachability, so nothing that survived can call what was removed. + // An interp-host build eliminates on a different axis entirely -- + // the member the method references does not exist on this platform + // -- which says nothing about whether anything calls it. Keep the + // slot; calling it reaches the stub, which is the honest answer for + // an API the platform does not have. + if (bm.isEliminated() && !BytecodeMethod.isInterpHost()) continue; if(bm.canBeVirtual()) { int offset = virtualMethods.indexOf(bm); if(offset < 0) { @@ -2257,7 +2580,7 @@ public boolean isIsAbstract() { public void setIsAbstract(boolean isAbstract) { this.isAbstract = isAbstract; } - + private void appendClassVFunctions(StringBuilder b) { // special case, class has no Class object within it so no real virtual functions b.append("JAVA_BOOLEAN virtual_java_lang_Class_equals___java_lang_Object_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject, JAVA_OBJECT __cn1Arg1) {\n" + diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index f868a4686a3..37e724108cc 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -340,11 +340,28 @@ private static void handleCleanOutput(ByteCodeTranslator b, File[] sources, File if (System.getProperty("INCLUDE_NPE_CHECKS", "false").equals("true")) { replaceInFile(cn1Globals, "//#define CN1_INCLUDE_NPE_CHECKS", "#define CN1_INCLUDE_NPE_CHECKS"); } - if ("true".equalsIgnoreCase(System.getProperty("cn1.onDeviceDebug", "false"))) { + // Ask BytecodeMethod rather than re-reading the property: an interp-host + // build turns on-device-debug emission on implicitly, and re-reading + // cn1.onDeviceDebug here would leave the macro commented out while the + // thunks and field tables were still emitted behind #ifdef + // CN1_ON_DEVICE_DEBUG -- i.e. the whole mechanism would silently + // compile to nothing. + if (BytecodeMethod.isOnDeviceDebug()) { replaceInFile(cn1Globals, "//#define CN1_ON_DEVICE_DEBUG", "#define CN1_ON_DEVICE_DEBUG"); } + if (BytecodeMethod.isInterpHost()) { + replaceInFile(cn1Globals, "//#define CN1_INTERP_HOST", "#define CN1_INTERP_HOST"); + } + // The ABI for the metadata the generated code emits under + // CN1_ON_DEVICE_DEBUG. Always copied, so include order never depends on + // which target is building; the weak sinks in cn1_reflect let a target + // without a debugger runtime link what it generated. + File cn1Reflect = new File(srcRoot, "cn1_reflect.h"); + copy(ByteCodeTranslator.class.getResourceAsStream("/cn1_reflect.h"), Files.newOutputStream(cn1Reflect.toPath())); File cn1GlobalsC = new File(srcRoot, "cn1_globals.c"); copy(ByteCodeTranslator.class.getResourceAsStream("/cn1_globals.m"), Files.newOutputStream(cn1GlobalsC.toPath())); + File cn1ReflectC = new File(srcRoot, "cn1_reflect.c"); + copy(ByteCodeTranslator.class.getResourceAsStream("/cn1_reflect.m"), Files.newOutputStream(cn1ReflectC.toPath())); File nativeMethodsC = new File(srcRoot, "nativeMethods.c"); copy(ByteCodeTranslator.class.getResourceAsStream("/nativeMethods.m"), Files.newOutputStream(nativeMethodsC.toPath())); if (System.getProperty("USE_RPMALLOC", "false").equals("true")) { @@ -674,16 +691,30 @@ private static void handleIosOutput(ByteCodeTranslator b, File[] sources, File d if (System.getProperty("INCLUDE_NPE_CHECKS", "false").equals("true")) { replaceInFile(cn1Globals, "//#define CN1_INCLUDE_NPE_CHECKS", "#define CN1_INCLUDE_NPE_CHECKS"); } - if ("true".equalsIgnoreCase(System.getProperty("cn1.onDeviceDebug", "false"))) { + // Ask BytecodeMethod rather than re-reading the property: an interp-host + // build turns on-device-debug emission on implicitly, and re-reading + // cn1.onDeviceDebug here would leave the macro commented out while the + // thunks and field tables were still emitted behind #ifdef + // CN1_ON_DEVICE_DEBUG -- i.e. the whole mechanism would silently + // compile to nothing. + if (BytecodeMethod.isOnDeviceDebug()) { replaceInFile(cn1Globals, "//#define CN1_ON_DEVICE_DEBUG", "#define CN1_ON_DEVICE_DEBUG"); } + if (BytecodeMethod.isInterpHost()) { + replaceInFile(cn1Globals, "//#define CN1_INTERP_HOST", "#define CN1_INTERP_HOST"); + } + // The ABI for the metadata the generated code emits under + // CN1_ON_DEVICE_DEBUG. Always copied, so include order never depends on + // which target is building; the weak sinks in cn1_reflect let a target + // without a debugger runtime link what it generated. + File cn1Reflect = new File(srcRoot, "cn1_reflect.h"); + copy(ByteCodeTranslator.class.getResourceAsStream("/cn1_reflect.h"), Files.newOutputStream(cn1Reflect.toPath())); File cn1GlobalsM = new File(srcRoot, "cn1_globals.m"); copy(ByteCodeTranslator.class.getResourceAsStream("/cn1_globals.m"), Files.newOutputStream(cn1GlobalsM.toPath())); + File cn1ReflectM = new File(srcRoot, "cn1_reflect.m"); + copy(ByteCodeTranslator.class.getResourceAsStream("/cn1_reflect.m"), Files.newOutputStream(cn1ReflectM.toPath())); File nativeMethods = new File(srcRoot, "nativeMethods.m"); copy(ByteCodeTranslator.class.getResourceAsStream("/nativeMethods.m"), Files.newOutputStream(nativeMethods.toPath())); - File javaIoFileM = new File(srcRoot, "java_io_File.m"); - copy(ByteCodeTranslator.class.getResourceAsStream("/java_io_File.m"), Files.newOutputStream(javaIoFileM.toPath())); - if (System.getProperty("USE_RPMALLOC", "false").equals("true")) { File malloc = new File(srcRoot, "malloc.c"); copy(ByteCodeTranslator.class.getResourceAsStream("/malloc.c"), Files.newOutputStream(malloc.toPath())); @@ -705,6 +736,26 @@ private static void handleIosOutput(ByteCodeTranslator b, File[] sources, File d Parser.writeOutput(srcRoot); + // java.io.File's native bodies. The file has to be named something the + // translator will not also emit, and has to be written after + // writeOutput: it used to be copied to "java_io_File.m" beforehand, so + // the generated class of the same name overwrote it and every + // java_io_File_*Impl symbol went missing. Nothing noticed because + // java.io.File is unreachable in a normal CN1 app -- CN1 code uses + // FileSystemStorage -- so reachability removed the callers before the + // link. An interp-host build keeps them and the link fails. + // + // This mirrors what the clean target has always done correctly + // ("java_io_File_runtime.c", copied after writeOutput), including the + // guard: these bodies open with #import "java_io_File.h", so writing + // them into a translation that dropped the class is a source file that + // cannot compile. A watch slice is translated separately from a stub + // main and reaches no java.io.File at all, which is exactly that case. + if (new File(srcRoot, "java_io_File.h").exists()) { + File javaIoFileM = new File(srcRoot, "java_io_File_runtime.m"); + copy(ByteCodeTranslator.class.getResourceAsStream("/java_io_File.m"), Files.newOutputStream(javaIoFileM.toPath())); + } + File templateInfoPlist = new File(srcRoot, appName + "-Info.plist"); copy(ByteCodeTranslator.class.getResourceAsStream("/template/template/template-Info.plist"), Files.newOutputStream(templateInfoPlist.toPath())); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java index f273614057a..e168f20cc3e 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java @@ -157,18 +157,73 @@ public static void setDependencyGraph(MethodDependencyGraph dependencyGraph) { */ static boolean onDeviceDebug; + /** + * When true the translator is building the CN1 device runtime host app -- + * the app that loads and interprets bytecode bundles pushed from a + * developer's machine. That app is not a normal CN1 app: it has to keep the + * entire API surface reachable (pushed code may call anything) and it has + * to let the interpreter patch vtable slots so an interpreted subclass of a + * framework class gets its overrides invoked by AOT callers. + * + * Implies {@link #onDeviceDebug} (we reuse the invoke thunks, field tables + * and symbol table it emits) and forces the optimizer off, since + * devirtualization and inlining would bypass the very vtable slots the + * interpreter needs to patch. Toggled via the cn1.interpHost system + * property. Never set for a normal app build. + */ + static boolean interpHost; + static { String op = System.getProperty("optimizer"); optimizerOn = op == null || op.equalsIgnoreCase("on"); //optimizerOn = false; onDeviceDebug = "true".equalsIgnoreCase(System.getProperty("cn1.onDeviceDebug", "false")); + + interpHost = "true".equalsIgnoreCase(System.getProperty("cn1.interpHost", "false")); + if (interpHost) { + // Order matters: both of these deliberately override whatever the + // properties above resolved to. An interp-host build that silently + // kept the optimizer on would devirtualize the calls the + // interpreter patches and fail in a way that's very hard to + // diagnose from the C output, so make it impossible to ask for. + onDeviceDebug = true; + optimizerOn = false; + } } public static boolean isOnDeviceDebug() { return onDeviceDebug; } + /** + * True when building the device runtime host app. See {@link #interpHost}. + */ + public static boolean isInterpHost() { + return interpHost; + } + + /** + * Whether an interp-host build may keep treating this class as closed -- + * inferring finality and devirtualizing calls to it -- because no + * interpreted subclass of it can exist. + * + *

Interpreted code extends framework classes: Form, Component, a Layout. + * It has no reason to extend the platform implementation layer, and the + * generated-metadata skip list in + * {@link ByteCodeClass#appendOnDeviceDebugInvokeThunks} already excludes + * the same package for the same reason.

+ * + *

Keeping the optimization here is not merely a saving. Every method of + * a class like {@code com.codename1.impl.ios.IOSNative} is native, and + * several have no Objective-C body in the port -- unreachable ones nothing + * ever called. Giving them vtable slots makes {@code __INIT_VTABLE_} take + * their addresses, which turns "never called" into "does not link".

+ */ + public static boolean isInterpOpaqueClass(String clsName) { + return clsName != null && clsName.startsWith("com_codename1_impl_"); + } + public boolean isBarebone() { return barebone; } @@ -2549,12 +2604,13 @@ public int getMethodOffset() { */ public void appendOnDeviceDebugInvokeThunk(String declaringClsName, StringBuilder b) { String symbol = declaringClsName + "_"; - if ("".equals(methodName)) { - // skipped at caller, but defensive - return; - } else if ("".equals(methodName)) { + // Class initializers have no callable form. Constructors DO get a thunk + // under an interp-host build (the interpreter needs `new Form()`), and + // are handled below; the caller decides whether to ask for one. + if ("".equals(methodName) || "__CLINIT__".equals(methodName)) { return; } + boolean ctor = isConstructor(); symbol += getCMethodName(); // Append the descriptor suffix the translator uses // (args + _R for non-void). @@ -2572,7 +2628,18 @@ public void appendOnDeviceDebugInvokeThunk(String declaringClsName, StringBuilde // class is final, so the dispatch was constant-folded) have no // virtual_ alias in their header, and the thunk has to call the // plain symbol or the C file won't compile. - boolean useVirtualPrefix = !staticMethod && !privateMethod && !virtualOverriden; + // java.lang.Class has no vtable of its own -- it is the one class whose + // objects have no class -- so ByteCodeClass emits only three + // hand-written virtual_ wrappers for it (equals/getClass/hashCode, + // inherited from Object) and none for the methods Class declares. Those + // declared methods are exactly the ones a thunk is generated for, and + // each has a direct symbol, so call that. Ordinary builds never hit this + // because dead code elimination removes most Class thunks first. + boolean noVirtualAlias = "java_lang_Class".equals(declaringClsName); + // Constructors are never dispatched virtually, so they have no + // virtual_ alias even though they are non-static and non-private. + boolean useVirtualPrefix = !ctor && !noVirtualAlias + && !staticMethod && !privateMethod && !virtualOverriden; String callSymbol = useVirtualPrefix ? ("virtual_" + fullSymbol) : fullSymbol; int mid = methodOffset; @@ -2590,7 +2657,22 @@ public void appendOnDeviceDebugInvokeThunk(String declaringClsName, StringBuilde b.append(" threadStateData->tryBlockOffset++;\n"); // Emit the actual call b.append(" "); - if (returnType.isVoid()) { + if (ctor) { + // Allocate first, then run against the fresh object and hand + // it back as the result -- so the interpreter gets `new Foo(args)` + // as a single invoke rather than having to model NEW/DUP/INVOKESPECIAL. + // + // __r is a plain C local across the call, which can allocate + // and therefore trigger a collection. That is safe because the + // collector conservatively scans each thread's native C stack + // (cn1GcScanThreadNativeStack, unconditional via + // CN1_CONSERVATIVE_GC_ROOTS in cn1_globals.h) in addition to the + // precise threadObjectStack walk -- the same property frameless + // frames rely on. Do not "optimize" __r into a reused slot. + b.append("JAVA_OBJECT __r = __NEW_").append(declaringClsName) + .append("(threadStateData);\n "); + b.append(callSymbol).append("(threadStateData, __r"); + } else if (returnType.isVoid()) { b.append(callSymbol).append("(threadStateData"); } else { // Capture return into a typed temp, then pack. @@ -2602,7 +2684,8 @@ public void appendOnDeviceDebugInvokeThunk(String declaringClsName, StringBuilde else b.append("JAVA_INT __r = "); b.append(callSymbol).append("(threadStateData"); } - if (!staticMethod) { + if (!staticMethod && !ctor) { + // the ctor path already passed the freshly allocated __r as the receiver b.append(", thisObj"); } for (int i = 0; i < arguments.size(); i++) { @@ -2620,7 +2703,12 @@ public void appendOnDeviceDebugInvokeThunk(String declaringClsName, StringBuilde b.append(");\n"); // Pop our try block (no exception path) and store the result. b.append(" threadStateData->tryBlockOffset--;\n"); - if (returnType.isVoid()) { + if (ctor) { + // A constructor's Java return type is void, but the thunk's value + // is the constructed object -- that is the whole point of it. + b.append(" result->type = 'L';\n"); + b.append(" result->value.o = __r;\n"); + } else if (returnType.isVoid()) { b.append(" result->type = 'V';\n"); } else { char rq = returnType.getQualifier(); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/DevicePush.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/DevicePush.java new file mode 100644 index 00000000000..f4feec38114 --- /dev/null +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/DevicePush.java @@ -0,0 +1,869 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.MethodNode; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.Properties; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Sends a compiled project to a device runtime and waits for the result. + * + *

Run it from an IDE: the IDE has already compiled the project, so this + * takes the class output and the sources, packages a bundle, waits for the + * device to dial in, and prints what the device made of it. Editing and + * re-running is the whole loop -- nothing is installed, nothing is rebuilt on + * the device.

+ * + *

Which way the connection goes

+ * + *

The device dials this tool, not the other way round. A phone cannot accept + * an inbound connection on a normal network, and on a USB session the device's + * loopback is mapped onto the desktop's. So this listens and waits.

+ * + *

Pairing

+ * + *

Over USB the transport is loopback and possession of the cable is the + * authentication. Over Wi-Fi it is not: any machine on the network could answer + * a device's dial, and the bundle carries the program's whole source. So a + * network session pairs first. This prints a six-digit code; the code is typed + * on the device; both ends then derive the same secret from it without ever + * sending it, and the device challenges this computer to prove it holds the + * same one. Every connection afterwards answers a fresh challenge that also + * covers the bundle, so a captured frame authenticates nothing the second time + * and a program cannot be swapped in behind a valid answer. The device still + * asks its user to approve, unless they chose "Always".

+ * + * @author Shai Almog + */ +public final class DevicePush { + private static final int MAGIC = 0x434E3150; // "CN1P" + private static final int V1 = 1; + + /** + * Challenge-response push. There was a v2 in which the peer id alone + * authorised a push -- a plaintext bearer token on a LAN -- and it is gone + * rather than deprecated. + */ + private static final int V3 = 3; + private static final int FRAME_PING = 0; + private static final int FRAME_PAIR = 1; + private static final int FRAME_PUSH = 2; + + /** Must equal InterpPairingSecret.ITERATIONS, or nothing pairs. */ + private static final int PAIRING_ITERATIONS = 20000; + + /** + * One instance, seeded once. A fresh SecureRandom per call re-seeds every + * time, which is both slower and worse, and these values are a peer id and + * a pairing code -- guessing either is the whole attack. + */ + private static final SecureRandom RANDOM = new SecureRandom(); + + private DevicePush() { + } + + public static void main(String[] args) throws Exception { + File classes = new File("target/classes"); + // Sources accumulate: a Kotlin project has classes under both + // `src/main/java` and `src/main/kotlin`, and the bundle needs every + // root so `SourceFile` attributes -- `MyApp.kt` for a Kotlin class -- + // resolve. Repeated `--source` on the command line each add one root. + List sources = new ArrayList(); + String mainClass = ""; + int port = 18234; + boolean lan = false; + String device = null; + + for (int i = 0; i < args.length; i++) { + String a = args[i]; + if ("--classes".equals(a)) { + classes = new File(args[++i]); + } else if ("--source".equals(a)) { + sources.add(new File(args[++i])); + } else if ("--main".equals(a)) { + mainClass = args[++i]; + } else if ("--port".equals(a)) { + port = Integer.parseInt(args[++i]); + } else if ("--lan".equals(a)) { + lan = true; + } else if ("--device".equals(a) && i + 1 < args.length) { + device = args[++i]; + lan = true; + } else if ("--help".equals(a)) { + usage(); + return; + } + } + if (sources.isEmpty()) { + // The default retains the classic single-root behaviour for a + // caller that never passes --source. A caller with more than + // one root passes each explicitly. + sources.add(new File("src/main/java")); + } + if (!classes.isDirectory()) { + System.err.println("no compiled classes at " + classes.getAbsolutePath() + + " -- build the project first"); + System.exit(2); + } + + byte[] bundle = buildBundle(classes, sources, mainClass); + System.out.println("bundle " + bundle.length + " bytes"); + + if (lan) { + System.out.println(); + System.out.println(" This computer: " + describeAddresses(port)); + System.out.println(" Enter that address on the device, under Desktop."); + System.out.println(); + } + // An explicit address, for a network that will not let a scan work -- + // guest Wi-Fi with client isolation, a VPN, two subnets. The device + // shows its own address on screen, and Maven passes it straight + // through: mvn -Ppush-lan package -Ddevruntime.device=192.168.1.50 + if (device == null) { + String p = System.getProperty("devruntime.device"); + if (p != null && p.trim().length() > 0) { + device = p.trim(); + lan = true; + } + } + explicitDevice = device; + push(bundle, port, lan); + } + + private static void usage() { + System.out.println("DevicePush [--classes dir] [--source dir] [--main Class]" + + " [--port 18234] [--lan] [--device
]"); + System.out.println(" --device connect straight to a device at this address," + + " which the runtime shows on screen"); + System.out.println(" --lan the device is on Wi-Fi rather than USB;" + + " listens on every interface and pairs first"); + } + + // ------------------------------------------------------------ the bundle + + private static byte[] buildBundle(File classesDir, List sourceRoots, String mainClass) + throws Exception { + InterpBundleWriter w = new InterpBundleWriter(); + List classes = new ArrayList(); + collect(classesDir, classes); + if (classes.isEmpty()) { + throw new IllegalStateException("no .class files under " + classesDir); + } + for (File f : classes) { + w.addClassFile(f); + } + // Every configured root contributes sources and resources. A Kotlin + // project passes `src/main/java` and `src/main/kotlin`; a mixed + // project may add another. The runtime refuses to run a class whose + // source it cannot show, and resources -- theme.res, CSS, images -- + // travel with the bundle so the program wears its own design rather + // than the host app's. + for (File sourceRoot : sourceRoots) { + if (sourceRoot.isDirectory()) { + w.addSourceTree(sourceRoot); + w.addResourceTree(sourceRoot); + } + File res = new File(sourceRoot.getParentFile(), "resources"); + if (res.isDirectory()) { + w.addResourceTree(res); + } + } + if (mainClass.length() == 0) { + mainClass = findEntryPoint(classes); + System.out.println("entry point " + mainClass.replace('/', '.')); + } + w.setMainClass(mainClass.replace('.', '/')); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + w.write(out); + return out.toByteArray(); + } + + /** + * The class to enter: a {@code main(String[])} if the project has one, + * otherwise a {@code Lifecycle} subclass, which is what a real application + * has. + */ + static String findEntryPoint(List classes) throws IOException { + java.util.Map supers = new java.util.HashMap(); + java.util.Set abstractClasses = new java.util.HashSet(); + java.util.List mains = new java.util.ArrayList(); + for (File f : classes) { + ClassNode cn = new ClassNode(); + new ClassReader(Files.readAllBytes(f.toPath())).accept(cn, ClassReader.SKIP_CODE); + for (Object mo : cn.methods) { + MethodNode m = (MethodNode) mo; + // Java's entry-point rule is `public static void main(String[])` + // exactly -- a package-private or private helper of the same + // signature is not a program entry, and picking one up here + // preferred an inaccessible method over a valid Lifecycle + // subclass and had the runtime try to invoke it. + if ("main".equals(m.name) && "([Ljava/lang/String;)V".equals(m.desc) + && (m.access & Opcodes.ACC_STATIC) != 0 + && (m.access & Opcodes.ACC_PUBLIC) != 0) { + mains.add(cn.name); + } + } + supers.put(cn.name, cn.superName); + if ((cn.access & (Opcodes.ACC_ABSTRACT | Opcodes.ACC_INTERFACE)) != 0) { + abstractClasses.add(cn.name); + } + } + if (!mains.isEmpty()) { + // Sorted, because listFiles() has no defined order: a tree with a + // second main -- a diagnostic launcher, a utility -- would + // otherwise push one program today and the other tomorrow from the + // same sources. Naming them says which was chosen and that there + // was a choice. + java.util.Collections.sort(mains); + if (mains.size() > 1) { + System.out.println("more than one main(String[]): " + mains + + " -- entering " + mains.get(0)); + } + return mains.get(0); + } + // Transitively, and skipping the abstract ones. A project whose app + // extends its own BaseApp extends Lifecycle has two Lifecycle + // descendants, and entering the wrong one runs a class that was never + // meant to be instantiated. + String lifecycle = null; + for (java.util.Map.Entry e : supers.entrySet()) { + if (abstractClasses.contains(e.getKey()) || !descendsFromLifecycle(e.getKey(), supers)) { + continue; + } + if (lifecycle == null) { + lifecycle = e.getKey(); + continue; + } + // Deepest wins: with BaseApp and MyApp both descending from + // Lifecycle, MyApp is the application. A genuine tie is broken by + // name, because entries arrive in hash order and an entry point + // that changes between two identical pushes is worse than either + // answer. + int mine = depthOf(e.getKey(), supers); + int best = depthOf(lifecycle, supers); + if (mine > best || (mine == best && e.getKey().compareTo(lifecycle) < 0)) { + lifecycle = e.getKey(); + } + } + if (lifecycle != null) { + return lifecycle; + } + throw new IllegalStateException( + "no entry point: expected a main(String[]) or a Lifecycle subclass"); + } + + /** + * Whether a class reaches Lifecycle through its superclasses. + * + *

Bounded by what has been seen, not by a count: a chain of classes is + * acyclic, and a count refused a hierarchy for being deep -- reporting that + * a project with an entry point has none.

+ */ + private static boolean descendsFromLifecycle(String name, + java.util.Map supers) { + java.util.Set seen = new java.util.HashSet(); + String parent = supers.get(name); + while (parent != null && seen.add(parent)) { + if ("com/codename1/system/Lifecycle".equals(parent)) { + return true; + } + parent = supers.get(parent); + } + return false; + } + + /** How far a class sits below the deepest ancestor the bundle knows. */ + private static int depthOf(String name, java.util.Map supers) { + java.util.Set seen = new java.util.HashSet(); + int depth = 0; + String at = supers.get(name); + while (at != null && seen.add(at)) { + depth++; + at = supers.get(at); + } + return depth; + } + + private static void collect(File dir, List out) { + File[] kids = dir.listFiles(); + if (kids == null) { + return; + } + for (File f : kids) { + if (f.isDirectory()) { + collect(f, out); + } else if (f.getName().endsWith(".class")) { + out.add(f); + } + } + } + + // ----------------------------------------------------------- the transport + + private static void push(byte[] payload, int port, boolean lan) throws Exception { + if (!lan) { + // Loopback: the only thing that can answer is a USB-authorised + // device or a simulator on this machine, so there is nothing for a + // pairing step to establish that possession has not already. + send(payload, port, null, true); + return; + } + String peerId = peerId(); + String peerName = System.getProperty("user.name") + "@" + + InetAddress.getLocalHost().getHostName(); + if (send(payload, port, peerId, false)) { + return; + } + if (!rejectedAsUnpaired()) { + // Denied on the device, or the program threw. Either way the push + // did not run, and a Maven goal that exits 0 there reports green + // for a program that never started. + System.exit(1); + } + { + // Either this computer has never paired, or the device has + // forgotten it -- reinstalled, or "forget paired computers". Both + // recover the same way, and doing it automatically beats making the + // user work out why a push that worked yesterday does not today. + String code = String.format("%06d", RANDOM.nextInt(1000000)); + System.out.println(); + System.out.println(" =============================="); + System.out.println(" Pairing code: " + code); + System.out.println(" =============================="); + System.out.println(" Type it on the device now and press Pair."); + System.out.println(" This computer is " + describeAddresses(port) + "."); + System.out.println(); + if (!pair(port, peerId, peerName, code)) { + System.exit(1); + } + System.out.println("paired; pushing"); + if (!send(payload, port, peerId, false)) { + System.exit(1); + } + } + } + + /** + * Pairs with a device: two round trips, since the device cannot challenge + * until a human has typed the code the challenge is answered with. + * + *

The secret is derived here and on the device from the same three + * inputs and is never transmitted. What this sends is an HMAC over the + * device's nonce, which authenticates this exchange and no other.

+ */ + private static boolean pair(int port, String peerId, String peerName, String code) + throws Exception { + Socket s = accept(port, false, 180000); + try { + s.setSoTimeout(180000); // the user has to read the code and type it + DataOutputStream out = new DataOutputStream(s.getOutputStream()); + DataInputStream in = new DataInputStream(s.getInputStream()); + out.writeInt(MAGIC); + out.writeInt(V3); + out.writeInt(FRAME_PAIR); + out.writeUTF(peerId); + out.writeUTF(peerName); + out.flush(); + + if (in.readByte() != 1) { + String message = in.readUTF(); + lastRejection = message; + System.out.println("FAILED: " + message); + return false; + } + String deviceId = in.readUTF(); + String challenge = in.readUTF(); + byte[] secret = deriveSecret(code, peerId, deviceId); + out.writeUTF(respond(secret, challenge, null)); + out.flush(); + boolean ok = report(s); + if (ok) { + rememberSecret(deviceId, secret); + } + return ok; + } finally { + s.close(); + } + } + + /** + * Sends a bundle. + * + *

Over loopback this is v1 and unauthenticated, because possession of + * the USB cable or of this machine already is the authentication. Over a + * network it is v3: the device names itself and issues a nonce, and the + * answer covers the bundle as well as the nonce, so what runs on the phone + * is what left this process.

+ */ + private static boolean send(byte[] payload, int port, String peerId, + boolean loopbackOnly) throws Exception { + System.out.println("awaiting the device on port " + port); + Socket s = accept(port, loopbackOnly, 120000); + try { + s.setSoTimeout(120000); + DataOutputStream out = new DataOutputStream(s.getOutputStream()); + DataInputStream in = new DataInputStream(s.getInputStream()); + out.writeInt(MAGIC); + if (loopbackOnly) { + out.writeInt(V1); + out.writeInt(payload.length); + out.write(payload); + out.flush(); + // The bundle is away; what follows is the device installing it + // and entering the program, which takes as long as the program + // takes. Timing out here would leave the desktop reporting a + // failed push for a program that is running. + s.setSoTimeout(0); + boolean ok = report(s); + if (!ok) { + System.exit(1); + } + return ok; + } + String desktopChallenge = hex(randomBytes(32)); + out.writeInt(V3); + out.writeInt(FRAME_PUSH); + out.writeUTF(peerId); + out.writeUTF(desktopChallenge); + out.flush(); + + if (in.readByte() != 1) { + String message = in.readUTF(); + lastRejection = message; + System.out.println("FAILED: " + message); + return false; + } + String deviceId = in.readUTF(); + String challenge = in.readUTF(); + String deviceProof = in.readUTF(); + // Which device answered decides which secret applies: one computer + // may be paired with several phones, and the dial-in gives no + // advance notice of which one this is. + byte[] secret = secretFor(deviceId); + if (secret == null) { + lastRejection = "this computer is not paired with this device"; + System.out.println("FAILED: " + lastRejection); + return false; + } + // The device proves itself before the bundle leaves this process. A + // device id is public, so anything on the LAN could answer this + // dial claiming to be a paired phone, and the bundle carries the + // program's whole source. + if (!respond(secret, desktopChallenge, null).equals(deviceProof)) { + lastRejection = "the device on the other end did not authenticate"; + System.out.println("FAILED: " + lastRejection); + return false; + } + out.writeUTF(respond(secret, challenge, payload)); + out.writeInt(payload.length); + out.write(payload); + out.flush(); + // Past this point the device may be waiting for a person to approve + // the push, and then installing and starting the program. Discovery + // and authentication kept their deadlines; this part cannot have + // one, or a slow start is reported as a failure while the program + // runs. + s.setSoTimeout(0); + return report(s); + } finally { + s.close(); + } + } + + /** + * Finds a device listening on this machine's own subnets. + * + *

Tried before waiting to be dialled, because it is the half of the + * search a desktop is actually good at: 254 addresses with a real thread + * pool and a 300ms timeout finish in about a second, where the same sweep + * from a phone takes long enough to look broken. iOS has no server socket, + * so nothing answers there and the wait below is what finds it.

+ */ + private static String scanForDevice(int port) { + final List candidates = new ArrayList(); + try { + Enumeration nics = NetworkInterface.getNetworkInterfaces(); + while (nics.hasMoreElements()) { + NetworkInterface nic = nics.nextElement(); + if (!nic.isUp() || nic.isLoopback()) { + continue; + } + for (java.net.InterfaceAddress ia : nic.getInterfaceAddresses()) { + InetAddress a = ia.getAddress(); + if (!(a instanceof java.net.Inet4Address)) { + continue; + } + String self = a.getHostAddress(); + String prefix = self.substring(0, self.lastIndexOf('.') + 1); + for (int i = 1; i <= 254; i++) { + String candidate = prefix + i; + if (!candidate.equals(self) && !candidates.contains(candidate)) { + candidates.add(candidate); + } + } + } + } + } catch (Exception failed) { + return null; + } + if (candidates.isEmpty()) { + return null; + } + System.out.println("looking for a device on " + candidates.size() + " addresses"); + final java.util.concurrent.atomic.AtomicReference found = + new java.util.concurrent.atomic.AtomicReference(); + java.util.concurrent.ExecutorService pool = + java.util.concurrent.Executors.newFixedThreadPool(64); + try { + for (final String candidate : candidates) { + pool.execute(new Runnable() { + public void run() { + if (found.get() != null) { + return; + } + if (isDeviceRuntime(candidate, port)) { + found.compareAndSet(null, candidate); + } + } + }); + } + pool.shutdown(); + pool.awaitTermination(6, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } finally { + pool.shutdownNow(); + } + String address = found.get(); + if (address != null) { + System.out.println("found a device at " + address); + } + return address; + } + + /** + * Asks an address whether it is a device runtime, and believes only an + * answer in our own protocol. + * + *

A separate connection from the one the push will use, because the + * question consumes a connection: the desktop speaks first in this + * protocol, so there is no way to probe without committing the frame.

+ */ + private static boolean isDeviceRuntime(String candidate, int port) { + Socket s = new Socket(); + try { + s.connect(new InetSocketAddress(candidate, port), 300); + s.setSoTimeout(1500); + DataOutputStream out = new DataOutputStream(s.getOutputStream()); + out.writeInt(MAGIC); + out.writeInt(V3); + out.writeInt(FRAME_PING); + out.flush(); + DataInputStream in = new DataInputStream(s.getInputStream()); + if (in.readByte() != 1) { + return false; + } + return in.readUTF().length() > 0; + } catch (IOException notTheRuntime) { + // Nobody there, or something there that does not speak this. + return false; + } finally { + try { + s.close(); + } catch (IOException ignored) { + // Nothing useful to do. + } + } + } + + /** Waits for the device to dial in. */ + private static Socket accept(int port, boolean loopbackOnly, int timeoutMs) throws IOException { + if (explicitDevice != null) { + Socket direct = new Socket(); + direct.connect(new InetSocketAddress(explicitDevice, port), 4000); + System.out.println("connected to " + explicitDevice); + return direct; + } + if (!loopbackOnly) { + if (discoveredDevice == null) { + String scanned = scanForDevice(port); + if (scanned != null) { + // Pinned for the rest of this push. A push is up to three + // exchanges -- try, pair, try again -- each its own + // connection, and scanning again each time picks whichever + // device answers first. With two runtimes on the network + // that means pairing one phone and then pushing to the + // other, which correctly reports that it is not paired. + discoveredDevice = scanned; + System.out.println("device at " + scanned); + } + } + if (discoveredDevice != null) { + // A fresh connection to the address that answered: the probe + // consumed the one it asked on. + Socket direct = new Socket(); + direct.connect(new InetSocketAddress(discoveredDevice, port), 4000); + return direct; + } + System.out.println("no device answered; waiting for one to call in"); + } + ServerSocket server = new ServerSocket(); + server.setReuseAddress(true); + // Bound to loopback for a USB session and to every interface for a + // network one. Binding wide by default would expose the listener -- + // and with it the program's source -- on every network the machine is + // attached to, for a workflow that does not need it. + server.bind(loopbackOnly + ? new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port) + : new InetSocketAddress(port)); + server.setSoTimeout(timeoutMs); + try { + return server.accept(); + } catch (java.net.SocketTimeoutException timeout) { + throw new IOException("the device never connected on port " + port + + ". Is the app running, and pointed at this computer?"); + } finally { + server.close(); + } + } + + /** Reads the device's answer and prints it. Returns whether it succeeded. */ + private static boolean report(Socket s) throws IOException { + DataInputStream in = new DataInputStream(s.getInputStream()); + boolean ok = in.readByte() == 1; + String message = in.readUTF(); + lastRejection = ok ? null : message; + System.out.println(ok ? "OK: " + message : "FAILED: " + message); + return ok; + } + + /// Why the device refused, so a recoverable refusal can be recovered from. + private static String lastRejection; + + /// An address given on the command line, tried before any searching. + private static String explicitDevice; + + /// The address a scan found, kept for the whole push so the pairing and the + /// push that follows it reach the same device. + private static String discoveredDevice; + + private static boolean rejectedAsUnpaired() { + return lastRejection != null && lastRejection.indexOf("not paired") >= 0; + } + + // -------------------------------------------------------------- identity + + /** A stable identity for this computer, so the device can recognise it. */ + private static String peerId() throws IOException { + Path f = Paths.get(System.getProperty("user.home"), ".codenameone", "devruntime-peer"); + if (Files.exists(f)) { + return new String(Files.readAllBytes(f), StandardCharsets.UTF_8).trim(); + } + String id = hex(randomBytes(16)); + createParent(f); + Files.write(f, id.getBytes(StandardCharsets.UTF_8)); + return id; + } + + /** Where the secrets established with each device are kept. */ + private static Path secretsFile() { + return Paths.get(System.getProperty("user.home"), ".codenameone", + "devruntime-secrets.properties"); + } + + private static byte[] secretFor(String deviceId) throws IOException { + Path f = secretsFile(); + if (!Files.exists(f)) { + return null; + } + Properties p = new Properties(); + InputStream in = Files.newInputStream(f); + try { + p.load(in); + } finally { + in.close(); + } + String hex = p.getProperty(deviceId); + return hex == null ? null : unhex(hex); + } + + private static void rememberSecret(String deviceId, byte[] secret) throws IOException { + Path f = secretsFile(); + Properties p = new Properties(); + if (Files.exists(f)) { + InputStream in = Files.newInputStream(f); + try { + p.load(in); + } finally { + in.close(); + } + } + p.setProperty(deviceId, hex(secret)); + createParent(f); + OutputStream out = Files.newOutputStream(f); + try { + p.store(out, "Codename One device runtime -- shared secrets, one per paired device"); + } finally { + out.close(); + } + // It authorises running code on somebody's phone; nobody else on this + // machine needs to read it. + try { + Files.setPosixFilePermissions(f, + PosixFilePermissions.fromString("rw-------")); + } catch (UnsupportedOperationException notPosix) { + // Windows: the default ACL is the user's own, which is the intent. + } + } + + /** Creates the .codenameone directory, if the path has one to create. */ + private static void createParent(Path f) throws IOException { + Path parent = f.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + } + + /** + * Must stay identical to {@code InterpPairingSecret} in the runtime. + * + *

Written against the JDK's own HMAC while the device half is written + * against Codename One's, because ParparVM has no {@code javax.crypto} -- + * two implementations of one standard, which is exactly what + * {@code InterpPairingSecretTest} exists to keep honest.

+ */ + private static byte[] deriveSecret(String code, String peerId, String deviceId) { + byte[] key = code.trim().getBytes(StandardCharsets.UTF_8); + byte[] block = hmac(key, + ("cn1-device-runtime|" + peerId + "|" + deviceId).getBytes(StandardCharsets.UTF_8)); + for (int i = 1; i < PAIRING_ITERATIONS; i++) { + block = hmac(key, block); + } + return block; + } + + /** The answer to a device's challenge, optionally covering a bundle. */ + private static String respond(byte[] secret, String challenge, byte[] payload) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret, "HmacSHA256")); + mac.update(challenge.getBytes(StandardCharsets.UTF_8)); + if (payload != null) { + mac.update(payload); + } + return hex(mac.doFinal()); + } catch (GeneralSecurityException impossible) { + throw new IllegalStateException("HmacSHA256 is required of every JRE", impossible); + } + } + + private static byte[] hmac(byte[] key, byte[] data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } catch (GeneralSecurityException impossible) { + throw new IllegalStateException("HmacSHA256 is required of every JRE", impossible); + } + } + + private static byte[] randomBytes(int count) { + byte[] out = new byte[count]; + RANDOM.nextBytes(out); + return out; + } + + private static String hex(byte[] data) { + StringBuilder sb = new StringBuilder(data.length * 2); + for (byte b : data) { + sb.append(Character.forDigit((b >> 4) & 0xf, 16)); + sb.append(Character.forDigit(b & 0xf, 16)); + } + return sb.toString(); + } + + private static byte[] unhex(String s) { + byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte)((Character.digit(s.charAt(i * 2), 16) << 4) + | Character.digit(s.charAt(i * 2 + 1), 16)); + } + return out; + } + + /** Every address of this machine a device could reasonably dial. */ + private static String describeAddresses(int port) { + StringBuilder sb = new StringBuilder(); + try { + Enumeration nics = NetworkInterface.getNetworkInterfaces(); + while (nics.hasMoreElements()) { + NetworkInterface nic = nics.nextElement(); + if (!nic.isUp() || nic.isLoopback()) { + continue; + } + Enumeration addrs = nic.getInetAddresses(); + while (addrs.hasMoreElements()) { + InetAddress a = addrs.nextElement(); + if (a instanceof java.net.Inet4Address) { + if (sb.length() > 0) { + sb.append(" or "); + } + sb.append(a.getHostAddress()); + } + } + } + } catch (Exception failed) { + return "(could not read this machine's addresses)"; + } + return sb.length() == 0 ? "(no network address)" : sb.toString(); + } +} diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/InterpBundleWriter.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/InterpBundleWriter.java new file mode 100644 index 00000000000..c083003b93c --- /dev/null +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/InterpBundleWriter.java @@ -0,0 +1,934 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.*; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Turns compiled classes into a {@code .cn1ip} bundle for the on-device + * interpreter. + * + *

The transform runs here, on the developer's machine, rather than on the + * device. The device then never parses a class file, never decodes a constant + * pool and never resolves a label: the bundle hands it flat int arrays whose + * operands are already indices into its own tables. That keeps the shipped app + * free of a class-file reader (smaller, and less to get wrong on a platform + * with no reflection) and moves every cost that can be paid once to the side + * that can afford it.

+ * + *

What it does not do is reinterpret the bytecode. Opcodes are preserved + * as-is, so the interpreter's behaviour can be checked against a real JVM + * running the same source -- which is what the conformance tests do.

+ * + * @author Shai Almog + */ +public class InterpBundleWriter { + private static final int MAGIC = 0x434E3149; + // Bumped to 4 with the field-access flags and LDC_DOUBLE raw-long-bits + // changes: an older reader would call `Double.parseDouble` on a raw-bit + // string like "4607182418800017408" and fail on the number, and would + // also lose the volatile flag the runtime now needs to fence field + // accesses. Kept in sync with InterpBundle.VERSION on the reader side. + private static final int VERSION = 4; + + private static final int EXTERN_CLASS = 0; + private static final int EXTERN_METHOD = 1; + private static final int EXTERN_FIELD = 2; + + private static final int LDC_INT = 0; + private static final int LDC_LONG = 1; + private static final int LDC_FLOAT = 2; + private static final int LDC_DOUBLE = 3; + private static final int LDC_STRING = 4; + private static final int LDC_CLASS = 5; + + private final Map stringPool = new LinkedHashMap(); + private final List strings = new ArrayList(); + + private final Map externPool = new LinkedHashMap(); + private final List externs = new ArrayList(); + + private final List interpreted = new ArrayList(); + private final Map sources = new LinkedHashMap(); + + /// The program's own resources -- theme.res, CSS, images -- keyed by the + /// path an application loads them with, e.g. `/theme.res`. + private final Map resources = new LinkedHashMap(); + private String mainClass; + + /** Names of the classes carried in this bundle, i.e. the interpreted set. */ + private final java.util.Set interpretedNames = new java.util.HashSet(); + + /** + * Adds a compiled class to the bundle. Everything it references that is not + * also added becomes an extern -- a symbol expected to exist in the host + * app. + */ + public void addClass(byte[] classFile) { + ClassNode cn = new ClassNode(); + new ClassReader(classFile).accept(cn, ClassReader.SKIP_FRAMES); + interpreted.add(cn); + interpretedNames.add(cn.name); + } + + /** Adds a class from a {@code .class} file. */ + public void addClassFile(File f) throws IOException { + InputStream in = new FileInputStream(f); + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + bos.write(buf, 0, n); + } + addClass(bos.toByteArray()); + } finally { + in.close(); + } + } + + /** + * Adds a source file. The runtime refuses to load a bundle whose + * interpreted classes are not all covered by sources -- the App Store's + * allowance for downloaded educational code is conditional on the user + * being able to see and edit what runs, so this is enforced rather than + * documented. + */ + public void addSource(String fileName, String text) { + sources.put(fileName, text); + } + + /** + * Adds a resource under the path an application would load it by. + * + *

Without these a pushed program wears the runtime host's theme, which + * is the wrong application's design and looks like a bug in yours.

+ */ + public void addResource(String path, byte[] data) { + resources.put(path.startsWith("/") ? path : "/" + path, data); + } + + /** + * Adds every non-source file under a directory, keyed by its path relative + * to that directory. Source files ({@code .java}, {@code .kt}) are skipped + * because {@link #addSourceTree} has already collected them into the + * bundle's source section; storing them again as resources doubles the + * payload of a Kotlin-heavy project and can push it past the service's + * 64 MiB limit even though the executable half would fit. + */ + public void addResourceTree(File dir) throws IOException { + addResourceTree(dir, dir); + } + + private void addResourceTree(File root, File dir) throws IOException { + File[] kids = dir.listFiles(); + if (kids == null) { + return; + } + for (File f : kids) { + if (f.isDirectory()) { + addResourceTree(root, f); + } else if (!isSourceFile(f.getName())) { + String rel = f.getAbsolutePath() + .substring(root.getAbsolutePath().length()) + .replace(File.separatorChar, '/'); + addResource(rel, Files.readAllBytes(f.toPath())); + } + } + } + + /** + * Adds every source file under a directory, recursively. + * + *

Both {@code .java} and {@code .kt} are collected: the pushed bundle's + * source lookup keys on the {@code SourceFile} attribute javac and kotlinc + * emit, so a Kotlin class advertises {@code MyApp.kt} and the runtime + * refuses the whole push as missing source if only {@code .java} is + * gathered here. Any other language producing JVM classes would need its + * extension added the same way.

+ * + *

Keyed by package rather than by file name. A bare name collides the + * moment a project has two {@code Util.java} in different packages, and the + * loser is simply absent -- which the runtime reports as "bundle is missing + * the source file Util.java" for a file that was right there. Since the + * source requirement is what lets the app run pushed code at all, losing one + * silently is not an option.

+ */ + public void addSourceTree(File dir) throws IOException { + File[] kids = dir.listFiles(); + if (kids == null) { + return; + } + for (File f : kids) { + if (f.isDirectory()) { + addSourceTree(f); + } else if (isSourceFile(f.getName())) { + String text = new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + addSource(sourceKey(packageOf(text), f.getName()), text); + } + } + } + + /// Whether a filename is a source extension that produces bundled classes. + /// Java and Kotlin only; both are compiled to the same class files the + /// interpreter runs, and their `SourceFile` attributes name a file with + /// one of these extensions. + private static boolean isSourceFile(String name) { + return name.endsWith(".java") || name.endsWith(".kt"); + } + + /** + * The bundle key for a source file: {@code com/foo/Util.java}, or the bare + * name in the default package. Matches how a class's own internal name and + * its {@code SourceFile} attribute combine, which is how the reader looks + * one up. + */ + public static String sourceKey(String packageName, String fileName) { + if (packageName == null || packageName.length() == 0) { + return fileName; + } + return packageName.replace('.', '/') + "/" + fileName; + } + + /** + * The declared package of a source file, read from the file rather than + * inferred from its path -- a source root is not always the package root. + */ + public static String packageOf(String text) { + // Tokens, not line starts. `/* license */ package com.example;` is one + // line of perfectly ordinary Java, and reading it as the default + // package stored the source under a key the runtime never looks up -- + // so the push was refused for missing source that had been supplied. + String code = stripComments(decodeUnicodeEscapes(text)); + int i = 0; + // Track paren depth so a `{` inside an annotation's argument list + // (`@p.A({String.class}) package p;`) reads as an array-initializer + // token rather than the class-body opening brace. Only the + // top-level `{` -- the class or interface body -- means "past + // anywhere a package declaration can appear". + int parens = 0; + while (i < code.length()) { + char c = code.charAt(i); + if (c == '(') { + parens++; + i++; + continue; + } + if (c == ')') { + if (parens > 0) { + parens--; + } + i++; + continue; + } + if (c == '{' && parens == 0) { + break; + } + if (Character.isJavaIdentifierStart(c)) { + int start = i; + while (i < code.length() && Character.isJavaIdentifierPart(code.charAt(i))) { + i++; + } + String token = code.substring(start, i); + if ("package".equals(token)) { + // Terminated by `;` (Java) or end-of-line (Kotlin, whose + // package declaration has no semicolon). Falling back to + // the next `;` in the file would land inside the class + // body for a Kotlin source, so the key would be a garbled + // "com.example fun ..." and the reader would look up a + // key nobody wrote. + int end = i; + while (end < code.length()) { + char t = code.charAt(end); + if (t == ';' || t == '\n' || t == '\r' || t == '{') { + break; + } + end++; + } + // Kotlin escapes non-identifier segments with backticks + // (``package com.`is`.foo``), and kotlinc keeps whatever + // is inside the backticks -- spaces and all -- as the + // segment's name while dropping the backticks themselves. + // Formatting whitespace outside the backticks is not part + // of the name (``package com . foo`` is one identifier). + // Stripping every space would fold ``com.`foo bar`.baz`` + // to ``com.foobar.baz`` while the compiler keeps the + // space, so the source would be keyed at a path the + // reader never looks up and the push would be refused. + return stripFormattingWhitespace(code.substring(i, end)); + } + if ("import".equals(token) || "class".equals(token) + || "interface".equals(token) || "enum".equals(token)) { + // `String.class` inside a package annotation -- + // `@p.A(String.class) package p;` -- is a class literal, + // not a type declaration; a dot preceding the token + // proves it. Stopping there would return the default + // package for a source that declares one, key it at the + // wrong path, and get the whole push refused as missing + // source. Skip the token and keep scanning for `package`. + if (!"class".equals(token) || !precededByDot(code, start)) { + // Past anything a package declaration may precede. + break; + } + } + continue; + } + i++; + } + return ""; + } + + /// Whether the character preceding `pos`, skipping whitespace, marks a + /// class literal rather than a class declaration keyword. + /// + /// Java spells the reference `String.class`, Kotlin spells it + /// `String::class`. Both mean "class literal here, not a type + /// declaration"; scanning past `class` in either case is the whole + /// point, since the file may still declare a `package` further down. + private static boolean precededByDot(String code, int pos) { + int j = pos - 1; + while (j >= 0 && Character.isWhitespace(code.charAt(j))) { + j--; + } + if (j < 0) { + return false; + } + char c = code.charAt(j); + // `X::class` -- the colon we land on is the second of a pair. + return c == '.' || (c == ':' && j > 0 && code.charAt(j - 1) == ':'); + } + + /// Removes whitespace outside backtick-escaped segments and drops the + /// backticks. See the caller for why: kotlinc keeps whitespace *inside* + /// backticks as part of the segment name. + private static String stripFormattingWhitespace(String raw) { + StringBuilder sb = new StringBuilder(raw.length()); + boolean escaped = false; + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + if (c == '`') { + escaped = !escaped; + continue; + } + if (!escaped && Character.isWhitespace(c)) { + continue; + } + sb.append(c); + } + return sb.toString(); + } + + /** + * The source with `\\uXXXX` escapes decoded, as javac decodes them first. + * + *

Java processes unicode escapes before anything else, so + * `\\u0070ackage com.example;` is a package declaration -- odd, legal, and + * invisible to a scanner working on the raw text. Only an odd number of + * preceding backslashes starts an escape, which is what keeps `\\\\u0070` + * the two characters it is.

+ */ + private static String decodeUnicodeEscapes(String text) { + if (text.indexOf("\\u") < 0) { + return text; + } + StringBuilder out = new StringBuilder(text.length()); + int i = 0; + while (i < text.length()) { + char c = text.charAt(i); + if (c != '\\') { + out.append(c); + i++; + continue; + } + int slashes = 0; + while (i < text.length() && text.charAt(i) == '\\') { + slashes++; + i++; + } + // A run of backslashes followed by u's: only an odd run escapes. + int us = 0; + while (i + us < text.length() && text.charAt(i + us) == 'u') { + us++; + } + if ((slashes & 1) == 1 && us > 0 && i + us + 4 <= text.length()) { + String hex = text.substring(i + us, i + us + 4); + try { + char decoded = (char) Integer.parseInt(hex, 16); + for (int k = 0; k < slashes - 1; k++) { + out.append('\\'); + } + out.append(decoded); + i += us + 4; + continue; + } catch (NumberFormatException notAnEscape) { + // Malformed; javac would reject the file, and this scan has + // no business deciding that. Left as written. + } + } + for (int k = 0; k < slashes; k++) { + out.append('\\'); + } + } + return out.toString(); + } + + /** + * The source with comments and literals blanked out. + * + *

Blanked rather than removed, so nothing shifts: only the scan above + * uses this, and it cares about what a token is, not where it sits.

+ */ + private static String stripComments(String text) { + StringBuilder out = new StringBuilder(text.length()); + int i = 0; + while (i < text.length()) { + char c = text.charAt(i); + if (c == '/' && i + 1 < text.length() && text.charAt(i + 1) == '/') { + while (i < text.length() && text.charAt(i) != '\n') { + i++; + } + } else if (c == '/' && i + 1 < text.length() && text.charAt(i + 1) == '*') { + i += 2; + while (i + 1 < text.length() + && !(text.charAt(i) == '*' && text.charAt(i + 1) == '/')) { + i++; + } + i = Math.min(i + 2, text.length()); + out.append(' '); + } else if (c == '"' && i + 2 < text.length() + && text.charAt(i + 1) == '"' && text.charAt(i + 2) == '"') { + // Kotlin `"""raw"""` and Java text-block `"""..."""`. Content + // may contain a lone `"` -- treating each quote as its own + // delimiter would expose a fake `package` inside a raw + // string, or consume the real one that follows. The literal + // ends at the next `"""`; nothing inside it has meaning to + // the package scanner. + i += 3; + while (i + 2 < text.length() + && !(text.charAt(i) == '"' + && text.charAt(i + 1) == '"' + && text.charAt(i + 2) == '"')) { + i++; + } + i = Math.min(i + 3, text.length()); + out.append(' '); + } else if (c == '"' || c == '\'') { + char quote = c; + i++; + while (i < text.length() && text.charAt(i) != quote) { + if (text.charAt(i) == '\\') { + i++; + } + i++; + } + i++; + out.append(' '); + } else { + out.append(c); + i++; + } + } + return out.toString(); + } + + /** The class whose {@code main} the runtime should enter. */ + public void setMainClass(String internalName) { + mainClass = internalName; + } + + /** Writes the bundle. */ + public void write(OutputStream rawOut) throws IOException { + // Lambdas become real classes before anything is encoded. Neither + // target can spin one at run time, and the ahead-of-time pass that + // handles this for a compiled application never sees a pushed bundle. + for (ClassNode lambda : InterpLambdaDesugar.desugar(interpreted)) { + interpreted.add(lambda); + interpretedNames.add(lambda.name); + } + + // Bodies are encoded first: doing so interns every string and extern + // they mention, so the pools are complete before they are written. + ByteArrayOutputStream classesBuf = new ByteArrayOutputStream(); + DataOutputStream cb = new DataOutputStream(classesBuf); + cb.writeInt(interpreted.size()); + for (ClassNode cn : interpreted) { + writeClass(cb, cn); + } + cb.flush(); + + DataOutputStream out = new DataOutputStream(rawOut); + out.writeInt(MAGIC); + out.writeInt(VERSION); + out.writeUTF(mainClass == null ? "" : mainClass); + + out.writeInt(strings.size()); + for (String s : strings) { + out.writeUTF(s); + } + + out.writeInt(externs.size()); + for (int[] e : externs) { + out.writeInt(e[0]); + out.writeInt(e[1]); + out.writeInt(e[2]); + out.writeInt(e[3]); + } + + classesBuf.writeTo(out); + + out.writeInt(sources.size()); + for (Map.Entry e : sources.entrySet()) { + out.writeUTF(e.getKey()); + // Stored uncompressed: ParparVM's java.util subset has no + // java.util.zip, so the device could not inflate it. Sources are + // small and the transport is a local socket, so nothing is lost. + byte[] utf8 = e.getValue().getBytes(StandardCharsets.UTF_8); + out.writeInt(utf8.length); + out.write(utf8); + } + + out.writeInt(resources.size()); + for (Map.Entry e : resources.entrySet()) { + out.writeUTF(e.getKey()); + out.writeInt(e.getValue().length); + out.write(e.getValue()); + } + out.flush(); + } + + + private int intern(String s) { + Integer existing = stringPool.get(s); + if (existing != null) { + return existing.intValue(); + } + int idx = strings.size(); + strings.add(s); + stringPool.put(s, Integer.valueOf(idx)); + return idx; + } + + private int externClass(String internalName) { + return extern(EXTERN_CLASS, internalName, "", ""); + } + + private int extern(int kind, String owner, String name, String desc) { + String key = kind + "|" + owner + "|" + name + "|" + desc; + Integer existing = externPool.get(key); + if (existing != null) { + return existing.intValue(); + } + int idx = externs.size(); + externs.add(new int[]{kind, intern(owner), intern(name), intern(desc)}); + externPool.put(key, Integer.valueOf(idx)); + return idx; + } + + /** + * The simple name javac recorded for a class, or "" when it has none. + * + *

An anonymous class answers "" -- it has a simple name and that name is + * empty, which is what {@code Class.getSimpleName()} reports. A class with + * no InnerClasses entry naming itself is top-level and answers null: its + * simple name is the last segment of its binary name, which the runtime + * works out rather than paying for a string.

+ */ + private static String simpleNameOf(ClassNode cn) { + if (cn.innerClasses == null) { + return null; + } + for (Object o : cn.innerClasses) { + org.objectweb.asm.tree.InnerClassNode icn = (org.objectweb.asm.tree.InnerClassNode) o; + if (cn.name.equals(icn.name)) { + // An entry with no name is an anonymous class: it has a simple + // name, and that name is empty. + return icn.innerName == null ? "" : icn.innerName; + } + } + return null; + } + + private void writeClass(DataOutputStream out, ClassNode cn) throws IOException { + out.writeInt(intern(cn.name)); + out.writeInt(cn.access); + out.writeUTF(cn.sourceFile == null ? "" : cn.sourceFile); + // The simple name, from the InnerClasses attribute rather than from the + // shape of the binary name. `Outer$1` is anonymous and has none, + // `Outer$1Local` is a local class called Local, and both a nested class + // and a top-level one may carry a `$` in their own identifier -- so + // splitting the name cannot tell them apart, and only the attribute + // knows. + // + // The flag says whether javac recorded an entry for this class at all, + // which is what separates an anonymous class (an entry with no name, + // whose simple name really is empty) from a top-level one (no entry, + // whose simple name is the last segment of its binary name -- `$` and + // all, as `Price$USD` is entitled to be called). + String recorded = simpleNameOf(cn); + out.writeBoolean(recorded != null); + out.writeUTF(recorded == null ? "" : recorded); + + // A supertype is either interpreted (named, resolved after load) or an + // extern. java/lang/Object is always an extern -- it is the host's. + String superName = cn.superName == null ? "java/lang/Object" : cn.superName; + boolean superInterpreted = interpretedNames.contains(superName); + out.writeBoolean(superInterpreted); + out.writeInt(superInterpreted ? intern(superName) : externClass(superName)); + + List interpIfaces = new ArrayList(); + List hostIfaces = new ArrayList(); + if (cn.interfaces != null) { + for (String i : cn.interfaces) { + if (interpretedNames.contains(i)) { + interpIfaces.add(i); + } else { + hostIfaces.add(Integer.valueOf(externClass(i))); + } + } + } + out.writeInt(interpIfaces.size()); + for (String i : interpIfaces) { + out.writeInt(intern(i)); + } + out.writeInt(hostIfaces.size()); + for (Integer i : hostIfaces) { + out.writeInt(i.intValue()); + } + + List instanceFields = new ArrayList(); + List staticFields = new ArrayList(); + for (FieldNode fn : cn.fields) { + if ((fn.access & Opcodes.ACC_STATIC) != 0) { + staticFields.add(fn); + } else { + instanceFields.add(fn); + } + } + out.writeInt(instanceFields.size()); + for (FieldNode fn : instanceFields) { + out.writeInt(intern(fn.name)); + out.writeInt(intern(fn.desc)); + // Access flags carry `ACC_VOLATILE`, which the interpreter needs + // to give the read/write a memory barrier -- otherwise a worker- + // thread handoff through a `volatile boolean ready` would let a + // reader observe `ready` while the writes it publishes are still + // stale. Older bundles dropped this; older readers ignore it + // because they read only the pairs they were built for. + out.writeInt(fn.access); + } + out.writeInt(staticFields.size()); + for (FieldNode fn : staticFields) { + out.writeInt(intern(fn.name)); + out.writeInt(intern(fn.desc)); + out.writeInt(fn.access); + } + + out.writeInt(cn.methods.size()); + for (MethodNode mn : cn.methods) { + writeMethod(out, mn); + } + } + + private void writeMethod(DataOutputStream out, MethodNode mn) throws IOException { + out.writeInt(intern(mn.name)); + out.writeInt(intern(mn.desc)); + out.writeInt(mn.access); + out.writeInt(mn.maxStack); + out.writeInt(mn.maxLocals); + + if (mn.instructions == null || mn.instructions.size() == 0) { + out.writeInt(0); // instruction count + out.writeInt(0); // code length + out.writeInt(0); // exception entries + out.writeInt(0); // line entries + return; + } + + Encoded enc = encode(mn); + + out.writeInt(enc.instructionOffsets.size()); + for (Integer off : enc.instructionOffsets) { + out.writeInt(off.intValue()); + } + out.writeInt(enc.code.size()); + for (Integer c : enc.code) { + out.writeInt(c.intValue()); + } + out.writeInt(enc.exceptions.size() / 4); + for (Integer e : enc.exceptions) { + out.writeInt(e.intValue()); + } + out.writeInt(enc.lines.size() / 2); + for (Integer l : enc.lines) { + out.writeInt(l.intValue()); + } + } + + private static final class Encoded { + final List code = new ArrayList(); + final List instructionOffsets = new ArrayList(); + final List exceptions = new ArrayList(); + final List lines = new ArrayList(); + } + + private Encoded encode(MethodNode mn) { + Encoded enc = new Encoded(); + + // Pass 1: assign an instruction index to every real instruction, so a + // label can be resolved to the index of the next one. Labels, frames + // and line markers are not instructions and do not get an index. + Map labelToIndex = new HashMap(); + List real = new ArrayList(); + List pendingLabels = new ArrayList(); + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LabelNode) { + pendingLabels.add((LabelNode) insn); + continue; + } + if (insn instanceof FrameNode || insn instanceof LineNumberNode) { + continue; + } + for (LabelNode ln : pendingLabels) { + labelToIndex.put(ln, Integer.valueOf(real.size())); + } + pendingLabels.clear(); + real.add(insn); + } + // Labels at the very end (an exception range's exclusive end, most + // often) resolve one past the last instruction. + for (LabelNode ln : pendingLabels) { + labelToIndex.put(ln, Integer.valueOf(real.size())); + } + + // Line numbers, recorded against the index of the instruction they + // precede. + int idx = 0; + for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) { + if (insn instanceof LineNumberNode) { + LineNumberNode lnn = (LineNumberNode) insn; + Integer at = labelToIndex.get(lnn.start); + if (at != null) { + enc.lines.add(at); + enc.lines.add(Integer.valueOf(lnn.line)); + } + } + } + + // Pass 2: emit. + for (idx = 0; idx < real.size(); idx++) { + enc.instructionOffsets.add(Integer.valueOf(enc.code.size())); + emit(enc, real.get(idx), labelToIndex); + } + + if (mn.tryCatchBlocks != null) { + for (Object o : mn.tryCatchBlocks) { + TryCatchBlockNode tc = (TryCatchBlockNode) o; + Integer start = labelToIndex.get(tc.start); + Integer end = labelToIndex.get(tc.end); + Integer handler = labelToIndex.get(tc.handler); + if (start == null || end == null || handler == null) { + continue; + } + enc.exceptions.add(start); + enc.exceptions.add(end); + enc.exceptions.add(handler); + enc.exceptions.add(Integer.valueOf(tc.type == null ? -1 : externClass(tc.type))); + } + } + return enc; + } + + private void emit(Encoded enc, AbstractInsnNode insn, Map labels) { + int op = insn.getOpcode(); + switch (insn.getType()) { + case AbstractInsnNode.INSN: + enc.code.add(Integer.valueOf(op)); + break; + case AbstractInsnNode.INT_INSN: { + IntInsnNode i = (IntInsnNode) insn; + enc.code.add(Integer.valueOf(op)); + enc.code.add(Integer.valueOf(i.operand)); + break; + } + case AbstractInsnNode.VAR_INSN: { + VarInsnNode v = (VarInsnNode) insn; + enc.code.add(Integer.valueOf(op)); + enc.code.add(Integer.valueOf(v.var)); + break; + } + case AbstractInsnNode.TYPE_INSN: { + TypeInsnNode t = (TypeInsnNode) insn; + enc.code.add(Integer.valueOf(op)); + enc.code.add(Integer.valueOf(externClass(t.desc))); + break; + } + case AbstractInsnNode.FIELD_INSN: { + FieldInsnNode f = (FieldInsnNode) insn; + enc.code.add(Integer.valueOf(op)); + enc.code.add(Integer.valueOf(extern(EXTERN_FIELD, f.owner, f.name, f.desc))); + break; + } + case AbstractInsnNode.METHOD_INSN: { + MethodInsnNode m = (MethodInsnNode) insn; + enc.code.add(Integer.valueOf(op)); + enc.code.add(Integer.valueOf(extern(EXTERN_METHOD, m.owner, m.name, m.desc))); + break; + } + case AbstractInsnNode.JUMP_INSN: { + JumpInsnNode j = (JumpInsnNode) insn; + Integer target = labels.get(j.label); + enc.code.add(Integer.valueOf(op)); + enc.code.add(target == null ? Integer.valueOf(-1) : target); + break; + } + case AbstractInsnNode.LDC_INSN: { + LdcInsnNode l = (LdcInsnNode) insn; + enc.code.add(Integer.valueOf(op)); + emitLdc(enc, l.cst); + break; + } + case AbstractInsnNode.IINC_INSN: { + IincInsnNode i = (IincInsnNode) insn; + enc.code.add(Integer.valueOf(op)); + enc.code.add(Integer.valueOf(i.var)); + enc.code.add(Integer.valueOf(i.incr)); + break; + } + case AbstractInsnNode.TABLESWITCH_INSN: { + TableSwitchInsnNode t = (TableSwitchInsnNode) insn; + // length, min, max, default, targets... + List body = new ArrayList(); + body.add(Integer.valueOf(t.min)); + body.add(Integer.valueOf(t.max)); + body.add(indexOf(labels, t.dflt)); + for (Object lbl : t.labels) { + body.add(indexOf(labels, (LabelNode) lbl)); + } + enc.code.add(Integer.valueOf(op)); + enc.code.add(Integer.valueOf(body.size())); + enc.code.addAll(body); + break; + } + case AbstractInsnNode.LOOKUPSWITCH_INSN: { + LookupSwitchInsnNode l = (LookupSwitchInsnNode) insn; + // length, default, count, (key, target)... + List body = new ArrayList(); + body.add(indexOf(labels, l.dflt)); + body.add(Integer.valueOf(l.keys.size())); + for (int i = 0; i < l.keys.size(); i++) { + body.add((Integer) l.keys.get(i)); + body.add(indexOf(labels, (LabelNode) l.labels.get(i))); + } + enc.code.add(Integer.valueOf(op)); + enc.code.add(Integer.valueOf(body.size())); + enc.code.addAll(body); + break; + } + case AbstractInsnNode.MULTIANEWARRAY_INSN: { + MultiANewArrayInsnNode m = (MultiANewArrayInsnNode) insn; + enc.code.add(Integer.valueOf(op)); + enc.code.add(Integer.valueOf(externClass(m.desc))); + enc.code.add(Integer.valueOf(m.dims)); + break; + } + case AbstractInsnNode.INVOKE_DYNAMIC_INSN: { + // Lambdas and method references are gone by now -- + // InterpLambdaDesugar rewrote them into real classes before + // encoding started. Anything still here is a bootstrap this + // build does not implement, and there is no runtime + // invokedynamic to fall back on, so say which one it is. + InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn; + throw new IllegalStateException( + "invokedynamic against " + indy.bsm.getOwner() + "." + indy.bsm.getName() + + " reached the bundle writer, and neither target has a runtime " + + "invokedynamic. String concatenation is handled by compiling " + + "pushed code with -XDstringConcat=inline; lambdas and method " + + "references are desugared by InterpLambdaDesugar. This is " + + "neither."); + } + default: + throw new IllegalStateException("unhandled instruction type " + insn.getType() + + " (opcode " + op + ")"); + } + } + + private static Integer indexOf(Map labels, LabelNode ln) { + Integer i = labels.get(ln); + return i == null ? Integer.valueOf(-1) : i; + } + + private void emitLdc(Encoded enc, Object cst) { + if (cst instanceof Integer) { + enc.code.add(Integer.valueOf(LDC_INT)); + enc.code.add((Integer) cst); + } else if (cst instanceof Long) { + // Encoded as a string so the int-array code stream stays uniform; + // the runtime parses it once at load, not per execution. + enc.code.add(Integer.valueOf(LDC_LONG)); + enc.code.add(Integer.valueOf(intern(cst.toString()))); + } else if (cst instanceof Float) { + enc.code.add(Integer.valueOf(LDC_FLOAT)); + // Raw bits, not canonicalising: `floatToIntBits` collapses every + // NaN pattern into 0x7fc00000, so an LDC of a noncanonical NaN + // constant would arrive at the interpreter as the canonical one + // and lose the payload the JVM preserves. + enc.code.add(Integer.valueOf(Float.floatToRawIntBits(((Float) cst).floatValue()))); + } else if (cst instanceof Double) { + enc.code.add(Integer.valueOf(LDC_DOUBLE)); + // Raw long bits, interned as the same "long-as-string" format + // LDC_LONG uses. Encoding via `Double.toString` reduced every NaN + // to "NaN" and the reader's `Double.parseDouble("NaN")` handed + // back the canonical 0x7ff8000000000000L, so a bytecode LDC of a + // noncanonical NaN would round-trip as the canonical one -- and + // `doubleToRawLongBits` would then see the wrong bits. + enc.code.add(Integer.valueOf(intern( + Long.toString(Double.doubleToRawLongBits(((Double) cst).doubleValue()))))); + } else if (cst instanceof String) { + enc.code.add(Integer.valueOf(LDC_STRING)); + enc.code.add(Integer.valueOf(intern((String) cst))); + } else if (cst instanceof Type) { + enc.code.add(Integer.valueOf(LDC_CLASS)); + enc.code.add(Integer.valueOf(externClass(((Type) cst).getInternalName()))); + } else { + throw new IllegalStateException("unsupported constant " + cst.getClass().getName()); + } + } +} diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/InterpLambdaDesugar.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/InterpLambdaDesugar.java new file mode 100644 index 00000000000..883263f1ca0 --- /dev/null +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/InterpLambdaDesugar.java @@ -0,0 +1,556 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.objectweb.asm.Handle; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.*; + +import java.util.ArrayList; +import java.util.List; + +/** + * Rewrites lambdas and method references into ordinary classes. + * + *

A lambda compiles to an {@code invokedynamic} that asks + * {@code LambdaMetafactory} to spin a class at run time. Neither target can do + * that: ParparVM has no {@code invokedynamic} and no {@code defineClass}, and + * Android is barred from loading dex it generates. The translator already + * desugars lambdas when it compiles an application ahead of time; a pushed + * bundle gets no such pass, which is why this one exists.

+ * + *

It runs on the desktop, over ASM trees, and produces exactly what the + * metafactory would have: one class per lambda site implementing the functional + * interface, holding the captured values in fields and forwarding the single + * abstract method to the lambda body. The call site becomes a plain static + * call. Nothing is left for the device to resolve.

+ * + *

The synthesized class inherits the source file of the class that contained + * the lambda, which is both true -- that is where the lambda is written -- and + * required, since the runtime refuses to execute a class whose source it cannot + * show the user.

+ * + * @author Shai Almog + */ +final class InterpLambdaDesugar { + private static final String METAFACTORY = "java/lang/invoke/LambdaMetafactory"; + + private InterpLambdaDesugar() { + } + + /** + * Desugars every lambda in the given classes. + * + *

The classes are rewritten in place; the synthesized classes are + * returned for the caller to add to the bundle.

+ */ + static List desugar(List classes) { + List generated = new ArrayList(); + // Every already-known name, so a synthesised lambda cannot pick one -- + // a user class literally called `Owner$$Lambda$0` (unlikely but legal) + // would otherwise be overwritten in `classesByName` by the first + // lambda in `Owner`, silently redirecting allocations of the user + // class to the lambda body. The set grows as generation goes so a + // later `Owner$$Lambda$1` cannot collide either. + java.util.HashSet takenNames = new java.util.HashSet(); + for (ClassNode cn : classes) { + takenNames.add(cn.name); + } + for (ClassNode cn : classes) { + int counter = 0; + for (MethodNode mn : cn.methods) { + if (mn.instructions == null) { + continue; + } + AbstractInsnNode insn = mn.instructions.getFirst(); + while (insn != null) { + AbstractInsnNode next = insn.getNext(); + if (insn instanceof InvokeDynamicInsnNode) { + InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn; + if (isLambda(indy)) { + counter = advancePastTaken(cn.name, counter, takenNames); + ClassNode lambda = synthesize(cn, indy, counter++); + takenNames.add(lambda.name); + generated.add(lambda); + mn.instructions.set(insn, new MethodInsnNode(Opcodes.INVOKESTATIC, + lambda.name, "create", indy.desc, false)); + // The captured values are already on the stack in + // the order the factory takes them, so replacing the + // instruction in place is the whole rewrite. + } + } + insn = next; + } + } + } + return generated; + } + + private static boolean isLambda(InvokeDynamicInsnNode indy) { + return METAFACTORY.equals(indy.bsm.getOwner()) + && ("metafactory".equals(indy.bsm.getName()) + || "altMetafactory".equals(indy.bsm.getName())); + } + + /** + * Builds the class the metafactory would have spun. + * + *

The first three bootstrap arguments mean the same thing for + * {@code metafactory} and {@code altMetafactory}. The latter then carries a + * flags word, marker interfaces and bridge signatures, and those are read + * rather than dropped: an intersection cast such as {@code (A & B) () -> + * "x"} names A as a marker and A's erased {@code ()Object} as a bridge, so + * a class carrying only B and only B's method fails the call site's own + * cast and cannot answer a call through A.

+ */ + /// Returns the next counter value whose synthesised lambda name is not + /// already taken by an input class or a previously generated lambda. + private static int advancePastTaken(String ownerName, int start, + java.util.HashSet takenNames) { + int at = start; + while (takenNames.contains(ownerName + "$$Lambda$" + at)) { + at++; + } + return at; + } + + private static ClassNode synthesize(ClassNode owner, InvokeDynamicInsnNode indy, int index) { + Type[] captured = Type.getArgumentTypes(indy.desc); + Type functionalInterface = Type.getReturnType(indy.desc); + Type sam = (Type) indy.bsmArgs[0]; + Handle impl = (Handle) indy.bsmArgs[1]; + Type instantiated = (Type) indy.bsmArgs[2]; + + ClassNode cn = new ClassNode(); + cn.version = owner.version; + cn.access = Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER | Opcodes.ACC_SYNTHETIC; + cn.name = owner.name + "$$Lambda$" + index; + cn.superName = "java/lang/Object"; + cn.interfaces = new ArrayList(); + cn.interfaces.add(functionalInterface.getInternalName()); + cn.sourceFile = owner.sourceFile; + cn.fields = new ArrayList(); + cn.methods = new ArrayList(); + + for (int i = 0; i < captured.length; i++) { + cn.fields.add(new FieldNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_FINAL, + "arg$" + i, captured[i].getDescriptor(), null, null)); + } + + cn.methods.add(constructor(cn, captured)); + cn.methods.add(factory(cn, captured, indy.desc)); + cn.methods.add(samMethod(cn, indy.name, sam, instantiated, impl, captured)); + addAltExtras(cn, indy, sam); + return cn; + } + + /// Set in altMetafactory's flags word when the lambda is serializable. + private static final int FLAG_SERIALIZABLE = 1; + + /// Set in altMetafactory's flags word when marker interfaces follow. + private static final int FLAG_MARKERS = 2; + + /// Set in altMetafactory's flags word when bridge signatures follow. + private static final int FLAG_BRIDGES = 4; + + /** + * Adds altMetafactory's marker interfaces and bridge methods. + * + *

The extras are positional: a flags word, then -- each only when its + * flag is set, and in this order -- a count of marker interfaces followed + * by that many types, then a count of bridge signatures followed by that + * many method types. A shape that does not parse is left alone rather than + * guessed at, which is the same outcome as before this was read at all.

+ */ + private static void addAltExtras(ClassNode cn, InvokeDynamicInsnNode indy, Type sam) { + if (!"altMetafactory".equals(indy.bsm.getName()) || indy.bsmArgs.length < 4 + || !(indy.bsmArgs[3] instanceof Integer)) { + return; + } + int flags = ((Integer) indy.bsmArgs[3]).intValue(); + int at = 4; + if ((flags & FLAG_SERIALIZABLE) != 0 + && !cn.interfaces.contains("java/io/Serializable")) { + // `(Runnable & Serializable) () -> {}` sets this flag and names no + // marker at all, and javac still emits the cast to Serializable -- + // so a class carrying only Runnable fails an intersection cast that + // is otherwise ordinary. The interface is added; writeReplace is + // not, so such a lambda is Serializable and would fail to serialize, + // which is what it does on the device anyway. + cn.interfaces.add("java/io/Serializable"); + } + if ((flags & FLAG_MARKERS) != 0) { + if (at >= indy.bsmArgs.length || !(indy.bsmArgs[at] instanceof Integer)) { + return; + } + int count = ((Integer) indy.bsmArgs[at++]).intValue(); + for (int i = 0; i < count && at < indy.bsmArgs.length; i++) { + Object marker = indy.bsmArgs[at++]; + if (marker instanceof Type) { + String name = ((Type) marker).getInternalName(); + if (!cn.interfaces.contains(name)) { + cn.interfaces.add(name); + } + } + } + } + if ((flags & FLAG_BRIDGES) == 0) { + return; + } + if (at >= indy.bsmArgs.length || !(indy.bsmArgs[at] instanceof Integer)) { + return; + } + int count = ((Integer) indy.bsmArgs[at++]).intValue(); + for (int i = 0; i < count && at < indy.bsmArgs.length; i++) { + Object bridge = indy.bsmArgs[at++]; + if (bridge instanceof Type + && !sam.getDescriptor().equals(((Type) bridge).getDescriptor())) { + cn.methods.add(bridgeMethod(cn, indy.name, sam, (Type) bridge)); + } + } + } + + /** + * One bridge: another erasure of the same method, forwarding to the SAM. + * + *

Two interfaces can declare the same method with different erased + * signatures -- {@code Object m()} and {@code String m()} -- and a class + * implementing both needs a body for each. This one adapts its arguments, + * calls the real implementation and adapts the result back.

+ */ + private static MethodNode bridgeMethod(ClassNode cn, String name, Type sam, Type bridge) { + Type[] bridgeArgs = bridge.getArgumentTypes(); + Type[] samArgs = sam.getArgumentTypes(); + MethodNode mn = new MethodNode( + Opcodes.ACC_PUBLIC | Opcodes.ACC_BRIDGE | Opcodes.ACC_SYNTHETIC, + name, bridge.getDescriptor(), null, null); + InsnList il = mn.instructions; + il.add(new VarInsnNode(Opcodes.ALOAD, 0)); + int local = 1; + int stack = 1; + for (int i = 0; i < bridgeArgs.length; i++) { + il.add(new VarInsnNode(bridgeArgs[i].getOpcode(Opcodes.ILOAD), local)); + local += bridgeArgs[i].getSize(); + if (i < samArgs.length) { + adapt(il, bridgeArgs[i], samArgs[i]); + } + stack += 2; + } + il.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, cn.name, name, + sam.getDescriptor(), false)); + Type samReturn = sam.getReturnType(); + Type bridgeReturn = bridge.getReturnType(); + if (bridgeReturn.getSort() == Type.VOID) { + if (samReturn.getSort() != Type.VOID) { + il.add(new InsnNode(samReturn.getSize() == 2 ? Opcodes.POP2 : Opcodes.POP)); + } + il.add(new InsnNode(Opcodes.RETURN)); + } else { + adapt(il, samReturn, bridgeReturn); + il.add(new InsnNode(bridgeReturn.getOpcode(Opcodes.IRETURN))); + } + mn.maxStack = Math.max(stack + 2, 4); + mn.maxLocals = local; + return mn; + } + + private static MethodNode constructor(ClassNode cn, Type[] captured) { + MethodNode mn = new MethodNode(Opcodes.ACC_PRIVATE, "", + Type.getMethodDescriptor(Type.VOID_TYPE, captured), null, null); + InsnList il = mn.instructions; + il.add(new VarInsnNode(Opcodes.ALOAD, 0)); + il.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false)); + int local = 1; + for (int i = 0; i < captured.length; i++) { + il.add(new VarInsnNode(Opcodes.ALOAD, 0)); + il.add(new VarInsnNode(captured[i].getOpcode(Opcodes.ILOAD), local)); + il.add(new FieldInsnNode(Opcodes.PUTFIELD, cn.name, "arg$" + i, + captured[i].getDescriptor())); + local += captured[i].getSize(); + } + il.add(new InsnNode(Opcodes.RETURN)); + mn.maxStack = 3; + mn.maxLocals = local; + return mn; + } + + /** + * The static factory the call site invokes. + * + *

It exists so the rewrite is a one-instruction substitution. Doing + * {@code new} at the call site would need the reference underneath the + * captured values that are already on the stack, which cannot be arranged + * without shuffling a stack whose shape depends on the captures.

+ */ + private static MethodNode factory(ClassNode cn, Type[] captured, String desc) { + MethodNode mn = new MethodNode(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "create", + desc, null, null); + InsnList il = mn.instructions; + il.add(new TypeInsnNode(Opcodes.NEW, cn.name)); + il.add(new InsnNode(Opcodes.DUP)); + int local = 0; + int size = 0; + for (int i = 0; i < captured.length; i++) { + il.add(new VarInsnNode(captured[i].getOpcode(Opcodes.ILOAD), local)); + local += captured[i].getSize(); + size += captured[i].getSize(); + } + il.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, cn.name, "", + Type.getMethodDescriptor(Type.VOID_TYPE, captured), false)); + il.add(new InsnNode(Opcodes.ARETURN)); + mn.maxStack = size + 2; + mn.maxLocals = Math.max(local, 1); + return mn; + } + + /** The functional interface's single abstract method, forwarding to the body. */ + private static MethodNode samMethod(ClassNode cn, String name, Type sam, Type instantiated, + Handle impl, Type[] captured) { + Type[] samArgs = sam.getArgumentTypes(); + Type[] instArgs = instantiated.getArgumentTypes(); + Type[] implArgs = Type.getArgumentTypes(impl.getDesc()); + + MethodNode mn = new MethodNode(Opcodes.ACC_PUBLIC, name, sam.getDescriptor(), null, null); + InsnList il = mn.instructions; + + boolean isConstructorRef = impl.getTag() == Opcodes.H_NEWINVOKESPECIAL; + boolean hasReceiver = impl.getTag() == Opcodes.H_INVOKEVIRTUAL + || impl.getTag() == Opcodes.H_INVOKEINTERFACE + || impl.getTag() == Opcodes.H_INVOKESPECIAL; + + // What the body expects, in order. A bound method reference takes its + // receiver from the captures; an unbound one takes it from the first + // argument of the interface method. Both are just the first value. + List targets = new ArrayList(); + if (hasReceiver) { + targets.add(Type.getObjectType(impl.getOwner())); + } + for (int i = 0; i < implArgs.length; i++) { + targets.add(implArgs[i]); + } + + if (isConstructorRef) { + il.add(new TypeInsnNode(Opcodes.NEW, impl.getOwner())); + il.add(new InsnNode(Opcodes.DUP)); + } + + int target = 0; + int stack = isConstructorRef ? 2 : 0; + for (int i = 0; i < captured.length; i++) { + il.add(new VarInsnNode(Opcodes.ALOAD, 0)); + il.add(new FieldInsnNode(Opcodes.GETFIELD, cn.name, "arg$" + i, + captured[i].getDescriptor())); + adapt(il, captured[i], targets.get(target++)); + stack += 2; + } + int local = 1; + for (int i = 0; i < samArgs.length; i++) { + il.add(new VarInsnNode(samArgs[i].getOpcode(Opcodes.ILOAD), local)); + local += samArgs[i].getSize(); + // The interface signature is erased; the lambda body is written + // against the instantiated one, so the value is narrowed first. + Type given = i < instArgs.length ? instArgs[i] : samArgs[i]; + adapt(il, samArgs[i], given); + adapt(il, given, targets.get(target++)); + stack += 2; + } + + il.add(new MethodInsnNode(invokeOpcode(impl), impl.getOwner(), impl.getName(), + impl.getDesc(), impl.isInterface())); + + Type implReturn = isConstructorRef + ? Type.getObjectType(impl.getOwner()) + : Type.getReturnType(impl.getDesc()); + Type samReturn = sam.getReturnType(); + if (samReturn.getSort() == Type.VOID) { + if (implReturn.getSort() != Type.VOID) { + il.add(new InsnNode(implReturn.getSize() == 2 ? Opcodes.POP2 : Opcodes.POP)); + } + il.add(new InsnNode(Opcodes.RETURN)); + } else { + adapt(il, implReturn, samReturn); + il.add(new InsnNode(samReturn.getOpcode(Opcodes.IRETURN))); + } + + mn.maxStack = Math.max(stack + 4, 6); + mn.maxLocals = local; + return mn; + } + + private static int invokeOpcode(Handle impl) { + switch (impl.getTag()) { + case Opcodes.H_INVOKESTATIC: + return Opcodes.INVOKESTATIC; + case Opcodes.H_INVOKEINTERFACE: + return Opcodes.INVOKEINTERFACE; + case Opcodes.H_INVOKESPECIAL: + case Opcodes.H_NEWINVOKESPECIAL: + return Opcodes.INVOKESPECIAL; + default: + return Opcodes.INVOKEVIRTUAL; + } + } + + /** + * Converts a value of one type to another, the way the metafactory would. + * + *

Boxing, unboxing, primitive widening and reference narrowing are all + * permitted between a functional interface's erased signature and the body + * it is bound to, and a lambda over {@code Integer} calling a method taking + * {@code int} is entirely ordinary.

+ */ + private static void adapt(InsnList il, Type from, Type to) { + if (from.equals(to) || to.getSort() == Type.VOID) { + return; + } + boolean fromPrimitive = isPrimitive(from); + boolean toPrimitive = isPrimitive(to); + + if (fromPrimitive && toPrimitive) { + widen(il, from, to); + return; + } + if (fromPrimitive) { + il.add(new MethodInsnNode(Opcodes.INVOKESTATIC, boxed(from), "valueOf", + "(" + from.getDescriptor() + ")L" + boxed(from) + ";", false)); + return; + } + if (toPrimitive) { + // Unbox what actually arrived, then widen. Casting to the + // destination's wrapper instead is wrong whenever the two differ: + // `Function f = Long::valueOf` hands the SAM an + // Integer and the implementation wants a long, and a CHECKCAST to + // Long fails on an Integer that was never anything else. + Type source = from.getSort() == Type.OBJECT && isBoxed(from) + ? unboxedType(from) : to; + String box = boxed(source); + il.add(new TypeInsnNode(Opcodes.CHECKCAST, box)); + il.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, box, unboxMethod(source), + "()" + source.getDescriptor(), false)); + widen(il, source, to); + return; + } + if (to.getSort() == Type.ARRAY) { + // An array destination needs the same narrowing as a plain object + // one. `Consumer` reaches `adapt` as Object -> [Ljava/ + // lang/String;, and gating on OBJECT alone left the erased argument + // uncast: the synthesized accept(Object) forwarded any reference + // straight to the interpreted lambda body, so a raw + // `Consumer.accept("bad")` reached the lambda instead of throwing + // ClassCastException. `Type.getInternalName()` on an array returns + // its descriptor form, which is what CHECKCAST expects. + il.add(new TypeInsnNode(Opcodes.CHECKCAST, to.getInternalName())); + return; + } + if (!"java/lang/Object".equals(to.getInternalName()) + && to.getSort() == Type.OBJECT) { + il.add(new TypeInsnNode(Opcodes.CHECKCAST, to.getInternalName())); + } + } + + /// Whether a reference type is one of the eight primitive wrappers. + private static boolean isBoxed(Type t) { + return unboxedTypeOrNull(t) != null; + } + + /// The primitive a wrapper wraps. + private static Type unboxedType(Type t) { + Type p = unboxedTypeOrNull(t); + return p == null ? t : p; + } + + private static Type unboxedTypeOrNull(Type t) { + String n = t.getInternalName(); + if ("java/lang/Integer".equals(n)) { + return Type.INT_TYPE; + } + if ("java/lang/Long".equals(n)) { + return Type.LONG_TYPE; + } + if ("java/lang/Short".equals(n)) { + return Type.SHORT_TYPE; + } + if ("java/lang/Byte".equals(n)) { + return Type.BYTE_TYPE; + } + if ("java/lang/Character".equals(n)) { + return Type.CHAR_TYPE; + } + if ("java/lang/Boolean".equals(n)) { + return Type.BOOLEAN_TYPE; + } + if ("java/lang/Float".equals(n)) { + return Type.FLOAT_TYPE; + } + if ("java/lang/Double".equals(n)) { + return Type.DOUBLE_TYPE; + } + return null; + } + + private static void widen(InsnList il, Type from, Type to) { + // byte, short, char and boolean are all int on the stack, so only the + // four wide conversions actually emit anything. + int f = from.getSort(); + int t = to.getSort(); + if (t == Type.LONG && f != Type.LONG) { + il.add(new InsnNode(f == Type.FLOAT ? Opcodes.F2L : f == Type.DOUBLE ? Opcodes.D2L : Opcodes.I2L)); + } else if (t == Type.FLOAT && f != Type.FLOAT) { + il.add(new InsnNode(f == Type.LONG ? Opcodes.L2F : f == Type.DOUBLE ? Opcodes.D2F : Opcodes.I2F)); + } else if (t == Type.DOUBLE && f != Type.DOUBLE) { + il.add(new InsnNode(f == Type.LONG ? Opcodes.L2D : f == Type.FLOAT ? Opcodes.F2D : Opcodes.I2D)); + } + } + + private static boolean isPrimitive(Type t) { + int s = t.getSort(); + return s != Type.OBJECT && s != Type.ARRAY && s != Type.METHOD; + } + + private static String boxed(Type t) { + switch (t.getSort()) { + case Type.BOOLEAN: return "java/lang/Boolean"; + case Type.BYTE: return "java/lang/Byte"; + case Type.CHAR: return "java/lang/Character"; + case Type.SHORT: return "java/lang/Short"; + case Type.INT: return "java/lang/Integer"; + case Type.LONG: return "java/lang/Long"; + case Type.FLOAT: return "java/lang/Float"; + default: return "java/lang/Double"; + } + } + + private static String unboxMethod(Type t) { + switch (t.getSort()) { + case Type.BOOLEAN: return "booleanValue"; + case Type.BYTE: return "byteValue"; + case Type.CHAR: return "charValue"; + case Type.SHORT: return "shortValue"; + case Type.INT: return "intValue"; + case Type.LONG: return "longValue"; + case Type.FLOAT: return "floatValue"; + default: return "doubleValue"; + } + } +} diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java index d7c37e5c1be..63250c05091 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptNativeRegistry.java @@ -55,10 +55,12 @@ enum NativeCategory { "cn1_java_lang_Character_toLowerCase_char_R_char", "cn1_java_lang_Character_toLowerCase_int_R_int", "cn1_java_lang_Double_doubleToLongBits_double_R_long", + "cn1_java_lang_Double_doubleToRawLongBits_double_R_long", "cn1_java_lang_Double_longBitsToDouble_long_R_double", "cn1_java_lang_Double_toStringImpl_double_boolean_R_java_lang_String", "cn1_java_lang_Enum_valueOf_java_lang_Class_java_lang_String_R_java_lang_Enum", "cn1_java_lang_Float_floatToIntBits_float_R_int", + "cn1_java_lang_Float_floatToRawIntBits_float_R_int", "cn1_java_lang_Float_intBitsToFloat_int_R_float", "cn1_java_lang_Float_toStringImpl_float_boolean_R_java_lang_String", "cn1_java_lang_Integer_toString_int_R_java_lang_String", diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java index 9da34edcba3..5eba406ad42 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java @@ -46,6 +46,7 @@ import org.objectweb.asm.tree.analysis.Frame; import com.codename1.tools.translator.bytecodes.BasicInstruction; import com.codename1.tools.translator.bytecodes.Instruction; +import com.codename1.tools.translator.bytecodes.Invoke; import org.objectweb.asm.TypePath; import org.objectweb.asm.commons.JSRInlinerAdapter; @@ -108,6 +109,24 @@ public static synchronized String resolveDevirtualizedOwner(ByteCodeClass owner, if (owner == null) { return null; } + if (BytecodeMethod.isInterpHost() && !owner.isFinalClass() + && !BytecodeMethod.isInterpOpaqueClass(owner.getClsName())) { + // "No reachable override" is a statement about the closed world, + // and an interp-host build does not have one: interpreted + // subclasses are synthesized at runtime and patch their overrides + // into a copy of the parent's vtable. Devirtualizing here would + // compile the call as a direct branch to the parent, so the + // override would never be reached however correctly it was + // installed. + // + // A class that is genuinely final in its class file is exempt -- + // nothing can subclass it, interpreted or not. That exemption is + // load-bearing rather than an optimization: java.lang.Class is + // final and is the one class ParparVM gives no vtable at all (see + // ByteCodeClass's appendClassVFunctions), so a virtual call on it + // has no symbol to link against. + return null; + } cn1EnsureSubclassIndex(); // any subclass DECLARING the method (abstract or not) keeps it virtual java.util.ArrayDeque stack = new java.util.ArrayDeque(); @@ -282,6 +301,47 @@ private static void writeSymbolSidecar(File outputDirectory) throws IOException } w.write(classSymbolRow(bc, src)); } + // Array class rows, for the device runtime only. A pushed + // `String[].class` arrives as the descriptor `[Ljava/lang/String;`, + // and without a row naming it the lookup fails and the class + // literal raises NoClassDefFoundError for a type the app certainly + // has. The ids are the ones cn1_array_N_id_ defines, computed the + // same way: a fixed start, 100 reserved for primitive arrays, then + // three ranks per class in class order. + // + // Three is a ParparVM constraint: every reference-array id in the + // AOT build is packed into three slots per class, `cn1_array_1_id_X` + // through `cn1_array_3_id_X`, and cn1_globals.m arithmetic (see + // `castImpl`) leans on that fixed step. Extending it means widening + // that layout across the whole runtime and every C symbol that + // names a rank-3 array. Pushed code using a rank 4+ array + // (`String[][][][]`, exceptionally rare in practice) resolves the + // outer descriptor to class id -1 and falls back to an untyped + // Object[] cascade -- functional, but reflection on the outer type + // reports Object[] rather than the source array type. Kept as a + // known limitation until ParparVM's array layout is generalised. + if (BytecodeMethod.isInterpHost()) { + int arrayId = classes.size() + 1 + 100; + for (ByteCodeClass bc : classes) { + String jvmName = bc.getOriginalClassName(); + if (jvmName == null || jvmName.isEmpty()) { + jvmName = bc.getClsName().replace('_', '/'); + } + String element = "L" + jvmName + ";"; + for (int rank = 1; rank <= 3; rank++) { + StringBuilder brackets = new StringBuilder(); + for (int i = 0; i < rank; i++) { + brackets.append('['); + } + // A distinct mangled name, or a consumer keyed by that + // column would map the class's own name to the array's + // id -- the last row wins -- and lose the class. + w.write("class\t" + arrayId + "\tarray" + rank + "__" + bc.getClsName() + + "\t\t-1\t" + brackets + element + "\t\n"); + arrayId++; + } + } + } // Emit instance-field metadata so the proxy can answer JDWP // ClassType.Fields / FieldsWithGeneric without a device round-trip, // and so ObjectReference.GetValues knows what (type, declaring class) @@ -303,6 +363,57 @@ private static void writeSymbolSidecar(File outputDirectory) throws IOException + "\t" + access + "\n"); } } + // Static fields, for the device runtime only. The debugger reads + // statics over its own command path and does not need these; the + // interpreter does, because a pushed GETSTATIC arrives as a name + // with nothing to resolve it against. Rows are separate from + // "field" because a static has no offset -- it is reached through + // the generated accessor registered under the same id. + if (BytecodeMethod.isInterpHost()) { + for (ByteCodeClass bc : classes) { + int classId = bc.getClassOffset(); + for (ByteCodeField bf : bc.getFields()) { + if (!bf.isStaticField()) continue; + int fid = getOrAssignFieldId(bc.getClsName(), bf.getFieldName()); + w.write("sfield\t" + classId + "\t" + fid + + "\t" + bf.getFieldName() + + "\t" + jvmDescriptorOf(bf) + + "\t" + jdwpAccessFlagsOf(bf) + "\n"); + } + } + } + // Device runtime host build only: publish the vtable layout so the + // on-device interpreter can synthesise a subclass at runtime -- + // malloc a clazz, memcpy the parent's vtable, then patch the slots + // the interpreted class overrides. Without this the runtime has no + // way to know which slot corresponds to, say, Component.paint. + // + // Rows are emitted only for methods this class DECLARES or + // overrides, mirroring exactly what __INIT_VTABLE_ patches. + // Emitting inherited slots too would be O(classes x hierarchy + // depth) -- Component alone has hundreds of virtual methods + // inherited by every widget. The runtime resolves an inherited + // method's slot by walking up the superclass chain, which it has to + // be able to do regardless. + if (BytecodeMethod.isInterpHost()) { + for (ByteCodeClass bc : classes) { + if (bc.isIsInterface() || bc.virtualMethodList == null) { + continue; + } + int classId = bc.getClassOffset(); + // Slot count is the full inherited-through table size -- + // that is the allocation size a synthetic vtable needs. + w.write("vtsize\t" + classId + "\t" + bc.virtualMethodList.size() + "\n"); + for (int slot = 0; slot < bc.virtualMethodList.size(); slot++) { + BytecodeMethod bm = bc.virtualMethodList.get(slot); + if (!bm.getClsName().equals(bc.getClsName())) { + continue; + } + w.write("vtable\t" + classId + "\t" + slot + + "\t" + bm.getMethodOffset() + "\n"); + } + } + } for (ByteCodeClass bc : classes) { int classId = bc.getClassOffset(); for (BytecodeMethod m : bc.getMethods()) { @@ -313,14 +424,20 @@ private static void writeSymbolSidecar(File outputDirectory) throws IOException if (desc == null) { desc = ""; } - // Extended method row: classId, name, desc, isStatic. - // Older proxies that only know 4 columns ignore the 5th - // because the parser slices with `split("\t", -1)` and - // size-checks before reading. + // Extended method row: classId, name, desc, isStatic, + // isPrivate. Older proxies that only know 4 or 5 columns + // ignore the tail because the parser slices with + // `split("\t", -1)` and size-checks before reading; the + // device runtime's interface dispatch needs both flags to + // filter out methods Java does not inherit through an + // interface, so a static or private interface member with + // the same descriptor as another interface's default + // cannot be picked over the default. w.write("method\t" + m.getMethodOffset() + "\t" + classId + "\t" + m.getMethodName() + "\t" + desc - + "\t" + (m.isStatic() ? "1" : "0") + "\n"); + + "\t" + (m.isStatic() ? "1" : "0") + + "\t" + (m.isPrivate() ? "1" : "0") + "\n"); Set lines = new TreeSet<>(); for (com.codename1.tools.translator.bytecodes.Instruction ins : m.getInstructions()) { if (ins instanceof com.codename1.tools.translator.bytecodes.LineNumber) { @@ -413,8 +530,48 @@ static String classSymbolRow(ByteCodeClass bc, String sourceFile) { // that predate original-name tracking. jvmName = bc.getClsName().replace('_', '/'); } + // Interfaces as a comma-separated seventh column. The device runtime + // resolves a call by walking up from the receiver's class, and a default + // method lives on an interface -- `new ArrayList().sort(c)` reaches + // java/util/List.sort, which no superclass of ArrayList declares. A + // reader that only knows about superclasses answers "no such method" + // for a method the app plainly has. + StringBuilder ifaces = new StringBuilder(); + if (bc.getBaseInterfacesObject() != null) { + for (ByteCodeClass iface : bc.getBaseInterfacesObject()) { + if (iface == null) { + continue; + } + if (ifaces.length() > 0) { + ifaces.append(','); + } + ifaces.append(iface.getClassOffset()); + } + } + // An eighth column: whether this interface declares a default method. + // JLS 12.4.1 initializes an interface when a class implementing it is + // initialized only if it declares one, and the device runtime cannot + // tell from anything else in this table -- a method row carries name, + // descriptor and staticness, not access flags. Without it the iOS + // linker had nothing to act on and left the ordering to first use. + boolean declaresDefault = false; + if (bc.isIsInterface()) { + for (BytecodeMethod m : bc.getMethods()) { + // Not private: an interface may declare a private helper with + // a body from JDK 9 onwards, and that is not a default method + // -- marking it as one initializes the interface earlier than + // Java does. + if (!m.isStatic() && !m.isAbstract() && !m.isPrivate() && !m.isEliminated() + && !"__CLINIT__".equals(m.getMethodName()) + && !"".equals(m.getMethodName())) { + declaresDefault = true; + break; + } + } + } return "class\t" + bc.getClassOffset() + "\t" + bc.getClsName() - + "\t" + sourceFile + "\t" + superId + "\t" + jvmName + "\n"; + + "\t" + sourceFile + "\t" + superId + "\t" + jvmName + + "\t" + ifaces + "\t" + (declaresDefault ? "1" : "0") + "\n"; } private static void appendClassOffset(ByteCodeClass bc, List clsIds) { @@ -787,14 +944,44 @@ public static void writeOutput(File outputDirectory) throws Exception { file = bc.getClsName(); bc.updateAllDependencies(); } - ByteCodeClass.markDependencies(classes, nativeSources); - Set unmarked = new HashSet<>(classes); - classes = ByteCodeClass.clearUnmarked(classes); - classes.forEach(unmarked::remove); int neliminated = 0; - for (ByteCodeClass removedClass : unmarked) { - removedClass.setEliminated(true); - neliminated++; + if (BytecodeMethod.isInterpHost()) { + // Device runtime host build: keep every class. The reachability + // graph is rooted in the host app's own code, but the code that + // actually matters hasn't been written yet -- it gets pushed + // from a developer's machine at runtime and may call any part + // of the API. Culling here would produce an app that works + // until someone's pushed program touches a class the host + // itself never mentions. + // + // The method-level cull below is already disabled, because + // interpHost forces optimizerOn off (see BytecodeMethod). + neliminated += eliminateMethodsReferencingAbsentClasses(); + // Recompute after the pass: a class's #include list is derived + // from its surviving methods' dependencies, so without this the + // class would still include the header of the absent class + // whose only reference we just removed. + for (ByteCodeClass bc : classes) { + file = bc.getClsName(); + bc.updateAllDependencies(); + } + // Mark everything without culling anything. Marking is what + // populates the constant pool, whose size is fixed in the index + // header before any class is emitted -- see markAll. + ByteCodeClass.markAll(classes, nativeSources); + if (ByteCodeTranslator.verbose) { + System.out.println("Interp host build: retaining all " + + classes.size() + " classes (dead code elimination disabled)"); + } + } else { + ByteCodeClass.markDependencies(classes, nativeSources); + Set unmarked = new HashSet<>(classes); + classes = ByteCodeClass.clearUnmarked(classes); + classes.forEach(unmarked::remove); + for (ByteCodeClass removedClass : unmarked) { + removedClass.setEliminated(true); + neliminated++; + } } // loop over methods and start eliminating the body of unused methods @@ -864,6 +1051,18 @@ public static void writeOutput(File outputDirectory) throws Exception { generateClassAndMethodIndexHeader(outputDirectory); + // Interp-host builds emit and register a rank 1--3 array clazz + // for every host class, matching the class rows the symbol + // sidecar already advertises. Without this a pushed program's + // `new HostType[1]` or `HostType[].class` would resolve to an + // id whose registry entry is absent -- classObjectById returns + // null and newObjectArray falls back to Object[]. Seeded here, + // once, so every gate downstream (getArrayClazz, the array + // struct emission loop, the extern block, the vtable + // initializer, the id-to-clazz registration) sees a matching + // arrayTypes entry. No-op outside interp-host. + ByteCodeClass.seedInterpHostArrayTypes(classes); + boolean concatenate = "true".equals(System.getProperty("concatenateFiles", "false")); ConcatenatingFileOutputStream cos = concatenate ? new ConcatenatingFileOutputStream(outputDirectory) : null; @@ -1170,6 +1369,215 @@ private static void writeFile(ByteCodeClass cls, File outputDir, ConcatenatingFi } } + /** + * Eliminates method bodies that reference a class the translation set does + * not contain. Only used by an interp-host build. + * + *

"Keep everything" cannot be taken literally, because the runtime + * itself does not compile under it. {@code java.lang.Throwable} declares + * {@code printStackTrace(PrintWriter)}, and ParparVM's JavaAPI has no + * {@code java.io.PrintWriter} at all — the reference has always dangled and + * ordinary builds never notice because the method is unreachable and the + * unused-method cull removes it first. With that cull off, the method + * survives, its class emits {@code #include "java_io_PrintWriter.h"}, and + * the build fails on a header that was never generated.

+ * + *

So the real invariant is "keep everything that is actually + * translatable". A method whose operands name an absent class could never + * have been called anyway: there is no such class on the device, so no + * pushed program can reach it either.

+ * + * @return the number of methods eliminated + */ + private static int eliminateMethodsReferencingAbsentClasses() { + Set present = new HashSet<>(); + for (ByteCodeClass bc : classes) { + present.add(bc.getClsName()); + } + int eliminated = 0; + for (ByteCodeClass bc : classes) { + for (BytecodeMethod m : bc.getMethods()) { + if (m.isEliminated()) { + continue; + } + String reason = null; + for (String dep : m.getDependentClasses()) { + if (!present.contains(dep)) { + reason = "absent class " + dep; + break; + } + } + if (reason == null) { + reason = unresolvableMemberOf(m); + } + if (reason != null) { + failIfLoadBearing(bc, m, reason); + m.setEliminated(true); + eliminated++; + if (ByteCodeTranslator.verbose) { + System.out.println("Interp host build: eliminating " + + bc.getClsName() + "." + m.getMethodName() + + " -- references " + reason); + } + } + } + } + return eliminated; + } + + /** + * Classes whose methods must never be stubbed out. + * + *

Everything else in an interp-host build can lose a method safely: it + * was unreachable, or it belongs to a corner of the JDK the device does not + * have. These are different. They are the device runtime itself, and a + * stubbed method here does not fail -- it succeeds, quietly, doing + * nothing.

+ */ + private static boolean isLoadBearingForInterp(String clsName) { + return clsName.startsWith("com_codename1_impl_interp_") + || clsName.startsWith("com_codename1_impl_ios_InterpIOS"); + } + + /** + * Refuses to eliminate a method the device runtime is built on. + * + *

This exists because of a specific failure that cost real time and gave + * no signal at all. A single call to {@code java.lang.reflect.Array + * .getLength} -- a method ParparVM's {@code Array} does not have -- made + * {@code InterpRuntime.run} unresolvable, so the pass eliminated it. The + * interpreter's main loop became an empty function. Every pushed program + * then "ran" instantly and reported success while executing none of its + * bytecode, and nothing anywhere said otherwise.

+ * + *

A build failure naming the method and the missing member is worth far + * more than a working build that does nothing, so this throws.

+ */ + private static void failIfLoadBearing(ByteCodeClass bc, BytecodeMethod m, String reason) { + if (!isLoadBearingForInterp(bc.getClsName())) { + return; + } + throw new IllegalStateException( + "interp-host build cannot stub " + bc.getClsName() + "." + m.getMethodName() + + ": it references " + reason + ", and this class is the device runtime itself. " + + "Eliminating it would produce an app that reports every pushed program as " + + "successful while running none of it. Either add the missing member to " + + "vm/JavaAPI, or rewrite the method to avoid it."); + } + + /** + * Describes the first member this method references that does not exist in + * the translation set, or null if every reference resolves. + * + *

The class-level check above is not enough. ParparVM's JavaAPI is a + * subset at member granularity too: {@code java.util.Locale} is + * present but has no {@code ROOT}, {@code java.lang.String} is present but + * has no {@code toLowerCase(Locale)}. Kotlin's stdlib calls both. An + * ordinary build never notices because the calling method is unreachable + * and the unused-method cull drops it; with that cull off the method is + * emitted and the C references a function no header ever declares.

+ * + *

Resolution walks the superclass chain and interfaces, so an inherited + * member counts as present. When the owner cannot be resolved at all the + * reference is left alone: that is either a class the class-level check + * already caught, or an array/primitive pseudo-owner this does not model.

+ */ + private static String unresolvableMemberOf(BytecodeMethod m) { + for (Instruction ins : m.getInstructions()) { + if (ins instanceof Invoke) { + Invoke inv = (Invoke) ins; + ByteCodeClass owner = getClassObject(mangle(inv.getOwner())); + if (owner == null || inv.getOwner().startsWith("[")) { + continue; + } + // The parser rewrites / to __INIT__/__CLINIT__ on + // the method it stores, while the invoke instruction keeps the + // JVM spelling. Comparing the two directly makes every + // constructor call look unresolvable, which would stub out + // every method that does `new X()`. + String name = inv.getName(); + if ("".equals(name)) { + name = "__INIT__"; + } else if ("".equals(name)) { + name = "__CLINIT__"; + } + BytecodeMethod target = findMethodInHierarchy(owner, name, inv.getDesc(), + new HashSet()); + if (target == null) { + return "absent method " + mangle(inv.getOwner()) + "." + + inv.getName() + inv.getDesc(); + } + // A static/instance disagreement is as fatal as an absent + // method: the emitted call passes the wrong number of + // arguments, which the C compiler rejects. ParparVM's JavaAPI + // has real cases -- java.util.regex.Matcher.quoteReplacement is + // declared as an instance method where the JDK specifies it + // static, so any javac/kotlinc-compiled INVOKESTATIC against it + // cannot be translated. + boolean callIsStatic = inv.getOpcode() == org.objectweb.asm.Opcodes.INVOKESTATIC; + if (callIsStatic != target.isStatic()) { + return (callIsStatic ? "static call to instance method " + : "instance call to static method ") + + mangle(inv.getOwner()) + "." + inv.getName() + inv.getDesc(); + } + } else if (ins instanceof com.codename1.tools.translator.bytecodes.Field) { + com.codename1.tools.translator.bytecodes.Field f = + (com.codename1.tools.translator.bytecodes.Field) ins; + ByteCodeClass owner = getClassObject(mangle(f.getOwner())); + if (owner == null || f.getOwner().startsWith("[")) { + continue; + } + if (!declaresFieldInHierarchy(owner, f.getFieldName(), new HashSet())) { + return "absent field " + mangle(f.getOwner()) + "." + f.getFieldName(); + } + } + } + return null; + } + + private static String mangle(String internalName) { + return internalName.replace('/', '_').replace('$', '_'); + } + + private static BytecodeMethod findMethodInHierarchy(ByteCodeClass bc, String name, String desc, + Set seen) { + while (bc != null && seen.add(bc.getClsName())) { + BytecodeMethod declared = bc.findDeclaredMethod(name, desc); + if (declared != null) { + return declared; + } + for (String iface : bc.getBaseInterfaces()) { + ByteCodeClass ic = getClassObject(mangle(iface)); + if (ic != null) { + BytecodeMethod m = findMethodInHierarchy(ic, name, desc, seen); + if (m != null) { + return m; + } + } + } + bc = bc.getBaseClassObject(); + } + return null; + } + + private static boolean declaresFieldInHierarchy(ByteCodeClass bc, String name, Set seen) { + while (bc != null && seen.add(bc.getClsName())) { + for (ByteCodeField bf : bc.getFields()) { + if (bf.getFieldName().equals(name)) { + return true; + } + } + for (String iface : bc.getBaseInterfaces()) { + ByteCodeClass ic = getClassObject(mangle(iface)); + if (ic != null && declaresFieldInHierarchy(ic, name, seen)) { + return true; + } + } + bc = bc.getBaseClassObject(); + } + return false; + } + public Parser() { super(Opcodes.ASM9); } diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java index 7e05e0a2320..77c1d4d0f1d 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/Invoke.java @@ -159,6 +159,16 @@ private String resolveConcreteInvokeOwner(ByteCodeClass ownerClass, boolean allo if (ownerClass == null || ownerClass.getConcreteClass() == null) { return null; } + if (BytecodeMethod.isInterpHost() && !ownerClass.isFinalClass() + && !BytecodeMethod.isInterpOpaqueClass(ownerClass.getClsName())) { + // "Exactly one concrete implementor" is a closed-world fact, and an + // interp-host build has no closed world -- an interpreted subclass + // synthesized at runtime is a second implementor the translator + // cannot see. Collapsing the call to the single known concrete + // owner would bypass the vtable the override is patched into. + // A genuinely final class is exempt; nothing can subclass it. + return null; + } String currentClass = getMethod() != null ? getMethod().getClsName() : null; if (currentClass == null && !allowMissingMethodContext) { return null; diff --git a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js index ed2d176d4ea..b736c5de4e5 100644 --- a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js +++ b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js @@ -5427,6 +5427,11 @@ bindNative(["cn1_java_lang_Long_toString_long_int_R_java_lang_String"], function bindNative(["cn1_java_lang_Character_toLowerCase_char_R_char"], function(ch) { return String.fromCharCode(ch | 0).toLowerCase().charCodeAt(0) | 0; }); bindNative(["cn1_java_lang_Character_toLowerCase_int_R_int"], function(ch) { return String.fromCharCode(ch | 0).toLowerCase().charCodeAt(0) | 0; }); bindNative(["cn1_java_lang_Float_floatToIntBits_float_R_int"], function(v) { return intBitsFromFloat(v); }); +// Raw bit conversion: same underlying representation as intBitsFromFloat -- +// no NaN canonicalisation is performed here either, since the JS runtime does +// not have a native NaN payload distinction; the entry point exists so the +// device runtime's non-canonicalising path resolves. +bindNative(["cn1_java_lang_Float_floatToRawIntBits_float_R_int"], function(v) { return intBitsFromFloat(v); }); bindNative(["cn1_java_lang_Float_intBitsToFloat_int_R_float"], function(bits) { return floatFromIntBits(bits); }); function formatJavaFloating(value, scientificNotation) { value = Number(value); @@ -5449,6 +5454,8 @@ bindNative(["cn1_java_lang_Float_toStringImpl_float_boolean_R_java_lang_String"] return createJavaString(formatJavaFloating(v, !!scientificNotation)); }); bindNative(["cn1_java_lang_Double_doubleToLongBits_double_R_long"], function(v) { return longBitsFromDouble(v); }); +// Raw bit conversion, same rationale as floatToRawIntBits above. +bindNative(["cn1_java_lang_Double_doubleToRawLongBits_double_R_long"], function(v) { return longBitsFromDouble(v); }); bindNative(["cn1_java_lang_Double_longBitsToDouble_long_R_double"], function(bits) { return doubleFromLongBits(bits); }); bindNative(["cn1_java_lang_Double_toStringImpl_double_boolean_R_java_lang_String"], function(v, scientificNotation) { return createJavaString(formatJavaFloating(v, !!scientificNotation)); diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 3d83f66f779..aa554214e32 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -1056,7 +1056,22 @@ JAVA_INT java_lang_Float_floatToIntBits___float_R_int(CODENAME_ONE_THREAD_STATE, JAVA_FLOAT f; JAVA_INT i; } u; - + + u.f = n1; + return u.i; +} + +JAVA_INT java_lang_Float_floatToRawIntBits___float_R_int(CODENAME_ONE_THREAD_STATE, JAVA_FLOAT n1) +{ + // Same as floatToIntBits on ParparVM: the union-based type-pun already + // preserves every NaN payload verbatim. The two entry points differ only + // in that this one is what a program calls when it explicitly does not + // want NaN canonicalisation, matching the JVM's convention. + union { + JAVA_FLOAT f; + JAVA_INT i; + } u; + u.f = n1; return u.i; } diff --git a/vm/JavaAPI/src/java/lang/Double.java b/vm/JavaAPI/src/java/lang/Double.java index 1bd1a5d7a3b..269474b7ee1 100644 --- a/vm/JavaAPI/src/java/lang/Double.java +++ b/vm/JavaAPI/src/java/lang/Double.java @@ -90,6 +90,15 @@ public byte byteValue(){ */ public native static long doubleToLongBits(double value); + /** + * Returns the raw IEEE 754 double-precision bit pattern of a double, + * without collapsing NaN payloads to the canonical 0x7ff8000000000000L + * the way {@link #doubleToLongBits} does. The device runtime uses this + * to store double slots so a pushed program that got a noncanonical NaN + * from a host call can read the same bits back out. + */ + public native static long doubleToRawLongBits(double value); + /** * Returns the double value of this Double. */ diff --git a/vm/JavaAPI/src/java/lang/ExceptionInInitializerError.java b/vm/JavaAPI/src/java/lang/ExceptionInInitializerError.java new file mode 100644 index 00000000000..b1cd6b707fa --- /dev/null +++ b/vm/JavaAPI/src/java/lang/ExceptionInInitializerError.java @@ -0,0 +1,71 @@ +/* + * 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.lang; +/** + * Signals that an unexpected exception has occurred in a static initializer. + * + *

Present so an interpreted class initializer can fail the way Java says it + * fails: the first touch of a class whose {@code } threw reports this, + * and every touch after it reports NoClassDefFoundError. Without the type, a + * pushed program's {@code catch (ExceptionInInitializerError e)} names a class + * the device does not have.

+ */ +public class ExceptionInInitializerError extends java.lang.LinkageError { + private java.lang.Throwable exception; + + /** + * Constructs an ExceptionInInitializerError with no detail message. + */ + public ExceptionInInitializerError(){ + } + + /** + * Constructs an ExceptionInInitializerError with the specified detail message. + * s - the detail message. + */ + public ExceptionInInitializerError(java.lang.String s){ + super(s); + } + + /** + * Constructs an ExceptionInInitializerError for the given throwable. + * thrown - the exception the initializer threw. + */ + public ExceptionInInitializerError(java.lang.Throwable thrown){ + this.exception = thrown; + } + + /** + * The exception the class initializer threw, or null. + */ + public java.lang.Throwable getException(){ + return exception; + } + + /** + * Same as getException(), for code written against the Throwable API. + */ + public java.lang.Throwable getCause(){ + return exception; + } +} diff --git a/vm/JavaAPI/src/java/lang/Float.java b/vm/JavaAPI/src/java/lang/Float.java index be993744dc5..f052d06240b 100644 --- a/vm/JavaAPI/src/java/lang/Float.java +++ b/vm/JavaAPI/src/java/lang/Float.java @@ -107,6 +107,15 @@ public boolean equals(java.lang.Object obj){ */ public native static int floatToIntBits(float value); + /** + * Returns the raw IEEE 754 single-precision bit pattern of a float, without + * collapsing NaN payloads to the canonical 0x7fc00000 the way + * {@link #floatToIntBits} does. The device runtime uses this to store + * float slots so a pushed program that got a noncanonical NaN from a + * host call can read the same bits back out. + */ + public native static int floatToRawIntBits(float value); + /** * Returns the float value of this Float object. */ diff --git a/vm/JavaAPI/src/java/lang/NoSuchMethodError.java b/vm/JavaAPI/src/java/lang/NoSuchMethodError.java new file mode 100644 index 00000000000..b5c646bc477 --- /dev/null +++ b/vm/JavaAPI/src/java/lang/NoSuchMethodError.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.lang; + +/** + * Thrown when the virtual machine notices that a program tries to reference, + * on a class or object, a method that does not exist. + *

+ * An ahead-of-time compiled program cannot raise this by itself -- a missing + * method is a compile error there. The device runtime can: a pushed program is + * linked against the app that happens to be installed, and naming a method that + * app does not carry is exactly this condition. The sibling + * {@link NoSuchFieldError} was already present; this completes the pair. + */ +public class NoSuchMethodError extends IncompatibleClassChangeError { + + private static final long serialVersionUID = -3765521442372831335L; + + /** + * Constructs a new {@code NoSuchMethodError} that includes the current + * stack trace. + */ + public NoSuchMethodError() { + super(); + } + + /** + * Constructs a new {@code NoSuchMethodError} with the current stack trace + * and the specified detail message. + * + * @param detailMessage + * the detail message for this error. + */ + public NoSuchMethodError(String detailMessage) { + super(detailMessage); + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/DevicePushEntryPointTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/DevicePushEntryPointTest.java new file mode 100644 index 00000000000..add9774ff21 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/DevicePushEntryPointTest.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.junit.jupiter.api.Test; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Which class a pushed tree enters. + * + *

A real application has no {@code main}: it has a {@code Lifecycle} + * subclass, and finding it is what makes pushing a project's own source tree + * work at all.

+ */ +class DevicePushEntryPointTest { + + private static final String LIFECYCLE = "com/codename1/system/Lifecycle"; + + /** + * The walk has to follow the whole chain. It was bounded at 64 edges, and a + * hierarchy longer than that reported "no entry point" for a project that + * plainly has one -- while the same bound made two deeper candidates tie, + * so which one won depended on hash order. + */ + @Test + void aDeepHierarchyStillFindsItsEntryPoint() throws Exception { + Path dir = Files.createTempDirectory("entry-point"); + List classes = new ArrayList(); + String parent = LIFECYCLE; + for (int i = 0; i < 80; i++) { + String name = "com/example/Base" + i; + classes.add(write(dir, name, parent, true)); + parent = name; + } + classes.add(write(dir, "com/example/App", parent, false)); + + assertEquals("com/example/App", DevicePush.findEntryPoint(classes), + "an 81-deep Lifecycle descendant is still the entry point"); + } + + /** The deepest concrete descendant wins: BaseApp is not the application. */ + @Test + void theDeepestConcreteDescendantWins() throws Exception { + Path dir = Files.createTempDirectory("entry-point-depth"); + List classes = new ArrayList(); + classes.add(write(dir, "com/example/BaseApp", LIFECYCLE, false)); + classes.add(write(dir, "com/example/MyApp", "com/example/BaseApp", false)); + + assertEquals("com/example/MyApp", DevicePush.findEntryPoint(classes)); + } + + /** + * Two equally deep candidates must not depend on hash order: the same tree + * pushed twice has to enter the same class. + */ + @Test + void aTieIsBrokenDeterministically() throws Exception { + Path dir = Files.createTempDirectory("entry-point-tie"); + List a = new ArrayList(); + a.add(write(dir, "com/example/Zeta", LIFECYCLE, false)); + a.add(write(dir, "com/example/Alpha", LIFECYCLE, false)); + List b = new ArrayList(a); + Collections.reverse(b); + + assertEquals("com/example/Alpha", DevicePush.findEntryPoint(a)); + assertEquals(DevicePush.findEntryPoint(a), DevicePush.findEntryPoint(b), + "the order the files were read in must not decide"); + } + + /** + * Two mains is a real shape -- an application plus a diagnostic launcher -- + * and {@code listFiles()} has no defined order, so returning the first one + * seen made identical sources push different programs on different + * machines, or after a rebuild. + */ + @Test + void severalMainsAreChosenBetweenDeterministically() throws Exception { + Path dir = Files.createTempDirectory("entry-point-mains"); + List classes = new ArrayList(); + classes.add(writeWithMain(dir, "com/example/Tool")); + classes.add(writeWithMain(dir, "com/example/App")); + List reversed = new ArrayList(classes); + Collections.reverse(reversed); + + assertEquals("com/example/App", DevicePush.findEntryPoint(classes)); + assertEquals(DevicePush.findEntryPoint(classes), DevicePush.findEntryPoint(reversed), + "the order the files were read in must not decide"); + } + + private static File writeWithMain(Path dir, String internalName) throws Exception { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null); + cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "main", + "([Ljava/lang/String;)V", null, null).visitEnd(); + cw.visitEnd(); + Path out = dir.resolve(internalName.replace('/', '_') + ".class"); + Files.write(out, cw.toByteArray()); + return out.toFile(); + } + + private static File write(Path dir, String internalName, String superName, boolean isAbstract) + throws Exception { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, + Opcodes.ACC_PUBLIC | (isAbstract ? Opcodes.ACC_ABSTRACT : 0), + internalName, null, superName, null); + cw.visitEnd(); + Path out = dir.resolve(internalName.replace('/', '_') + ".class"); + Files.write(out, cw.toByteArray()); + return out.toFile(); + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/InterpHostSymbolTableTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/InterpHostSymbolTableTest.java new file mode 100644 index 00000000000..8b35e4552ed --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/InterpHostSymbolTableTest.java @@ -0,0 +1,366 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * An interpreted class that extends a framework class has to get its overrides + * called by AOT code that was compiled long before that class existed. The + * mechanism is runtime clazz synthesis: copy the parent's vtable, then patch the + * slots the interpreted subclass overrides. + * + *

That is only possible if the device knows the vtable layout, which nothing + * previously published — the symbol table described classes, methods, fields and + * lines, but not slots. These pin the layout rows an interp-host build emits, and + * pin that a plain debug build still does not pay for them.

+ * + *

Runs the real translator end to end rather than a hand-built fixture, + * because the property under test is about the relationship between the emitted + * vtable initialisers and the emitted symbol table — two separate passes that a + * fixture could keep consistent by accident.

+ */ +@EnabledIf("com.codename1.tools.translator.InterpHostSymbolTableTest#hasCompiler") +class InterpHostSymbolTableTest { + + static boolean hasCompiler() { + return !CompilerHelper.getAvailableCompilers("1.8").isEmpty(); + } + + private static final String APP = + "public class Main {\n" + + " public static class Base {\n" + + " public void greet() { System.out.println(\"base\"); }\n" + + " public int size() { return 1; }\n" + + " }\n" + + " public static class Derived extends Base {\n" + + " public void greet() { System.out.println(\"derived\"); }\n" + + " }\n" + + " public static void main(String[] args) { new Derived().greet(); }\n" + + "}\n"; + + /** + * Every class that can be subclassed publishes how many slots its vtable + * has. The runtime mallocs a synthetic vtable of exactly this size and + * memcpys the parent's into it; a missing or wrong count is a heap + * overflow, not a missing feature. + */ + @Test + void anInterpHostBuildPublishesAVtableSizeForEveryConcreteClass() throws Exception { + SymbolRows rows = translate(true); + + assertFalse(rows.vtableSizes.isEmpty(), "no vtsize rows were emitted"); + Integer derived = rows.vtableSizes.get(rows.classIdOf("Main$Derived")); + assertNotNull(derived, "Main$Derived should publish a vtable size"); + assertTrue(derived > 0, "a concrete class should have a non-empty vtable, was " + derived); + } + + /** + * A slot row says "this method occupies this slot of this class's vtable". + * An override has to land on the slot its parent's declaration occupies, or + * patching it would redirect an unrelated method. + */ + @Test + void anOverrideOccupiesTheSameSlotAsTheMethodItOverrides() throws Exception { + SymbolRows rows = translate(true); + + int baseId = rows.classIdOf("Main$Base"); + int derivedId = rows.classIdOf("Main$Derived"); + + Integer baseSlot = rows.slotOfMethodNamed(baseId, "greet"); + Integer derivedSlot = rows.slotOfMethodNamed(derivedId, "greet"); + + assertNotNull(baseSlot, "Base.greet should have a vtable slot, rows:\n" + rows); + assertNotNull(derivedSlot, "Derived.greet should have a vtable slot, rows:\n" + rows); + assertTrue(baseSlot.equals(derivedSlot), + "Derived.greet should override Base.greet's slot " + baseSlot + + " but took " + derivedSlot + ", rows:\n" + rows); + } + + /** + * Slot rows are emitted only for methods a class declares or overrides — + * exactly what __INIT_VTABLE_<cls> patches. Listing inherited slots + * too would be O(classes x hierarchy depth); the runtime walks up the + * superclass chain instead, which it must be able to do regardless. + */ + @Test + void inheritedSlotsAreNotRepeatedUnderTheSubclass() throws Exception { + SymbolRows rows = translate(true); + + int derivedId = rows.classIdOf("Main$Derived"); + // Derived overrides greet() but inherits size() unchanged. + assertNotNull(rows.slotOfMethodNamed(derivedId, "greet"), + "the overridden method should be listed under the subclass"); + assertTrue(rows.slotOfMethodNamed(derivedId, "size") == null, + "the inherited method should not be repeated under the subclass, rows:\n" + rows); + } + + /** + * The layout rows exist for the device runtime host and nothing else. A + * plain on-device-debug build links its symbol table into the binary, so + * paying for rows jdb never reads would grow every debug build. + */ + @Test + void aPlainDebugBuildEmitsNoVtableRows() throws Exception { + SymbolRows rows = translate(false); + + assertTrue(rows.vtableSizes.isEmpty(), + "a debug build should emit no vtsize rows, got " + rows.vtableSizes.size()); + assertTrue(rows.slots.isEmpty(), + "a debug build should emit no vtable rows, got " + rows.slots.size()); + assertFalse(rows.classIds.isEmpty(), + "a debug build should still emit the rest of the symbol table"); + } + + /** + * Pushed code may call any part of the API, including classes the host app + * itself never mentions. An interp-host build therefore keeps every class + * the translator saw, where a normal build culls the unreachable ones. + */ + @Test + void anInterpHostBuildRetainsClassesANormalBuildWouldCull() throws Exception { + int keptWithInterpHost = translate(true).classIds.size(); + int keptNormally = translate(false).classIds.size(); + + assertTrue(keptWithInterpHost > keptNormally, + "interp-host should retain more classes than a culling build (" + + keptWithInterpHost + " vs " + keptNormally + ")"); + } + + /** + * The thunks, field tables and vtable rows are all emitted behind {@code + * #ifdef CN1_ON_DEVICE_DEBUG}, and the macro itself lives commented-out in + * cn1_globals.h until the translator uncomments it. An interp-host build + * enables on-device-debug emission implicitly rather than through the + * cn1.onDeviceDebug property, so a rewrite keyed on that property leaves + * every thunk compiling to nothing — a mechanism that is entirely absent at + * runtime while looking present in the generated sources. + */ + @Test + void anInterpHostBuildUncommentsBothMacros() throws Exception { + translate(true); + String globals = readGlobalsHeader(lastOutputDir); + + assertTrue(globals.contains("\n#define CN1_ON_DEVICE_DEBUG"), + "interp-host implies on-device-debug, so its macro must be enabled"); + assertTrue(globals.contains("\n#define CN1_INTERP_HOST"), + "the interp-host macro must be enabled"); + } + + /** A normal build leaves both commented out and pays for neither. */ + @Test + void aNormalBuildLeavesTheInterpHostMacroOff() throws Exception { + translate(false); + String globals = readGlobalsHeader(lastOutputDir); + + assertTrue(globals.contains("//#define CN1_INTERP_HOST"), + "a non-interp-host build must leave the macro commented out"); + } + + // ---------------------------------------------------------------- helpers + + /** Output directory of the most recent {@link #translate} call. */ + private Path lastOutputDir; + + private static String readGlobalsHeader(Path outputDir) throws Exception { + Path h = Files.walk(outputDir) + .filter(p -> p.getFileName().toString().equals("cn1_globals.h")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("no cn1_globals.h under " + outputDir)); + return new String(Files.readAllBytes(h), StandardCharsets.UTF_8); + } + + private SymbolRows translate(boolean interpHost) throws Exception { + CompilerHelper.CompilerConfig config = CompilerHelper.getAvailableCompilers("1.8").get(0); + + Path sourceDir = Files.createTempDirectory("interp-sym-src"); + Path classesDir = Files.createTempDirectory("interp-sym-classes"); + Path outputDir = Files.createTempDirectory("interp-sym-out"); + lastOutputDir = outputDir; + + Files.write(sourceDir.resolve("Main.java"), APP.getBytes(StandardCharsets.UTF_8)); + + Path javaApiDir = Files.createTempDirectory("interp-sym-api"); + CompilerHelper.compileJavaAPI(javaApiDir, config); + + List args = new ArrayList<>(Arrays.asList( + "-source", config.targetVersion, + "-target", config.targetVersion)); + args.add(CompilerHelper.useClasspath(config) ? "-classpath" : "-bootclasspath"); + args.add(javaApiDir.toString()); + args.addAll(Arrays.asList("-d", classesDir.toString(), + sourceDir.resolve("Main.java").toString())); + if (CompilerHelper.compile(config.jdkHome, args) != 0) { + throw new IllegalStateException("fixture did not compile: " + + CompilerHelper.getLastErrorLog()); + } + CompilerHelper.copyDirectory(javaApiDir, classesDir); + + // The translator reads its flags from system properties in a static + // initialiser, and runTranslator loads it in a fresh URLClassLoader -- + // so setting them here takes effect for exactly this translation. + String previousInterp = System.getProperty("cn1.interpHost"); + String previousDebug = System.getProperty("cn1.onDeviceDebug"); + System.setProperty("cn1.interpHost", Boolean.toString(interpHost)); + System.setProperty("cn1.onDeviceDebug", "true"); + try { + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "InterpSymApp"); + } finally { + restore("cn1.interpHost", previousInterp); + restore("cn1.onDeviceDebug", previousDebug); + } + + return SymbolRows.parse(readSymbolTable(outputDir)); + } + + private static void restore(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + /** Finds cn1_debug_symbols.c, extracts its byte array and gunzips it. */ + private static String readSymbolTable(Path outputDir) throws Exception { + Path symbols = Files.walk(outputDir) + .filter(p -> p.getFileName().toString().equals("cn1_debug_symbols.c")) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "no cn1_debug_symbols.c under " + outputDir)); + + String c = new String(Files.readAllBytes(symbols), StandardCharsets.UTF_8); + int start = c.indexOf('{', c.indexOf("cn1_debug_symbols_gz[]")); + int end = c.indexOf("};", start); + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + Matcher m = Pattern.compile("0x([0-9a-fA-F]{2})").matcher(c.substring(start, end)); + while (m.find()) { + raw.write(Integer.parseInt(m.group(1), 16)); + } + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (InputStream gz = new GZIPInputStream(new ByteArrayInputStream(raw.toByteArray()))) { + byte[] buf = new byte[8192]; + int n; + while ((n = gz.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + + /** The subset of symbol-table rows these tests reason about. */ + private static final class SymbolRows { + /** mangled class name -> classId */ + final Map classIds = new HashMap<>(); + /** classId -> vtable slot count */ + final Map vtableSizes = new HashMap<>(); + /** "classId:slot" -> methodId */ + final Map slots = new HashMap<>(); + /** methodId -> method name */ + final Map methodNames = new HashMap<>(); + /** methodId -> declaring classId */ + final Map methodOwners = new HashMap<>(); + + static SymbolRows parse(String table) { + SymbolRows r = new SymbolRows(); + for (String line : table.split("\n")) { + String[] p = line.split("\t", -1); + switch (p[0]) { + case "class": + if (p.length >= 3) r.classIds.put(p[2], Integer.parseInt(p[1])); + break; + case "vtsize": + if (p.length >= 3) { + r.vtableSizes.put(Integer.parseInt(p[1]), Integer.parseInt(p[2])); + } + break; + case "vtable": + if (p.length >= 4) { + r.slots.put(p[1] + ":" + p[2], Integer.parseInt(p[3])); + } + break; + case "method": + if (p.length >= 4) { + int mid = Integer.parseInt(p[1]); + r.methodOwners.put(mid, Integer.parseInt(p[2])); + r.methodNames.put(mid, p[3]); + } + break; + default: + break; + } + } + return r; + } + + int classIdOf(String jvmSimpleName) { + String mangled = jvmSimpleName.replace('$', '_'); + Integer id = classIds.get(mangled); + assertNotNull(id, "no class row for " + mangled + ", had: " + classIds.keySet()); + return id; + } + + /** The vtable slot the given class assigns to a method of that name. */ + Integer slotOfMethodNamed(int classId, String methodName) { + for (Map.Entry e : slots.entrySet()) { + if (!e.getKey().startsWith(classId + ":")) { + continue; + } + if (methodName.equals(methodNames.get(e.getValue()))) { + return Integer.parseInt(e.getKey().split(":")[1]); + } + } + return null; + } + + @Override + public String toString() { + Set vt = new HashSet<>(slots.keySet()); + return "classes=" + classIds.size() + " vtsize=" + vtableSizes.size() + + " slots=" + vt.size(); + } + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/InterpHostThunkTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/InterpHostThunkTest.java new file mode 100644 index 00000000000..91b226e8d59 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/InterpHostThunkTest.java @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The device runtime host build has to be able to construct a framework object + * from interpreted code — {@code new Form()} written in a pushed program has to + * reach {@code com_codename1_ui_Form}'s real constructor. + * + *

Every other method is reachable through the on-device-debug invoke thunks, + * but those deliberately skip constructors: jdb never constructs, so a debug + * build has no use for them. An interp-host build does, and a constructor thunk + * is not just "the method thunk applied to <init>" — it has to allocate + * first, pass the fresh object as the receiver, and hand it back as the thunk's + * value even though the Java return type is {@code void}.

+ * + *

Asserted on the emitted C because the alternative is discovering it as a + * link error, or worse a wrong-arity call, during a device build.

+ */ +class InterpHostThunkTest { + + private static final String HOST = "com/example/InterpCtorHost"; + private static final String MANGLED = "com_example_InterpCtorHost"; + + private boolean previousInterpHost; + private boolean previousOnDeviceDebug; + private boolean previousOptimizer; + + @BeforeEach + void enableInterpHost() { + Parser.cleanup(); + previousInterpHost = BytecodeMethod.interpHost; + previousOnDeviceDebug = BytecodeMethod.onDeviceDebug; + previousOptimizer = BytecodeMethod.optimizerOn; + // Mirrors what the cn1.interpHost system property does in + // BytecodeMethod's static initialiser. + BytecodeMethod.interpHost = true; + BytecodeMethod.onDeviceDebug = true; + BytecodeMethod.optimizerOn = false; + } + + @AfterEach + void restore() { + BytecodeMethod.interpHost = previousInterpHost; + BytecodeMethod.onDeviceDebug = previousOnDeviceDebug; + BytecodeMethod.optimizerOn = previousOptimizer; + Parser.cleanup(); + } + + /** + * The thunk allocates through {@code __NEW_} and runs {@code } + * against that object. Without the allocation there is nothing to construct + * into; the debugger's method thunks get their receiver handed to them. + */ + @Test + void aConstructorThunkAllocatesBeforeRunningInit() throws Exception { + String thunk = ctorThunkFor("()V"); + + assertTrue(thunk.contains("JAVA_OBJECT __r = __NEW_" + MANGLED + "(threadStateData);"), + "the ctor thunk should allocate the receiver, was:\n" + thunk); + assertTrue(thunk.contains(MANGLED + "___INIT____(threadStateData, __r)"), + "the ctor thunk should run against the allocated object, was:\n" + thunk); + } + + /** + * A constructor's Java return type is void, but the whole point of the + * thunk is the object it produces. Returning 'V' would leave the + * interpreter with a successfully constructed object it cannot reach. + */ + @Test + void aConstructorThunkReturnsTheConstructedObject() throws Exception { + String thunk = ctorThunkFor("()V"); + + assertTrue(thunk.contains("result->type = 'L';"), + "the ctor thunk should report an object result, was:\n" + thunk); + assertTrue(thunk.contains("result->value.o = __r;"), + "the ctor thunk should hand back the allocated object, was:\n" + thunk); + assertFalse(thunk.contains("result->type = 'V';"), + "the ctor thunk must not report a void result, was:\n" + thunk); + } + + /** + * Constructors are non-static and non-private, which is exactly the shape + * that normally earns a {@code virtual_} alias — but they are never + * dispatched virtually, so no such symbol is emitted and calling it would + * not link. + */ + @Test + void aConstructorThunkCallsTheDirectSymbolNotTheVirtualAlias() throws Exception { + String thunk = ctorThunkFor("()V"); + + assertFalse(thunk.contains("virtual_" + MANGLED + "___INIT__"), + "a ctor has no virtual_ alias to call, was:\n" + thunk); + } + + /** + * Arguments still come out of the uniform {@code cn1_invoke_arg} array, and + * the receiver slot is the freshly allocated object rather than the + * caller-supplied {@code thisObj} — passing both would be a wrong-arity + * call that fails at compile time on a device build. + */ + @Test + void anArgumentTakingConstructorPassesTheFreshObjectThenItsArguments() throws Exception { + String descriptor = "(ILjava/lang/Object;)V"; + String thunk = ctorThunkFor(descriptor); + + assertTrue(thunk.contains(MANGLED + "___INIT____" + cSuffixFor(descriptor) + + "(threadStateData, __r, args[0].i, args[1].o)"), + "the ctor thunk should pass __r then the unpacked args, was:\n" + thunk); + assertFalse(thunk.contains(", thisObj,"), + "the ctor thunk must not also pass the caller's receiver, was:\n" + thunk); + } + + /** + * A plain on-device-debug build keeps its current, smaller output. The + * thunks are per-method C functions across the whole closed world, so + * emitting constructors unconditionally would grow every debug build for a + * consumer (jdb) that never constructs. + */ + @Test + void aPlainDebugBuildEmitsNoConstructorThunks() throws Exception { + BytecodeMethod.interpHost = false; + + String generated = translateHost(); + + assertFalse(generated.contains("__NEW_" + MANGLED + "(threadStateData);\n " + + MANGLED + "___INIT__"), + "a debug build should not emit constructor thunks, was:\n" + generated); + assertTrue(generated.contains("__cn1_dbg_invoke_"), + "a debug build should still emit ordinary method thunks"); + } + + /** + * Registration is what makes a thunk reachable — an emitted but + * unregistered thunk is dead code the interpreter can never call. + */ + @Test + void everyEmittedConstructorThunkIsRegistered() throws Exception { + String generated = translateHost(); + + Matcher m = Pattern.compile( + "static void __cn1_dbg_invoke_(\\d+)\\([^)]*\\) \\{\\n" + + " \\(void\\)args; \\(void\\)thisObj;[\\s\\S]*?" + + "JAVA_OBJECT __r = __NEW_").matcher(generated); + int ctorThunks = 0; + while (m.find()) { + ctorThunks++; + String id = m.group(1); + assertTrue(generated.contains( + "cn1_debugger_register_invoke_thunk(" + id + ", &__cn1_dbg_invoke_" + id + ");"), + "ctor thunk " + id + " is emitted but never registered"); + } + assertEquals(2, ctorThunks, + "both fixture constructors should get a thunk, was:\n" + generated); + } + + /** + * Returns the body of the thunk generated for the constructor with the + * given descriptor, located by the {@code __NEW_} allocation that only a + * constructor thunk contains. + */ + private String ctorThunkFor(String descriptor) throws Exception { + String generated = translateHost(); + String argSuffix = cSuffixFor(descriptor); + String initCall = MANGLED + "___INIT____" + argSuffix + "(threadStateData, __r"; + + int call = generated.indexOf(initCall); + assertTrue(call > 0, + "no ctor thunk for " + descriptor + " (looked for " + initCall + ") in:\n" + generated); + int start = generated.lastIndexOf("static void __cn1_dbg_invoke_", call); + assertTrue(start >= 0, "ctor thunk body has no header, in:\n" + generated); + int end = generated.indexOf("\nstatic void __cn1_dbg_invoke_", call); + if (end < 0) { + end = generated.indexOf("\n__attribute__((constructor))", call); + } + assertTrue(end > start, "could not delimit the ctor thunk body, in:\n" + generated); + return generated.substring(start, end); + } + + /** The translator's C name suffix for the fixture's two descriptors. */ + private String cSuffixFor(String descriptor) { + if ("()V".equals(descriptor)) { + return ""; + } + if ("(ILjava/lang/Object;)V".equals(descriptor)) { + // each arg contributes its own leading underscore + return "_int_java_lang_Object"; + } + throw new IllegalArgumentException(descriptor); + } + + private String translateHost() throws Exception { + Parser.cleanup(); + Parser.parse(writeHostClass().toFile()); + + ByteCodeClass objectClass = + new ByteCodeClass("java_lang_Object", "java/lang/Object"); + ByteCodeClass host = Parser.getClassObject(MANGLED); + assertNotNull(host, "fixture class should have parsed"); + host.setBaseClassObject(objectClass); + host.setBaseInterfacesObject(Collections.emptyList()); + host.updateAllDependencies(); + + List classes = Arrays.asList(objectClass, host); + // A real build assigns globally unique method offsets in + // generateClassAndMethodIndexHeader before emitting any C. Thunks are + // named and registered by that offset, so without this every thunk here + // would be __cn1_dbg_invoke_0 -- duplicate definitions that would not + // compile, and a registry with one entry instead of three. + int offset = 0; + for (ByteCodeClass bc : classes) { + offset = bc.updateMethodOffsets(offset); + } + return host.generateCCode(classes); + } + + /** + * A concrete class with two constructors — a no-arg one and one taking a + * primitive plus a reference, so the argument-unpacking path is covered — + * and one ordinary method to contrast against. + */ + private Path writeHostClass() throws Exception { + ClassWriter cw = new ClassWriter(0); + cw.visit(Opcodes.V1_8, + Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_SUPER, + HOST, null, "java/lang/Object", null); + + MethodVisitor noArg = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + noArg.visitCode(); + noArg.visitVarInsn(Opcodes.ALOAD, 0); + noArg.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + noArg.visitInsn(Opcodes.RETURN); + noArg.visitMaxs(1, 1); + noArg.visitEnd(); + + MethodVisitor withArgs = cw.visitMethod( + Opcodes.ACC_PUBLIC, "", "(ILjava/lang/Object;)V", null, null); + withArgs.visitCode(); + withArgs.visitVarInsn(Opcodes.ALOAD, 0); + withArgs.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + withArgs.visitInsn(Opcodes.RETURN); + withArgs.visitMaxs(1, 3); + withArgs.visitEnd(); + + MethodVisitor ping = cw.visitMethod(Opcodes.ACC_PUBLIC, "ping", "()I", null, null); + ping.visitCode(); + ping.visitInsn(Opcodes.ICONST_1); + ping.visitInsn(Opcodes.IRETURN); + ping.visitMaxs(1, 1); + ping.visitEnd(); + + cw.visitEnd(); + + Path dir = Files.createTempDirectory("cn1-interp-host"); + Path classFile = dir.resolve("InterpCtorHost.class"); + Files.write(classFile, cw.toByteArray()); + return classFile; + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/InterpHostVtableSynthesisIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/InterpHostVtableSynthesisIntegrationTest.java new file mode 100644 index 00000000000..fecd7103d98 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/InterpHostVtableSynthesisIntegrationTest.java @@ -0,0 +1,366 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Runtime clazz synthesis, end to end: a class that exists only at runtime is + * dispatched to, type-checked, and survives collection. + * + *

This is the load-bearing claim of the device-runtime design. An + * interpreted {@code class MyForm extends Form} has to be an object the AOT + * framework accepts as a {@code Form} and whose overrides the framework calls — + * but ParparVM cannot generate classes, has no {@code defineClass}, and iOS + * forbids writing executable memory. What it does have is a heap-allocated, + * slot-indexed vtable per class, so a subclass can be built by copying the + * parent's clazz and repointing the overridden slots.

+ * + *

Everything here is exercised through the real translator and a real + * compiled binary, because the properties under test — that the AOT caller's + * dispatch reaches the trampoline, that {@code instanceof} still answers, that + * the collector does not reclaim an object whose clazz it never saw at build + * time — are all properties of generated code and the runtime, not of the + * translator's text output.

+ * + *

Runs the {@code clean} target rather than the iOS one: it emits the same C + * against the same {@code cn1_globals} runtime, so the object model is + * identical, and it runs on the host in seconds.

+ */ +@EnabledOnOs({OS.MAC, OS.LINUX}) +@EnabledIf("com.codename1.tools.translator.InterpHostVtableSynthesisIntegrationTest#hasToolchain") +class InterpHostVtableSynthesisIntegrationTest { + + static boolean hasToolchain() { + return !CompilerHelper.getAvailableCompilers("1.8").isEmpty(); + } + + /** + * {@code Base.greet()} is the method the synthetic subclass overrides. + * {@code size()} is a second virtual method so the overridden one is not + * trivially at slot 0, and {@code tag} is an inherited field the synthetic + * object must still be able to hold at the offset AOT code expects. + */ + private static final String APP_TEMPLATE = + "public class Main {\n" + + " public static class Base {\n" + + " public int tag = 7;\n" + + " public int size() { return 1; }\n" + + " public String greet() { return \"base\"; }\n" + + " public String describe() { return greet() + \"/\" + size(); }\n" + + " }\n" + + " static native void installInterpSubclass(int slot, int slotCount);\n" + + " static native Object newInterpObject();\n" + + " static native int scanSlotOfBaseGreet(int slotCount);\n" + + "\n" + + " public static void main(String[] args) {\n" + + " int slot = %SLOT%;\n" + + " int slotCount = %SLOTCOUNT%;\n" + + " System.out.println(\"SCAN:\" + scanSlotOfBaseGreet(slotCount));\n" + + " installInterpSubclass(slot, slotCount);\n" + + " Object raw = newInterpObject();\n" + + " System.out.println(\"INSTANCEOF:\" + (raw instanceof Base));\n" + + " Base b = (Base) raw;\n" + + " System.out.println(\"DISPATCH:\" + b.greet());\n" + + " System.out.println(\"INHERITED:\" + b.size());\n" + + " System.out.println(\"INTERNAL:\" + b.describe());\n" + + " Base plain = new Base();\n" + + " System.out.println(\"UNAFFECTED:\" + plain.greet());\n" + + " b.tag = 42;\n" + + " StringBuilder sink = new StringBuilder();\n" + + " for (int i = 0; i < 400000; i++) { sink.append('x'); if (sink.length() > 64) sink.setLength(0); }\n" + + " System.out.println(\"AFTERGC:\" + b.greet() + \":\" + b.tag);\n" + + " System.out.println(\"DONE\");\n" + + " }\n" + + "}\n"; + + /** + * The whole mechanism in one run. Asserted as a group because these are not + * independent behaviours — they are the several ways a single synthetic + * clazz has to behave like a real one, and a failure in any of them means + * the same thing: the object model does not accept runtime subclasses. + */ + @Test + void aRuntimeSynthesizedSubclassBehavesLikeACompiledOne() throws Exception { + Symbols symbols = translateProbeAndReadSymbols(); + + int slotCount = symbols.vtableSizeOf("Main_Base"); + int slot = symbols.slotOf("Main_Base", "greet"); + assertTrue(slotCount > 0, "Main$Base should publish a vtable size"); + assertTrue(slot >= 0, "Main$Base.greet should publish a vtable slot"); + + String output = buildAndRun(slot, slotCount); + + // The symbol table's claim about the slot, checked against the address + // actually sitting in the built binary's vtable. If these disagree the + // rows are worse than useless -- patching would redirect some other + // method, silently. + assertTrue(output.contains("SCAN:" + slot), + "the symbol table says greet is at slot " + slot + + " but the binary's vtable disagrees, output:\n" + output); + + assertTrue(output.contains("INSTANCEOF:true"), + "a synthetic subclass must still satisfy `instanceof Base`, output:\n" + output); + assertTrue(output.contains("DISPATCH:interpreted"), + "an AOT caller holding a Base reference must reach the trampoline, output:\n" + output); + assertTrue(output.contains("INHERITED:1"), + "unpatched slots must still reach the parent implementation, output:\n" + output); + // describe() is AOT code inside the parent calling greet() on itself -- + // the override has to win there too, or a framework method that calls an + // overridable method would silently get the base behaviour. + assertTrue(output.contains("INTERNAL:interpreted/1"), + "a self-call from AOT parent code must reach the override, output:\n" + output); + assertTrue(output.contains("UNAFFECTED:base"), + "patching the synthetic vtable must not disturb the parent class, output:\n" + output); + assertTrue(output.contains("AFTERGC:interpreted:42"), + "the synthetic clazz and its instance must survive collection, output:\n" + output); + assertTrue(output.contains("DONE"), + "the program should run to completion, output:\n" + output); + } + + // ---------------------------------------------------------------- helpers + + /** + * First pass: translate the app with placeholder constants purely to obtain + * the symbol table, which is where the vtable layout comes from. The + * constants cannot be known before this, since the layout is decided by the + * translator. + */ + private Symbols translateProbeAndReadSymbols() throws Exception { + Path out = translate(render(0, 0), Files.createTempDirectory("interp-vt-probe"), false); + return Symbols.parse(readSymbolTable(out)); + } + + private String render(int slot, int slotCount) { + return APP_TEMPLATE + .replace("%SLOT%", Integer.toString(slot)) + .replace("%SLOTCOUNT%", Integer.toString(slotCount)); + } + + /** + * Second pass: translate with the real constants, drop the interp runtime + * into the generated source root, then build and run. + * + *

The C file is copied in after translation rather than before. The + * translator's readNativeFiles pass only matters for dependency marking, + * and an interp-host build has dead code elimination disabled — so there is + * nothing to mark. CMake globs {@code *.c} out of the source root at + * configure time, which happens later still.

+ */ + private String buildAndRun(int slot, int slotCount) throws Exception { + Path outputDir = Files.createTempDirectory("interp-vt-run"); + translate(render(slot, slotCount), outputDir, true); + + Path srcRoot = Files.walk(outputDir) + .filter(Files::isDirectory) + .filter(p -> p.getFileName().toString().endsWith("-src")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("no -src root under " + outputDir)); + Files.copy(spikeSource(), srcRoot.resolve("cn1_interp_spike.c"), + StandardCopyOption.REPLACE_EXISTING); + + Path distDir = outputDir.resolve("dist"); + CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget( + distDir.resolve("CMakeLists.txt"), srcRoot.getFileName().toString()); + + Path buildDir = distDir.resolve("build"); + Files.createDirectories(buildDir); + List configure = new ArrayList<>(Arrays.asList( + "cmake", "-S", distDir.toString(), "-B", buildDir.toString())); + configure.addAll(CompilerHelper.cmakeToolchainArgs()); + CleanTargetIntegrationTest.runCommand(configure, distDir); + CleanTargetIntegrationTest.runCommand( + Arrays.asList("cmake", "--build", buildDir.toString()), distDir); + + Path exe = buildDir.resolve(CompilerHelper.executableName("InterpVtApp")); + return CleanTargetIntegrationTest.runCommand(Arrays.asList(exe.toString()), buildDir); + } + + private static Path spikeSource() { + Path p = Paths.get("src", "test", "resources", "interp", "cn1_interp_spike.c") + .toAbsolutePath(); + assertTrue(Files.exists(p), "spike runtime missing at " + p); + return p; + } + + /** Compiles the fixture and runs the translator over it. Returns outputDir. */ + private Path translate(String source, Path outputDir, boolean keepSources) throws Exception { + CompilerHelper.CompilerConfig config = CompilerHelper.getAvailableCompilers("1.8").get(0); + + Path sourceDir = Files.createTempDirectory("interp-vt-src"); + Path classesDir = Files.createTempDirectory("interp-vt-classes"); + Files.write(sourceDir.resolve("Main.java"), source.getBytes(StandardCharsets.UTF_8)); + + Path javaApiDir = Files.createTempDirectory("interp-vt-api"); + CompilerHelper.compileJavaAPI(javaApiDir, config); + + List args = new ArrayList<>(Arrays.asList( + "-source", config.targetVersion, "-target", config.targetVersion)); + args.add(CompilerHelper.useClasspath(config) ? "-classpath" : "-bootclasspath"); + args.add(javaApiDir.toString()); + args.addAll(Arrays.asList("-d", classesDir.toString(), + sourceDir.resolve("Main.java").toString())); + if (CompilerHelper.compile(config.jdkHome, args) != 0) { + throw new IllegalStateException("fixture did not compile: " + + CompilerHelper.getLastErrorLog()); + } + CompilerHelper.copyDirectory(javaApiDir, classesDir); + + String previousInterp = System.getProperty("cn1.interpHost"); + System.setProperty("cn1.interpHost", "true"); + // The spike's three natives are implemented in cn1_interp_spike.c, which + // is copied into the generated source root *after* translation -- the + // root does not exist until then, and cmake globs the directory at + // configure time. So the native-signature check has nothing to find and + // would abort a translation whose natives are in fact implemented. CI + // sets CN1_NATIVE_VERIFY=strict for every forked translation, and the + // property wins over the environment, which is what makes this local + // opt-out possible. + String previousVerify = System.getProperty( + NativeSignatureVerifier.MODE_PROPERTY); + System.setProperty(NativeSignatureVerifier.MODE_PROPERTY, "off"); + try { + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "InterpVtApp"); + } finally { + if (previousInterp == null) { + System.clearProperty("cn1.interpHost"); + } else { + System.setProperty("cn1.interpHost", previousInterp); + } + if (previousVerify == null) { + System.clearProperty(NativeSignatureVerifier.MODE_PROPERTY); + } else { + System.setProperty(NativeSignatureVerifier.MODE_PROPERTY, previousVerify); + } + } + return outputDir; + } + + private static String readSymbolTable(Path outputDir) throws Exception { + Path symbols = Files.walk(outputDir) + .filter(p -> p.getFileName().toString().equals("cn1_debug_symbols.c")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("no symbol blob under " + outputDir)); + + String c = new String(Files.readAllBytes(symbols), StandardCharsets.UTF_8); + int start = c.indexOf('{', c.indexOf("cn1_debug_symbols_gz[]")); + int end = c.indexOf("};", start); + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + Matcher m = Pattern.compile("0x([0-9a-fA-F]{2})").matcher(c.substring(start, end)); + while (m.find()) { + raw.write(Integer.parseInt(m.group(1), 16)); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (InputStream gz = new GZIPInputStream(new ByteArrayInputStream(raw.toByteArray()))) { + byte[] buf = new byte[8192]; + int n; + while ((n = gz.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + + /** The vtable-layout view of the symbol table. */ + private static final class Symbols { + private final Map classIds = new HashMap<>(); + private final Map vtableSizes = new HashMap<>(); + private final Map slotByClassAndMethodId = new HashMap<>(); + private final Map methodNames = new HashMap<>(); + + static Symbols parse(String table) { + Symbols s = new Symbols(); + for (String line : table.split("\n")) { + String[] p = line.split("\t", -1); + switch (p[0]) { + case "class": + if (p.length >= 3) s.classIds.put(p[2], Integer.parseInt(p[1])); + break; + case "vtsize": + if (p.length >= 3) { + s.vtableSizes.put(Integer.parseInt(p[1]), Integer.parseInt(p[2])); + } + break; + case "vtable": + if (p.length >= 4) { + s.slotByClassAndMethodId.put(p[1] + ":" + p[3], Integer.parseInt(p[2])); + } + break; + case "method": + if (p.length >= 4) s.methodNames.put(Integer.parseInt(p[1]), p[3]); + break; + default: + break; + } + } + return s; + } + + int vtableSizeOf(String mangledClass) { + Integer id = classIds.get(mangledClass); + assertNotNull(id, "no class row for " + mangledClass); + Integer size = vtableSizes.get(id); + assertNotNull(size, "no vtsize row for " + mangledClass); + return size; + } + + int slotOf(String mangledClass, String methodName) { + Integer id = classIds.get(mangledClass); + assertNotNull(id, "no class row for " + mangledClass); + for (Map.Entry e : slotByClassAndMethodId.entrySet()) { + String[] parts = e.getKey().split(":"); + if (!parts[0].equals(String.valueOf(id))) { + continue; + } + if (methodName.equals(methodNames.get(Integer.parseInt(parts[1])))) { + return e.getValue(); + } + } + return -1; + } + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/ParserTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/ParserTest.java index beb7709ec30..d2d87914a6d 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/ParserTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/ParserTest.java @@ -131,12 +131,41 @@ void symbolClassRowsPreserveOriginalJvmNames() { ); cls.setClassOffset(17); + // The seventh column lists the implemented interfaces' ids, empty here. + // The device runtime reads it to resolve a default method, which lives + // on an interface and nowhere in the superclass chain. The eighth says + // whether this class is an interface declaring one -- 0 for a class. assertEquals( - "class\t17\tcom_example_my_app_Main_1\tMain.java\t-1\tcom/example/my_app/Main$1\n", + "class\t17\tcom_example_my_app_Main_1\tMain.java\t-1\tcom/example/my_app/Main$1\t\t0\n", Parser.classSymbolRow(cls, "Main.java") ); } + /** + * JLS 12.4.1 initializes an interface along with a class implementing it + * only when the interface declares a default method, and nothing else in + * the symbol table records access flags -- a method row carries name, + * descriptor and staticness. Without this column the iOS linker had no way + * to tell the two apart and left the ordering to first use. + */ + @Test + void symbolClassRowsMarkDefaultBearingInterfaces() throws Exception { + Parser.cleanup(); + + Parser.parse(createGreeterInterfaceWithDefaultMethod().toFile()); + Parser.parse(createGreeterImplementation().toFile()); + + ByteCodeClass greeter = Parser.getClassObject("com_example_Greeter"); + ByteCodeClass impl = Parser.getClassObject("com_example_GreeterImpl"); + greeter.setBaseInterfacesObject(Collections.emptyList()); + impl.setBaseInterfacesObject(Collections.singletonList(greeter)); + + assertTrue(Parser.classSymbolRow(greeter, "Greeter.java").endsWith("\t1\n"), + "an interface declaring a default method should be marked"); + assertTrue(Parser.classSymbolRow(impl, "GreeterImpl.java").endsWith("\t0\n"), + "a class implementing it is not itself default-bearing"); + } + @Test void translatesDefaultInterfaceMethodImplementations() throws Exception { Parser.cleanup(); diff --git a/vm/tests/src/test/resources/interp/AndroidInterpSpike.java b/vm/tests/src/test/resources/interp/AndroidInterpSpike.java new file mode 100644 index 00000000000..2ef6f2c7192 --- /dev/null +++ b/vm/tests/src/test/resources/interp/AndroidInterpSpike.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/* + * Android counterpart of the iOS runtime-clazz-synthesis spike. + * + * Dalvik/ART has no patchable vtable and Google Play forbids loading dex at + * runtime, so the plan's Android mechanism is different from the iOS one: a + * subclass generated at BUILD time, whose every overridable method either + * delegates to the interpreter or calls super. Interp_Base below is written by + * hand in exactly the shape that generator would emit. + * + * The claims under test are identical to the iOS spike's, because they are + * claims about the object model rather than about either mechanism: + * dispatch from an AOT caller, instanceof, inherited methods, parent self-calls, + * non-interference with the real class, and GC survival. + */ +import java.lang.reflect.Method; + +public class AndroidInterpSpike { + + /** Stands in for a framework class an interpreted program subclasses. */ + public static class Base { + public int tag = 7; + public int size() { return 1; } + public String greet() { return "base"; } + public String describe() { return greet() + "/" + size(); } + } + + /** + * Stands in for the interpreter's state for one interpreted object: which + * methods the interpreted class overrides, and how to run them. + */ + static final class InterpInstance { + private final java.util.Set overridden; + InterpInstance(String... names) { + overridden = new java.util.HashSet(java.util.Arrays.asList(names)); + } + boolean overrides(String slot) { return overridden.contains(slot); } + Object call(String slot) { return "interpreted"; } + } + + /** + * The build-time generated shim. Every overridable method is guarded: if + * the interpreted class overrides it, run the interpreter; otherwise defer + * to super. super_* bridges give interpreted code a way back to super. + */ + public static final class Interp_Base extends Base { + private final InterpInstance $i; + Interp_Base(InterpInstance i) { this.$i = i; } + + @Override public String greet() { + return $i.overrides("greet") ? (String) $i.call("greet") : super.greet(); + } + public String super_greet() { return super.greet(); } + + @Override public int size() { + return $i.overrides("size") ? ((Integer) $i.call("size")).intValue() : super.size(); + } + public int super_size() { return super.size(); } + } + + public static void main(String[] args) throws Exception { + // The Android InterpLinker binds framework calls through real + // reflection rather than generated dispatch -- this is that path. + Method greet = Base.class.getMethod("greet"); + + Object raw = new Interp_Base(new InterpInstance("greet")); + System.out.println("INSTANCEOF:" + (raw instanceof Base)); + + Base b = (Base) raw; + System.out.println("DISPATCH:" + b.greet()); + System.out.println("INHERITED:" + b.size()); + // describe() is compiled parent code calling greet() on itself; the + // override has to win there too. + System.out.println("INTERNAL:" + b.describe()); + System.out.println("REFLECT:" + greet.invoke(b)); + + Base plain = new Base(); + System.out.println("UNAFFECTED:" + plain.greet()); + + b.tag = 42; + StringBuilder sink = new StringBuilder(); + for (int i = 0; i < 400000; i++) { sink.append('x'); if (sink.length() > 64) sink.setLength(0); } + System.gc(); + System.out.println("AFTERGC:" + b.greet() + ":" + b.tag); + System.out.println("DONE"); + } +} diff --git a/vm/tests/src/test/resources/interp/cn1_interp_spike.c b/vm/tests/src/test/resources/interp/cn1_interp_spike.c new file mode 100644 index 00000000000..b036e4aa908 --- /dev/null +++ b/vm/tests/src/test/resources/interp/cn1_interp_spike.c @@ -0,0 +1,145 @@ +/* + * 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. + */ + +/* + * Runtime clazz synthesis -- the mechanism that lets a class the AOT compiler + * never saw be subclassed from interpreted code and still have its overrides + * called by AOT callers. + * + * ParparVM allocates each class's vtable on the heap and fills it by slot + * (see ByteCodeClass's __INIT_VTABLE_), so a subclass that exists only at + * runtime can be built by copying the parent's clazz, copying its vtable, and + * repointing the slots the subclass overrides at an interpreter trampoline. + * Nothing is generated and nothing is written to executable memory, which is + * what makes this viable on iOS. + * + * This file is the Phase 0 spike: the trampoline returns a fixed string instead + * of entering an interpreter, so what is under test is purely the object model + * -- dispatch, instanceof, and GC survival. + */ + +#include "cn1_globals.h" +#include "Main_Base.h" + +#include +#include + +extern struct clazz class__Main_Base; + +/* The AOT-compiled Base.greet(), i.e. what occupies the slot before patching. */ +extern JAVA_OBJECT Main_Base_greet___R_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT me); + +/* + * A class's vtable is malloc'd and filled by its static initializer, not at + * load time -- so a parent that has never been touched still has vtable == 0. + * Anything that reads or copies the parent's layout has to force that first. + * The real interpreter has the same obligation before synthesizing a subclass. + */ +extern void __STATIC_INITIALIZER_Main_Base(CODENAME_ONE_THREAD_STATE); + +/* The synthesized subclass. One is enough for the spike. */ +static struct clazz* interpClazz = 0; + +/* + * Stands in for "the interpreter evaluates the overridden method". Its + * signature has to match the slot it replaces exactly -- the AOT caller casts + * the slot to the parent declaration's function-pointer type and calls it. + */ +static JAVA_OBJECT interp_trampoline_greet(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT me) { + (void)me; + return newStringFromCString(threadStateData, "interpreted"); +} + +/* + * Independently recovers the vtable slot of the AOT Base.greet() by scanning + * for its address. The Java side separately reads the slot out of the + * translator's symbol table; the test asserts the two agree, which is what + * makes the emitted vtable rows trustworthy rather than merely present. + */ +JAVA_INT Main_scanSlotOfBaseGreet___int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_INT slotCount) { + void** vt; + JAVA_INT i; + __STATIC_INITIALIZER_Main_Base(threadStateData); + vt = class__Main_Base.vtable; + if (vt == 0) { + return -1; + } + for (i = 0; i < slotCount; i++) { + if (vt[i] == (void*)&Main_Base_greet___R_java_lang_String) { + return i; + } + } + return -1; +} + +/* + * Builds the synthetic clazz. slot and slotCount both come from the symbol + * table the translator emitted for this build. + * + * The clazz is memcpy'd rather than assigned because several of its members are + * const -- they are compile-time constants for every AOT class, and this is the + * one caller that legitimately produces a clazz at runtime. + * + * classId is deliberately left as the parent's. instanceofFunction indexes a + * static classInstanceOf[destId] table, so a fresh id would have no row and + * every type check against the synthetic class would fail. Inheriting the + * parent's id makes `instanceof Base` answer true, which is the semantics an + * interpreted subclass wants; the cost is that getClass().getName() reports the + * parent, which the interpreter overrides at its own level. + */ +JAVA_VOID Main_installInterpSubclass___int_int(CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_INT slotCount) { + struct clazz* parent = &class__Main_Base; + struct clazz* synth; + void** vt; + + /* The parent's vtable does not exist until its static initializer runs. */ + __STATIC_INITIALIZER_Main_Base(threadStateData); + + synth = (struct clazz*)malloc(sizeof(struct clazz)); + vt = (void**)malloc(sizeof(void*) * (size_t)slotCount); + + memcpy(synth, parent, sizeof(struct clazz)); + memcpy(vt, parent->vtable, sizeof(void*) * (size_t)slotCount); + vt[slot] = (void*)&interp_trampoline_greet; + + synth->vtable = vt; + synth->clsName = "Main_Base$Interp"; + /* Cleared so the GC's exact clazz registry takes this address on its own + * merits rather than inheriting the parent's "already registered" flag -- + * otherwise the conservative mark guard would not recognise it and every + * instance would look like a false positive. */ + synth->cn1ClazzRegistered = 0; + CN1_CLAZZ_REGISTER(synth); + + interpClazz = synth; + (void)threadStateData; +} + +/* + * Allocates an instance of the synthetic class. Sized as the parent so every + * inherited field sits at the offset AOT code compiled for; an interpreted + * subclass's own fields live outside the object in interpreter state. + */ +JAVA_OBJECT Main_newInterpObject___R_java_lang_Object(CODENAME_ONE_THREAD_STATE) { + return codenameOneGcMalloc(threadStateData, sizeof(struct obj__Main_Base), interpClazz); +}