Skip to content

Commit ddc43de

Browse files
shai-almogclaude
andcommitted
Add a device runtime that runs pushed Codename One apps on a phone
Codename One apps run two ways today: the JavaSE simulator, which is not a device, or a cloud device build, which costs minutes per iteration. This adds a third: install one app on a phone, and from then on push a project to it from an IDE and watch it run natively in seconds. The app is not a shell around a compiled build. Pushed classes are interpreted on the device against the framework already compiled into it, so nothing is built, signed or installed between edits. How the pieces fit ------------------ com.codename1.interp is the interpreter: one interpreted frame per real frame, so Display.invokeAndBlock and every blocking idiom built on it still work. A per-thread fuel counter bounds runaway code, and the budget is per entry into the interpreter rather than per session -- measuring it per session kills every callback that arrives later than the budget, which in an application whose whole life is callbacks is every button press. Interpreted classes reach the framework through InterpLinker: invoke thunks on iOS, reflection on Android. A linker must dispatch on the receiver's class, not the call site's declared type -- list.add(x) names java.util.List, and resolving from there finds AbstractList.add, whose body throws. Extending a framework class needs an object the framework accepts, which neither platform can define at run time. Generated shims provide it: every public, non-final, constructible class and every public interface the device exposes, derived by scanning the framework jar and codenameone-java-runtime rather than curated. A hand-maintained list is a promise that applications only subclass what somebody anticipated, and its failure mode is not an error but an override that is silently never called. Lambdas and method references are rewritten into real classes when the bundle is written, since neither target has a runtime invokedynamic. Enums are answered by the interpreter, java.lang.Enum having no shim and needing none. Store compliance is built in rather than bolted on: the runtime refuses to load a bundle whose sources it cannot show, and shows them. Verified -------- 4798 core tests, 506 translator tests, 43 interpreter tests, SpotBugs at zero, and a 20-program device battery (scripts/devruntime-probes) passing on both an Android emulator and the iOS simulator -- including a four-file, three-package application entered through Lifecycle rather than main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 399f180 commit ddc43de

1,110 files changed

Lines changed: 829760 additions & 14303 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# Weekly build of the device runtime for testers.
2+
#
3+
# Uploads to Google Play internal testing and to TestFlight. It does not promote
4+
# to production and does not submit for App Store review -- see
5+
# scripts/cn1-device-runtime/store/README.md for why that is deliberate.
6+
#
7+
# Without credentials the job reports what is missing and stops. It never
8+
# publishes half of a release.
9+
name: Device runtime store build
10+
11+
on:
12+
schedule:
13+
# Monday morning, so a failure has a working week in front of it.
14+
- cron: '0 6 * * 1'
15+
workflow_dispatch:
16+
inputs:
17+
dry_run:
18+
description: 'Build and check credentials without uploading'
19+
type: boolean
20+
default: false
21+
22+
concurrency:
23+
group: device-runtime-store
24+
cancel-in-progress: false
25+
26+
jobs:
27+
preflight:
28+
runs-on: ubuntu-latest
29+
outputs:
30+
android: ${{ steps.check.outputs.android }}
31+
ios: ${{ steps.check.outputs.ios }}
32+
steps:
33+
- id: check
34+
name: Which stores are configured
35+
env:
36+
PLAY_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
37+
KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
38+
ASC_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }}
39+
IOS_CERT: ${{ secrets.IOS_DIST_CERT_P12 }}
40+
run: |
41+
android=false
42+
ios=false
43+
if [ -n "$PLAY_JSON" ] && [ -n "$KEYSTORE" ]; then android=true; fi
44+
if [ -n "$ASC_KEY" ] && [ -n "$IOS_CERT" ]; then ios=true; fi
45+
echo "android=$android" >> "$GITHUB_OUTPUT"
46+
echo "ios=$ios" >> "$GITHUB_OUTPUT"
47+
echo "### Device runtime store build" >> "$GITHUB_STEP_SUMMARY"
48+
echo "" >> "$GITHUB_STEP_SUMMARY"
49+
echo "| Store | Configured |" >> "$GITHUB_STEP_SUMMARY"
50+
echo "|---|---|" >> "$GITHUB_STEP_SUMMARY"
51+
echo "| Google Play | $android |" >> "$GITHUB_STEP_SUMMARY"
52+
echo "| App Store | $ios |" >> "$GITHUB_STEP_SUMMARY"
53+
if [ "$android" = false ] && [ "$ios" = false ]; then
54+
echo "" >> "$GITHUB_STEP_SUMMARY"
55+
echo "No publishing credentials are present, so nothing was uploaded." >> "$GITHUB_STEP_SUMMARY"
56+
echo "The secrets each store needs are listed in" >> "$GITHUB_STEP_SUMMARY"
57+
echo "\`scripts/cn1-device-runtime/store/README.md\`." >> "$GITHUB_STEP_SUMMARY"
58+
fi
59+
60+
android:
61+
needs: preflight
62+
if: needs.preflight.outputs.android == 'true'
63+
runs-on: ubuntu-latest
64+
steps:
65+
- uses: actions/checkout@v4
66+
67+
- name: JDK 8 for the framework, JDK 17 for the Android port
68+
uses: actions/setup-java@v4
69+
with:
70+
distribution: temurin
71+
java-version: |
72+
8
73+
17
74+
75+
- name: Build the framework and the Android port
76+
run: |
77+
cd maven
78+
mvn -B -q -DskipTests install -pl core,parparvm -am
79+
mvn -B -q -DskipTests -Pcompile-android install -pl android
80+
81+
- name: Regenerate and verify the shims
82+
# The shims are the app's compiled contract with pushed code. The script
83+
# fails if one will not compile, if a load-bearing shim is missing, or if
84+
# generation is not reproducible -- all of which are release blockers.
85+
run: scripts/generate-interp-shims.sh
86+
87+
- name: Build the app bundle
88+
env:
89+
JAVA17_HOME: ${{ env.JAVA_HOME_17_X64 }}
90+
run: |
91+
cd scripts/cn1-device-runtime
92+
mvn -B -q package -DskipTests \
93+
-Dcodename1.platform=android \
94+
-Dcodename1.buildTarget=android-source \
95+
-Dopen=false
96+
97+
- name: Sign and assemble
98+
env:
99+
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
100+
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
101+
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
102+
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
103+
run: |
104+
set -euo pipefail
105+
gradle_dir="$(find scripts/cn1-device-runtime/android/target -maxdepth 1 \
106+
-name '*-android-source' -type d | head -1)"
107+
[ -n "$gradle_dir" ] || { echo "no gradle project was generated" >&2; exit 1; }
108+
echo "$KEYSTORE_B64" | base64 -d > "$gradle_dir/upload.keystore"
109+
cd "$gradle_dir"
110+
./gradlew --no-daemon bundleRelease \
111+
-Pandroid.injected.signing.store.file=upload.keystore \
112+
-Pandroid.injected.signing.store.password="$KEYSTORE_PASSWORD" \
113+
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
114+
-Pandroid.injected.signing.key.password="$KEY_PASSWORD"
115+
116+
- name: Upload to internal testing
117+
if: ${{ !inputs.dry_run }}
118+
uses: r0adkll/upload-google-play@v1
119+
with:
120+
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
121+
packageName: com.codenameone.devruntime
122+
releaseFiles: scripts/cn1-device-runtime/android/target/*-android-source/app/build/outputs/bundle/release/*.aab
123+
track: internal
124+
status: completed
125+
whatsNewDirectory: scripts/cn1-device-runtime/fastlane/metadata/android/en-US/changelogs
126+
127+
ios:
128+
needs: preflight
129+
if: needs.preflight.outputs.ios == 'true'
130+
runs-on: macos-14
131+
steps:
132+
- uses: actions/checkout@v4
133+
134+
- name: JDK 8 for the framework, JDK 17 for the translator
135+
uses: actions/setup-java@v4
136+
with:
137+
distribution: temurin
138+
java-version: |
139+
8
140+
17
141+
142+
- name: Build the framework, translator and iOS port
143+
run: |
144+
cd maven
145+
mvn -B -q -DskipTests install -pl core,parparvm,ios -am
146+
147+
- name: Regenerate and verify the shims
148+
run: scripts/generate-interp-shims.sh
149+
150+
- name: Translate to Xcode
151+
env:
152+
JAVA17_HOME: ${{ env.JAVA_HOME_17_X64 }}
153+
run: |
154+
cd scripts/cn1-device-runtime
155+
mvn -B -q package -DskipTests \
156+
-Dcodename1.platform=ios \
157+
-Dcodename1.buildTarget=ios-source \
158+
-Dcodename1.arg.ios.interpHost=true \
159+
-Dopen=false
160+
161+
- name: Import signing material
162+
env:
163+
CERT_P12: ${{ secrets.IOS_DIST_CERT_P12 }}
164+
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
165+
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
166+
run: |
167+
set -euo pipefail
168+
keychain=build.keychain
169+
security create-keychain -p actions "$keychain"
170+
security default-keychain -s "$keychain"
171+
security unlock-keychain -p actions "$keychain"
172+
echo "$CERT_P12" | base64 -d > cert.p12
173+
security import cert.p12 -k "$keychain" -P "$CERT_PASSWORD" \
174+
-T /usr/bin/codesign
175+
security set-key-partition-list -S apple-tool:,apple: -s -k actions "$keychain"
176+
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
177+
echo "$PROFILE" | base64 -d > \
178+
~/Library/MobileDevice/Provisioning\ Profiles/devruntime.mobileprovision
179+
180+
- name: Archive and export
181+
run: |
182+
set -euo pipefail
183+
src="$(find scripts/cn1-device-runtime/ios/target -maxdepth 1 \
184+
-name '*-ios-source' -type d | head -1)"
185+
[ -n "$src" ] || { echo "no Xcode project was generated" >&2; exit 1; }
186+
cd "$src"
187+
xcodebuild -workspace CN1DeviceRuntime.xcworkspace \
188+
-scheme CN1DeviceRuntime -configuration Release \
189+
-archivePath build/CN1DeviceRuntime.xcarchive archive
190+
xcodebuild -exportArchive \
191+
-archivePath build/CN1DeviceRuntime.xcarchive \
192+
-exportPath build/ipa \
193+
-exportOptionsPlist ../../store/ExportOptions.plist
194+
195+
- name: Upload to TestFlight
196+
if: ${{ !inputs.dry_run }}
197+
env:
198+
ASC_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
199+
ASC_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }}
200+
ASC_PRIVATE_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }}
201+
run: |
202+
set -euo pipefail
203+
mkdir -p ~/private_keys
204+
echo "$ASC_PRIVATE_KEY" > ~/private_keys/AuthKey_$ASC_KEY_ID.p8
205+
src="$(find scripts/cn1-device-runtime/ios/target -maxdepth 1 \
206+
-name '*-ios-source' -type d | head -1)"
207+
xcrun altool --upload-app -f "$src"/build/ipa/*.ipa -t ios \
208+
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,6 @@ scripts/fidelity-app/common/src/main/resources/*ThemeDev.res
132132
# build time (common/pom.xml copy-native-themes); never commit the duplicate.
133133
scripts/fidelity-app/common/src/main/resources/iOSModernTheme.res
134134
scripts/fidelity-app/common/src/main/resources/AndroidMaterialTheme.res
135+
136+
# Local Maven repository used for isolated local builds (see .m2-local)
137+
.m2-local/

CLAUDE.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,59 @@ mvn -B -DskipTests=true -f ../vm/ByteCodeTranslator/pom.xml verify
199199

200200
Findings land in each module's `target/spotbugsXml.xml`.
201201

202+
### Device runtime (on-device interpreter)
203+
204+
`scripts/hellocodenameone/` is a device runtime: an app that runs Codename One
205+
programs pushed from a desktop, interpreted rather than compiled. See
206+
`docs/developer-guide/Device-Runtime.asciidoc`. Verify on both platforms --
207+
`scripts/run-device-runtime-android.sh <Program.java>` (minutes) and
208+
`scripts/run-device-runtime-ios.sh <Program.java>` (~30 min).
209+
210+
- **A pushed `main` runs on the EDT**, like an app's `start()`. `callSerially`
211+
is legal there, `callSeriallyAndWait` is not.
212+
- **The EDT budget is per entry into the interpreter, not per session.** Every
213+
framework callback is a fresh entry. Measuring from the start of the run makes
214+
the budget expire once and stay expired, killing every later callback -- i.e.
215+
every button press -- with "ran without yielding".
216+
- **Lambdas and method references are desugared** by `InterpLambdaDesugar` when
217+
the bundle is written; neither target can spin a class at run time. String
218+
concatenation needs `-XDstringConcat=inline` (cn1-push.sh passes it).
219+
- **A linker must dispatch on the receiver's class, not the call site's owner.**
220+
`list.add(x)` compiles to a call naming `java.util.List`; resolving from there
221+
finds `AbstractList.add`, whose body throws `UnsupportedOperationException`.
222+
Android got this free from reflection; iOS resolves the receiver's class id
223+
and walks up from there.
224+
- **`synchronized` uses the real host monitor**, so it interoperates with the
225+
framework and `wait`/`notify` work. A synchronized method wraps the call (on
226+
the peer where there is one); a synchronized block runs its region nested
227+
inside a real `synchronized`, and `monitorexit` returns to the enclosing
228+
level, which is what releases it.
229+
- **A pushed tree's non-`.java` files become resources**, published to
230+
`CodenameOneImplementation` -- not `Display`, which `Resources.openLayered`
231+
never passes through.
232+
- **`java.lang.Enum` has no shim and needs none** -- the interpreter answers
233+
name/ordinal/valueOf itself, since Java forbids naming Enum as a superclass.
234+
- **The device dials out; the desktop listens.** A listening socket inside the
235+
iOS simulator is unreachable from the host. Android needs `adb reverse`, not
236+
`adb forward`. Both runtimes dial the same host port, so a running emulator
237+
app will answer a push meant for the simulator -- the iOS script force-stops
238+
it for exactly this reason.
239+
- **Push a source tree, not a file**: `scripts/cn1-push.sh src/main/java 18234`.
240+
The entry point is discovered -- a `main`, else a `Lifecycle` subclass, which
241+
is what a real app has. `scripts/devruntime-probes/` holds the battery of
242+
programs that found the defects worth knowing about; run it after touching
243+
the interpreter, the linkers or the shims.
244+
- **Regenerate shims with `scripts/generate-interp-shims.sh`** after changing
245+
`GenerateInterpShims`. They are checked in on purpose. The script fails if a
246+
shim will not compile (never prune -- that once ate `Interp_ui_Form`), if a
247+
load-bearing shim is missing, or if generation is not reproducible.
248+
- **The generator reads the device's `java.*` from the `codenameone-java-runtime`
249+
jar with ASM, not by reflecting over the JDK**, and runs under `JAVA17_HOME`.
250+
The two disagree about which methods exist, which are `final`, which
251+
interfaces a class implements, and which constructors exist. Note `javap`
252+
resolves `java.*` from the platform even with `-cp`, so inspect the extracted
253+
`.class` file directly or you will be reading the JDK's copy.
254+
202255
### Never rely on ClassCastException
203256

204257
**ParparVM's `CHECKCAST` is unchecked.** `BC_CHECKCAST` expands to nothing and the

CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4544,12 +4544,59 @@ private int scanBackFirst(char[] chars, int ixStart, int ixEnd) {
45444544
///
45454545
/// input stream for the resource or null if not found
45464546
public InputStream getResourceAsStream(Class cls, String resource) {
4547+
InputStream local = localResource(resource);
4548+
if (local != null) {
4549+
return local;
4550+
}
45474551
if (cls != null) {
45484552
return cls.getResourceAsStream(resource);
45494553
}
45504554
return CodenameOneImplementation.class.getResourceAsStream(resource);
45514555
}
45524556

4557+
/// Resources supplied at run time rather than compiled into the app.
4558+
///
4559+
/// The device runtime pushes a program's own `theme.res`, CSS and images
4560+
/// here. They have to be visible from this layer rather than from
4561+
/// `Display`, because the calls that matter never pass through `Display`:
4562+
/// `Resources.openLayered("/theme")` and `UIManager.initFirstTheme` resolve
4563+
/// inside the framework, which asks the implementation directly.
4564+
///
4565+
/// Empty in an ordinary app, and one emptiness check on a path that
4566+
/// already touches the file system. Allocated eagerly rather than lazily:
4567+
/// a push arrives on a socket thread while the event thread may be reading,
4568+
/// and lazily creating a shared static under that is how entries go missing.
4569+
private static final java.util.Hashtable localResources = new java.util.Hashtable();
4570+
4571+
/// Publishes a resource under the path an application would load it by,
4572+
/// e.g. `/theme.res`. A null value removes it.
4573+
public static void setLocalResource(String path, byte[] data) {
4574+
if (data == null) {
4575+
localResources.remove(path);
4576+
} else {
4577+
localResources.put(path, data);
4578+
}
4579+
}
4580+
4581+
/// Drops every published resource, so a newly pushed program does not
4582+
/// inherit the previous one's theme.
4583+
public static void clearLocalResources() {
4584+
localResources.clear();
4585+
}
4586+
4587+
/// A published resource as a stream, or null. Platform implementations call
4588+
/// this before falling back to the classpath.
4589+
protected static InputStream localResource(String resource) {
4590+
if (resource == null || localResources.isEmpty()) {
4591+
return null;
4592+
}
4593+
byte[] data = (byte[])localResources.get(resource);
4594+
if (data == null && !resource.startsWith("/")) {
4595+
data = (byte[])localResources.get("/" + resource);
4596+
}
4597+
return data == null ? null : new java.io.ByteArrayInputStream(data);
4598+
}
4599+
45534600
/// Animations should return true to allow the native image animation to update
45544601
///
45554602
/// #### Parameters
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
3+
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4+
* This code is free software; you can redistribute it and/or modify it
5+
* under the terms of the GNU General Public License version 2 only, as
6+
* published by the Free Software Foundation. Codename One designates this
7+
* particular file as subject to the "Classpath" exception as provided
8+
* by Oracle in the LICENSE file that accompanied this code.
9+
*
10+
* This code is distributed in the hope that it will be useful, but WITHOUT
11+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13+
* version 2 for more details (a copy is included in the LICENSE file that
14+
* accompanied this code).
15+
*
16+
* You should have received a copy of the GNU General Public License version
17+
* 2 along with this work; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
19+
*
20+
* Please contact Codename One through http://www.codenameone.com/ if you
21+
* need additional information or have any questions.
22+
*/
23+
package com.codename1.interp;
24+
25+
/// Implemented by a generated shim: a framework subclass standing in for an
26+
/// interpreted class.
27+
///
28+
/// Lets the runtime recover the interpreted object from the host-visible peer,
29+
/// which is what makes the round trip work -- interpreted code hands its peer
30+
/// to the framework, the framework hands the peer back to a listener, and the
31+
/// runtime has to get from there to the interpreted instance again.
32+
///
33+
/// @author Shai Almog
34+
public interface InterpBacked {
35+
/// The interpreted object this peer stands for.
36+
InterpObject getInterpObject();
37+
}

0 commit comments

Comments
 (0)