diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml
index 8cefcec3378..fa7493a7fe5 100644
--- a/.github/workflows/developer-guide-docs.yml
+++ b/.github/workflows/developer-guide-docs.yml
@@ -149,7 +149,10 @@ jobs:
if: github.event_name != 'pull_request' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.demos == 'true' || steps.changes.outputs.workflow == 'true'
run: |
set -euo pipefail
- xvfb-run -a mvn -B -ntp -f maven/pom.xml \
+ # Through retry.sh, transient failures only, like the demo install below:
+ # this is the step that resolves the most from Central, and a single 502
+ # on a plugin dependency (maven-shared-utils) failed the whole guide build.
+ RETRY_ONLY_MATCHING=transient xvfb-run -a bash scripts/ci/retry.sh mvn -B -ntp -f maven/pom.xml \
-pl core,javase,android,css-compiler,codenameone-maven-plugin,backend \
-am install \
-Plocal-dev-javase \
diff --git a/.github/workflows/flutter-bench.yml b/.github/workflows/flutter-bench.yml
new file mode 100644
index 00000000000..65be01d61f8
--- /dev/null
+++ b/.github/workflows/flutter-bench.yml
@@ -0,0 +1,505 @@
+name: Flutter benchmark
+
+# Measures ONE application built two ways -- Flutter's own release build and
+# the identical Dart source transpiled by Codename One -- and publishes the
+# comparison to the pull request and to port status.
+#
+# Neither application is vendored. `scripts/flutter-bench/app/prepare.sh` takes
+# the gallery from the Flutter SDK this workflow installs
+# ($FLUTTER_ROOT/dev/integration_tests/new_gallery) and copies the SAME 159
+# files into both sides, so the comparison cannot drift: there is no second
+# copy for an edit to land on. The Codename One side is generated from the
+# SHIPPING archetype, which means a change that breaks the documented Flutter
+# wiring breaks this workflow too instead of passing unnoticed.
+
+on:
+ pull_request:
+ paths:
+ - 'maven/flutter-runtime/**'
+ - 'maven/dart-transpiler/**'
+ - 'maven/dart-runtime/**'
+ - 'maven/codenameone-maven-plugin/**'
+ # The builders the plugin runs live here since the build engine split out.
+ - 'maven/build-engine/**'
+ - 'maven/cn1app-archetype/**'
+ - 'scripts/flutter-bench/**'
+ # The regression gate's rules and CPU detection are the ParparVM gate's own
+ # (flutter_baseline.py runs vm/selfhost/perf_baseline.py with its own layout).
+ - 'vm/selfhost/perf_baseline.py'
+ - 'vm/selfhost/perf-gate.py'
+ - '.github/workflows/flutter-bench.yml'
+ schedule:
+ # Nightly, so port status carries a current number and the PR runs have a
+ # trend to sit against rather than a single point.
+ - cron: '0 3 * * *'
+ workflow_dispatch:
+ inputs:
+ platforms:
+ description: 'Comma separated platform ids, or "all"'
+ default: 'all'
+
+concurrency:
+ group: flutter-bench-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ # The Flutter release this benchmark measures against, PINNED rather than
+ # tracking stable.
+ #
+ # A benchmark whose reference moves on its own is not one: the gallery, its
+ # dependency resolution and Flutter's own code generation all change between
+ # releases, so a number from last week and a number from today would differ
+ # for reasons that have nothing to do with this repository. Tracking stable
+ # also broke outright -- 3.47.5's tree resolved google_fonts to a version
+ # without robotoCondensed and three studies stopped compiling.
+ #
+ # Bumping this is a deliberate act that re-baselines the comparison: expect
+ # every start-up and memory ratio to move, and rebaseline them in the same
+ # change's own overlay (scripts/flutter-bench/flutter_baseline.py calibrate
+ # --pr N --reason "Flutter " over every platform's baseline-*.json).
+ FLUTTER_REF: '3.35.4'
+
+permissions:
+ contents: read
+ pull-requests: write
+ # The framework job runs in the pr-ci-container image on ghcr, and pulling it
+ # needs this. Without it the job dies in "Initialize containers" with a bare
+ # "Error response from daemon: denied", which reads like the image is missing
+ # rather than like the token cannot see it.
+ packages: read
+
+jobs:
+ # The framework is built ONCE, here, and the platform jobs consume the
+ # artifacts. Two reasons it cannot be built per platform:
+ #
+ # * It needs JDK 8. codenameone-javase imports javafx.*, which no JDK after
+ # 8 carries, so a JDK 17 build of the reactor fails compiling the JavaSE
+ # port. An earlier version of this workflow set up JDK 8 and then JDK 17,
+ # and since the last setup-java wins it silently built everything on 17.
+ # * macOS arm64 runners have no Temurin 8 at all -- setup-java reports
+ # "Could not find satisfied version for SemVer 8", the oldest offered
+ # being 11.
+ #
+ # The container is the same one pr.yml uses, which bakes JDK 8 and a
+ # cn1-binaries snapshot.
+ framework:
+ name: framework
+ runs-on: ubuntu-latest
+ container: ghcr.io/codenameone/codenameone/pr-ci-container:latest
+ timeout-minutes: 90
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Link cn1-binaries
+ run: ln -s /opt/cn1-binaries ../cn1-binaries
+
+ # The container supplies JAVA_HOME_8 and JAVA_HOME_17; this is how pr.yml
+ # selects between them. Not setup-java, which would download a second JDK
+ # into an image that already has the right ones, and not a hard-coded
+ # path -- an earlier version of this guessed
+ # /usr/lib/jvm/java-8-openjdk-amd64 and the build died on "The JAVA_HOME
+ # environment variable is not defined correctly", which says nothing
+ # about which JDK was wanted or what was there.
+ - name: Check the container's JDKs
+ run: |
+ for var in JAVA_HOME_8 JAVA_HOME_17; do
+ eval "path=\${$var:-}"
+ if [ -z "$path" ] || [ ! -x "$path/bin/javac" ]; then
+ echo "$var is unset or has no javac: '${path:-}'" >&2
+ exit 1
+ fi
+ echo "$var=$path"
+ done
+
+ # JDK 8 for everything that targets it. The flutter modules are NOT in
+ # this reactor: maven/pom.xml puts them behind a profile that activates
+ # on JDK 17 and newer, precisely because they cannot compile here.
+ - name: Build the framework on JDK 8
+ run: |
+ cd maven
+ # -Plocal-dev-javase is required to build the JavaSE port, which
+ # -am pulls in. Without it the build dies in javase's
+ # cn1-generate-build-hint-data execution, which is a long way from
+ # anything this workflow is about.
+ # Through retry.sh, like every other Maven step in this repository: Central
+ # throttles runners with 403/429, and a bare mvn failed this job twice in
+ # one day at resolving org.junit:junit-bom before compiling anything.
+ JAVA_HOME="$JAVA_HOME_8" bash ../scripts/ci/retry.sh mvn -B -q install -DskipTests \
+ -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \
+ -Plocal-dev-javase \
+ -Dmaven.repo.local="$GITHUB_WORKSPACE/m2" \
+ -pl core,dart-transpiler,codenameone-maven-plugin,cn1app-archetype -am
+
+ # Without -am: everything these depend on was installed by the pass
+ # above, and re-resolving it here would rebuild core on the wrong JDK.
+ - name: Build the Flutter runtime on JDK 17
+ run: |
+ cd maven
+ JAVA_HOME="$JAVA_HOME_17" bash ../scripts/ci/retry.sh mvn -B -q install -DskipTests \
+ -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \
+ -Plocal-dev-javase \
+ -Dmaven.repo.local="$GITHUB_WORKSPACE/m2" \
+ -pl dart-runtime,flutter-runtime
+
+ - name: Pack the repository
+ run: tar -C "$GITHUB_WORKSPACE" -czf framework-m2.tgz m2
+
+ - name: Upload the repository
+ uses: actions/upload-artifact@v4
+ with:
+ name: flutter-bench-framework
+ path: framework-m2.tgz
+ retention-days: 1
+
+ # The platforms to measure. Every one on a pull request or the nightly run; on a
+ # manual dispatch, the ones its `platforms` input names. The input used to be
+ # read by nothing, so asking to re-run one platform launched all six -- several
+ # of them two-hour native builds.
+ plan:
+ name: plan
+ runs-on: ubuntu-latest
+ outputs:
+ include: ${{ steps.pick.outputs.include }}
+ steps:
+ - id: pick
+ env:
+ REQUESTED: ${{ github.event_name == 'workflow_dispatch' && inputs.platforms || 'all' }}
+ shell: python3 {0}
+ run: |
+ import json, os, sys
+ ALL = [
+ {"platform": "macos", "runner": "macos-latest"},
+ {"platform": "ios", "runner": "macos-latest"},
+ {"platform": "android", "runner": "ubuntu-latest"},
+ {"platform": "linux", "runner": "ubuntu-latest"},
+ # PINNED, like FLUTTER_REF and for the same reason. windows-latest
+ # is Server 2025 and its Visual Studio has moved to 2026 (major
+ # 18), which Flutter 3.35.4 predates: it detects that install and
+ # reports it healthy, then maps it to no generator it knows and
+ # emits "Visual Studio 16 2019" instead. Pinning Flutter without
+ # pinning the toolchain it has to drive only moved the moving part.
+ {"platform": "windows", "runner": "windows-2022"},
+ {"platform": "javascript", "runner": "ubuntu-latest"},
+ ]
+ requested = os.environ.get("REQUESTED", "").strip().lower()
+ if requested in ("", "all"):
+ chosen = ALL
+ else:
+ names = [n.strip() for n in requested.split(",") if n.strip()]
+ known = {p["platform"] for p in ALL}
+ unknown = [n for n in names if n not in known]
+ if unknown or not names:
+ # Loud, not an empty matrix: a typo must not "succeed" by measuring nothing.
+ sys.exit("unknown platform(s) %s; choose from %s, or all"
+ % (", ".join(unknown) or "", ", ".join(sorted(known))))
+ chosen = [p for p in ALL if p["platform"] in names]
+ print("measuring: " + ", ".join(p["platform"] for p in chosen))
+ with open(os.environ["GITHUB_OUTPUT"], "a") as out:
+ out.write("include=" + json.dumps(chosen) + "\n")
+
+ measure:
+ name: ${{ matrix.platform }}
+ needs: [framework, plan]
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 120
+ env:
+ # Which pull request's overlay (scripts/flutter-bench/baseline/pr/.json) is
+ # this run's own: a stale own overlay is judged around, not refused, and the
+ # printed calibrate command names it. A dispatched run asks gh for the branch's.
+ CN1_PR_NUMBER: ${{ github.event.pull_request.number }}
+ GH_TOKEN: ${{ github.token }}
+ strategy:
+ # One platform failing must not hide the others: a benchmark that
+ # publishes nothing because a single runner broke is worse than one that
+ # publishes five platforms and says the sixth did not run.
+ fail-fast: false
+ matrix:
+ include: ${{ fromJson(needs.plan.outputs.include) }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ # JDK 17 only. Nothing here builds the framework, so nothing here needs
+ # the JDK 8 that is unavailable on an arm64 macOS runner anyway.
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 17
+
+ - name: Download the framework
+ uses: actions/download-artifact@v4
+ with:
+ name: flutter-bench-framework
+
+ - name: Unpack the framework
+ shell: bash
+ run: tar -xzf framework-m2.tgz
+
+ # A full SDK CHECKOUT, not a release archive. The benchmark's application
+ # is dev/integration_tests/new_gallery inside the SDK, and the published
+ # release archives do not carry dev/ at all -- so the usual setup actions,
+ # which unpack a release, leave prepare.sh with nothing to copy. Cloning
+ # the SDK is also what pins the reference: the revision is recorded in
+ # every result, so a number can be traced to the Flutter that produced it.
+ - name: Check out the Flutter SDK
+ shell: bash
+ run: |
+ git clone --depth 1 --branch "$FLUTTER_REF" \
+ https://github.com/flutter/flutter.git "$RUNNER_TEMP/flutter"
+ echo "$RUNNER_TEMP/flutter/bin" >> "$GITHUB_PATH"
+ echo "FLUTTER_ROOT=$RUNNER_TEMP/flutter" >> "$GITHUB_ENV"
+
+ - name: Report the toolchain, and prove the gallery is present
+ shell: bash
+ run: |
+ flutter --version
+ flutter precache
+ test -d "$FLUTTER_ROOT/dev/integration_tests/new_gallery/lib" || {
+ echo "this Flutter checkout has no gallery; prepare.sh cannot run" >&2
+ exit 1; }
+ echo "gallery Dart files: $(find "$FLUTTER_ROOT/dev/integration_tests/new_gallery/lib" -name '*.dart' | wc -l)"
+ test -f "$FLUTTER_ROOT/pubspec.lock" || {
+ echo "the SDK checkout has no root pubspec.lock; prepare.sh needs it" >&2
+ echo "to hold the gallery's unpinned dependencies at tested versions" >&2
+ exit 1; }
+
+ # A benchmark records the toolchain it measured with; on Windows it also
+ # has to CHECK it. Flutter picks the CMake generator from the Visual
+ # Studio it detects and falls back to "Visual Studio 16 2019" when it
+ # detects NONE -- so an undetected Visual Studio does not say so. It fails
+ # minutes later inside CMake, claiming VS 2019 could not be found, which
+ # is true and is not the problem. vswhere's own view is printed beside
+ # Flutter's so the two can be compared: a Visual Studio that vswhere lists
+ # and Flutter does not is a different fault from one that is simply absent.
+ - name: Report the Windows toolchain, and prove Flutter can see it
+ if: ${{ runner.os == 'Windows' }}
+ shell: bash
+ run: |
+ VS_VERSION=""
+ VSWHERE="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
+ if [ -x "$VSWHERE" ]; then
+ echo "== vswhere sees:"
+ VS_VERSION="$("$VSWHERE" -products '*' -prerelease -format value \
+ -property installationVersion 2>/dev/null | head -1)"
+ echo "${VS_VERSION:-}"
+ "$VSWHERE" -products '*' -prerelease -format value \
+ -property installationPath || true
+ else
+ echo "== vswhere is not installed at the usual path"
+ fi
+ echo "== flutter doctor:"
+ flutter doctor -v 2>&1 | tee /tmp/doctor.txt || true
+ # Checking that Flutter can MAP the install, not that it can see one.
+ # Presence is the wrong question: doctor reported
+ # "[OK] Visual Studio - develop Windows apps (Visual Studio Enterprise
+ # 2026 18.9.2)" on the very run this replaces, and the build then died
+ # in CMake. flutter_tools/lib/src/windows/visual_studio.dart maps major
+ # 17 and nothing else; every other major, detected or not, silently
+ # becomes the "Visual Studio 16 2019" generator. Update SUPPORTED_VS
+ # when FLUTTER_REF moves, and note that it is the CMake generator
+ # mapping that decides, not what doctor is willing to tick.
+ SUPPORTED_VS=17
+ MAJOR="$(printf '%s' "$VS_VERSION" | cut -d. -f1)"
+ if [ -z "$MAJOR" ] || [ "$MAJOR" != "$SUPPORTED_VS" ]; then
+ echo "This runner's Visual Studio is major '${MAJOR:-none}', and Flutter" >&2
+ echo "$FLUTTER_REF only maps major $SUPPORTED_VS to a CMake generator." >&2
+ echo "It would fall back to 'Visual Studio 16 2019' and fail inside" >&2
+ echo "CMake rather than here -- naming a version nobody asked for." >&2
+ echo "Pin the runner image to one carrying Visual Studio" >&2
+ echo "$SUPPORTED_VS, or move FLUTTER_REF and re-record baselines." >&2
+ exit 1
+ fi
+
+ # Flutter's own Linux build and Codename One's native Linux port both
+ # BOTH sides need a native toolchain here, and they need different
+ # things. Flutter's desktop build wants clang and a C++ standard library;
+ # the Codename One side compiles the native Linux port, whose CMakeLists
+ # resolves the whole GTK/GStreamer/WebKit stack through pkg_check_modules
+ # and stops at the first one missing. The shared script is that stack --
+ # an ad-hoc subset here configured far enough to fail on libcurl, several
+ # minutes into a build that had already transpiled and compiled.
+ - name: Install the Linux desktop toolchain
+ if: ${{ matrix.platform == 'linux' }}
+ run: bash scripts/ci/install-linux-native-deps.sh clang libstdc++-12-dev
+
+ # The Codename One build opens an AWT window (its CSS compiler), so a
+ # headless runner fails inside `cn1:css` after the transpile has already
+ # succeeded. build_apps.sh wraps Maven in xvfb-run on Linux and refuses
+ # to run without it rather than failing later and less legibly. The
+ # helper short-circuits when the image already ships xvfb, which these
+ # usually do.
+ - name: Install xvfb
+ if: ${{ runner.os == 'Linux' }}
+ run: bash scripts/ci/apt-get-install.sh xvfb
+
+ - name: Prepare both applications from one source
+ shell: bash
+ run: |
+ scripts/flutter-bench/app/prepare.sh \
+ --work "${RUNNER_TEMP}/fbench" \
+ --maven-repo "${GITHUB_WORKSPACE}/m2"
+
+ - name: Build both applications
+ id: build
+ shell: bash
+ run: |
+ scripts/flutter-bench/app/build_apps.sh \
+ --work "${RUNNER_TEMP}/fbench" \
+ --platform "${{ matrix.platform }}" \
+ --maven-repo "${GITHUB_WORKSPACE}/m2" | tee "${RUNNER_TEMP}/paths.txt"
+ grep '^cn1=' "${RUNNER_TEMP}/paths.txt" >> "$GITHUB_OUTPUT"
+ grep '^flutter=' "${RUNNER_TEMP}/paths.txt" >> "$GITHUB_OUTPUT"
+
+ # Every leg runs the same measure.sh; the legs differ only in what wraps it.
+ #
+ # Linux runs under Xvfb because both applications are GTK programs that
+ # need a display. build_apps.sh's xvfb-run does not help here: it lives
+ # exactly as long as the Maven build it wraps, so the Measure step started
+ # with no display at all and the adapter reported the platform
+ # unavailable after both applications had built.
+ - name: Measure
+ if: ${{ matrix.platform != 'android' }}
+ shell: bash
+ run: |
+ prefix=""
+ if [ "$RUNNER_OS" = "Linux" ]; then
+ prefix="xvfb-run -a"
+ fi
+ $prefix scripts/flutter-bench/app/measure.sh "${{ matrix.platform }}" \
+ "${{ steps.build.outputs.cn1 }}" "${{ steps.build.outputs.flutter }}" \
+ "${RUNNER_TEMP}/bench-out"
+
+ # Android needs a device. A hosted runner has none, so the adapter found no
+ # emulator and recorded the whole platform as unavailable after building
+ # both APKs. Same emulator, API level and image as the Android port's own
+ # instrumentation leg (scripts-android.yml).
+ - name: Enable KVM for the Android emulator
+ if: ${{ matrix.platform == 'android' }}
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ - name: Measure on an Android emulator
+ if: ${{ matrix.platform == 'android' }}
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: 36
+ arch: x86_64
+ target: google_apis
+ disk-size: 4096M
+ # One line: the action runs each line of `script` in its own shell.
+ script: scripts/flutter-bench/app/measure.sh android "${{ steps.build.outputs.cn1 }}" "${{ steps.build.outputs.flutter }}" "${{ runner.temp }}/bench-out"
+
+ # The result and the baseline candidate measured from it. The candidate is
+ # what `flutter_baseline.py calibrate --pr N` reads to write the pull
+ # request's overlay when a row moved, so it travels with the result rather
+ # than having to be re-derived.
+ - name: Upload the result
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: flutter-bench-${{ matrix.platform }}
+ path: |
+ ${{ runner.temp }}/bench-out/result-${{ matrix.platform }}.json
+ ${{ runner.temp }}/bench-out/baseline-${{ matrix.platform }}.json
+ if-no-files-found: warn
+
+ publish:
+ name: Publish
+ needs: measure
+ # always(), so a platform that regressed still gets its numbers in front of
+ # a reviewer. The `measure` jobs already carry the red status; hiding the
+ # comment as well would leave the failure with no explanation attached.
+ if: always()
+ runs-on: ubuntu-latest
+ # Read-only contents: on a pull request this job runs the pull request's own
+ # files (run_bench.py, publish-quality-comment.js with an authenticated
+ # client), so it must never hold a token that can write the repository. The
+ # master-only publication that needs contents: write is its own job,
+ # publish-status, which a pull_request event never reaches. Job permissions
+ # replace the workflow's, so the comment's permission is restated.
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Collect every platform's result
+ uses: actions/download-artifact@v4
+ with:
+ pattern: flutter-bench-*
+ path: results
+ merge-multiple: true
+
+ - name: Render the comment
+ run: |
+ python3 scripts/flutter-bench/run_bench.py \
+ --render 'results/result-*.json' \
+ --markdown flutter-benchmark.md
+ cat flutter-benchmark.md
+
+ - name: Publish the benchmark comment
+ if: ${{ github.event_name == 'pull_request' && hashFiles('flutter-benchmark.md') != '' }}
+ uses: actions/github-script@v9
+ with:
+ retries: 3
+ script: |
+ const { publishQualityComment } = require('./.github/scripts/publish-quality-comment.js');
+ await publishQualityComment({
+ github, context, core,
+ marker: '',
+ reportPath: 'flutter-benchmark.md',
+ });
+
+ - name: Upload the rendered report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: flutter-benchmark-report
+ path: flutter-benchmark.md
+
+ # The only job holding contents: write. It runs for a scheduled or dispatched
+ # run of master alone -- never for a pull_request event -- so the code it
+ # executes is master's, not a contributor's.
+ publish-status:
+ name: Publish port status
+ needs: measure
+ # Only a run of every platform: port status shows all of them, and a dispatch
+ # for a subset would publish a table missing the rest.
+ if: >-
+ always() &&
+ github.event_name != 'pull_request' &&
+ github.ref == 'refs/heads/master' &&
+ (github.event_name == 'schedule' ||
+ (github.event_name == 'workflow_dispatch' && (inputs.platforms == 'all' || inputs.platforms == '')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Collect every platform's result
+ uses: actions/download-artifact@v4
+ with:
+ pattern: flutter-bench-*
+ path: results
+ merge-multiple: true
+
+ # Published to the data branch the website build reads, the way every
+ # port's own status is. Writing it into docs/website in this job's
+ # checkout, as this step used to, put it where nothing would ever see it:
+ # the checkout is discarded when the job ends and nothing committed it.
+ # Master-only, like the port reports, so a branch's numbers never reach
+ # the public page.
+ - name: Publish the benchmark to the port status data
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ python3 scripts/flutter-bench/port_status.py \
+ --results 'results/result-*.json' \
+ --out "${RUNNER_TEMP}/port-status"
+ python3 scripts/flutter-bench/publish_benchmark.py \
+ "${RUNNER_TEMP}/port-status/flutter_benchmark.json" "${{ github.repository }}"
+
diff --git a/.github/workflows/input-validation.yml b/.github/workflows/input-validation.yml
index ac2264228eb..3f4d6c170ce 100644
--- a/.github/workflows/input-validation.yml
+++ b/.github/workflows/input-validation.yml
@@ -36,6 +36,16 @@ on:
- 'scripts/build-ios-app.sh'
workflow_dispatch: {}
+env:
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
+
concurrency:
# Only the latest revision needs validation. PR numbers keep forks isolated;
# event names keep a manual/scheduled run from cancelling a push or PR run.
diff --git a/.github/workflows/ios-packaging.yml b/.github/workflows/ios-packaging.yml
index 8cf6839b651..b192a845fb7 100644
--- a/.github/workflows/ios-packaging.yml
+++ b/.github/workflows/ios-packaging.yml
@@ -15,6 +15,16 @@ concurrency:
group: packaging-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
+env:
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
+
jobs:
build-port:
if: ${{ !inputs.port_prepared }}
diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml
index 27259c7e8c9..067ff02e172 100644
--- a/.github/workflows/linux-build-run.yml
+++ b/.github/workflows/linux-build-run.yml
@@ -87,6 +87,16 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
+env:
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
+
jobs:
# Build the codenameone maven plugin (it needs CEF, which is x64-only on Linux)
# and the hellocodenameone screenshot suite, then publish the suite + ads-mock
@@ -173,34 +183,14 @@ jobs:
- name: Check out repository
uses: actions/checkout@v6
- # The native Linux port's full dependency stack: GTK3 + Cairo + Pango +
- # GdkPixbuf + GLib/GIO (render/widgets), FontConfig/FreeType (bundled-font
- # registration), libcurl (HTTP), GStreamer (media/camera/audio), WebKitGTK
- # (browser), libsecret (secure storage), libnotify (notifications), GeoClue
- # (location), libepoxy + EGL/GLES + the Mesa software driver (the offscreen
- # 3D backend). Plus CMake/Ninja, Xvfb and a font for Pango to lay out.
- #
- # The GStreamer plugin set matters as much as the library: base + good carry
- # appsrc/appsink, videoconvert and mp4mux but NO codec. VideoIO's encoder is
- # x264enc/x265enc (plugins-ugly) parsed by h264parse/h265parse (plugins-bad)
- # with avenc_aac for audio and avdec_* for playback (libav), so without those
- # three packages the port has no encoder and no H.264 decoder at all.
+ # The port's full dependency stack, shared with the Flutter benchmark,
+ # which builds the same local-linux-device target. See the script for what
+ # each group is for and why the GStreamer plugin set is part of it.
- name: Install the GTK build stack + toolchain
- run: |
- set -e
- # Through the shared helper: it refreshes the index once when a .deb
- # 404s, which is what a point release landing mid-job looks like --
- # three openssl packages took this job out on 2026-08-25.
- bash scripts/ci/apt-get-install.sh \
- cmake ninja-build pkg-config unzip xvfb fonts-dejavu-core \
- libgtk-3-dev libcairo2-dev libpango1.0-dev libgdk-pixbuf-2.0-dev libglib2.0-dev \
- libfontconfig1-dev libfreetype-dev \
- libcurl4-openssl-dev libssl-dev \
- libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
- gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav \
- libwebkit2gtk-4.1-dev libsecret-1-dev libnotify-dev libgeoclue-2-dev \
- libepoxy-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri \
- clang time
+ run: bash scripts/ci/install-linux-native-deps.sh
+
+ - name: Install clang and time
+ run: bash scripts/ci/apt-get-install.sh clang time
- name: Set up JDK 8
uses: actions/setup-java@v5
diff --git a/.github/workflows/parparvm-parallel-mark.yml b/.github/workflows/parparvm-parallel-mark.yml
index a974516ef4e..36789497844 100644
--- a/.github/workflows/parparvm-parallel-mark.yml
+++ b/.github/workflows/parparvm-parallel-mark.yml
@@ -135,15 +135,18 @@ jobs:
# nothing for the tests module to resolve against, which is how the first
# revision of this workflow failed on all three arms including the control.
# This mirrors parparvm-tests.yml.
+ # Through retry.sh, transient failures only: a TLS handshake that did not
+ # complete fetching maven-clean-plugin from Central failed this step once,
+ # before anything under test had been built.
- name: Build JavaAPI and the translator
- run: mvn -B clean package -pl JavaAPI -am -DskipTests
+ run: RETRY_ONLY_MATCHING=transient bash ../scripts/ci/retry.sh mvn -B clean package -pl JavaAPI -am -DskipTests
working-directory: vm
- name: GC suite
env:
CN1_TEST_EXTRA_CFLAGS: ${{ matrix.cflags }}
run: |
- mvn -B test -pl tests -am \
+ RETRY_ONLY_MATCHING=transient bash ../scripts/ci/retry.sh mvn -B test -pl tests -am \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dtest='GcHeapIntegrityIntegrationTest,GcOverflowSpiralIntegrationTest,GcUncooperativeThreadIntegrationTest,LargeArrayGcIntegrationTest,LowMemoryThrottleIntegrationTest,BibopPageFloorIntegrationTest'
working-directory: vm
diff --git a/.github/workflows/parparvm-selfhost.yml b/.github/workflows/parparvm-selfhost.yml
index 322b8b0ee70..0192b6f661d 100644
--- a/.github/workflows/parparvm-selfhost.yml
+++ b/.github/workflows/parparvm-selfhost.yml
@@ -44,6 +44,14 @@ concurrency:
env:
CN1_NATIVE_VERIFY: strict
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
jobs:
selfhost:
diff --git a/.github/workflows/parparvm-tests-windows.yml b/.github/workflows/parparvm-tests-windows.yml
index 669fd8a6cdc..787f04b945f 100644
--- a/.github/workflows/parparvm-tests-windows.yml
+++ b/.github/workflows/parparvm-tests-windows.yml
@@ -81,6 +81,14 @@ env:
# the translator so a customer build that succeeds today keeps succeeding; see
# NativeSignatureVerifier.
CN1_NATIVE_VERIFY: strict
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
jobs:
vm-tests-windows:
@@ -359,11 +367,26 @@ jobs:
with:
distribution: 'temurin'
java-version: '8'
- cache: 'maven'
- name: Save JDK 8 path
shell: pwsh
run: echo "JDK_8_HOME=$env:JAVA_HOME" >> $env:GITHUB_ENV
+ # The Maven repository, restored from the NEAREST earlier run rather than an
+ # exact match. setup-java's cache: 'maven' keys on the hash of every pom.xml
+ # and restores nothing on a miss, so any change to a pom started this job
+ # from an empty repository -- and Central rate-limits Windows runners hard:
+ # one such run got 429 Too Many Requests on junit-bom through all five
+ # attempts of the retry loop below, over ten minutes, before building
+ # anything. A near match already holds almost every artifact, so the few new
+ # ones are all that has to come from Central.
+ - name: Maven repository cache
+ uses: actions/cache@v4
+ with:
+ path: ~/.m2/repository
+ key: maven-${{ runner.os }}-screenshot-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ maven-${{ runner.os }}-screenshot-
+
# Install the locally-built artifacts the hellocodenameone Maven build needs:
# codename1-core + the Windows port (translator inputs), the codenameone
# maven plugin (runs transcode-svg / css / annotation processing during the
diff --git a/.github/workflows/parparvm-tests.yml b/.github/workflows/parparvm-tests.yml
index 6a3af1f9e61..c2a7b176d1e 100644
--- a/.github/workflows/parparvm-tests.yml
+++ b/.github/workflows/parparvm-tests.yml
@@ -92,6 +92,14 @@ env:
# the translator so a customer build that succeeds today keeps succeeding; see
# NativeSignatureVerifier.
CN1_NATIVE_VERIFY: strict
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
jobs:
# EVERY DOCUMENTED RUNTIME CONFIGURATION MUST STILL COMPILE.
diff --git a/.github/workflows/perf-baseline.yml b/.github/workflows/perf-baseline.yml
index f8a7a92c440..6867ea0106b 100644
--- a/.github/workflows/perf-baseline.yml
+++ b/.github/workflows/perf-baseline.yml
@@ -1,5 +1,10 @@
name: Performance baselines
+# Two gates keep their baselines this way, with one set of rules
+# (vm/selfhost/perf_baseline.py): the ParparVM performance gate in
+# vm/selfhost/perf-baseline/, and the Flutter benchmark's regression gate in
+# scripts/flutter-bench/baseline/ (scripts/flutter-bench/flutter_baseline.py).
+#
# The ParparVM performance gate's baselines live in vm/selfhost/perf-baseline/:
# base/ holds the consolidated rows, and each pull request that changes a baseline
# writes only its own pr/.json, so baseline changes stay in the pull
@@ -49,16 +54,25 @@ jobs:
working-directory: vm/selfhost
run: python3 -m unittest test_perf_gate
+ - name: The Flutter benchmark gate's unit tests
+ working-directory: scripts/flutter-bench
+ run: python3 -m unittest test_flutter_baseline
+
- name: Check the baselines
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
+ # Both, even when the first fails, so one comment shows every problem.
+ status=0
if [ -n "${BASE_SHA}" ]; then
- python3 vm/selfhost/perf_baseline.py check --base "${BASE_SHA}" --pr "${PR_NUMBER}"
+ python3 vm/selfhost/perf_baseline.py check --base "${BASE_SHA}" --pr "${PR_NUMBER}" || status=1
+ python3 scripts/flutter-bench/flutter_baseline.py check --base "${BASE_SHA}" --pr "${PR_NUMBER}" || status=1
else
- python3 vm/selfhost/perf_baseline.py check
+ python3 vm/selfhost/perf_baseline.py check || status=1
+ python3 scripts/flutter-bench/flutter_baseline.py check || status=1
fi
+ exit $status
fold:
if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
@@ -87,14 +101,15 @@ jobs:
git fetch --quiet origin master
git reset --quiet --hard origin/master
python3 vm/selfhost/perf_baseline.py fold
- git add -A vm/selfhost/perf-baseline
+ python3 scripts/flutter-bench/flutter_baseline.py fold
+ git add -A vm/selfhost/perf-baseline scripts/flutter-bench/baseline
if git diff --staged --quiet; then
echo "No overlays to fold."
echo "folded=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git diff --staged --stat
- git commit --quiet -m "ci: fold merged performance baselines into perf-baseline/base"
+ git commit --quiet -m "ci: fold merged performance and Flutter benchmark baselines into base/"
if git push origin HEAD:master; then
echo "folded=true" >> "$GITHUB_OUTPUT"
exit 0
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 980910452e0..0d7f252752f 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -153,6 +153,14 @@ env:
# outcome of customer builds that succeed today, so it is ours to enforce, not
# theirs to trip over. See NativeSignatureVerifier.
CN1_NATIVE_VERIFY: strict
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
concurrency:
# Only the latest revision needs validation. PR numbers keep forks isolated;
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 896118dd33e..b7ac3cf0c0e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -359,6 +359,46 @@ jobs:
[ "$code" = "200" ] || { echo "MISSING on R2: codenameone-guibuilder (HTTP $code)" >&2; exit 1; }
echo "ok: codenameone-guibuilder ${GITHUB_REF_NAME} on R2"
+ # --- Flutter runtime and Dart runtime -----------------------------------
+ # codenameone-dart-runtime and codenameone-flutter-runtime compile at
+ # release 17, so the maven/ reactor only includes them on JDK 17 (the
+ # flutter-modules profile) and the JDK 8 core pass above never builds them.
+ # Without this pass a project that declares codenameone-flutter-runtime at a
+ # released version -- as the archetype documentation says to -- cannot
+ # resolve it. The transpiler itself builds on JDK 8 and ships with the core.
+ - name: Build and stage the Flutter runtimes
+ id: deploy_flutter
+ continue-on-error: true
+ if: >-
+ always() && (steps.deploy.outcome == 'success' ||
+ steps.r2_core.outcome == 'success')
+ run: |
+ export GPG_TTY=$(tty)
+ cd maven
+ # -pl without -am: the parent and core were released above and are in
+ # the local repository at this version; re-staging them here would
+ # produce new signatures for artifacts that are already immutable on R2.
+ # With -pl the plugin stages under the FIRST reactor module's target --
+ # maven/dart-runtime/target/central-staging, holding both runtimes --
+ # not under maven/target, so the core tree is left untouched.
+ xvfb-run -a mvn deploy -Psign-artifacts -Dgpg.passphrase=$MAVEN_GPG_PASSPHRASE \
+ -pl dart-runtime,flutter-runtime -DskipTests -DskipPublishing=true
+ env:
+ MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }}
+
+ - name: Publish Flutter runtimes to R2
+ id: r2_flutter
+ continue-on-error: true
+ if: always() && steps.deploy_flutter.conclusion != 'skipped'
+ run: |
+ bash maven/scripts/r2/publish-staging-to-r2.sh maven/dart-runtime/target/central-staging
+ for artifact in codenameone-dart-runtime codenameone-flutter-runtime; do
+ url="${R2_BASE_URL}/com/codenameone/${artifact}/${GITHUB_REF_NAME}/${artifact}-${GITHUB_REF_NAME}.pom?cb=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ code=$(curl -s -o /dev/null -w "%{http_code}" "$url")
+ [ "$code" = "200" ] || { echo "MISSING on R2: $artifact (HTTP $code)" >&2; exit 1; }
+ echo "ok: $artifact ${GITHUB_REF_NAME} on R2"
+ done
+
- name: Mark the R2 release complete
id: r2_mark_complete
continue-on-error: true
@@ -387,12 +427,14 @@ jobs:
steps.deploy_certificatewizard.outcome == 'success' &&
steps.deploy_settings.outcome == 'success' &&
steps.deploy_guibuilder.outcome == 'success' &&
+ steps.deploy_flutter.outcome == 'success' &&
steps.r2_core.outcome == 'success' &&
steps.r2_core_confirm.outcome == 'success' &&
steps.r2_gamebuilder.outcome == 'success' &&
steps.r2_certificatewizard.outcome == 'success' &&
steps.r2_settings.outcome == 'success' &&
- steps.r2_guibuilder.outcome == 'success'
+ steps.r2_guibuilder.outcome == 'success' &&
+ steps.r2_flutter.outcome == 'success'
run: bash maven/scripts/r2/mark-release-complete.sh "${GITHUB_REF_NAME}"
- name: Regenerate R2 metadata and archetype catalog
@@ -445,12 +487,14 @@ jobs:
bad "${{ steps.deploy_certificatewizard.outcome }}" && fail "Signing Wizard build"
bad "${{ steps.deploy_settings.outcome }}" && fail "Settings build"
bad "${{ steps.deploy_guibuilder.outcome }}" && fail "GUI Builder build"
+ bad "${{ steps.deploy_flutter.outcome }}" && fail "Flutter runtimes build"
bad "${{ steps.r2_core.outcome }}" && fail "core reactor -> R2"
bad "${{ steps.r2_core_confirm.outcome }}" && fail "core artifacts missing on R2"
bad "${{ steps.r2_gamebuilder.outcome }}" && fail "Game Builder -> R2"
bad "${{ steps.r2_certificatewizard.outcome }}" && fail "Signing Wizard -> R2"
bad "${{ steps.r2_settings.outcome }}" && fail "Settings -> R2"
bad "${{ steps.r2_guibuilder.outcome }}" && fail "GUI Builder -> R2"
+ bad "${{ steps.r2_flutter.outcome }}" && fail "Flutter runtimes -> R2"
bad "${{ steps.r2_mark_complete.outcome }}" && fail "marking the R2 release complete"
bad "${{ steps.r2_metadata.outcome }}" && fail "R2 metadata regeneration"
# A skipped marker is not a legitimate skip: it is how a release ends up
diff --git a/.github/workflows/scripts-ios-native.yml b/.github/workflows/scripts-ios-native.yml
index aa38ee6ed66..8bde486014f 100644
--- a/.github/workflows/scripts-ios-native.yml
+++ b/.github/workflows/scripts-ios-native.yml
@@ -15,6 +15,16 @@ concurrency:
group: native-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
+env:
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
+
jobs:
build-port:
if: ${{ !inputs.port_prepared }}
diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml
index c1ab94c9f5f..b278eb04436 100644
--- a/.github/workflows/scripts-ios.yml
+++ b/.github/workflows/scripts-ios.yml
@@ -127,6 +127,16 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
+env:
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
+
jobs:
select:
runs-on: ubuntu-24.04
diff --git a/.github/workflows/scripts-mac-catalyst.yml b/.github/workflows/scripts-mac-catalyst.yml
index bec32802ab8..76893b51cf4 100644
--- a/.github/workflows/scripts-mac-catalyst.yml
+++ b/.github/workflows/scripts-mac-catalyst.yml
@@ -23,6 +23,16 @@ on:
type: boolean
default: false
+env:
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
+
concurrency:
# Only the latest revision needs validation. PR numbers keep forks isolated;
# event names keep a manual/scheduled run from cancelling a push or PR run.
diff --git a/.github/workflows/scripts-macos.yml b/.github/workflows/scripts-macos.yml
index b0d3cb8f7f1..ef74c29c143 100644
--- a/.github/workflows/scripts-macos.yml
+++ b/.github/workflows/scripts-macos.yml
@@ -114,6 +114,16 @@ on:
# the ORM, the database layer or the HTTP parser cannot reach this
# transport.
- 'vm/backend/demo/cn1ss/**'
+env:
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
+
concurrency:
# Only the latest revision needs validation. PR numbers keep forks isolated;
# event names keep a manual/scheduled run from cancelling a push or PR run.
diff --git a/.github/workflows/windows-cross-build-run.yml b/.github/workflows/windows-cross-build-run.yml
index 368b4b483ec..e4f9923b97d 100644
--- a/.github/workflows/windows-cross-build-run.yml
+++ b/.github/workflows/windows-cross-build-run.yml
@@ -58,6 +58,16 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
+env:
+ # A method the translator culled as unreachable is still emitted as a stub,
+ # because dispatch tables name it. With this on, the stub aborts with the
+ # method's name instead of returning 0, so a wrong cull fails the suite that
+ # reached it rather than shipping a silent null. Ours to enforce, like
+ # CN1_NATIVE_VERIFY: customer builds keep the plain stub. See
+ # BytecodeMethod.appendMethodC. Not in the size or perf benchmarks, whose
+ # binaries should be the ones customers get.
+ CN1_CULL_TRAP: '1'
+
jobs:
cross-build:
name: cross-build suite exe (Linux, x64 + WebView2)
diff --git a/.gitignore b/.gitignore
index 2e669e77b79..edf21be70d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -197,5 +197,10 @@ cn1-build-hints.json
# every run; the parts under native-themes//*.css are the source.
native-themes/*/target/
+# Resolved from the port-status-data branch during the website build
+# (scripts/website/sync_port_status_reports.sh); never committed, because
+# there is no checked-in fallback for it.
+docs/website/data/port_status_flutter_benchmark.json
+
# Rendered from vm/selfhost/perf-baseline by scripts/website/build.sh; never committed.
docs/website/data/port_status_jdk25.json
diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/Android.java b/CodenameOne/src/com/codename1/annotations/buildhints/Android.java
index 086ae2dc24b..11bf532a421 100644
--- a/CodenameOne/src/com/codename1/annotations/buildhints/Android.java
+++ b/CodenameOne/src/com/codename1/annotations/buildhints/Android.java
@@ -132,14 +132,15 @@
int targetSDKVersion() default 0;
/// `auto`, `modern` / `material`, `hololight` (default for existing apps),
- /// `legacy`. `auto` and `modern` / `material` opt in to the CSS-generated
+ /// `legacy`, `custom`. `auto` and `modern` / `material` opt in to the CSS-generated
/// Android Material 3 theme from `native-themes/android-material/theme.css`.
/// `hololight` is Android Holo Light (what the framework shipped on API 14+
/// before this refactor). `legacy` loads the pre-Holo Android theme. The
/// legacy alias `cn1.androidTheme` is still accepted, and `and.hololight=true`
/// still maps to `hololight`. The default stays on `hololight` for existing
- /// apps until you flip in a future release.
- @Hint(name = "and.themeMode", valuePattern = "auto|modern|hololight|legacy")
+ /// apps until you flip in a future release. `custom` packages and installs no
+ /// Android theme at all, for an application whose own theme is the only one.
+ @Hint(name = "and.themeMode", valuePattern = "auto|modern|hololight|legacy|custom")
ThemeMode themeMode() default ThemeMode.DEFAULT;
/// Statements added to the top-level Gradle build file rather than the app
diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/Build.java b/CodenameOne/src/com/codename1/annotations/buildhints/Build.java
index 88eaa0c470b..09a893c780c 100644
--- a/CodenameOne/src/com/codename1/annotations/buildhints/Build.java
+++ b/CodenameOne/src/com/codename1/annotations/buildhints/Build.java
@@ -53,8 +53,11 @@
/// override that sets `ios.themeMode` and `and.themeMode` together when those
/// aren't set explicitly. `modern` = liquid glass + Material 3, `legacy` =
/// iOS 7 flat + Holo Light, `custom` disables the framework native theme
- /// entirely. The legacy alias `cn1.nativeTheme` is still accepted.
+ /// entirely: no platform theme is packaged or installed on any port, desktop
+ /// included, for an application whose own theme sets `includeNativeBool: false`.
+ /// The legacy alias `cn1.nativeTheme` is still accepted.
///
+ /// `native` and `custom` are the two values that reach the desktop too.
/// `native` is `modern` plus the desktop: it additionally selects the host's
/// own desktop theme -- Fluent, Aqua or Adwaita -- the way
/// `desktop.themeMode = auto` does. `modern` stops short of the desktop on
diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java b/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java
index acb104c5c67..7d960c2eb2f 100644
--- a/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java
+++ b/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java
@@ -173,14 +173,16 @@
@Hint(valuePattern = "26|27")
IosThemeGeneration themeGeneration() default IosThemeGeneration.DEFAULT;
- /// `auto` (default), `modern`, `ios7`, `legacy`. `auto` (unset) keeps the
+ /// `auto` (default), `modern`, `ios7`, `legacy`, `custom`. `auto` (unset) keeps the
/// existing iOS 7 flat theme so pre-refactor screenshot goldens and apps see
/// no behavior change. `modern` / `liquid` opts in to the CSS-generated iOS
/// Modern (liquid-glass) theme shipped from
/// `native-themes/ios-modern/theme.css`. `ios7` / `flat` is the same as `auto`
/// - pre-liquid iOS 7 flat theme; `legacy` / `iphone` loads the pre-iOS 7
/// iPhone theme. The `auto` -> modern flip is planned for a future release.
- @Hint(valuePattern = "auto|modern|ios7|legacy")
+ /// `custom` packages and installs no iOS theme at all, for an application
+ /// whose own theme is the only one.
+ @Hint(valuePattern = "auto|modern|ios7|legacy|custom")
ThemeMode themeMode() default ThemeMode.DEFAULT;
/// Allows intercepting a URL call using the syntax `urlPrefix`
diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/Mac.java b/CodenameOne/src/com/codename1/annotations/buildhints/Mac.java
index 1693829cae9..c1df6478747 100644
--- a/CodenameOne/src/com/codename1/annotations/buildhints/Mac.java
+++ b/CodenameOne/src/com/codename1/annotations/buildhints/Mac.java
@@ -201,7 +201,8 @@
/// is no such history here, and the legacy theme defines no dark styles at
/// all, so an application on it renders light however it asks for dark. The
/// cross-platform `nativeTheme` hint is honoured when this is unset, with
- /// `legacy` mapping to `ios7`.
+ /// `legacy` mapping to `ios7`. `custom` packages and installs no native theme
+ /// at all, for an application whose own theme is the only one.
String themeMode() default "";
/// macOS builds. Raw XML members added to the generated `Info.plist`, the
diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/ThemeMode.java b/CodenameOne/src/com/codename1/annotations/buildhints/ThemeMode.java
index d8e0560207e..42a1b0e0777 100644
--- a/CodenameOne/src/com/codename1/annotations/buildhints/ThemeMode.java
+++ b/CodenameOne/src/com/codename1/annotations/buildhints/ThemeMode.java
@@ -73,6 +73,14 @@ public enum ThemeMode {
LEGACY,
/// The theme the application ships, rather than a platform one.
+ ///
+ /// No native theme is packaged and none is installed, on every port: the
+ /// application's own theme is the only one it loads. Pair it with
+ /// `includeNativeBool: false` in the theme's CSS `#Constants`, which stops the
+ /// theme from asking for a native base in the first place. The hint is what
+ /// keeps the platform theme files out of the application, and what stops the
+ /// Linux and Windows ports from installing theirs at start-up whatever the
+ /// theme says.
@HintValue("custom")
CUSTOM;
}
diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
index ffec2d464df..e41b06fe921 100644
--- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
+++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
@@ -1174,6 +1174,57 @@ public void drawImageRounded(Object graphics, Object img, int x, int y, int w, i
drawImage(graphics, img, x, y, w, h);
}
+ /// Draws the region (u0, v0, du, dv) of an image -- normalised, 0,0 being its
+ /// top left and 1,1 its bottom right -- scaled into the destination, with the
+ /// DESTINATION's corners rounded. Only called when
+ /// `isRoundedImageDrawSupported()` answers true.
+ ///
+ /// The default draws the region square: the whole image placed so the region
+ /// lands on the destination, clipped to it. A port that rounds should override
+ /// it; one that does not never reaches it.
+ ///
+ /// #### Parameters
+ ///
+ /// - `graphics`: the graphics context
+ ///
+ /// - `img`: the image
+ ///
+ /// - `x`: destination x
+ ///
+ /// - `y`: destination y
+ ///
+ /// - `w`: destination width
+ ///
+ /// - `h`: destination height
+ ///
+ /// - `cornerRadius`: radius in destination pixels
+ ///
+ /// - `u0`: left of the region, normalised
+ ///
+ /// - `v0`: top of the region, normalised
+ ///
+ /// - `du`: width of the region, normalised
+ ///
+ /// - `dv`: height of the region, normalised
+ public void drawImageRegionRounded(Object graphics, Object img, int x, int y, int w, int h,
+ float cornerRadius, float u0, float v0, float du, float dv) {
+ if (du <= 0 || dv <= 0) {
+ return;
+ }
+ int fw = (int) Math.round(w / (double) du);
+ int fh = (int) Math.round(h / (double) dv);
+ int fx = x - (int) Math.round(u0 * (double) fw);
+ int fy = y - (int) Math.round(v0 * (double) fh);
+ int cx = getClipX(graphics);
+ int cy = getClipY(graphics);
+ int cw = getClipWidth(graphics);
+ int ch = getClipHeight(graphics);
+ clipRect(graphics, x, y, w, h);
+ drawImage(graphics, img, fx, fy, fw, fh);
+ setClip(graphics, cx, cy, cw, ch);
+ }
+
+
/// Returns the width of a native image
///
/// #### Parameters
@@ -5409,6 +5460,19 @@ public boolean downloadBytesAsFile(String fileName, byte[] bytes) {
///
/// #### Returns
///
+ /// The platform's own logical-pixel scale factor: device pixels per logical pixel,
+ /// the number iOS calls `UIScreen.scale` and Android calls `density`.
+ ///
+ /// This is NOT the same question as [#getDeviceDensity], even though the two are
+ /// easily confused. Density is a coarse DPI bucket used to pick artwork and to size
+ /// things in physical units. The scale factor is what the platform itself uses to
+ /// convert its own layout units into pixels, and on iOS it is only ever 1, 2 or 3 --
+ /// never the 3.5 that a 560-dpi bucket would imply. Anything laying out in
+ /// platform-logical units (a Flutter-style `dp`) has to ask this question, not the
+ /// density one, or it renders every dimension off by the ratio between them.
+ ///
+ /// #### Returns
+ ///
/// pixels per logical pixel, or 0 when the platform does not report one -- callers
/// should then fall back to deriving it from the density bucket
public float getDevicePixelRatio() {
diff --git a/CodenameOne/src/com/codename1/l10n/SimpleDateFormat.java b/CodenameOne/src/com/codename1/l10n/SimpleDateFormat.java
index 4ccc96e8958..4834b909f26 100644
--- a/CodenameOne/src/com/codename1/l10n/SimpleDateFormat.java
+++ b/CodenameOne/src/com/codename1/l10n/SimpleDateFormat.java
@@ -117,6 +117,8 @@ public class SimpleDateFormat extends DateFormat {
private String pattern;
/// The parsed pattern
private List patternTokens;
+ /// The zone fields are formatted in; null means the device's default zone.
+ private TimeZone timeZone;
/// Construct a SimpleDateFormat with no pattern.
public SimpleDateFormat() {
@@ -240,9 +242,33 @@ public int hashCode() {
public Object clone() {
SimpleDateFormat sdf = new SimpleDateFormat(pattern);
sdf.setDateFormatSymbols(dateFormatSymbols);
+ sdf.timeZone = timeZone;
return sdf;
}
+ /// Sets the time zone the date's fields are formatted in, as `java.text.DateFormat`
+ /// does. Without one the device's default zone is used.
+ ///
+ /// A formatter fixed to the device zone cannot print an instant's fields in any other:
+ /// a UTC time had to be moved onto a local one first, and inside a daylight-saving gap
+ /// that local time does not exist, so its hour came out shifted.
+ ///
+ /// #### Parameters
+ ///
+ /// - `zone`: the zone, or null for the device's default
+ public void setTimeZone(TimeZone zone) {
+ this.timeZone = zone;
+ }
+
+ /// The zone set with `setTimeZone`, or the device's default when none was.
+ ///
+ /// #### Returns
+ ///
+ /// the zone fields are formatted in
+ public TimeZone getTimeZone() {
+ return timeZone != null ? timeZone : TimeZone.getDefault();
+ }
+
/*
* (non-Javadoc)
*
@@ -272,8 +298,8 @@ String format(Date source, StringBuilder toAppendTo) {
if (pattern == null) {
return super.format(source, toAppendTo);
}
- // format based on local timezone
- Calendar calendar = Calendar.getInstance(TimeZone.getDefault());
+ // format in the configured zone, the device's by default
+ Calendar calendar = Calendar.getInstance(getTimeZone());
calendar.setTime(source);
List pattern = getPatternTokens();
for (String token : pattern) {
@@ -561,7 +587,9 @@ public Date parse(String source) throws ParseException {
}
}
- TimeZone localTimezone = Calendar.getInstance().getTimeZone();
+ // Text without a zone of its own is read in the formatter's zone, the one
+ // setTimeZone configured, so parse round-trips what format wrote.
+ TimeZone localTimezone = getTimeZone();
calendar.getTime(); // this seems to be necessary to calculate the time before changing the timzezone
calendar.setTimeZone(localTimezone);
if (pmMinutes != 0) {
@@ -669,7 +697,7 @@ int parseNumber(String source, int ofs, String name, int min, int max) throws Pa
///
/// - `source`
int getLocalDSTOffset(Calendar source) {
- TimeZone localTimezone = Calendar.getInstance().getTimeZone();
+ TimeZone localTimezone = getTimeZone();
int rawOffset = localTimezone.getRawOffset() / MILLIS_TO_MINUTES;
return getOffsetInMinutes(source, localTimezone) - rawOffset;
}
diff --git a/CodenameOne/src/com/codename1/location/GeofenceManager.java b/CodenameOne/src/com/codename1/location/GeofenceManager.java
index fb881b666d8..9ea9913840e 100644
--- a/CodenameOne/src/com/codename1/location/GeofenceManager.java
+++ b/CodenameOne/src/com/codename1/location/GeofenceManager.java
@@ -259,6 +259,8 @@ public synchronized Class extends GeofenceListener> getListenerClass() {
String className = (String) Storage.getInstance().readObject(LISTENER_CLASS_KEY);
if (className != null) {
try {
+ // ParparVM's ReachabilityCull (FOR_NAME_SITES) keeps only
+ // GeofenceListener implementations for this forName.
listenerClass = (Class) Class.forName(className);
} catch (Throwable t) {
Log.e(t);
diff --git a/CodenameOne/src/com/codename1/system/NativeLookup.java b/CodenameOne/src/com/codename1/system/NativeLookup.java
index a7fe39eeed8..4fc9eb24acb 100644
--- a/CodenameOne/src/com/codename1/system/NativeLookup.java
+++ b/CodenameOne/src/com/codename1/system/NativeLookup.java
@@ -80,7 +80,9 @@ public static T create(Class c) {
if (cls != null) {
return (T) cls.newInstance();
}
- // special case for JavaSE native interfaces
+ // special case for JavaSE native interfaces. ParparVM's ReachabilityCull
+ // (FOR_NAME_SITES) keeps only NativeInterface implementations for this
+ // forName; a name of any other type here must be added there.
return (T) Class.forName(c.getName() + "Impl").newInstance();
} catch (Throwable ex) {
if (verbose) {
diff --git a/CodenameOne/src/com/codename1/testing/DeviceRunner.java b/CodenameOne/src/com/codename1/testing/DeviceRunner.java
index 6a126fbe7e1..6eca3fa1f0b 100644
--- a/CodenameOne/src/com/codename1/testing/DeviceRunner.java
+++ b/CodenameOne/src/com/codename1/testing/DeviceRunner.java
@@ -107,6 +107,8 @@ public void runTests() {
@SuppressWarnings("BanClassForName")
public void runTest(String testClassName) {
try {
+ // ParparVM's ReachabilityCull (FOR_NAME_SITES) keeps only UnitTest
+ // implementations for this forName.
final UnitTest t = (UnitTest) Class.forName(testClassName).newInstance();
try {
TestReporting.getInstance().startingTestCase(t.getClass().getName());
diff --git a/CodenameOne/src/com/codename1/ui/Dialog.java b/CodenameOne/src/com/codename1/ui/Dialog.java
index ff8de3029ee..9fc864d2410 100644
--- a/CodenameOne/src/com/codename1/ui/Dialog.java
+++ b/CodenameOne/src/com/codename1/ui/Dialog.java
@@ -3898,6 +3898,16 @@ public boolean animate() {
}
private boolean isTimedOut() {
+ // Disposing is EDT work, and this is reached from off it: invokeAndBlock's
+ // waiting thread polls isDisposed() while a modal show blocks, and it used to
+ // dispose the dialog and deregister its animation from that thread, while the
+ // EDT was walking the same animation list -- an ArrayIndexOutOfBoundsException
+ // out of ArrayList.remove. Off the EDT the deadline is left alone; the EDT's own
+ // poll (animate) and the timeout clock, which calls back onto the EDT, dispose
+ // it, and the waiting thread sees it disposed on its next check.
+ if (!Display.getInstance().isEdt()) {
+ return false;
+ }
if (time != 0 && System.currentTimeMillis() >= time) {
time = 0;
cancelTimeoutClock();
diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java
index de239447359..b408c69a955 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -1363,6 +1363,31 @@ public boolean isInTransition() {
return false;
}
+ /// The form transition currently being painted, or null when there is none.
+ ///
+ /// The companion to [isInTransition()][#isInTransition()], for code that needs the
+ /// transition itself rather than the fact of one. A transition paints the frame
+ /// BETWEEN two forms, so neither form can be asked what is on screen while one is
+ /// running - painting the destination gives the finished state and painting the
+ /// source gives the state before it began. Handing back the transition lets a caller
+ /// paint the frame that is actually being shown, which is what capturing a transition
+ /// for comparison needs, and pairs with
+ /// [AnimationTime][com.codename1.ui.animations.AnimationTime] to step one frame by
+ /// frame.
+ ///
+ /// #### Returns
+ ///
+ /// the running transition, or null
+ public Transition getRunningTransition() {
+ if (animationQueue != null && !animationQueue.isEmpty()) {
+ Animation a = animationQueue.get(0);
+ if (a instanceof Transition) {
+ return (Transition) a;
+ }
+ }
+ return null;
+ }
+
// Seems to be a false positive on this rule
@SuppressWarnings({"PMD.SimplifyConditional", "PMD.AvoidBranchingStatementAsLastInLoop"})
private void paintTransitionAnimation() {
diff --git a/CodenameOne/src/com/codename1/ui/Graphics.java b/CodenameOne/src/com/codename1/ui/Graphics.java
index cd018150a10..40af8af3b6c 100644
--- a/CodenameOne/src/com/codename1/ui/Graphics.java
+++ b/CodenameOne/src/com/codename1/ui/Graphics.java
@@ -1030,6 +1030,57 @@ public void drawImageRounded(Image img, int x, int y, int w, int h, float corner
x + xTranslate, y + yTranslate, w, h, cornerRadius);
}
+ /// Draws part of an image, scaled into a destination rectangle, with the
+ /// destination's corners rounded -- without building a cropped or rounded copy
+ /// of the image, on platforms that support it.
+ ///
+ /// This is `drawImageRounded` for a picture scaled to overflow the box it
+ /// sits in, a "cover" fit: drawing the whole picture rounded would put the
+ /// corners outside the box, where a clip cuts them off and the box shows
+ /// square. Where rounding is not supported, the region is drawn square,
+ /// clipped to the destination.
+ ///
+ /// #### Parameters
+ ///
+ /// - `img`: the image to draw
+ /// - `srcX`: left of the region, in the image's pixels
+ /// - `srcY`: top of the region, in the image's pixels
+ /// - `srcW`: width of the region, in the image's pixels
+ /// - `srcH`: height of the region, in the image's pixels
+ /// - `x`: destination x
+ /// - `y`: destination y
+ /// - `w`: destination width
+ /// - `h`: destination height
+ /// - `cornerRadius`: radius in pixels, clamped to half the smaller side of
+ /// the destination
+ public void drawImageRegionRounded(Image img, int srcX, int srcY, int srcW, int srcH,
+ int x, int y, int w, int h, float cornerRadius) {
+ int iw = img.getWidth();
+ int ih = img.getHeight();
+ if (iw <= 0 || ih <= 0 || srcW <= 0 || srcH <= 0 || w <= 0 || h <= 0) {
+ return;
+ }
+ float u0 = srcX / (float) iw;
+ float v0 = srcY / (float) ih;
+ float du = srcW / (float) iw;
+ float dv = srcH / (float) ih;
+ if (isRoundedImageSupported(img)) {
+ impl.drawImageRegionRounded(nativeGraphics, img.roundedDrawPeer(),
+ x + xTranslate, y + yTranslate, w, h, cornerRadius, u0, v0, du, dv);
+ return;
+ }
+ // Square: the whole image placed so the region lands on the destination,
+ // clipped to the destination.
+ int fw = (int) Math.round(w / (double) du);
+ int fh = (int) Math.round(h / (double) dv);
+ int fx = x - (int) Math.round(u0 * (double) fw);
+ int fy = y - (int) Math.round(v0 * (double) fh);
+ int[] clip = getClip();
+ clipRect(x, y, w, h);
+ drawImage(img, fx, fy, fw, fh);
+ setClip(clip[0], clip[1], clip[2], clip[3]);
+ }
+
public void drawImage(Image img, int x, int y, int w, int h) {
if (impl.isScaledImageDrawingSupported()) {
img.drawImage(this, nativeGraphics, x, y, w, h);
diff --git a/CodenameOne/src/com/codename1/ui/animations/CommonTransitions.java b/CodenameOne/src/com/codename1/ui/animations/CommonTransitions.java
index a5deb4110bd..20506b74d7a 100644
--- a/CodenameOne/src/com/codename1/ui/animations/CommonTransitions.java
+++ b/CodenameOne/src/com/codename1/ui/animations/CommonTransitions.java
@@ -1439,6 +1439,15 @@ public Transition copy(boolean reverse) {
break;
}
retVal.linearMotion = linearMotion;
+ // A motion the caller supplied has to survive the copy. Display copies a
+ // transition before running it, so everything set through setMotion() -- which is
+ // public API, documented as the way to give a transition "a more appropriate
+ // physical feel" -- was dropped on the way to the screen and the default ease ran
+ // instead. Silently: the transition still played, just not the one that was asked
+ // for.
+ retVal.motionSetManually = motionSetManually;
+ retVal.motion = motion;
+ retVal.lazyMotion = lazyMotion;
return retVal;
}
diff --git a/CodenameOne/src/com/codename1/ui/animations/ContainerTransformTransition.java b/CodenameOne/src/com/codename1/ui/animations/ContainerTransformTransition.java
new file mode 100644
index 00000000000..f8ca04c3d7a
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/animations/ContainerTransformTransition.java
@@ -0,0 +1,561 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.ui.animations;
+
+import com.codename1.ui.Component;
+import com.codename1.ui.Container;
+import com.codename1.ui.Form;
+import com.codename1.ui.Graphics;
+import com.codename1.ui.Image;
+import com.codename1.ui.geom.GeneralPath;
+
+/// A transition in which one component GROWS into the whole of the next form, the way
+/// Material's container transform does: a card or a button becomes the page it opens.
+///
+/// The difference from [MorphTransition][MorphTransition] is what is being animated.
+/// A morph moves a component from where it is in one form to where the same component is
+/// in the other, so it needs a counterpart on both sides and it animates a COMPONENT.
+/// This animates a SURFACE: a rounded rectangle travels from the tapped component's
+/// bounds out to the full form, its corners straightening as it goes, and the two
+/// contents cross-fade inside it -- the thing that was tapped fading out while the page
+/// fades in. Nothing needs to exist on both sides, which is the usual case: a button does
+/// not reappear on the page it opened.
+///
+/// Geometry follows a fast-out-slow-in curve and the cross-fade is deliberately not
+/// symmetric: the outgoing content is gone by the time the incoming content begins,
+/// so the two are never both half visible, which reads as a dissolve rather than a
+/// transformation.
+///
+/// Use it where a tap on something becomes a screen:
+///
+/// ```java
+/// tappedCard.setName("card");
+/// nextForm.setTransitionInAnimator(
+/// ContainerTransformTransition.create("card", 300));
+/// nextForm.show();
+/// ```
+public final class ContainerTransformTransition extends Transition {
+
+ /// Material's container transform curve: fast out, slow in.
+ private static final float CP0 = 0.4f;
+ private static final float CP1 = 0.0f;
+ private static final float CP2 = 0.2f;
+ private static final float CP3 = 1.0f;
+
+
+ /// Material states this transform's colour and opacity changes in fifths of the run.
+ private static final float FIFTH = 0.2f;
+
+ /// Material's scrim over the page being left: black at 54% opacity.
+ private static final int SCRIM_ALPHA = 138;
+
+ private static final int SCALE = 1000;
+
+ private final String componentName;
+ private final int duration;
+
+ private Motion motion;
+ private int progress;
+ private Image sourceBuffer;
+ private Image destBuffer;
+ /// The tapped component on its own, so it can fade out inside the growing surface.
+ private Image originBuffer;
+ private int startX;
+ private int startY;
+ private int startW;
+ private int startH;
+ private int startRadius;
+ private int surfaceColor;
+ private int openColor;
+ private GeneralPath path;
+
+ /// Whether this instance is the CLOSE half, which runs on the mirrored curve.
+ private boolean closing;
+
+ /// Whether the thing being grown out of was round, which changes the SHAPE of the
+ /// travelling surface for the whole run -- see the squash in paint.
+ private boolean originRound;
+
+ private ContainerTransformTransition(String componentName, int duration) {
+ this.componentName = componentName;
+ this.duration = duration;
+ }
+
+ /// Creates a transition that grows the named component into the next form.
+ ///
+ /// #### Parameters
+ ///
+ /// - `componentName`: the [Component#setName(String)][Component#setName(String)] of the
+ /// component in the OUTGOING form that the next form grows out of. When no component
+ /// carries that name the transition still runs, growing from the centre of the screen.
+ ///
+ /// - `duration`: the duration in milliseconds
+ ///
+ /// #### Returns
+ ///
+ /// the transition
+ public static ContainerTransformTransition create(String componentName, int duration) {
+ return new ContainerTransformTransition(componentName, duration);
+ }
+
+ private static Component findByName(Container root, String name) {
+ int count = root.getComponentCount();
+ for (int iter = 0; iter < count; iter++) {
+ Component c = root.getComponentAt(iter);
+ String n = c.getName();
+ if (n != null && n.equals(name)) {
+ return c;
+ }
+ if (c instanceof Container) {
+ Component child = findByName((Container) c, name);
+ if (child != null) {
+ return child;
+ }
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public void initTransition() {
+ Component source = getSource();
+ Component destination = getDestination();
+ // Cleared first: a transition object can be reused, and an early return must not
+ // leave the previous run's motion to be replayed.
+ motion = null;
+ // The Transition contract allows no source -- the first Form shown has nothing to
+ // transition from -- and dereferencing it here made that first show throw. With
+ // either side missing there is nothing to transform between, so no animation.
+ if (source == null || destination == null) {
+ return;
+ }
+ int w = destination.getWidth();
+ int h = destination.getHeight();
+ if (w <= 0 || h <= 0) {
+ return;
+ }
+ // The SAME curve both ways. Closing is the open progress run backwards (see
+ // paint), and 1 - curve(elapsed) is already the mirrored easing -- selecting a
+ // mirrored curve here as well would mirror it twice and give
+ // curve(1 - elapsed), which is a different motion.
+ motion = Motion.createCubicBezierMotion(0, SCALE, duration, CP0, CP1, CP2, CP3);
+ motion.start();
+ progress = 0;
+
+ sourceBuffer = Image.createImage(source.getWidth(), source.getHeight());
+ source.paintComponent(sourceBuffer.getGraphics(), true);
+ destBuffer = Image.createImage(w, h);
+ destination.paintComponent(destBuffer.getGraphics(), true);
+
+ // The thing the surface grows out of lives on whichever page is NOT the one
+ // travelling. Opening, that is the page being left; CLOSING, it is the page being
+ // returned to -- so looking on the source form either way found nothing on the
+ // way back, and the transform fell through to its "no origin" guess and played
+ // the opening animation out of the middle of the screen. Going back looked
+ // nothing like the way in.
+ Component anchorOn = closing ? destination : source;
+ Form anchorForm = anchorOn.getComponentForm();
+ Component origin = anchorForm == null || componentName == null
+ ? null : findByName(anchorForm, componentName);
+ if (origin == null) {
+ // Nothing to grow from. The middle of the screen is a poor guess but it is a
+ // transition rather than nothing at all, and the caller still gets the fade.
+ startW = Math.max(1, w / 8);
+ startH = startW;
+ startX = (w - startW) / 2;
+ startY = (h - startH) / 2;
+ startRadius = startW / 2;
+ surfaceColor = openPage().getStyle().getBgColor();
+ openColor = surfaceColor;
+ } else {
+ startX = origin.getAbsoluteX();
+ startY = origin.getAbsoluteY();
+ startW = Math.max(1, origin.getWidth());
+ startH = Math.max(1, origin.getHeight());
+ // A round thing stays round while it grows; anything else keeps its corners.
+ startRadius = Math.min(startW, startH) / 2;
+ // Square to within a pixel or two IS the test for round: a circular button is
+ // the only thing that can have been one, and the squash below would be wrong
+ // for a card.
+ originRound = Math.abs(startW - startH) <= Math.max(2, startW / 16);
+ surfaceColor = origin.getStyle().getBgColor();
+ openColor = openPage().getStyle().getBgColor();
+ // Cut out of the PAGE's snapshot, not painted from the component.
+ //
+ // The named component is the tapped surface itself, and what is drawn on top
+ // of it -- a glyph, a label, a whole row -- can be a separate component beside
+ // it rather than a child of it. Painting the component alone therefore gave a
+ // bare capsule: the compose button's pencil was simply absent from the card
+ // for the whole transform, where the reference carries it the entire way.
+ // The page has already been photographed a few lines above, and in that
+ // photograph the button is whole.
+ Image anchorShot = closing ? destBuffer : sourceBuffer;
+ originBuffer = Image.createImage(startW, startH, 0);
+ originBuffer.getGraphics().drawImage(anchorShot, -startX, -startY);
+ // The commonest colour in it, not the middle pixel: the middle of a button is
+ // usually its glyph, and taking that made the growing surface the colour of
+ // the icon instead of the colour of the button.
+ surfaceColor = dominantColor(originBuffer, origin.getStyle().getBgColor());
+ // ...and then keep only what was drawn ON the button.
+ //
+ // A cut-out of the page brings the page with it: the compose button sits in
+ // the notch of the bottom bar, so its rectangle is mostly dark bar, and scaled
+ // four times into the card that read as a black frame around the glyph. What
+ // belongs to the button is what lies inside its outline and is not its own
+ // colour -- which is exactly its content, and the card is already painting the
+ // colour underneath it.
+ originBuffer = maskToContent(originBuffer, startW, startH, surfaceColor, originRound);
+ }
+ }
+
+ /// The page that TRAVELS: the one growing out of the origin, or shrinking back into
+ /// it. Opening it is the destination; closing it is the source.
+ private Component openPage() {
+ return closing ? getSource() : getDestination();
+ }
+
+ /// The snapshot of the page that travels.
+ private Image openBuffer() {
+ return closing ? sourceBuffer : destBuffer;
+ }
+
+ /// The snapshot of the page that stays put underneath.
+ private Image staticBuffer() {
+ return closing ? destBuffer : sourceBuffer;
+ }
+
+ @Override
+ public boolean animate() {
+ if (motion == null) {
+ return false;
+ }
+ progress = motion.getValue();
+ return !motion.isFinished();
+ }
+
+ @Override
+ public void paint(Graphics g) {
+ if (motion == null || openBuffer() == null) {
+ return;
+ }
+ // Geometry follows the curve; everything else does not. Material drives the
+ // rectangle off a fast-out-slow-in animation and the colours and opacities off
+ // the RAW one, in fifths: the page behind dims over the first fifth, then the
+ // surface colour and the incoming content cross over during the second, and the
+ // rest of the run is the page settling into place.
+ //
+ // Both are OPEN progress -- 0 is folded into the origin, 1 is the full page --
+ // and closing runs them backwards. Everything below is written once, for the way
+ // in, and the way out is the same transform played in reverse: the rectangle
+ // shrinks back into what was tapped, the scrim lifts, and the contents cross
+ // over the other way. Without this the close ran the OPENING animation, so a
+ // page folded away by growing out of its button a second time.
+ float t = ((float) progress) / SCALE;
+ float linear = motion.getDuration() <= 0 ? 1f
+ : Math.min(1f, ((float) motion.getCurrentMotionTime()) / motion.getDuration());
+ // Only the GEOMETRY reverses. The fifths that govern the colours and the two
+ // contents are measured from the start of whichever run is playing, so closing
+ // crosses them over at the same point in its own run rather than at the mirrored
+ // point -- it just crosses them the other way round, which is the swap below.
+ if (closing) {
+ t = 1f - t;
+ }
+ float cross = crossover(linear);
+ Component dest = getDestination();
+ int fullW = dest.getWidth();
+ int fullH = dest.getHeight();
+
+ // What we came from, unchanged and underneath: the page being left does not move
+ // in a container transform, it is covered.
+ if (staticBuffer() != null) {
+ g.drawImage(staticBuffer(), 0, 0);
+ }
+ // ...and dimmed. Without the scrim the whole background stays at full brightness
+ // through the transition, which is most of the screen disagreeing with the
+ // reference for most of the run -- far more pixels than the surface itself.
+ // Off the CURVED progress, not the raw clock -- unlike the opacities and the
+ // surface colour below, which Material does drive off the raw one. Getting this
+ // one wrong is not a subtle shading difference: the scrim covers the whole
+ // screen, so while it is ramping, every pixel is at the wrong brightness. It
+ // cost a single frame 83% wrong pixels against the reference, between two
+ // neighbours at 5% and 13%, because the raw clock reaches full dim more than
+ // twice as fast as the curve does.
+ //
+ // Measured at the 50ms frame of a 300ms run, mean luma over the screen:
+ // raw predicts 116.6 and we rendered 117.3; the curve predicts 163.4 and the
+ // reference rendered 163.2.
+ // Opening, the scrim arrives over the first fifth and then stands. Closing, it
+ // does NOT mirror that: it lifts smoothly across the whole run, in proportion to
+ // how much of the transform is left. Mirroring the fifths instead held it at
+ // full black over the middle of the run and then dropped it in one step -- the
+ // page behind stayed dark almost until the surface had gone, where the reference
+ // has it brightening the whole way.
+ int scrim = closing
+ ? (int) (SCRIM_ALPHA * t)
+ : (int) (SCRIM_ALPHA * Math.min(1f, t / FIFTH));
+ if (scrim > 0) {
+ int old = g.getAlpha();
+ g.setAlpha(scrim);
+ g.setColor(0);
+ g.fillRect(0, 0, fullW, fullH);
+ g.setAlpha(old);
+ }
+
+ int x = lerp(startX, 0, t);
+ int y = lerp(startY, 0, t);
+ int w = lerp(startW, fullW, t);
+ int h = lerp(startH, fullH, t);
+
+ // The SHAPE is not the rectangle. Material lerps the tapped thing's outline into
+ // the page's, and a circle squashes the rectangle toward a square about its centre
+ // as it goes -- that is what keeps a round button looking round instead of
+ // stretching into a lozenge the instant it starts to grow.
+ //
+ // It is most of the geometry, not a rounding detail. Measured against the
+ // reference at the middle of the close, the rectangle is 685 x 1392 and the
+ // painted surface 685 x 1066: the same box and the same centre, 326 pixels
+ // shorter. Painting the rectangle itself put a third of the card's height in the
+ // wrong place for the whole run.
+ float circularity = originRound ? 1f - t : 0f;
+ int px = x;
+ int py = y;
+ int pw = w;
+ int ph = h;
+ if (circularity > 0) {
+ if (w < h) {
+ int d = (int) (circularity * (h - w) / 2f);
+ py += d;
+ ph -= 2 * d;
+ } else {
+ int d = (int) (circularity * (w - h) / 2f);
+ px += d;
+ pw -= 2 * d;
+ }
+ }
+ // Off the UNADJUSTED box, as Flutter's _adjustBorderRadius is: the radius that
+ // makes the squashed box a circle is half the short side of the box it came from.
+ int radius = originRound
+ ? (int) (circularity * Math.min(w, h) / 2f)
+ : lerp(startRadius, 0, t);
+
+ int[] clip = g.getClip();
+ if (radius > 0 && g.isShapeClipSupported()) {
+ g.setClip(roundRect(px, py, pw, ph, radius));
+ } else {
+ g.setClip(px, py, pw, ph);
+ }
+
+ // The surface holds the tapped thing's colour for the first fifth, crosses to the
+ // page's over the second, and is the page's thereafter.
+ // Opening runs the tapped thing's colour to the page's; closing runs it back.
+ g.setColor(closing ? blend(openColor, surfaceColor, cross)
+ : blend(surfaceColor, openColor, cross));
+ g.fillRect(px, py, pw, ph);
+
+ // Both contents are drawn at their OWN size scaled to the box's WIDTH, anchored
+ // at its top-left corner.
+ //
+ // This is the part that makes it a transform rather than a window. Drawn at 1:1
+ // and clipped, the page inside a half-sized box is the page's top-left QUARTER,
+ // so folding the compose page away showed a crop of its header sliding about
+ // while the reference shows the whole page shrinking into the button. Width, not
+ // height: the aspect ratios of a button and a page have nothing to do with each
+ // other, and fitting the width is what keeps the text at the size the box implies.
+ //
+ // The tapped thing stays FULLY OPAQUE the whole way and is simply covered as the
+ // page arrives over it. That is what the fade variant of the transform does -- its
+ // closed content has a constant opacity of 1 and only the page's opacity moves --
+ // and it is the visible difference between a button that becomes the page and two
+ // pictures dissolving into each other. Measured on the reference at the middle of
+ // the close, the pencil inside the shrinking card is pure black, not a tint.
+ if (originBuffer != null) {
+ drawFittedToWidth(g, originBuffer, x, y, w);
+ }
+
+ float open = closing ? 1f - cross : cross;
+ if (open > 0) {
+ int old = g.getAlpha();
+ g.setAlpha((int) (255 * open));
+ drawFittedToWidth(g, openBuffer(), x, y, w);
+ g.setAlpha(old);
+ }
+ g.setClip(clip[0], clip[1], clip[2], clip[3]);
+ }
+
+ /// Draws an image scaled so its WIDTH is {@code w}, anchored at {@code x, y}, with
+ /// its aspect ratio kept. The caller's clip decides how much of it is seen.
+ private static void drawFittedToWidth(Graphics g, Image img, int x, int y, int w) {
+ if (img == null || img.getWidth() <= 0) {
+ return;
+ }
+ int h = Math.max(1, (int) ((long) img.getHeight() * w / img.getWidth()));
+ g.drawImage(img, x, y, Math.max(1, w), h);
+ }
+
+ /// 0 before the second fifth, 1 after it, and the crossing in between.
+ private static float crossover(float linear) {
+ if (linear <= FIFTH) {
+ return 0f;
+ }
+ if (linear >= FIFTH * 2) {
+ return 1f;
+ }
+ return (linear - FIFTH) / FIFTH;
+ }
+
+ private GeneralPath roundRect(int x, int y, int w, int h, int r) {
+ if (path == null) {
+ path = new GeneralPath();
+ }
+ path.reset();
+ int rad = Math.min(r, Math.min(w, h) / 2);
+ // CLOCKWISE, explicitly. GeneralPath.arcTo defaults to counter-clockwise, and the
+ // rectangle below is walked clockwise, so every corner took the long way round --
+ // a 270 degree sweep that bulges back into the box instead of a 90 degree one.
+ // With a small radius that reads as a slightly soft corner; with a large one the
+ // shape is unrecognisable, and this transition's corners reach half the short side
+ // at the start of the run.
+ path.moveTo(x + rad, y);
+ path.lineTo(x + w - rad, y);
+ path.arcTo(x + w - rad, y + rad, x + w, y + rad, true);
+ path.lineTo(x + w, y + h - rad);
+ path.arcTo(x + w - rad, y + h - rad, x + w - rad, y + h, true);
+ path.lineTo(x + rad, y + h);
+ path.arcTo(x + rad, y + h - rad, x, y + h - rad, true);
+ path.lineTo(x, y + rad);
+ path.arcTo(x + rad, y + rad, x + rad, y, true);
+ path.closePath();
+ return path;
+ }
+
+ private static int lerp(int from, int to, float t) {
+ return from + (int) ((to - from) * t);
+ }
+
+ /// Clears everything outside the tapped thing's outline, and everything inside it that
+ /// is the thing's own colour, leaving its content on transparency.
+ private static Image maskToContent(Image img, int w, int h, int surface, boolean round) {
+ try {
+ int[] px = img.getRGB();
+ int cx = w / 2;
+ int cy = h / 2;
+ // Inside the outline by a few pixels. The edge of a round button is
+ // anti-aliased against whatever is behind it, so the outermost ring is neither
+ // the button's colour nor its content -- and magnified four times it drew a
+ // pale arc across the card that belongs to nothing.
+ int rad = Math.min(w, h) / 2;
+ rad -= Math.max(2, rad / 12);
+ int radSq = rad * rad;
+ int sr = (surface >> 16) & 0xff;
+ int sg = (surface >> 8) & 0xff;
+ int sb = surface & 0xff;
+ for (int y = 0; y < h; y++) {
+ for (int x = 0; x < w; x++) {
+ int i = y * w + x;
+ if (round) {
+ int dx = x - cx;
+ int dy = y - cy;
+ if (dx * dx + dy * dy > radSq) {
+ px[i] = 0;
+ continue;
+ }
+ }
+ int p = px[i];
+ if (Math.abs(((p >> 16) & 0xff) - sr) <= TOLERANCE
+ && Math.abs(((p >> 8) & 0xff) - sg) <= TOLERANCE
+ && Math.abs((p & 0xff) - sb) <= TOLERANCE) {
+ px[i] = 0;
+ }
+ }
+ }
+ return Image.createImage(px, w, h);
+ } catch (Throwable t) {
+ return img;
+ }
+ }
+
+ /// How close to the surface colour counts as the surface rather than its content.
+ private static final int TOLERANCE = 24;
+
+ /// The commonest opaque colour in a snapshot, which is the surface colour of whatever
+ /// was tapped however it came to be painted. Falls back to {@code fallback} when the
+ /// snapshot is empty or unreadable.
+ private static int dominantColor(Image img, int fallback) {
+ try {
+ int[] rgb = img.getRGB();
+ java.util.HashMap counts = new java.util.HashMap();
+ int best = fallback;
+ int bestN = 0;
+ for (int pixel : rgb) {
+ if (((pixel >>> 24) & 0xff) < 128) {
+ continue;
+ }
+ Integer key = Integer.valueOf(pixel & 0xffffff);
+ Object prev = counts.get(key);
+ // instanceof rather than a bare cast inside this try: ParparVM's
+ // CHECKCAST is unchecked, so a failed cast does not throw on iOS
+ // and the catch below would never see it -- the wrong object
+ // would simply be read as an Integer.
+ int n = prev instanceof Integer ? ((Integer) prev).intValue() + 1 : 1;
+ counts.put(key, Integer.valueOf(n));
+ if (n > bestN) {
+ bestN = n;
+ best = key.intValue();
+ }
+ }
+ return bestN == 0 ? fallback : best;
+ } catch (Throwable t) {
+ return fallback;
+ }
+ }
+
+ /// Mixes two packed RGB colours, channel by channel.
+ private static int blend(int from, int to, float t) {
+ int r = lerp((from >> 16) & 0xff, (to >> 16) & 0xff, t);
+ int g = lerp((from >> 8) & 0xff, (to >> 8) & 0xff, t);
+ int b = lerp(from & 0xff, to & 0xff, t);
+ return (r << 16) | (g << 8) | b;
+ }
+
+ @Override
+ public void cleanup() {
+ // The base releases source and destination -- both forms and their whole
+ // component trees, which a caller holding this transition (from
+ // Display.getRunningTransition) otherwise kept reachable after it ended.
+ super.cleanup();
+ sourceBuffer = null;
+ destBuffer = null;
+ originBuffer = null;
+ motion = null;
+ path = null;
+ }
+
+ @Override
+ public Transition copy(boolean reverse) {
+ ContainerTransformTransition t =
+ new ContainerTransformTransition(componentName, duration);
+ t.closing = reverse;
+ return t;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/animations/CupertinoPageTransition.java b/CodenameOne/src/com/codename1/ui/animations/CupertinoPageTransition.java
new file mode 100644
index 00000000000..f37e46e97d4
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/animations/CupertinoPageTransition.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+
+package com.codename1.ui.animations;
+
+import com.codename1.ui.Component;
+import com.codename1.ui.Graphics;
+import com.codename1.ui.Image;
+
+/// The iOS page push: two pages moving at different speeds, over different distances.
+///
+/// A plain slide holds the two pages a fixed screen apart and moves the pair, which makes
+/// them one rigid object. This transition is the reason iOS depth reads the way it does:
+/// the arriving page crosses the WHOLE screen while the page it covers drifts only a
+/// THIRD of it, and each rides its own curve. The gap between them closes as they travel,
+/// which is what says one is in front of the other.
+///
+/// Sliding both the full distance is not a subtle difference. A third of the way through
+/// a push the strip of the old page still showing is not dimmer or shifted -- it is a
+/// different PART of that page: measured against the reference at 150ms of a 500ms push
+/// on a 1125px screen, the old page belongs 270px to the left and ours had it 855px to
+/// the left, so the visible strip showed its far edge where the reference shows its
+/// middle.
+public final class CupertinoPageTransition extends Transition {
+
+ /// The arriving page's curve: fast ease in to slow ease out, a three-point cubic.
+ private static final float[] ARRIVING = {
+ 0.056f, 0.024f, 0.108f, 0.3085f,
+ 0.198f, 0.541f,
+ 0.3655f, 1.0f, 0.5465f, 0.989f,
+ };
+
+ /// The departing page's curve: linear to ease out. A DIFFERENT curve, which is half
+ /// of why the two do not move as one piece.
+ private static final float[] DEPARTING = {0.35f, 0.91f, 0.33f, 0.97f};
+
+ /// How far the covered page travels, as a fraction of the screen. The other half of
+ /// why they do not move as one piece.
+ private static final int PARALLAX_DENOMINATOR = 3;
+
+ private static final int SCALE = 1000;
+
+ private final int duration;
+ private boolean back;
+
+ private Motion arriving;
+ private Motion departing;
+ private Image sourceBuffer;
+ private Image destBuffer;
+
+ private CupertinoPageTransition(int duration) {
+ this.duration = duration;
+ }
+
+ /// Creates the transition.
+ ///
+ /// #### Parameters
+ ///
+ /// - `duration`: the push duration in milliseconds
+ ///
+ /// #### Returns
+ ///
+ /// the transition
+ public static CupertinoPageTransition create(int duration) {
+ return new CupertinoPageTransition(duration);
+ }
+
+ /// The push duration in milliseconds.
+ ///
+ /// #### Returns
+ ///
+ /// the duration this transition was created with
+ public int getDuration() {
+ return duration;
+ }
+
+ /// Whether this instance plays the way BACK, with the roles of the two pages swapped.
+ ///
+ /// #### Returns
+ ///
+ /// true if this is the pop half of the transition
+ public boolean isBack() {
+ return back;
+ }
+
+ @Override
+ public void initTransition() {
+ Component source = getSource();
+ Component destination = getDestination();
+ if (source == null || destination == null) {
+ return;
+ }
+ int w = destination.getWidth();
+ int h = destination.getHeight();
+ if (w <= 0 || h <= 0) {
+ return;
+ }
+ arriving = Motion.createThreePointCubicMotion(0, SCALE, duration,
+ ARRIVING[0], ARRIVING[1], ARRIVING[2], ARRIVING[3], ARRIVING[4],
+ ARRIVING[5], ARRIVING[6], ARRIVING[7], ARRIVING[8], ARRIVING[9]);
+ departing = Motion.createCubicBezierMotion(0, SCALE, duration,
+ DEPARTING[0], DEPARTING[1], DEPARTING[2], DEPARTING[3]);
+ arriving.start();
+ departing.start();
+
+ sourceBuffer = Image.createImage(source.getWidth(), source.getHeight());
+ source.paintComponent(sourceBuffer.getGraphics(), true);
+ destBuffer = Image.createImage(w, h);
+ destination.paintComponent(destBuffer.getGraphics(), true);
+ }
+
+ @Override
+ public boolean animate() {
+ // Both, though they are set and cleared together: a reader (and a static analyser)
+ // should not have to know that to see this is safe.
+ if (arriving == null || departing == null) {
+ return false;
+ }
+ departing.getValue();
+ return !arriving.isFinished();
+ }
+
+ @Override
+ public void paint(Graphics g) {
+ Component destination = getDestination();
+ if (arriving == null || departing == null || destination == null) {
+ return;
+ }
+ int w = destination.getWidth();
+ float front = arriving.getValue() / (float) SCALE;
+ float behind = departing.getValue() / (float) SCALE;
+ int parallax = w / PARALLAX_DENOMINATOR;
+
+ int sourceX;
+ int destX;
+ if (back) {
+ // Going back: the page on top leaves across the whole screen, and the one
+ // underneath comes home from the third of the way out it was left at.
+ sourceX = Math.round(w * front);
+ destX = -Math.round(parallax * (1 - behind));
+ } else {
+ sourceX = -Math.round(parallax * behind);
+ destX = Math.round(w * (1 - front));
+ }
+
+ // The covered page first: the arriving one is opaque and passes over it.
+ if (sourceBuffer != null) {
+ g.drawImage(sourceBuffer, sourceX, 0);
+ }
+ if (destBuffer != null) {
+ g.drawImage(destBuffer, destX, 0);
+ }
+ }
+
+ @Override
+ public void cleanup() {
+ super.cleanup();
+ sourceBuffer = null;
+ destBuffer = null;
+ arriving = null;
+ departing = null;
+ }
+
+ @Override
+ public Transition copy(boolean reverse) {
+ CupertinoPageTransition t = new CupertinoPageTransition(duration);
+ t.back = reverse;
+ return t;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/animations/Motion.java b/CodenameOne/src/com/codename1/ui/animations/Motion.java
index d05fb54eaab..a4b6c3cc828 100644
--- a/CodenameOne/src/com/codename1/ui/animations/Motion.java
+++ b/CodenameOne/src/com/codename1/ui/animations/Motion.java
@@ -46,6 +46,7 @@ public class Motion {
private static final int COLOR_LINEAR = 5;
private static final int EXPONENTIAL_DECAY = 6;
private static final int CRITICAL_DAMPED_SPRING = 7;
+ private static final int THREE_POINT_CUBIC = 8;
private static boolean slowMotion;
private final int[] previousLastReturnedValue = new int[3];
private final long[] previousLastReturnedValueTime = new long[3];
@@ -60,6 +61,14 @@ public class Motion {
private int lastReturnedValue;
private long currentMotionTime = -1;
private long previousCurrentMotionTime = -1;
+ /// The joint and the second segment's control points of a three-point cubic.
+ private float midX;
+ private float midY;
+ private float q0;
+ private float q1;
+ private float q2;
+ private float q3;
+
private float p0;
private float p1;
private float p2;
@@ -163,6 +172,53 @@ public static Motion createCubicBezierMotion(int sourceValue, int destinationVal
return m;
}
+ /// A curve made of TWO cubic beziers joined at a point, which a single cubic cannot
+ /// express.
+ ///
+ /// A plain `cubic-bezier` is monotonic in a way some motion is not: it cannot
+ /// accelerate hard, ease, and then ease out again, because it has only two control
+ /// points to spend. Curves that do this are specified as a pair of beziers meeting at
+ /// a midpoint, each with its own controls, and the joint is where the character of
+ /// the motion changes.
+ ///
+ /// The segments are evaluated in their own normalized space and rescaled, so each
+ /// half is an ordinary CSS cubic-bezier and the two meet exactly at the midpoint.
+ ///
+ /// #### Parameters
+ ///
+ /// - `sourceValue`: the initial value
+ ///
+ /// - `destinationValue`: the value at the end of the motion
+ ///
+ /// - `duration`: the motion duration in milliseconds
+ ///
+ /// - `a1X`, `a1Y`, `b1X`, `b1Y`: control points of the first segment
+ ///
+ /// - `midX`, `midY`: the point the two segments meet at
+ ///
+ /// - `a2X`, `a2Y`, `b2X`, `b2Y`: control points of the second segment
+ ///
+ /// #### Returns
+ ///
+ /// Motion instance
+ public static Motion createThreePointCubicMotion(int sourceValue, int destinationValue,
+ int duration, float a1X, float a1Y, float b1X, float b1Y,
+ float midX, float midY, float a2X, float a2Y, float b2X, float b2Y) {
+ Motion m = new Motion(sourceValue, destinationValue, duration);
+ m.motionType = THREE_POINT_CUBIC;
+ m.p0 = a1X;
+ m.p1 = a1Y;
+ m.p2 = b1X;
+ m.p3 = b1Y;
+ m.midX = midX;
+ m.midY = midY;
+ m.q0 = a2X;
+ m.q1 = a2Y;
+ m.q2 = b2X;
+ m.q3 = b2Y;
+ return m;
+ }
+
/// Equivalent to createCubicBezierMotion with 0, 0.42, 0.58, 1.0 as arguments.
///
/// #### Parameters
@@ -483,6 +539,73 @@ private int getSplineValue() {
return x;
}
+ private int getThreePointCubicValue() {
+ if (isFinished()) {
+ return destinationValue;
+ }
+ float totalTime = duration;
+ float currentTime = Math.min((int) getCurrentMotionTime(), (int) totalTime);
+ if (currentTime < 0f) {
+ currentTime = 0f;
+ }
+ float t = currentTime / totalTime;
+
+ // Each segment is solved in its OWN normalized space: the controls are expressed
+ // relative to the segment's start and divided by its extent, so the solver below
+ // sees an ordinary cubic-bezier from (0,0) to (1,1). The result is then scaled
+ // back, which is what makes the two halves meet exactly at the midpoint instead
+ // of stepping there.
+ boolean first = t < midX;
+ float scaleX = first ? midX : 1f - midX;
+ float scaleY = first ? midY : 1f - midY;
+ float value;
+ if (scaleX <= 0f) {
+ // A segment with no duration is only ever reached at its end.
+ value = first ? midY : 1f;
+ } else if (scaleY <= 0f) {
+ // No vertical extent -- the midpoint lies on the top or bottom edge -- so the
+ // Y axis cannot be normalized. Substituting linear progress here made the
+ // value run toward t and then jump back at the join. X still normalizes, so
+ // solve for the curve parameter as usual and evaluate this segment's Y cubic
+ // from its real endpoints and controls.
+ float scaledT = (t - (first ? 0f : midX)) / scaleX;
+ float x1 = first ? p0 / scaleX : (q0 - midX) / scaleX;
+ float x2 = first ? p2 / scaleX : (q2 - midX) / scaleX;
+ float u = solveBezierForT(scaledT, x1, x2);
+ float y0 = first ? 0f : midY;
+ float y3 = first ? midY : 1f;
+ float c1 = first ? p1 : q1;
+ float c2 = first ? p3 : q3;
+ float inv = 1f - u;
+ value = inv * inv * inv * y0 + 3f * inv * inv * u * c1 + 3f * inv * u * u * c2 + u * u * u * y3;
+ } else {
+ float scaledT = (t - (first ? 0f : midX)) / scaleX;
+ float x1;
+ float y1;
+ float x2;
+ float y2;
+ if (first) {
+ x1 = p0 / scaleX;
+ y1 = p1 / scaleY;
+ x2 = p2 / scaleX;
+ y2 = p3 / scaleY;
+ } else {
+ x1 = (q0 - midX) / scaleX;
+ y1 = (q1 - midY) / scaleY;
+ x2 = (q2 - midX) / scaleX;
+ y2 = (q3 - midY) / scaleY;
+ }
+ float u = solveBezierForT(scaledT, x1, x2);
+ value = bezierAxis(u, y1, y2) * scaleY + (first ? 0f : midY);
+ }
+
+ float dis = Math.abs(destinationValue - sourceValue);
+ if (destinationValue > sourceValue) {
+ return sourceValue + (int) (value * dis);
+ }
+ return sourceValue - (int) (value * dis);
+ }
+
private int getCubicValue() {
//make sure we reach the destination value.
if (isFinished()) {
@@ -629,6 +752,9 @@ public int getValue() {
case CUBIC:
lastReturnedValue = getCubicValue();
break;
+ case THREE_POINT_CUBIC:
+ lastReturnedValue = getThreePointCubicValue();
+ break;
case FRICTION:
lastReturnedValue = getFriction();
break;
diff --git a/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java b/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java
index ee90cc09155..692d71efa74 100644
--- a/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java
+++ b/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java
@@ -533,11 +533,27 @@ public boolean equals(Shape shape, Transform t) {
return true;
}
if (shape instanceof Rectangle) {
+ // A path equals a rectangle only when it IS that rectangle. Comparing bounds
+ // alone made EVERY non-rectangular path equal to its own bounding box, and
+ // the clip bookkeeping asks exactly this question before deciding that a
+ // setClip changes nothing and can be skipped.
+ //
+ // That is a silent, total failure of shaped clipping wherever the shape fills
+ // its own component, which is the usual case: a component's clip has just been
+ // narrowed to its bounds, so a circle or rounded rectangle inscribed in it
+ // arrives with bounds equal to the current clip, compares EQUAL, and is
+ // DISCARDED. The subtree then paints square with nothing reported. Only ports
+ // whose clip state goes through this comparison are affected, which is why it
+ // could be reproduced on a device and never in the desktop simulator.
+ //
+ // isRectangle() walks the path, so it is asked only once the bounds have
+ // already matched -- the rare case. A clip that genuinely changed is rejected
+ // on the bounds alone, as before.
Rectangle r = (Rectangle) shape;
Rectangle tmpRect = createRectFromPool();
try {
getBounds(tmpRect);
- return r.equals(tmpRect);
+ return r.equals(tmpRect) && isRectangle();
} finally {
recycle(tmpRect);
}
diff --git a/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java b/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java
index c1e9bc7ea3c..bf1a8b4236d 100644
--- a/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java
+++ b/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java
@@ -72,6 +72,16 @@ public class DefaultLookAndFeel extends LookAndFeel implements FocusListener {
private Image[] rButtonImages = null;
private Image[] chkBoxImagesFocus = null;
private Image[] rButtonImagesFocus = null;
+
+ // The five groups above are built on first use rather than in refreshTheme; see
+ // refreshTheme. Each flag says its group still has to be built from the current
+ // theme, and a setter clears its own group's, so an image an application set
+ // itself is never overwritten by a late build.
+ private boolean comboImagePending;
+ private boolean chkBoxImagesPending;
+ private boolean chkBoxImagesFocusPending;
+ private boolean rButtonImagesPending;
+ private boolean rButtonImagesFocusPending;
private boolean tickWhenFocused = true;
private char passwordChar = '\u25CF';
//used for the pull to refresh feature
@@ -193,6 +203,7 @@ public void setCheckBoxImages(Image checkedX, Image uncheckedX) {
///
/// - `disabledUnchecked`: same as unchecked for the disabled state
public void setCheckBoxImages(Image checkedX, Image uncheckedX, Image disabledChecked, Image disabledUnchecked) {
+ chkBoxImagesPending = false;
if (checkedX == null || uncheckedX == null) {
chkBoxImages = null;
} else {
@@ -218,6 +229,7 @@ public void setCheckBoxImages(Image checkedX, Image uncheckedX, Image disabledCh
///
/// - `disabledUnchecked`: same as unchecked for the disabled state
public void setCheckBoxFocusImages(Image checkedX, Image uncheckedX, Image disabledChecked, Image disabledUnchecked) {
+ chkBoxImagesFocusPending = false;
if (checkedX == null || uncheckedX == null) {
chkBoxImagesFocus = null;
} else {
@@ -237,6 +249,7 @@ public void setCheckBoxFocusImages(Image checkedX, Image uncheckedX, Image disab
///
/// - `picker`: picker image
public void setComboBoxImage(Image picker) {
+ comboImagePending = false;
comboImage = picker;
}
@@ -248,6 +261,7 @@ public void setComboBoxImage(Image picker) {
///
/// - `unselected`: the image to draw in order to represent an unselected radio button
public void setRadioButtonImages(Image selected, Image unselected) {
+ rButtonImagesPending = false;
if (selected == null || unselected == null) {
rButtonImages = null;
} else {
@@ -267,6 +281,7 @@ public void setRadioButtonImages(Image selected, Image unselected) {
///
/// - `disabledUnselected`: same as unselected for the disabled state
public void setRadioButtonImages(Image selected, Image unselected, Image disabledSelected, Image disabledUnselected) {
+ rButtonImagesPending = false;
if (selected == null || unselected == null) {
rButtonImages = null;
} else {
@@ -292,6 +307,7 @@ public void setRadioButtonImages(Image selected, Image unselected, Image disable
///
/// - `disabledUnselected`: same as unselected for the disabled state
public void setRadioButtonFocusImages(Image selected, Image unselected, Image disabledSelected, Image disabledUnselected) {
+ rButtonImagesFocusPending = false;
if (selected == null || unselected == null) {
rButtonImagesFocus = null;
} else {
@@ -320,6 +336,7 @@ public void setPasswordChar(char c) {
///
/// images representing the radio button or null for using the default drawing
public Image[] getRadioButtonImages() {
+ ensureRadioButtonImages();
return rButtonImages;
}
@@ -329,6 +346,7 @@ public Image[] getRadioButtonImages() {
///
/// images representing the radio button or null for using the default drawing
public Image[] getRadioButtonFocusImages() {
+ ensureRadioButtonFocusImages();
return rButtonImagesFocus;
}
@@ -338,6 +356,7 @@ public Image[] getRadioButtonFocusImages() {
///
/// images representing the check box or null for using the default drawing
public Image[] getCheckBoxImages() {
+ ensureCheckBoxImages();
return chkBoxImages;
}
@@ -347,6 +366,7 @@ public Image[] getCheckBoxImages() {
///
/// images representing the check box or null for using the default drawing
public Image[] getCheckBoxFocusImages() {
+ ensureCheckBoxFocusImages();
return chkBoxImagesFocus;
}
@@ -363,6 +383,8 @@ public void drawButton(Graphics g, Button b) {
/// {@inheritDoc}
@Override
public void drawCheckBox(Graphics g, Button cb) {
+ ensureCheckBoxImages();
+ ensureCheckBoxFocusImages();
if (chkBoxImages != null) {
Image x;
if (chkBoxImagesFocus != null && chkBoxImagesFocus[0] != null && cb.hasFocus() && Display.getInstance().shouldRenderSelection(cb)) {
@@ -631,6 +653,8 @@ public Span calculateLabelSpan(TextSelection sel, Label l) {
/// {@inheritDoc}
@Override
public void drawRadioButton(Graphics g, Button rb) {
+ ensureRadioButtonImages();
+ ensureRadioButtonFocusImages();
if (rButtonImages != null) {
Image x;
if (rButtonImagesFocus != null && rButtonImagesFocus[0] != null && rb.hasFocus() && Display.getInstance().shouldRenderSelection(rb)) {
@@ -681,6 +705,7 @@ public void drawRadioButton(Graphics g, Button rb) {
/// {@inheritDoc}
@Override
public void drawComboBox(Graphics g, List cb) {
+ ensureComboBoxImage();
int border = 2;
Style style = cb.getStyle();
int leftPadding = style.getPaddingLeft(cb.isRTL());
@@ -984,6 +1009,7 @@ public Dimension getButtonPreferredSize(Button b) {
/// {@inheritDoc}
@Override
public Dimension getCheckBoxPreferredSize(Button cb) {
+ ensureCheckBoxImages();
if (cb.isToggle()) {
return getButtonPreferredSize(cb);
}
@@ -1201,6 +1227,7 @@ private Dimension getListPreferredSizeImpl(List l) {
/// {@inheritDoc}
@Override
public Dimension getRadioButtonPreferredSize(Button rb) {
+ ensureRadioButtonImages();
if (rb.isToggle()) {
return getButtonPreferredSize(rb);
}
@@ -1810,6 +1837,7 @@ private boolean fastCharWidthCheck(String s, int length, int width, int charWidt
/// {@inheritDoc}
@Override
public Dimension getComboBoxPreferredSize(List cb) {
+ ensureComboBoxImage();
Dimension d = getListPreferredSize(cb);
Image comboBoxImage = ((ComboBox) cb).getComboBoxImage() != null ? ((ComboBox) cb).getComboBoxImage() : comboImage;
if (comboBoxImage != null) {
@@ -2622,8 +2650,25 @@ public void refreshTheme(boolean b) {
chkBoxImagesFocus = null;
rButtonImagesFocus = null;
super.refreshTheme(b);
- UIManager m = getUIManager();
- Image combo = m.getThemeImageConstant("comboImage");
+ // Built on first use, not here. Every theme install ends in this method, and
+ // building these meant loading the Material icon font and rendering eight
+ // glyph images for check boxes, radio buttons and the combo box arrow -- on
+ // the transpiled Flutter gallery about a third of its whole theme install,
+ // for controls most screens never show. What is built is unchanged: the same
+ // code, reading the same theme, the first time anything asks for it.
+ comboImagePending = true;
+ chkBoxImagesPending = true;
+ chkBoxImagesFocusPending = true;
+ rButtonImagesPending = true;
+ rButtonImagesFocusPending = true;
+ }
+
+ private void ensureComboBoxImage() {
+ if (!comboImagePending) {
+ return;
+ }
+ comboImagePending = false;
+ Image combo = getUIManager().getThemeImageConstant("comboImage");
if (combo != null) {
setComboBoxImage(combo);
} else {
@@ -2633,10 +2678,34 @@ public void refreshTheme(boolean b) {
setComboBoxImage(combo);
}
}
- updateCheckBoxConstants(m, false, "");
- updateCheckBoxConstants(m, true, "Focus");
- updateRadioButtonConstants(m, false, "");
- updateRadioButtonConstants(m, true, "Focus");
+ }
+
+ private void ensureCheckBoxImages() {
+ if (chkBoxImagesPending) {
+ chkBoxImagesPending = false;
+ updateCheckBoxConstants(getUIManager(), false, "");
+ }
+ }
+
+ private void ensureCheckBoxFocusImages() {
+ if (chkBoxImagesFocusPending) {
+ chkBoxImagesFocusPending = false;
+ updateCheckBoxConstants(getUIManager(), true, "Focus");
+ }
+ }
+
+ private void ensureRadioButtonImages() {
+ if (rButtonImagesPending) {
+ rButtonImagesPending = false;
+ updateRadioButtonConstants(getUIManager(), false, "");
+ }
+ }
+
+ private void ensureRadioButtonFocusImages() {
+ if (rButtonImagesFocusPending) {
+ rButtonImagesFocusPending = false;
+ updateRadioButtonConstants(getUIManager(), true, "Focus");
+ }
}
/// Builds a check-box / radio glyph. When sizeMM is non-null it sizes the box
diff --git a/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java b/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java
index 7f01c3ea526..1578c2474f5 100644
--- a/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java
+++ b/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java
@@ -152,6 +152,9 @@ public final class RoundBorder extends Border {
/// square. This is ignored when the rectangle mode is false
private boolean onlyRightRounded;
private boolean uiid;
+ /// True when the shape is drawn straight onto the Graphics rather than
+ /// through a cached offscreen image. Off by default; see `directPaint`.
+ private boolean directPaint;
/// This is useful for showing an Uber like stroke effect progress bar
private int strokeAngle = 360;
@@ -523,6 +526,44 @@ public boolean isOnlyRightRounded() {
return onlyRightRounded;
}
+ /// Draws the shape straight onto the Graphics instead of through a cached
+ /// offscreen image.
+ ///
+ /// OFF by default, and that default is a compatibility decision rather
+ /// than a preference. Drawing directly anti-aliases the outline against
+ /// whatever is really behind it; the image path anti-aliases against
+ /// transparency and then composites the result, so alpha is quantised
+ /// twice. The two agree to within a fraction of a pixel along the edge --
+ /// invisible to a person, and still a difference a screenshot test
+ /// measures. Turning it on everywhere would move pixels in the UI of every
+ /// application that already uses this border.
+ ///
+ /// Turn it on for a shape whose SIZE ANIMATES, which is where the image
+ /// path is not merely slower but unusable: the cache is keyed by size, so
+ /// a growing circle misses it on every frame and allocates, fills and
+ /// discards a surface as large as the component each time.
+ ///
+ /// #### Parameters
+ ///
+ /// - `directPaint`: true to draw without an offscreen image
+ ///
+ /// #### Returns
+ ///
+ /// border instance so these calls can be chained
+ public RoundBorder directPaint(boolean directPaint) {
+ this.directPaint = directPaint;
+ return this;
+ }
+
+ /// Whether this border draws without an offscreen image.
+ ///
+ /// #### Returns
+ ///
+ /// True when direct painting is enabled.
+ public boolean isDirectPaint() {
+ return directPaint;
+ }
+
private Image createTargetImage(Component c, int w, int h, boolean fast) {
Image target = ImageFactory.createImage(c, w, h, 0);
@@ -616,12 +657,67 @@ private Image createTargetImage(Component c, int w, int h, boolean fast) {
return target;
}
+ /// Whether this border can be drawn straight onto the Graphics instead of
+ /// through an offscreen image.
+ ///
+ /// Two configurations genuinely need the image and are excluded. A shadow is
+ /// built by overdrawing the shape once per spread step and then blurring the
+ /// result, which is a read-back of what has been drawn so far and therefore
+ /// has to own its surface. The `uiid` mode paints the COMPONENT's background
+ /// painter through a shape clip, swapping the component's border out while it
+ /// does so, and that re-entry is only safe against a surface of its own.
+ ///
+ /// Everything else is a fill and an optional stroke of a circle or a pill in
+ /// this border's own colours -- `fillShape` never reads the destination -- so
+ /// drawing it directly produces the same pixels without allocating anything.
+ private boolean canPaintDirectly(Graphics g) {
+ return directPaint && shadowOpacity <= 0 && !uiid && g.isAntiAliasingSupported();
+ }
+
+ /// Draws the shape onto `g` with the same geometry `createTargetImage` would
+ /// have drawn into an image of the component's size.
+ ///
+ /// The clip reproduces the one the image gave for free: a stroke is centred
+ /// on the outline, so half of it falls outside the box and used to be cropped
+ /// by the image's own bounds.
+ private void paintDirectly(Graphics g, int x, int y, int w, int h) {
+ int priorColor = g.getColor();
+ int priorAlpha = g.getAlpha();
+ boolean priorAntiAliased = g.isAntiAliased();
+ g.pushClip();
+ try {
+ g.clipRect(x, y, w, h);
+ g.translate(x, y);
+ try {
+ g.setAntiAliased(true);
+ fillShape(g, color, opacity, w, h, true);
+ } finally {
+ g.translate(-x, -y);
+ }
+ } finally {
+ g.popClip();
+ g.setAntiAliased(priorAntiAliased);
+ g.setColor(priorColor);
+ g.setAlpha(priorAlpha);
+ }
+ }
+
@Override
public void paintBorderBackground(Graphics g, final Component c) {
final int w = c.getWidth();
final int h = c.getHeight();
int x = c.getX();
int y = c.getY();
+ if (w > 0 && h > 0 && canPaintDirectly(g)) {
+ // NO IMAGE, when the caller has asked for it. The cached path
+ // allocates a mutable image the size of the component and rebuilds
+ // it on every size change, which is ruinous for a shape that
+ // ANIMATES its size: a coach-mark circle growing to 1618x1618 over
+ // half a second throws away a 10MB surface per frame and queues a
+ // refinement pass behind each one. A flat fill needs none of that.
+ paintDirectly(g, x, y, w, h);
+ return;
+ }
if (w > 0 && h > 0) {
Object k = c.getClientProperty(CACHE_KEY + instanceVal);
if (k instanceof CacheValue) {
diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java
index bd4f443c314..0261010aa30 100644
--- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java
+++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java
@@ -411,6 +411,10 @@ private void rememberNativeValue(String key, Object value) {
private Hashtable resourceBundle;
private Map bundle;
private boolean wasThemeInstalled;
+
+ /// A base theme a port has asked to install, not yet installed; see
+ /// [#setDeferredBaseTheme(Runnable)].
+ private Runnable deferredBaseTheme;
/// This EventDispatcher holds all listeners who would like to register to
/// Theme refreshed event
private EventDispatcher themelisteners;
@@ -630,13 +634,47 @@ public void setUseLargerTextScale(boolean useLargerTextScale) {
this.useLargerTextScale = useLargerTextScale;
}
+ /// Registers a base theme to install the first time anything reads the theme,
+ /// instead of immediately.
+ ///
+ /// For a port that supplies a default look to applications with no theme of
+ /// their own. Installing it eagerly while the display initializes is wasted
+ /// whenever the application then installs a theme with [#setThemeProps(Hashtable)]:
+ /// that call resets the property table, so the base theme and every style built
+ /// from it are discarded before anything is drawn. A Flutter application always
+ /// does this, and so does any theme without `@includeNativeBool`. Deferred,
+ /// the base theme is installed only if something reads the theme first -- a
+ /// style, a theme constant or the look and feel -- or when a theme is layered
+ /// on it with [#addThemeProps(Hashtable)]. A theme installed with
+ /// `setThemeProps` supersedes it, and one that asks for the native theme
+ /// through `@includeNativeBool` installs it at that point as it always has.
+ ///
+ /// #### Parameters
+ ///
+ /// - `install`: installs the base theme, typically `Display.installNativeTheme()`;
+ /// run at most once, on the thread that first reads the theme
+ public void setDeferredBaseTheme(Runnable install) {
+ deferredBaseTheme = install;
+ }
+
+ /// Installs a deferred base theme, if one is still pending.
+ ///
+ /// Cleared before it runs, because installing a theme reads the theme itself.
+ private void ensureBaseTheme() {
+ Runnable install = deferredBaseTheme;
+ if (install != null) {
+ deferredBaseTheme = null;
+ install.run();
+ }
+ }
+
/// Indicates if a theme was previously installed since the last reset
///
/// #### Returns
///
/// true if setThemeProps was invoked
public boolean wasThemeInstalled() {
- return wasThemeInstalled;
+ return wasThemeInstalled || deferredBaseTheme != null;
}
/// Returns the currently installed look and feel
@@ -645,6 +683,7 @@ public boolean wasThemeInstalled() {
///
/// the currently installed look and feel
public LookAndFeel getLookAndFeel() {
+ ensureBaseTheme();
return current;
}
@@ -766,6 +805,7 @@ public void setComponentSelectedStyle(String id, Style style) {
///
/// the appropriate style (this method never returns null)
public final Style getComponentStyle(String id) {
+ ensureBaseTheme();
return getComponentStyleImpl(id, false, "");
}
@@ -781,6 +821,7 @@ public final Style getComponentStyle(String id) {
/// The IconUIID corresponding to the given ID - or null if none is defined in the theme.
///
public String getIconUIIDFor(String id) {
+ ensureBaseTheme();
if (id == null || id.length() == 0) {
return null;
}
@@ -838,6 +879,7 @@ public Style parseComponentStyle(Resources theme, String baseStyle, String id, S
///
/// the appropriate style (this method never returns null)
public Style getComponentSelectedStyle(String id) {
+ ensureBaseTheme();
return getComponentStyleImpl(id, true, "sel#");
}
@@ -882,6 +924,7 @@ public Style parseComponentSelectedStyle(Resources theme, String baseStyle, Stri
///
/// the appropriate style (this method never returns null)
public Style getComponentCustomStyle(String id, String type) {
+ ensureBaseTheme();
return getComponentStyleImpl(id, false, type + "#");
}
@@ -916,6 +959,7 @@ public Style getComponentCustomStyle(String id, String type) {
///
/// true when that custom style is installed or declared for this UIID
public boolean hasComponentCustomStyle(String id, String type) {
+ ensureBaseTheme();
if (type == null || type.length() == 0) {
return false;
}
@@ -1034,6 +1078,8 @@ private Style getComponentStyleImpl(String id, boolean selected, String prefix)
///
/// the name of the current theme for theme switching UI's
public String getThemeName() {
+ // The name of a deferred base theme is only known once it is installed.
+ ensureBaseTheme();
if (themeProps != null) {
return (String) themeProps.get("name");
}
@@ -1051,6 +1097,9 @@ HashMap getThemeProps() {
///
/// - `themeProps`: the properties of the given theme
public void setThemeProps(Hashtable themeProps) {
+ // Replacing the whole theme supersedes a deferred base theme: it would be
+ // reset away here before anything saw it. See setDeferredBaseTheme.
+ deferredBaseTheme = null;
if (accessible) {
setThemePropsImpl(themeProps);
}
@@ -1999,6 +2048,8 @@ private void resetThemeProps(Hashtable installedTheme) {
///
/// - `themeProps`: the properties of the given theme
public void addThemeProps(Hashtable themeProps) {
+ // Layered ON the base theme, so a deferred one has to be installed first.
+ ensureBaseTheme();
if (accessible) {
restoreLargerTextFonts();
restoreNativeThemeValues();
@@ -2081,6 +2132,9 @@ public void zoomFonts(float factor) {
if (factor == 1f) {
return;
}
+ // Scale the base theme itself; a deferred one installed after this call
+ // would otherwise come in at its original size.
+ ensureBaseTheme();
// Zoom the bundled originals, then reapply OS and accessibility settings.
// Otherwise zoom replaces the instances tracked by the restoration maps.
restoreLargerTextFonts();
@@ -2196,6 +2250,7 @@ public void refreshTheme() {
///
/// the value of the constant or the default if the constant isn't in the theme
public int getThemeConstant(String constantName, int def) {
+ ensureBaseTheme();
String v = (String) themeConstants.get(constantName);
if (v != null) {
try {
@@ -2219,6 +2274,7 @@ public int getThemeConstant(String constantName, int def) {
///
/// the value of the constant or the default if the constant isn't in the theme
public final String getThemeConstant(String constantName, String def) {
+ ensureBaseTheme();
String v = (String) themeConstants.get(constantName);
if (v != null) {
return v;
@@ -2238,6 +2294,7 @@ public final String getThemeConstant(String constantName, String def) {
///
/// the value of the constant or the default if the constant isn't in the theme
public final boolean isThemeConstant(String constantName, boolean def) {
+ ensureBaseTheme();
String c = getThemeConstant(constantName, null);
if (c == null) {
return def;
@@ -2255,6 +2312,7 @@ public final boolean isThemeConstant(String constantName, boolean def) {
///
/// the value of the constant or null if the constant isn't in the theme
public Boolean isThemeConstant(String constantName) {
+ ensureBaseTheme();
String c = getThemeConstant(constantName, null);
if (c == null) {
return null;
@@ -2275,6 +2333,7 @@ public Boolean isThemeConstant(String constantName) {
///
/// the image if defined
public Image getThemeImageConstant(String constantName) {
+ ensureBaseTheme();
return (Image) themeConstants.get(constantName);
}
@@ -2288,6 +2347,7 @@ public Image getThemeImageConstant(String constantName) {
///
/// the mask if defined
public Object getThemeMaskConstant(String constantName) {
+ ensureBaseTheme();
Object o = themeConstants.get(constantName + "Mask");
if (o != null) {
return o;
diff --git a/CodenameOne/src/com/codename1/ui/spinner/DateSpinner3D.java b/CodenameOne/src/com/codename1/ui/spinner/DateSpinner3D.java
index 289f265e716..db966497e65 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/DateSpinner3D.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/DateSpinner3D.java
@@ -37,10 +37,20 @@
import static com.codename1.ui.CN.convertToPixels;
-/// A date spinner allows selecting a date value within the given date range
+/// Three wheels -- day, month and year -- that together select one date inside
+/// a configured range.
///
-/// This is used by the Picker when in lightweight mode.
-class DateSpinner3D extends Container implements InternalPickerWidget {
+/// The body of a lightweight [Picker][com.codename1.ui.spinner.Picker] of type
+/// date, and usable on its own as an ordinary `Container` when a form wants
+/// the wheels inline rather than in a dialog. The range is set with
+/// `#setStartYear(int)` and `#setEndYear(int)`; a value outside it is clamped.
+///
+/// [getValue()][#getValue()] and [setValue(Object)][#setValue(Object)] carry a
+/// `java.util.Date`. The wheels select only the date, but the time of day of
+/// whatever was last passed to `setValue` is remembered and handed back by
+/// `getValue`, so a round trip through this widget does not silently move a
+/// timestamp to midnight.
+public class DateSpinner3D extends Container implements InternalPickerWidget {
private final SimpleDateFormat monthFormat = new SimpleDateFormat("MMMM");
private final Container wrapper = new Container(BoxLayout.x());
private final Calendar tmpCal = Calendar.getInstance();
diff --git a/CodenameOne/src/com/codename1/ui/spinner/DateTimeSpinner3D.java b/CodenameOne/src/com/codename1/ui/spinner/DateTimeSpinner3D.java
index 2f3be6cf6b0..30adc79da47 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/DateTimeSpinner3D.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/DateTimeSpinner3D.java
@@ -36,10 +36,17 @@
import static com.codename1.ui.CN.convertToPixels;
-/// The date and time spinner extends the time spinner by allowing to pick a specific day as well
+/// A date and a time of day on one row of wheels: a day wheel beside the hour
+/// and minute wheels of [TimeSpinner3D][com.codename1.ui.spinner.TimeSpinner3D].
///
-/// Used by Picker in lightweight mode.
-class DateTimeSpinner3D extends Container implements InternalPickerWidget {
+/// The body of a lightweight [Picker][com.codename1.ui.spinner.Picker] of type
+/// date and time, and usable on its own as an ordinary `Container`. The day
+/// wheel spans the configured range and shows each day by name rather than by
+/// number, which is what the iOS picker it follows does.
+///
+/// [getValue()][#getValue()] and [setValue(Object)][#setValue(Object)] carry a
+/// `java.util.Date` in which both the date and the time components are used.
+public class DateTimeSpinner3D extends Container implements InternalPickerWidget {
private final Date today = new Date();
private final int off;
private final Container wrapper = new Container(BoxLayout.x());
diff --git a/CodenameOne/src/com/codename1/ui/spinner/DurationSpinner3D.java b/CodenameOne/src/com/codename1/ui/spinner/DurationSpinner3D.java
index 27088a61a99..a54adb539dc 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/DurationSpinner3D.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/DurationSpinner3D.java
@@ -31,8 +31,18 @@
import com.codename1.ui.plaf.Style;
import com.codename1.ui.plaf.UIManager;
-/// A duration Spinner widget Used by the Picker in lightweight mode.
-class DurationSpinner3D extends Container implements InternalPickerWidget {
+/// Wheels that select a LENGTH of time rather than a point in it -- days,
+/// hours and minutes, according to how it is configured.
+///
+/// The body of a lightweight [Picker][com.codename1.ui.spinner.Picker] of type
+/// duration, and usable on its own as an ordinary `Container`. Which wheels
+/// appear is decided at construction; a duration picker that only needs hours
+/// and minutes does not show a day wheel.
+///
+/// [getValue()][#getValue()] and [setValue(Object)][#setValue(Object)] carry
+/// the duration in MILLISECONDS, as a `Long`, so that it can be added to a
+/// timestamp without unit conversion at the call site.
+public class DurationSpinner3D extends Container implements InternalPickerWidget {
public static final int FIELD_YEAR = 0;
public static final int FIELD_MONTH = 1;
public static final int FIELD_DAY = 2;
diff --git a/CodenameOne/src/com/codename1/ui/spinner/InternalPickerWidget.java b/CodenameOne/src/com/codename1/ui/spinner/InternalPickerWidget.java
index 58a7e864a4e..cb0b29190a0 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/InternalPickerWidget.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/InternalPickerWidget.java
@@ -22,10 +22,44 @@
*/
package com.codename1.ui.spinner;
-/// Interface for 3D spinners that allow selected values to set and retrieved
-/// by the Picker.
-interface InternalPickerWidget {
+/// The value contract every lightweight picker spinner implements, so
+/// [Picker][com.codename1.ui.spinner.Picker] can drive any of them without
+/// knowing which one it holds.
+///
+/// A `Picker` in lightweight mode owns one of the `*Spinner3D` containers
+/// according to its type -- date, time, date and time, duration or a plain
+/// string list -- and moves a value in and out of it through this interface
+/// alone. Implementing it is what makes a container usable as the body of a
+/// picker.
+///
+/// The value's runtime type is the implementation's own: `Date` for
+/// [DateSpinner3D][com.codename1.ui.spinner.DateSpinner3D], an `int[]` of
+/// hours and minutes for
+/// [DurationSpinner3D][com.codename1.ui.spinner.DurationSpinner3D], and so on.
+/// Each implementation documents what it expects; passing something else is a
+/// programming error rather than a recoverable condition.
+///
+/// This is public because the lightweight spinners are, and those are usable
+/// on their own -- embedded in a form rather than shown in a picker dialog.
+/// The name is retained for source compatibility with the releases in which it
+/// was package private.
+public interface InternalPickerWidget {
+ /// The currently selected value.
+ ///
+ /// #### Returns
+ ///
+ /// the selection, in whatever type this implementation documents; never
+ /// null once the widget has been laid out
Object getValue();
+ /// Moves the selection to `value`.
+ ///
+ /// The widget scrolls to the new selection rather than jumping, when it is
+ /// already on screen.
+ ///
+ /// #### Parameters
+ ///
+ /// - `value`: the new selection, in the type this implementation
+ /// documents. A value outside the widget's range is clamped into it.
void setValue(Object value);
}
diff --git a/CodenameOne/src/com/codename1/ui/spinner/Spinner3D.java b/CodenameOne/src/com/codename1/ui/spinner/Spinner3D.java
index 1ad4b070d60..3443daff769 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/Spinner3D.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/Spinner3D.java
@@ -20,6 +20,7 @@
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
+
package com.codename1.ui.spinner;
import com.codename1.l10n.DateFormat;
@@ -44,10 +45,26 @@
import static com.codename1.ui.ComponentSelector.$;
-/// A spinner widget that tries to look and feel like the iOS picker.
+/// A scrolling wheel of values styled after the iOS picker, and the body of a
+/// lightweight [Picker][com.codename1.ui.spinner.Picker].
+///
+/// Rows come from a [ListModel][com.codename1.ui.list.ListModel], so the
+/// contents can be anything a model can produce; the selection is whatever
+/// that model holds at the selected index. `Spinner3D` is the plain-list
+/// member of the family, beside
+/// [DateSpinner3D][com.codename1.ui.spinner.DateSpinner3D],
+/// [TimeSpinner3D][com.codename1.ui.spinner.TimeSpinner3D],
+/// [DateTimeSpinner3D][com.codename1.ui.spinner.DateTimeSpinner3D] and
+/// [DurationSpinner3D][com.codename1.ui.spinner.DurationSpinner3D].
+///
+/// It is an ordinary `Container`, so it can be added to a form directly rather
+/// than shown through a picker dialog -- which is why it is public. Use
+/// `Picker` instead when you want the platform's native picker where one
+/// exists, because a `Picker` falls back to this only in lightweight mode.
///
-/// This is used by the Picker widget when in lightweight mode.
-class Spinner3D extends Container implements InternalPickerWidget {
+/// [getValue()][#getValue()] and [setValue(Object)][#setValue(Object)] carry
+/// the selected model element.
+public class Spinner3D extends Container implements InternalPickerWidget {
private final SpinnerNode root;
private final ScrollingContainer scroller;
diff --git a/CodenameOne/src/com/codename1/ui/spinner/TimeSpinner3D.java b/CodenameOne/src/com/codename1/ui/spinner/TimeSpinner3D.java
index d95ff479c4d..9d69675853f 100644
--- a/CodenameOne/src/com/codename1/ui/spinner/TimeSpinner3D.java
+++ b/CodenameOne/src/com/codename1/ui/spinner/TimeSpinner3D.java
@@ -36,11 +36,22 @@
import static com.codename1.ui.CN.convertToPixels;
-/// Allows selecting a time of day either in 24 hour batches or AM/PM format.
+/// Hour and minute wheels selecting a time of day, in either 24 hour or AM/PM
+/// form according to `#setShowMeridiem(boolean)`.
///
-/// If `#setDurationMode(boolean)` is true then this will allow
-/// users to set a duration in hours and minutes.
-class TimeSpinner3D extends Container implements InternalPickerWidget {
+/// The body of a lightweight [Picker][com.codename1.ui.spinner.Picker] of type
+/// time, and usable on its own as an ordinary `Container`. The minute wheel
+/// can step by more than one minute, which is what a picker offering quarter
+/// hours uses.
+///
+/// With `#setDurationMode(boolean)` set it selects a LENGTH of time instead,
+/// and the meridiem is not shown; prefer
+/// [DurationSpinner3D][com.codename1.ui.spinner.DurationSpinner3D] for that,
+/// which also offers a day wheel.
+///
+/// [getValue()][#getValue()] and [setValue(Object)][#setValue(Object)] carry
+/// the time as minutes since midnight, as an `Integer`.
+public class TimeSpinner3D extends Container implements InternalPickerWidget {
static final int DEFAULT_MINUTE_STEP = 5;
private Spinner3D hour;
diff --git a/CodenameOne/src/com/codename1/ui/util/Resources.java b/CodenameOne/src/com/codename1/ui/util/Resources.java
index bb1abd0f84a..33c28d2c531 100644
--- a/CodenameOne/src/com/codename1/ui/util/Resources.java
+++ b/CodenameOne/src/com/codename1/ui/util/Resources.java
@@ -481,7 +481,14 @@ void openFile(InputStream input) throws IOException {
}
private void openFileImpl(InputStream input) throws IOException {
- this.input = new DataInputStream(input);
+ // Parsing is thousands of two- and four-byte reads, and the stream a port
+ // hands back for a bundled resource is usually com.codename1.io's
+ // BufferedInputStream, whose every read is synchronized and stamps the
+ // clock three times for its network idle timeout. A 3463-key theme took
+ // 5-6ms to open on macOS, much of it in gettimeofday. A plain private
+ // buffer in front of it makes each small read an array index.
+ this.input = new DataInputStream(input instanceof ByteArrayInputStream
+ ? input : new ParseBuffer(input));
int resourceCount = this.input.readShort();
if (resourceCount < 0) {
throw new IOException("Invalid resource file!");
@@ -1411,16 +1418,41 @@ Font loadBitmapFont(DataInputStream input, String id, Font font) throws IOExcept
return font;
}
+ /// System fonts this resource file has already asked for, by face, style and size.
+ private HashMap systemFonts;
+
+ /// One system font per face/style/size for the whole file.
+ ///
+ /// Every font entry in a theme starts from a system font -- the fallback, and the
+ /// style a TrueType font is derived with -- and Font.createSystemFont builds a new
+ /// native font on each call. A theme has a font entry for every UIID and state:
+ /// the Flutter runtime's Material theme has 330, nearly all the same system font,
+ /// and on Linux each one was a Pango font with its metrics measured -- 319 of the
+ /// 360 font creations in a launch were the same Sans 18px. A font is immutable, so
+ /// the entries can share one.
+ private Font systemFont(int face, int style, int size) {
+ if (systemFonts == null) {
+ systemFonts = new HashMap();
+ }
+ Integer key = Integer.valueOf(((face & 0xff) << 16) | ((style & 0xff) << 8) | (size & 0xff));
+ Font f = systemFonts.get(key);
+ if (f == null) {
+ f = Font.createSystemFont(face, style, size);
+ systemFonts.put(key, f);
+ }
+ return f;
+ }
+
Font createTrueTypeFont(Font f, String fontName, String fileName, float fontSize, int sizeSetting) {
switch (sizeSetting) {
case 0: // small
- fontSize = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_SMALL).getHeight();
+ fontSize = systemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_SMALL).getHeight();
break;
case 1: // medium
- fontSize = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM).getHeight();
+ fontSize = systemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM).getHeight();
break;
case 2: // large
- fontSize = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE).getHeight();
+ fontSize = systemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE).getHeight();
break;
case 3: // millimetres
fontSize = Display.getInstance().convertToPixels((int) (fontSize * 10), true) / 10.0f;
@@ -1733,7 +1765,7 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException {
continue;
}
} else {
- f = Font.createSystemFont(input.readByte(), input.readByte(), input.readByte());
+ f = systemFont(input.readByte(), input.readByte(), input.readByte());
if (minorVersion > 4) {
boolean hasTTF = input.readBoolean();
if (hasTTF) {
@@ -2224,4 +2256,87 @@ static class MediaRule {
String rawKey;
String translatedKey;
}
+
+ /// An unsynchronized read buffer for parsing; see openFileImpl.
+ private static final class ParseBuffer extends InputStream {
+ private final InputStream in;
+ private final byte[] buf = new byte[8192];
+ private int pos;
+ private int count;
+
+ ParseBuffer(InputStream in) {
+ this.in = in;
+ }
+
+ private boolean fill() throws IOException {
+ pos = 0;
+ count = in.read(buf, 0, buf.length);
+ if (count < 0) {
+ count = 0;
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int read() throws IOException {
+ while (pos >= count) {
+ if (!fill()) {
+ return -1;
+ }
+ }
+ return buf[pos++] & 0xff;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ if (len == 0) {
+ return 0;
+ }
+ int n = 0;
+ int avail = count - pos;
+ if (avail > 0) {
+ n = Math.min(avail, len);
+ System.arraycopy(buf, pos, b, off, n);
+ pos += n;
+ if (n == len) {
+ return n;
+ }
+ }
+ if (len - n >= buf.length) {
+ // A large read (an image) goes straight to the source.
+ int r = in.read(b, off + n, len - n);
+ if (r < 0) {
+ return n == 0 ? -1 : n;
+ }
+ return n + r;
+ }
+ if (!fill()) {
+ return n == 0 ? -1 : n;
+ }
+ int m = Math.min(count - pos, len - n);
+ System.arraycopy(buf, pos, b, off + n, m);
+ pos += m;
+ return n + m;
+ }
+
+ @Override
+ public long skip(long n) throws IOException {
+ if (n <= 0) {
+ return 0;
+ }
+ int avail = count - pos;
+ if (avail > 0) {
+ int k = (int) Math.min(avail, n);
+ pos += k;
+ return k;
+ }
+ return in.skip(n);
+ }
+
+ @Override
+ public int available() throws IOException {
+ return (count - pos) + in.available();
+ }
+ }
}
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
index 5f2fa550db5..6f9579ab073 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
@@ -5987,6 +5987,12 @@ public boolean dispatchKeyEvent(KeyEvent event) {
private boolean nativeThemeAvailable;
public boolean hasNativeTheme() {
+ if ("custom".equals(nativeThemeMode())) {
+ // The application ships the only theme it uses, and the builder packaged
+ // no native one for it. Asked every time rather than cached: the mode is a
+ // Display property the application may set before its theme loads.
+ return false;
+ }
if (!testedNativeTheme) {
testedNativeTheme = true;
try {
@@ -6007,50 +6013,58 @@ public boolean hasNativeTheme() {
return nativeThemeAvailable;
}
+ /// The theme flavor the build hints ask for. and.themeMode is the per-platform
+ /// hint (auto | modern | material | hololight | legacy | custom); the legacy
+ /// name cn1.androidTheme is still honored for back-compat. The cross-platform
+ /// shortcut nativeTheme=modern/legacy/custom (deprecated alias: cn1.nativeTheme)
+ /// feeds in when no platform-specific hint is set. Default stays on
+ /// android_holo_light - what master shipped and what existing screenshot goldens
+ /// are anchored against. The ancient pre-Holo androidTheme.res is only reached
+ /// via explicit and.hololight=true (historical back-compat) or
+ /// and.themeMode=legacy.
+ ///
+ /// Mirrored by the builders' NativeThemes.androidThemesFor, which decides which
+ /// of the themes are packaged at all.
+ private static String nativeThemeMode() {
+ Display d = Display.getInstance();
+ String mode = d.getProperty("and.themeMode",
+ d.getProperty("cn1.androidTheme", null));
+ if (mode != null) {
+ return asciiLower(mode.trim());
+ }
+ String shared = d.getProperty("nativeTheme",
+ d.getProperty("cn1.nativeTheme", null));
+ // "native" is "modern plus the desktop": the desktop half belongs to
+ // the JavaSE port, and Android's own answer to "the platform's own
+ // look" is Material either way. Without it the value fell through to
+ // the hololight default below, so asking for the native look got the
+ // legacy one.
+ if ("modern".equalsIgnoreCase(shared) || "native".equalsIgnoreCase(shared)) {
+ return "material";
+ }
+ if ("custom".equalsIgnoreCase(shared)) {
+ return "custom";
+ }
+ if ("legacy".equalsIgnoreCase(shared)) {
+ return "hololight";
+ }
+ if ("true".equalsIgnoreCase(d.getProperty("and.hololight", "false"))) {
+ return "legacy";
+ }
+ return "hololight";
+ }
+
/**
* Installs the native theme, this is only applicable if hasNativeTheme()
* returned true. Notice that this method might replace the
* DefaultLookAndFeel instance and the default transitions.
*/
public void installNativeTheme() {
- hasNativeTheme();
- if (!nativeThemeAvailable) {
+ if (!hasNativeTheme()) {
return;
}
try {
- // Resolve desired theme flavor. and.themeMode is the per-platform
- // hint (auto | modern | material | hololight | legacy); the legacy
- // name cn1.androidTheme is still honored for back-compat. The
- // cross-platform shortcut nativeTheme=modern/legacy (deprecated
- // alias: cn1.nativeTheme) feeds in when no platform-specific hint
- // is set. Default stays on android_holo_light - what master
- // shipped and what existing screenshot goldens are anchored
- // against. The ancient pre-Holo androidTheme.res is only reached
- // via explicit and.hololight=true (historical back-compat) or
- // and.themeMode=legacy.
- Display d = Display.getInstance();
- String mode = d.getProperty("and.themeMode",
- d.getProperty("cn1.androidTheme", null));
- if (mode == null) {
- String shared = d.getProperty("nativeTheme",
- d.getProperty("cn1.nativeTheme", null));
- // "native" is "modern plus the desktop": the desktop half belongs to
- // the JavaSE port, and Android's own answer to "the platform's own
- // look" is Material either way. Without it the value fell through to
- // the hololight default below, so asking for the native look got the
- // legacy one.
- if ("modern".equalsIgnoreCase(shared) || "native".equalsIgnoreCase(shared)) {
- mode = "material";
- } else if ("legacy".equalsIgnoreCase(shared)) {
- mode = "hololight";
- } else if ("true".equalsIgnoreCase(d.getProperty("and.hololight", "false"))) {
- mode = "legacy";
- } else {
- mode = "hololight";
- }
- } else {
- mode = mode.toLowerCase();
- }
+ String mode = nativeThemeMode();
String resPath;
if ("material".equals(mode) || "modern".equals(mode) || "auto".equals(mode)) {
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index edcf111ccc4..7179de02591 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -3406,6 +3406,10 @@ private static String resolveAutoNativeTheme(String platformName) {
if ("legacy".equalsIgnoreCase(iosMode) || "iphone".equalsIgnoreCase(iosMode)) {
return "iPhoneTheme";
}
+ if ("custom".equalsIgnoreCase(iosMode)) {
+ // The device installs none; the modern default below would not match it.
+ return null;
+ }
}
String shared = sharedNativeThemeHint();
if ("legacy".equalsIgnoreCase(shared)) {
@@ -3432,6 +3436,10 @@ private static String resolveAutoNativeTheme(String platformName) {
if ("legacy".equalsIgnoreCase(andMode)) {
return "androidTheme";
}
+ if ("custom".equalsIgnoreCase(andMode)) {
+ // The device installs none; Material 3 below would not match it.
+ return null;
+ }
}
String shared = sharedNativeThemeHint();
if ("legacy".equalsIgnoreCase(shared)) {
@@ -4230,6 +4238,8 @@ public void blit() {
if(menuDisplayed){
return;
}
+ long blitStartNanos = BLIT_TRACE ? System.nanoTime() : 0;
+ long bufferDoneNanos = 0;
// We keep a blitCounter that gets reset in paintComponent()
// If blit is called a number of times with no call to paintComponet
@@ -4289,6 +4299,9 @@ public void blit() {
}
+ if (BLIT_TRACE) {
+ bufferDoneNanos = System.nanoTime();
+ }
try {
Runnable r = new Runnable() {
public void run() {
@@ -4346,6 +4359,10 @@ public void run() {
} catch(Exception err) {
err.printStackTrace();
}
+ if (BLIT_TRACE) {
+ recordBlit(bufferDoneNanos - blitStartNanos,
+ System.nanoTime() - bufferDoneNanos, bufferSafeMode);
+ }
}
public void blit(int x, int y, int w, int h) {
@@ -13011,6 +13028,60 @@ private void checkLastFrame() {
/**
* @inheritDoc
*/
+ /**
+ * Diagnostic for simulator frame pacing, enabled with -Dcn1.blit.trace=true.
+ *
+ *
The simulator presents a frame by handing the buffer to AWT through
+ * {@code SwingUtilities.invokeAndWait}, which BLOCKS the Codename One EDT until the
+ * AWT event thread has run it. That makes the simulator's frame rate a property of
+ * AWT's scheduling rather than of how long the app takes to paint, and it is
+ * invisible to any measurement taken inside Codename One - which is exactly why an
+ * app can paint a frame in 5ms and still advance only a few times a second.
+ *
+ *
The two phases are reported separately because they have different causes: the
+ * buffer copy is work the simulator does (and in {@code bufferSafeMode} it copies
+ * the whole screen under a lock, every frame), while the present time is pure
+ * waiting on AWT.
+ */
+ private static final boolean BLIT_TRACE = "true".equals(System.getProperty("cn1.blit.trace"));
+ private static int blitTraceCount;
+ private static long blitTraceBufferNanos;
+ private static long blitTracePresentNanos;
+ private static long blitTraceWorstPresentNanos;
+ private static long blitTraceLastReport;
+ private static int blitTraceSafeModeFrames;
+
+ private static synchronized void recordBlit(long bufferNanos, long presentNanos,
+ boolean safeMode) {
+ blitTraceCount++;
+ blitTraceBufferNanos += bufferNanos;
+ blitTracePresentNanos += presentNanos;
+ blitTraceWorstPresentNanos = Math.max(blitTraceWorstPresentNanos, presentNanos);
+ if (safeMode) {
+ blitTraceSafeModeFrames++;
+ }
+ long now = System.currentTimeMillis();
+ if (blitTraceLastReport == 0) {
+ blitTraceLastReport = now;
+ return;
+ }
+ if (now - blitTraceLastReport < 1000) {
+ return;
+ }
+ System.out.println("BLITTRACE frames=" + blitTraceCount
+ + " fps=" + (blitTraceCount * 1000L / Math.max(1, now - blitTraceLastReport))
+ + " bufferMs=" + (blitTraceBufferNanos / 1000000.0 / blitTraceCount)
+ + " presentMs=" + (blitTracePresentNanos / 1000000.0 / blitTraceCount)
+ + " worstPresentMs=" + (blitTraceWorstPresentNanos / 1000000.0)
+ + " safeModeFrames=" + blitTraceSafeModeFrames);
+ blitTraceCount = 0;
+ blitTraceBufferNanos = 0;
+ blitTracePresentNanos = 0;
+ blitTraceWorstPresentNanos = 0;
+ blitTraceSafeModeFrames = 0;
+ blitTraceLastReport = now;
+ }
+
public void flushGraphics(int x, int y, int width, int height) {
if (isShowEDTWarnings()) {
checkEDT();
@@ -14540,7 +14611,7 @@ private java.awt.Font desktopNativeFont(String alias) {
Float weight;
if ("Thin".equals(weightName)) weight = TextAttribute.WEIGHT_EXTRA_LIGHT;
else if ("Light".equals(weightName)) weight = TextAttribute.WEIGHT_LIGHT;
- else if ("Regular".equals(weightName)) weight = TextAttribute.WEIGHT_REGULAR;
+ else if ("Regular".equals(weightName) || "Normal".equals(weightName)) weight = TextAttribute.WEIGHT_REGULAR;
else if ("Bold".equals(weightName)) weight = TextAttribute.WEIGHT_BOLD;
else if ("Black".equals(weightName)) weight = TextAttribute.WEIGHT_HEAVY;
else throw new IllegalArgumentException("Unsupported native font type: " + alias);
@@ -14585,6 +14656,15 @@ public Object loadTrueTypeFont(String fontName, String fileName) {
res = "Medium";
break;
+ // The system face at its TRUE regular weight. native:MainRegular
+ // has meant Roboto-Medium since the alias was introduced and the
+ // iOS port maps it one step heavy too, so the honest regular gets
+ // a name of its own rather than a change that would restyle every
+ // existing application.
+ case "native:MainNormal":
+ res = "Regular";
+ break;
+
case "native:MainBold":
res = "Bold";
break;
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java
index 6fcbcb1ce24..fbf1d983012 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/ParparVMBootstrap.java
@@ -48,8 +48,8 @@ public static void bootstrap(Lifecycle lifecycle) {
* As {@link #bootstrap(Lifecycle)}, but runs {@code afterInit} once {@code Display} is
* initialized and before the lifecycle's {@code init}/{@code start} callbacks. The generated
* launcher uses this to stamp the app-hardening metadata (so {@code Hardening.isHardened()} and
- * any crash raised during {@code init}/{@code start} already see the mapping id and level),
- * which a post-bootstrap stamp would miss because {@code run()} invokes the lifecycle inline.
+ * any crash raised during {@code init}/{@code start} already see the mapping id and level).
+ * The lifecycle itself is then queued to the EDT, so {@code afterInit} always runs first.
*
* @param lifecycle the application lifecycle
* @param afterInit code to run after {@code Display.init} and before the lifecycle starts; may be null
@@ -61,7 +61,12 @@ public static void bootstrap(Lifecycle lifecycle, Runnable afterInit) {
if (afterInit != null) {
afterInit.run();
}
- bootstrap.run();
+ // The lifecycle runs on the EDT, as it does on every other port (iOS hands its stub
+ // to Display.init, which runs it there). This used to call run() inline, on the
+ // worker's main thread, so init() and start() ran off the EDT: anything that checks
+ // -- a transpiled Flutter app's runApp asserts it -- threw before the first frame,
+ // and the transpiled Flutter gallery never got past its loading screen.
+ Display.getInstance().callSerially(bootstrap);
}
// ``window.cn1Initialized = true`` lands on the worker's global
diff --git a/Ports/JavaScriptPort/src/main/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js
index c25ad12abea..6b0ece58965 100644
--- a/Ports/JavaScriptPort/src/main/webapp/port.js
+++ b/Ports/JavaScriptPort/src/main/webapp/port.js
@@ -3505,92 +3505,12 @@ bindCiFallback("HTML5Implementation.determineFontHeightCoerce", [
return 16.0;
});
-const hashMapComputeHashCodeImplMethodId = "cn1_java_util_HashMap_computeHashCode_java_lang_Object_R_int__impl";
-const hashMapComputeHashCodeMethodId = "cn1_java_util_HashMap_computeHashCode_java_lang_Object_R_int";
-
-bindCiFallback("HashMap.computeHashCodeNullKey", [
- hashMapComputeHashCodeImplMethodId,
- hashMapComputeHashCodeMethodId
-], function*(key) {
- if (key == null) {
- emitDiagLine("PARPAR:DIAG:FALLBACK:hashMapComputeHashCode:nullKey=1");
- return 0;
- }
- // Resolve the translator-generated original lazily — port.js evaluates
- // before translated_app.js, so a snapshot taken at load time would be
- // null and force every non-null lookup down the resolveVirtual fallback.
- // jvm.translatedMethods is populated by bindNative when registering
- // the native overrides; checking it last preserves any port-specific
- // override of the same method.
- let original = null;
- if (jvm && jvm.translatedMethods) {
- original = jvm.translatedMethods[hashMapComputeHashCodeImplMethodId]
- || jvm.translatedMethods[hashMapComputeHashCodeMethodId]
- || null;
- }
- if (typeof original !== "function") {
- if (typeof global[hashMapComputeHashCodeImplMethodId] === "function"
- && !global[hashMapComputeHashCodeImplMethodId].__cn1CiFallbackSymbol) {
- original = global[hashMapComputeHashCodeImplMethodId];
- } else if (typeof global[hashMapComputeHashCodeMethodId] === "function"
- && !global[hashMapComputeHashCodeMethodId].__cn1CiFallbackSymbol) {
- original = global[hashMapComputeHashCodeMethodId];
- }
- }
- if (typeof original === "function") {
- return yield* cn1_ivAdapt(original(key));
- }
- // Last-ditch path when the translated original genuinely isn't
- // available. ``computeHashCode(key)`` is just ``key.hashCode()`` —
- // dispatch via the SHARED dispatch id (``cn1_s_hashCode_R_int``), not
- // the legacy class-specific name. Every translated class registers its
- // ``hashCode`` slot under the shared key after the dispatch-id
- // refactor; resolving against ``cn1_java_lang_Object_hashCode_R_int``
- // skips that slot and silently returns the inherited Object.hashCode
- // (identity hash), which made every String key in CSSBorder.STYLE_MAP
- // store under its identity hash and every subsequent ``get("solid")``
- // miss the entry.
- var hashCodeMethod = jvm.resolveVirtual(key.__class || "java_lang_Object",
- "cn1_s_hashCode_R_int");
- if (typeof hashCodeMethod === "function") {
- return yield* cn1_ivAdapt(hashCodeMethod(key));
- }
- return 0;
-});
-if (typeof global[hashMapComputeHashCodeImplMethodId] === "function") {
- const originalHashMapComputeHashCodeImpl = global[hashMapComputeHashCodeImplMethodId];
- global[hashMapComputeHashCodeImplMethodId] = function*(key) {
- if (key == null) {
- emitDiagLine("PARPAR:DIAG:FALLBACK:hashMapComputeHashCodeDirect:nullKey=1");
- return 0;
- }
- return yield* cn1_ivAdapt(originalHashMapComputeHashCodeImpl(key));
- };
- emitDiagLine("PARPAR:DIAG:INIT:shim=hashMapComputeHashCodeImplNullKey");
-}
-if (typeof global[hashMapComputeHashCodeMethodId] === "function") {
- const originalHashMapComputeHashCode = global[hashMapComputeHashCodeMethodId];
- global[hashMapComputeHashCodeMethodId] = function*(key) {
- if (key == null) {
- emitDiagLine("PARPAR:DIAG:FALLBACK:hashMapComputeHashCodeDirect:nullKey=1");
- return 0;
- }
- return yield* cn1_ivAdapt(originalHashMapComputeHashCode(key));
- };
- emitDiagLine("PARPAR:DIAG:INIT:shim=hashMapComputeHashCodeNullKey");
-}
-const hashMapClassDef = jvm.classes && jvm.classes["java_util_HashMap"];
-if (hashMapClassDef && hashMapClassDef.methods && typeof hashMapClassDef.methods[hashMapComputeHashCodeMethodId] === "function") {
- const originalClassHashMapComputeHashCode = hashMapClassDef.methods[hashMapComputeHashCodeMethodId];
- hashMapClassDef.methods[hashMapComputeHashCodeMethodId] = function*(__cn1ThisObject, key) {
- if (key == null) {
- emitDiagLine("PARPAR:DIAG:FALLBACK:hashMapComputeHashCodeClass:nullKey=1");
- return 0;
- }
- return yield* cn1_ivAdapt(originalClassHashMapComputeHashCode(__cn1ThisObject, key));
- };
- emitDiagLine("PARPAR:DIAG:INIT:shim=hashMapComputeHashCodeClassNullKey");
-}
+// HashMap.computeHashCode used to carry a null-key fallback here, three ways (a
+// bindCiFallback and two direct replacements). The compact HashMap never passes it
+// a null key -- its one caller, cn1Marker, answers null itself -- so the fallback
+// could not fire. It was not free: a replaced method is suspending by definition,
+// and this one sat under every HashMap get/put/containsKey, so every lookup in
+// every app was a generator.
const styleSetPaddingUnitMethodId = "cn1_com_codename1_ui_plaf_Style_setPaddingUnit_byte_1ARRAY";
const styleSetMarginUnitMethodId = "cn1_com_codename1_ui_plaf_Style_setMarginUnit_byte_1ARRAY";
@@ -5751,8 +5671,6 @@ const cn1ssWs = {
queue: [], // {test, bytes} buffered while the socket is still connecting
pending: 0 // sent-but-unacked frames, for an optional flush at suite end
};
-cn1RefreshAlias(hashMapComputeHashCodeMethodId, global[hashMapComputeHashCodeMethodId]);
-cn1RefreshAlias(hashMapComputeHashCodeImplMethodId, global[hashMapComputeHashCodeImplMethodId]);
function cn1ssWsHost() {
try {
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_text.c b/Ports/LinuxPort/nativeSources/cn1_linux_text.c
index 1817e317cd9..a86f492f681 100644
--- a/Ports/LinuxPort/nativeSources/cn1_linux_text.c
+++ b/Ports/LinuxPort/nativeSources/cn1_linux_text.c
@@ -66,8 +66,33 @@ PangoContext* cn1LinuxPangoContext(void) {
return cn1Pango;
}
+/* Metrics already computed, keyed by font description (Pango's own hash/equality,
+ * so family, weight, style and size all take part). pango_context_get_metrics
+ * shapes a sample string to estimate character widths this port never reads, and
+ * a theme creates the same few descriptions over and over: on the transpiled
+ * Flutter gallery it was 7-8% of the launch's CPU. The cache returns exactly what
+ * Pango answered the first time, so no layout moves. Each entry owns a copy of its
+ * key; nothing is ever evicted, since a program uses a bounded set of fonts. The
+ * value packs ascent and height, both small non-negative pixel counts.
+ * Process-wide, like cn1Pango beside it: a thread-local copy would survive the
+ * reset in loadTrueTypeFont on every thread but the one that registered the font. */
+static GHashTable* cn1MetricsCache = NULL;
+
/* Snapshots ascent/height for the description into the CN1Font. */
static void cn1FontMetrics(CN1Font* f) {
+ gpointer hit;
+ if (cn1MetricsCache == NULL) {
+ cn1MetricsCache = g_hash_table_new_full(
+ (GHashFunc) pango_font_description_hash,
+ (GEqualFunc) pango_font_description_equal,
+ (GDestroyNotify) pango_font_description_free, NULL);
+ }
+ if (g_hash_table_lookup_extended(cn1MetricsCache, f->desc, NULL, &hit)) {
+ guint64 packed = (guint64) (guintptr) hit;
+ f->ascent = (int) (packed >> 32);
+ f->height = (int) (packed & 0xffffffffu);
+ return;
+ }
PangoFontMetrics* m = pango_context_get_metrics(cn1LinuxPangoContext(), f->desc, 0);
if (m != 0) {
f->ascent = pango_font_metrics_get_ascent(m) / PANGO_SCALE;
@@ -77,6 +102,11 @@ static void cn1FontMetrics(CN1Font* f) {
f->ascent = f->pixelSize;
f->height = f->pixelSize + f->pixelSize / 4;
}
+ if (sizeof(gpointer) >= 8 && f->ascent >= 0 && f->height >= 0) {
+ guint64 packed = ((guint64) (guint32) f->ascent << 32) | (guint32) f->height;
+ g_hash_table_insert(cn1MetricsCache, pango_font_description_copy(f->desc),
+ (gpointer) (guintptr) packed);
+ }
}
static CN1Font* cn1MakeFont(const char* family, int weight, int italic, int pixelSize) {
@@ -158,7 +188,53 @@ static void cn1LayoutSetText(PangoLayout* layout, const char* s, int len) {
g_free(valid);
}
-static int cn1MeasureUtf8(CN1Font* f, const char* utf8, int byteLen) {
+/* Widths already measured, keyed by (font, exact UTF-8 bytes). Measuring shapes
+ * the string through Pango, and a UI measures the same labels in the same fonts
+ * over and over -- every layout pass, every preferred-size query: ~5.6% of the
+ * transpiled Flutter gallery's launch. The cache returns exactly what Pango
+ * answered, so nothing moves. The font's address is a sound key because a
+ * CN1Font is never freed. Bounded by dropping everything once it holds
+ * CN1_WIDTH_CACHE_MAX entries -- a UI's working set is far smaller, and a full
+ * clear keeps the bound without bookkeeping on the hot path. Cleared with the
+ * metrics cache when a TrueType font is registered. Process-wide, like cn1Pango. */
+#define CN1_WIDTH_CACHE_MAX 8192
+#define CN1_WIDTH_CACHE_TEXT 256
+typedef struct {
+ CN1Font* font;
+ int len;
+ guint hash;
+ char bytes[1];
+} CN1WidthKey;
+
+static GHashTable* cn1WidthCache = NULL;
+
+static guint cn1WidthKeyHash(gconstpointer p) {
+ return ((const CN1WidthKey*) p)->hash;
+}
+
+static gboolean cn1WidthKeyEqual(gconstpointer a, gconstpointer b) {
+ const CN1WidthKey* x = (const CN1WidthKey*) a;
+ const CN1WidthKey* y = (const CN1WidthKey*) b;
+ return x->font == y->font && x->len == y->len && memcmp(x->bytes, y->bytes, (size_t) x->len) == 0;
+}
+
+static guint cn1WidthHash(CN1Font* f, const char* s, int len) {
+ /* FNV-1a over the bytes, seeded with the font's address. */
+ guint h = 2166136261u ^ (guint) (guintptr) f;
+ int i;
+ for (i = 0; i < len; i++) {
+ h = (h ^ (unsigned char) s[i]) * 16777619u;
+ }
+ return h;
+}
+
+static void cn1WidthCacheClear(void) {
+ if (cn1WidthCache != NULL) {
+ g_hash_table_remove_all(cn1WidthCache);
+ }
+}
+
+static int cn1MeasureUtf8Uncached(CN1Font* f, const char* utf8, int byteLen) {
PangoLayout* layout = pango_layout_new(cn1LinuxPangoContext());
int w = 0, h = 0;
pango_layout_set_font_description(layout, f->desc);
@@ -168,6 +244,47 @@ static int cn1MeasureUtf8(CN1Font* f, const char* utf8, int byteLen) {
return w;
}
+static int cn1MeasureUtf8(CN1Font* f, const char* utf8, int byteLen) {
+ union {
+ CN1WidthKey key;
+ char room[sizeof(CN1WidthKey) + CN1_WIDTH_CACHE_TEXT];
+ } probe;
+ CN1WidthKey* key;
+ gpointer hit;
+ int len;
+ int w;
+ if (utf8 == NULL) {
+ return cn1MeasureUtf8Uncached(f, utf8, byteLen);
+ }
+ len = byteLen < 0 ? (int) strlen(utf8) : byteLen;
+ if (len > CN1_WIDTH_CACHE_TEXT) {
+ /* Long text is measured once per wrap and rarely repeats; not worth a copy. */
+ return cn1MeasureUtf8Uncached(f, utf8, byteLen);
+ }
+ if (cn1WidthCache == NULL) {
+ cn1WidthCache = g_hash_table_new_full(cn1WidthKeyHash, cn1WidthKeyEqual, free, NULL);
+ }
+ /* Looked up with a key on the stack, so a hit allocates nothing. */
+ probe.key.font = f;
+ probe.key.len = len;
+ probe.key.hash = cn1WidthHash(f, utf8, len);
+ memcpy(probe.key.bytes, utf8, (size_t) len);
+ if (g_hash_table_lookup_extended(cn1WidthCache, &probe.key, NULL, &hit)) {
+ /* Stored +1 so a zero width is distinguishable from a missing entry. */
+ return (int) (gintptr) hit - 1;
+ }
+ w = cn1MeasureUtf8Uncached(f, utf8, len);
+ key = (CN1WidthKey*) malloc(sizeof(CN1WidthKey) + (size_t) len);
+ if (key != NULL) {
+ memcpy(key, &probe.key, sizeof(CN1WidthKey) + (size_t) len);
+ if (g_hash_table_size(cn1WidthCache) >= CN1_WIDTH_CACHE_MAX) {
+ g_hash_table_remove_all(cn1WidthCache);
+ }
+ g_hash_table_insert(cn1WidthCache, key, (gpointer) (gintptr) (w + 1));
+ }
+ return w;
+}
+
JAVA_INT com_codename1_impl_linux_LinuxNative_stringWidth___long_java_lang_String_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG font, JAVA_OBJECT str) {
const char* s;
if (str == JAVA_NULL) {
@@ -276,6 +393,12 @@ JAVA_LONG com_codename1_impl_linux_LinuxNative_loadTrueTypeFontFromMemory___java
* layout sees the freshly registered family. */
pango_cairo_font_map_set_default(0);
cn1Pango = 0;
+ /* A family that fell back to a system face until now may resolve to
+ * the new one, so every cached answer is suspect. */
+ if (cn1MetricsCache != NULL) {
+ g_hash_table_remove_all(cn1MetricsCache);
+ }
+ cn1WidthCacheClear();
} else {
close(fd);
}
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_window.c b/Ports/LinuxPort/nativeSources/cn1_linux_window.c
index efccf73b015..f18b4f42f87 100644
--- a/Ports/LinuxPort/nativeSources/cn1_linux_window.c
+++ b/Ports/LinuxPort/nativeSources/cn1_linux_window.c
@@ -1058,6 +1058,15 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_initDisplay___java_lang_String_in
}
cn1GtkThread = pthread_self();
+ /* GDK's own OpenGL, off unless the user asked for something else. Opening the
+ * display otherwise probes GLX to pick GL-capable visuals -- epoxy_glx_version,
+ * glXQueryServerString and a dlopen of the Mesa driver -- which was the larger
+ * part of gtk_init and about a tenth of the whole launch on the CI runner. This
+ * port never uses GDK's GL: it draws with cairo, and the 3D backend creates its
+ * own EGL context. WebKitGTK (the BrowserComponent peer) still renders without
+ * it, verified with WebKitGTK 2.50 under Xvfb. Not overwritten when set, so
+ * GDK_GL=... from the environment keeps working for anyone who needs it. */
+ g_setenv("GDK_GL", "disable", FALSE);
gtk_init(0, 0);
cn1Window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(cn1Window), t);
@@ -1543,8 +1552,41 @@ static void cn1A11yBeginMain(void* ignored) {
}
}
+/* One tree publication is queued on the calling thread and applied in a single
+ * hop to the GTK thread by accessibilityEnd. Each node and action used to make
+ * its own cn1LinuxRunOnMainAndWait, so publishing the gallery's tree was hundreds
+ * of synchronous round trips between the event dispatch thread and the GTK loop,
+ * each one waiting for the loop to come round. Begin, node, action and end are
+ * always called in that order from the one thread that publishes the tree, and
+ * only that thread touches this queue: accessibilityEnd hands the whole array to
+ * the GTK thread as a batch and starts a fresh one, so the queue needs no lock. */
+typedef struct {
+ int action; /* 0 = node, 1 = action */
+ void* data;
+} CN1A11yOp;
+static CN1A11yOp* cn1A11yOps = 0;
+static int cn1A11yOpCount = 0;
+static int cn1A11yOpCapacity = 0;
+
+static void cn1A11yQueue(int action, void* data) {
+ if (cn1A11yOpCount == cn1A11yOpCapacity) {
+ int capacity = cn1A11yOpCapacity == 0 ? 256 : cn1A11yOpCapacity * 2;
+ CN1A11yOp* grown = (CN1A11yOp*) realloc(cn1A11yOps, (size_t) capacity * sizeof(CN1A11yOp));
+ if (grown == 0) {
+ return;
+ }
+ cn1A11yOps = grown;
+ cn1A11yOpCapacity = capacity;
+ }
+ cn1A11yOps[cn1A11yOpCount].action = action;
+ cn1A11yOps[cn1A11yOpCount].data = data;
+ cn1A11yOpCount++;
+}
+
JAVA_VOID com_codename1_impl_linux_LinuxNative_accessibilityBegin__(CODENAME_ONE_THREAD_STATE) {
- cn1LinuxRunOnMainAndWait(cn1A11yBeginMain, 0);
+ /* Nothing reaches GTK yet; see accessibilityEnd. A publication that never
+ * reached its end leaves ops behind, and they are applied with this one. */
+ (void) threadStateData;
}
typedef struct {
@@ -1614,7 +1656,7 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_accessibilityNode___long_long_jav
call->label = label == JAVA_NULL ? 0 : strdup(stringToUTF8(threadStateData, label));
call->description = description == JAVA_NULL ? 0 : strdup(stringToUTF8(threadStateData, description));
call->value = value == JAVA_NULL ? 0 : strdup(stringToUTF8(threadStateData, value));
- cn1LinuxRunOnMainAndWait(cn1A11yNodeMain, call);
+ cn1A11yQueue(0, call);
}
static void cn1A11yActionActivated(GtkWidget* widget, gpointer pointer) {
@@ -1662,17 +1704,104 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_accessibilityAction___long_java_l
action->nodeId = nodeId; action->actionHash = actionHash;
action->actionId = actionId == JAVA_NULL ? strdup("") : strdup(stringToUTF8(threadStateData, actionId));
action->label = label == JAVA_NULL ? 0 : strdup(stringToUTF8(threadStateData, label));
- cn1LinuxRunOnMainAndWait(cn1A11yActionMain, action);
+ cn1A11yQueue(1, action);
+}
+
+/* A complete publication, handed from the publishing thread to the GTK thread. */
+typedef struct {
+ CN1A11yOp* ops;
+ int count;
+} CN1A11yBatch;
+
+/* The newest publication not yet applied, and whether an idle is already queued
+ * to apply it. Guarded by cn1A11yPendingLock: the publishing thread stores, the
+ * GTK thread takes. */
+static GMutex cn1A11yPendingLock;
+static CN1A11yBatch* cn1A11yPending = 0;
+static int cn1A11yApplyQueued = 0;
+
+static void cn1A11yFreeBatch(CN1A11yBatch* batch) {
+ int i;
+ if (batch == 0) {
+ return;
+ }
+ for (i = 0; i < batch->count; i++) {
+ if (batch->ops[i].action) {
+ cn1A11yActionFree(batch->ops[i].data, 0);
+ } else {
+ CN1A11yNodeCall* call = (CN1A11yNodeCall*) batch->ops[i].data;
+ free(call->role); free(call->label); free(call->description); free(call->value); free(call);
+ }
+ }
+ free(batch->ops);
+ free(batch);
}
-static void cn1A11yEndMain(void* pointer) {
- (void) pointer;
+static gboolean cn1A11yApplyIdle(gpointer ignored) {
+ int i;
+ CN1A11yBatch* batch;
+ (void) ignored;
+ g_mutex_lock(&cn1A11yPendingLock);
+ batch = cn1A11yPending;
+ cn1A11yPending = 0;
+ cn1A11yApplyQueued = 0;
+ g_mutex_unlock(&cn1A11yPendingLock);
+ if (batch == 0) {
+ return G_SOURCE_REMOVE;
+ }
+ cn1A11yBeginMain(0);
+ for (i = 0; i < batch->count; i++) {
+ if (batch->ops[i].action) {
+ cn1A11yActionMain(batch->ops[i].data);
+ } else {
+ cn1A11yNodeMain(batch->ops[i].data);
+ }
+ }
+ /* The node and action payloads now belong to the widgets and signal
+ * closures they were applied to; only the array is ours. */
+ free(batch->ops);
+ free(batch);
if (cn1AccessibilityFixed) gtk_widget_show_all(cn1AccessibilityFixed);
+ return G_SOURCE_REMOVE;
}
+/* Applied on the GTK thread LATER, at low priority, instead of while this thread
+ * waits. Building the tree creates a real GTK widget per node -- toggle buttons,
+ * entries, scales -- which on the first form of the transpiled Flutter gallery
+ * was about 5% of the launch's CPU, all of it before the first frame, with the
+ * event dispatch thread blocked until it finished. Nothing reads the tree back
+ * synchronously, and assistive technology cannot tell a tree built a few
+ * milliseconds after the frame from one built before it.
+ *
+ * Every publication replaces the whole tree (cn1A11yBeginMain destroys what the
+ * previous one built), so a publication that is still waiting when a newer one
+ * arrives is simply superseded and freed: a burst of form changes costs one
+ * rebuild, not one per change. */
JAVA_VOID com_codename1_impl_linux_LinuxNative_accessibilityEnd___int(CODENAME_ONE_THREAD_STATE, JAVA_INT changeType) {
+ CN1A11yBatch* batch;
+ CN1A11yBatch* superseded;
+ int queue;
(void) changeType;
- cn1LinuxRunOnMainAndWait(cn1A11yEndMain, 0);
+ (void) threadStateData;
+ batch = (CN1A11yBatch*) calloc(1, sizeof(CN1A11yBatch));
+ if (batch == 0) {
+ return;
+ }
+ batch->ops = cn1A11yOps;
+ batch->count = cn1A11yOpCount;
+ cn1A11yOps = 0;
+ cn1A11yOpCount = 0;
+ cn1A11yOpCapacity = 0;
+ g_mutex_lock(&cn1A11yPendingLock);
+ superseded = cn1A11yPending;
+ cn1A11yPending = batch;
+ queue = !cn1A11yApplyQueued;
+ cn1A11yApplyQueued = 1;
+ g_mutex_unlock(&cn1A11yPendingLock);
+ cn1A11yFreeBatch(superseded);
+ if (queue) {
+ gdk_threads_add_idle_full(G_PRIORITY_LOW, cn1A11yApplyIdle, 0, 0);
+ }
}
JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_pumpMessages___R_boolean(CODENAME_ONE_THREAD_STATE) {
diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
index 6847c31ffe0..0872f5f9d09 100644
--- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
+++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
@@ -219,9 +219,29 @@ private static byte[] readFully(InputStream i) throws IOException {
*/
private static LinuxImplementation mainLoopInstance;
+ /// The size the main window opens at, before the user or the window manager
+ /// changes it. Set by the generated bootstrap stub from the `desktop.width` and
+ /// `desktop.height` build hints, BEFORE Display.init: init creates the window,
+ /// and a resize afterwards would lay the first form out twice.
+ private static int defaultWindowWidth = 800;
+ private static int defaultWindowHeight = 600;
+
+ /// Called by the generated bootstrap stub only; see defaultWindowWidth.
+ ///
+ /// @param width the window's initial content width in pixels; ignored unless positive
+ /// @param height the window's initial content height in pixels; ignored unless positive
+ public static void setDefaultWindowSize(int width, int height) {
+ if (width > 0) {
+ defaultWindowWidth = width;
+ }
+ if (height > 0) {
+ defaultWindowHeight = height;
+ }
+ }
+
@Override
public void init(Object m) {
- LinuxNative.initDisplay("Codename One", 800, 600);
+ LinuxNative.initDisplay("Codename One", defaultWindowWidth, defaultWindowHeight);
windowGraphicsPeer = LinuxNative.getWindowGraphics();
windowGraphics = Long.valueOf(windowGraphicsPeer);
defaultFont = Long.valueOf(LinuxNative.getDefaultFont());
@@ -230,7 +250,19 @@ public void init(Object m) {
screenDpi = 96;
}
mainLoopInstance = this;
- installNativeTheme();
+ // Deferred, not installed here: an application that installs its own theme
+ // with setThemeProps -- every Flutter application, and any theme without
+ // @includeNativeBool -- resets the table and discards this one before
+ // anything reads it, and parsing and applying it was about 26ms of every
+ // launch of the transpiled Flutter gallery, all of it thrown away. It is
+ // still installed for an application with no theme of its own, the moment
+ // anything first reads the theme. See UIManager.setDeferredBaseTheme.
+ com.codename1.ui.plaf.UIManager.getInstance().setDeferredBaseTheme(new Runnable() {
+ @Override
+ public void run() {
+ installNativeTheme();
+ }
+ });
}
/**
@@ -300,7 +332,8 @@ public boolean hasNativeTheme() {
* iOSModernTheme.res. An app that loads its own theme layers over this.
* Silently does nothing when the theme resource is absent. This is the
* framework hook (UIManager calls it for {@code @includeNativeBool} themes);
- * init() also calls it so an app with no theme of its own still gets it.
+ * init() defers it (UIManager.setDeferredBaseTheme) so an app with no
+ * theme of its own still gets it, without paying for it when the app installs one.
*/
private boolean installingNativeTheme;
@@ -1931,6 +1964,25 @@ public void drawImage(Object graphics, Object img, int x, int y) {
LinuxNative.drawImage(peer(graphics), peer(img), x, y);
}
+ /// Cairo scales while it paints (drawImageScaled: the same cairo_scale and
+ /// default filter a pre-scaled copy would have been made with), so this port
+ /// can draw an image at any size directly. It used to answer the inherited
+ /// false, and Graphics.drawImage(img, x, y, w, h) then scaled the picture
+ /// first -- which for an EncodedImage means EncodedImage.scaled: decode,
+ /// scale, RE-ENCODE to PNG (or lossy JPEG for an opaque picture), decode
+ /// again. On every paint, since nothing kept the result: the transpiled
+ /// Flutter gallery ran 13 PNG encodes before its first frame, all from
+ /// scaled draws of its cards, icons and logo.
+ @Override
+ public boolean isScaledImageDrawingSupported() {
+ return true;
+ }
+
+ @Override
+ public void drawImage(Object graphics, Object img, int x, int y, int w, int h) {
+ LinuxNative.drawImageScaled(peer(graphics), peer(img), x, y, w, h);
+ }
+
@Override
public void drawRGB(Object graphics, int[] rgbData, int offset, int x, int y, int w, int h, boolean processAlpha) {
LinuxNative.drawRGB(peer(graphics), rgbData, offset, x, y, w, h, processAlpha);
diff --git a/Ports/MacPort/nativeSources/CN1MacAppDelegate.m b/Ports/MacPort/nativeSources/CN1MacAppDelegate.m
index 745c5dd708a..8cfb5080048 100644
--- a/Ports/MacPort/nativeSources/CN1MacAppDelegate.m
+++ b/Ports/MacPort/nativeSources/CN1MacAppDelegate.m
@@ -660,6 +660,10 @@ @implementation CN1MacAppDelegate
// paint path reads, and it has to be right from the first frame.
- (void)applicationDidFinishLaunching:(NSNotification *)notification {
+ // Normally a no-op: CN1MacBuildMainWindowBeforeRun released it before
+ // [NSApp run]. This is the backstop for a generated main that never calls
+ // that, so held menu commands cannot stay held for good.
+ CN1MacReleaseStartupMenu();
// Installed unconditionally, and this early. A scheduled notification is
// delivered through this delegate whether it fires while the application is
// frontmost or the user opens it from Notification Center, so without it a
@@ -965,8 +969,80 @@ - (void)application:(NSApplication *)application
@end
-/// Installs the delegate and the menu bar. Called from the generated main,
-/// before [NSApp run].
+/// Lets the event dispatch thread put its first frame on screen BEFORE [NSApp run].
+///
+/// [NSApp run] starts with finishLaunching, which is AppKit's own launch work --
+/// will/didFinishLaunching, state restoration, activation, the menu bar -- and it
+/// runs synchronously on the main thread before the run loop first turns. Every
+/// hop the event dispatch thread makes onto the main queue waits behind all of it,
+/// and the first frame is such a hop (the flush in CN1MacViewController). Measured
+/// on the GitHub macOS runner, per launch: the Java side had shown its first Form
+/// at ~175 ms and the window existed at ~197 ms, but the main thread then spent
+/// ~60-110 ms in finishLaunching and another ~50-80 ms in activation before it
+/// served the flush, so the frame went out at ~330-560 ms. The EDT sat blocked in
+/// dispatch_sync for that whole stretch (100-130 ms on a single geometry getter).
+///
+/// So the run loop is turned here, in the default mode, until the first frame
+/// has been presented. That services exactly what the EDT is waiting for -- the
+/// main dispatch queue -- and nothing that needs the launch to be finished: input
+/// events are not dequeued until NSApp does it, and the window was already
+/// ordered front by the build above, so the frame lands in a visible window.
+/// Anything the launch delivers later (activation, open-URL, notification
+/// responses) still goes through the delegate, which already copes with either
+/// order relative to the Java side being ready (see cn1_mac_runtime_markJavaReady).
+///
+/// Bounded, because an application is free to show nothing for a long time: past
+/// the deadline the launch simply proceeds as it did before, and the frame is
+/// served by [NSApp run] like any other hop. The bound is short enough that a
+/// slow first screen does not hold the menu bar and activation hostage, and long
+/// enough to cover a normal first frame on a slow machine.
+static void CN1MacServeFirstFrameBeforeRun(void) {
+ extern BOOL cn1MacFirstFramePresented;
+ CFAbsoluteTime deadline = CFAbsoluteTimeGetCurrent() + 0.75;
+ while (!cn1MacFirstFramePresented) {
+ CFTimeInterval left = deadline - CFAbsoluteTimeGetCurrent();
+ if (left <= 0) {
+ break;
+ }
+ // returnAfterSourceHandled: come back after every serviced block, so the
+ // loop notices the frame as soon as the flush that presented it returns.
+ // A pool per turn, because nothing else provides one here: this runs from
+ // the generated main before [NSApp run], whose event loop is what normally
+ // drains the main thread's autoreleased objects -- and a frame makes many.
+ SInt32 result;
+ @autoreleasepool {
+ result = CFRunLoopRunInMode(kCFRunLoopDefaultMode, left, true);
+ }
+ if (result == kCFRunLoopRunFinished) {
+ // Nothing in the mode to wait on; never the case on the main run loop,
+ // which always services the main queue, but a spin is worse than leaving.
+ break;
+ }
+ }
+ cn1StartupPhase("firstFrameServedBeforeRun");
+}
+
+/// Builds the main window on the main thread before [NSApp run], called from the
+/// generated main right after it has dispatched the VM boot. The queued build in
+/// CN1MacInstallAppDelegate then finds the window already there, and stays only as
+/// the fallback for a generated main that predates this function.
+///
+/// Measured on the GitHub macOS runner (flutter-bench, transpiled gallery): the
+/// queued build could not start until [NSApp run] had finished launching, ~264 ms
+/// into the process, and the first frame waited on it. Built here it starts at
+/// ~130 ms, overlapping the VM boot, and the Java side's first paint no longer
+/// waits for a busy main thread (125 ms -> 7-32 ms).
+void CN1MacBuildMainWindowBeforeRun(void) {
+ cn1StartupPhase("buildWindowBeforeRun");
+ (void)[CN1MacHost sharedHost].renderingView;
+ CN1MacServeFirstFrameBeforeRun();
+ // The menu bar and any commands held back while the frame was served.
+ CN1MacReleaseStartupMenu();
+}
+
+/// Installs the delegate. Called from the generated main, before [NSApp run]; the
+/// menu bar is built later, once the first frame is out (see the generated main
+/// and CN1MacReleaseStartupMenu).
void CN1MacInstallAppDelegate(void) {
static CN1MacAppDelegate *delegate = nil;
if (delegate == nil) {
@@ -986,6 +1062,10 @@ void CN1MacInstallAppDelegate(void) {
// thread skip a dispatch_sync onto a main queue that is still busy.
extern void CN1MacPublishPrimaryScale(void);
CN1MacPublishPrimaryScale();
+ // And the size the window will really open at, for the same reason: the first
+ // layout happens before the window exists. See CN1MacHost.m.
+ extern void CN1MacPublishExpectedContentSize(void);
+ CN1MacPublishExpectedContentSize();
// Build the window HERE, once, on this thread.
//
// It has to exist before the event dispatch thread first asks anything about
diff --git a/Ports/MacPort/nativeSources/CN1MacChrome.m b/Ports/MacPort/nativeSources/CN1MacChrome.m
index 63ad663b940..93e9288b450 100644
--- a/Ports/MacPort/nativeSources/CN1MacChrome.m
+++ b/Ports/MacPort/nativeSources/CN1MacChrome.m
@@ -204,7 +204,50 @@ static void cn1PruneEmptyGeneratedMenus(NSMenu *mainMenu) {
#endif
}
+/// Commands published before the first frame, and whether that hold is over.
+/// Main thread only, like everything that touches the menu.
+static NSArray *cn1PendingStartupRows = nil;
+static BOOL cn1StartupMenuReleased = NO;
+
+static void cn1ApplyMenuCommands(NSArray *rows);
+
void CN1MacHostSetMenuCommands(NSArray *rows) {
+ // Held until the first frame is on screen. A Form's commands are published
+ // as it is shown, which at launch is while the main thread is still serving
+ // the first frame before [NSApp run] -- and applying them builds the whole
+ // menu bar first (CN1MacInstallMainMenu), 10-20ms of main thread in front of
+ // the frame the user is waiting for on the benchmark runner. Nobody can open
+ // a menu before [NSApp run] takes events, so only the latest set is kept and
+ // CN1MacReleaseStartupMenu applies it once the frame is out.
+ if (!cn1StartupMenuReleased) {
+#ifndef CN1_USE_ARC
+ [cn1PendingStartupRows release];
+ cn1PendingStartupRows = [rows retain];
+#else
+ cn1PendingStartupRows = rows;
+#endif
+ return;
+ }
+ cn1ApplyMenuCommands(rows);
+}
+
+void CN1MacReleaseStartupMenu(void) {
+ if (cn1StartupMenuReleased) {
+ return;
+ }
+ cn1StartupMenuReleased = YES;
+ CN1MacInstallMainMenu();
+ if (cn1PendingStartupRows != nil) {
+ NSArray *rows = cn1PendingStartupRows;
+ cn1PendingStartupRows = nil;
+ cn1ApplyMenuCommands(rows);
+#ifndef CN1_USE_ARC
+ [rows release];
+#endif
+ }
+}
+
+static void cn1ApplyMenuCommands(NSArray *rows) {
#ifndef CN1_USE_ARC
[cn1MenuRows release];
cn1MenuRows = [rows retain];
diff --git a/Ports/MacPort/nativeSources/CN1MacHost.m b/Ports/MacPort/nativeSources/CN1MacHost.m
index 931e94b8a81..050539ea722 100644
--- a/Ports/MacPort/nativeSources/CN1MacHost.m
+++ b/Ports/MacPort/nativeSources/CN1MacHost.m
@@ -111,6 +111,87 @@ void CN1MacRefreshScaleValue(void) {
}
}
+/// The content size the main window will open at, in points, published once on
+/// the main thread before the application's main runs. 0 until then.
+///
+/// The window is created at the default 1024x685, but AppKit fits a window to the
+/// screen's visible frame when it is shown -- and on a small display, the menu bar
+/// and the Dock leave less than that. The GitHub macOS runner opens it at 1024x642.
+/// The size query answered 685 until the window existed and screenSizeChanged then
+/// said 642, so every launch there built and laid the tree out twice and settled
+/// 14MB heavier. Fitting the size up front, and building the window at it, makes
+/// the first answer the final one.
+static int cn1MacExpectedContentWidth = 0;
+static int cn1MacExpectedContentHeight = 0;
+
+static NSWindowStyleMask cn1MacMainWindowStyle(void) {
+ return NSWindowStyleMaskTitled | NSWindowStyleMaskClosable
+ | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable;
+}
+
+/// Main thread only; see cn1MacExpectedContentWidth.
+void CN1MacPublishExpectedContentSize(void) {
+ if (![NSThread isMainThread]) {
+ return;
+ }
+ CGFloat w = CN1_MAC_DEFAULT_WIDTH;
+ CGFloat h = CN1_MAC_DEFAULT_HEIGHT;
+ // The size the window will be given, not the size it is built with. Two things
+ // override the default after construction: macos.fixedWindowSize (the
+ // CN1FixedWindow* keys, applied by setFixedContentSize) and the frame AppKit
+ // restores from setFrameAutosaveName -- the size the user, or the previous
+ // launch, left it at. The benchmark runner hit the second one: the first launch
+ // fitted correctly, and every later launch restored the saved frame and laid the
+ // tree out twice again.
+ NSNumber *fixedW = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CN1FixedWindowWidth"];
+ NSNumber *fixedH = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CN1FixedWindowHeight"];
+ if (fixedW != nil && fixedH != nil && fixedW.doubleValue > 0 && fixedH.doubleValue > 0) {
+ w = fixedW.doubleValue;
+ h = fixedH.doubleValue;
+ } else {
+ NSString *saved = [[NSUserDefaults standardUserDefaults]
+ stringForKey:@"NSWindow Frame CN1MainWindow"];
+ NSArray *parts = [saved componentsSeparatedByCharactersInSet:
+ [NSCharacterSet whitespaceCharacterSet]];
+ if (parts.count >= 4) {
+ NSRect savedFrame = NSMakeRect(parts[0].doubleValue, parts[1].doubleValue,
+ parts[2].doubleValue, parts[3].doubleValue);
+ NSRect savedContent = [NSWindow contentRectForFrameRect:savedFrame
+ styleMask:cn1MacMainWindowStyle()];
+ if (savedContent.size.width > 0 && savedContent.size.height > 0) {
+ w = savedContent.size.width;
+ h = savedContent.size.height;
+ }
+ }
+ }
+ NSArray *screens = [NSScreen screens];
+ if (screens.count > 0) {
+ NSRect visible = [[screens objectAtIndex:0] visibleFrame];
+ NSRect content = NSMakeRect(0, 0, w, h);
+ NSRect frame = [NSWindow frameRectForContentRect:content styleMask:cn1MacMainWindowStyle()];
+ CGFloat chromeW = frame.size.width - content.size.width;
+ CGFloat chromeH = frame.size.height - content.size.height;
+ if (visible.size.width - chromeW < w) {
+ w = floor(visible.size.width - chromeW);
+ }
+ if (visible.size.height - chromeH < h) {
+ h = floor(visible.size.height - chromeH);
+ }
+ }
+ __atomic_store_n(&cn1MacExpectedContentWidth, (int)w, __ATOMIC_RELEASE);
+ __atomic_store_n(&cn1MacExpectedContentHeight, (int)h, __ATOMIC_RELEASE);
+}
+
+static CGFloat cn1MacContentWidthPoints(void) {
+ int w = __atomic_load_n(&cn1MacExpectedContentWidth, __ATOMIC_ACQUIRE);
+ return w > 0 ? w : CN1_MAC_DEFAULT_WIDTH;
+}
+
+static CGFloat cn1MacContentHeightPoints(void) {
+ int h = __atomic_load_n(&cn1MacExpectedContentHeight, __ATOMIC_ACQUIRE);
+ return h > 0 ? h : CN1_MAC_DEFAULT_HEIGHT;
+}
+
@implementation CN1MacHost {
NSWindow *_window;
METALView *_renderingView;
@@ -170,17 +251,27 @@ - (void)buildWindow {
return;
}
- NSRect frame = NSMakeRect(0, 0, CN1_MAC_DEFAULT_WIDTH, CN1_MAC_DEFAULT_HEIGHT);
- NSWindowStyleMask style = NSWindowStyleMaskTitled
- | NSWindowStyleMaskClosable
- | NSWindowStyleMaskMiniaturizable
- | NSWindowStyleMaskResizable;
+ cn1StartupPhase("buildWindow.enter");
+ NSRect frame = NSMakeRect(0, 0, cn1MacContentWidthPoints(), cn1MacContentHeightPoints());
+ NSWindowStyleMask style = cn1MacMainWindowStyle();
// CN1MacWindow, not NSWindow: a modal dialog usually blocks THIS window, and
// blocking it has to include refusing key focus. See CN1AppKitWindows.h.
_window = [[CN1MacWindow alloc] initWithContentRect:frame
styleMask:style
backing:NSBackingStoreBuffered
defer:NO];
+ // Published HERE, the instant builtWindow stops being nil, rather than from
+ // the block CN1MacInstallAppDelegate queues for later. In between,
+ // macMonitorForMainWindow found a built window with nothing published and
+ // marshalled onto this thread to ask it -- which, at start-up, is busy
+ // finishing this very build and then launching the application. Measured on
+ // the benchmark runner: the event dispatch thread sat 60-70ms in that hop,
+ // inside UIManager's constructor, on the launches where its first
+ // convertToPixels landed after the window existed. The window has no screen
+ // yet, so this publishes the primary one -- the same answer the hop gave --
+ // and the republish below corrects it once the window is placed.
+ extern void CN1MacPublishMainWindowScreen(void);
+ CN1MacPublishMainWindowScreen();
((CN1MacWindow *)_window).cn1AcceptsKey = YES;
// Remembers where the user left it between launches. One line, and its
// absence is the kind of thing that makes an app feel unfinished.
@@ -260,6 +351,27 @@ - (void)buildWindow {
// this process in front. Without this the app launches, runs and draws --
// behind whatever the user was already looking at.
[NSApp activateIgnoringOtherApps:YES];
+ // On screen now, so the real screen rather than the primary stand-in.
+ CN1MacPublishMainWindowScreen();
+ cn1StartupPhase("buildWindow.exit");
+}
+
+/// The scale to answer with before the window exists.
+///
+/// The default size is in POINTS -- it is the content rectangle the window is
+/// built with -- and Codename One lays out in device pixels. Answering it
+/// unscaled told a Retina launch its screen was 1024x685 until the window
+/// arrived and screenSizeChanged said 2048x1370, so the first layout of every
+/// launch was done at half size and then thrown away: the whole tree was built,
+/// laid out and rebuilt inside the first frame. The primary screen's scale is
+/// already published before the application's main runs (see
+/// CN1MacPublishPrimaryScale), so the answer can be in pixels without touching
+/// AppKit off the main thread. 0 from the reader means "not safe to guess" -- a
+/// mixed-scale desktop -- and then this stays at 1, as it always did.
+static CGFloat cn1MacDefaultScale(void) {
+ extern int cn1MacPublishedScaleTimes100(void);
+ int s100 = cn1MacPublishedScaleTimes100();
+ return s100 > 0 ? s100 / 100.0 : 1.0;
}
/// Answering a size query must not WAIT for the window either.
@@ -275,7 +387,7 @@ - (void)buildWindow {
- (int)displayWidth {
NSView *v = _renderingView;
if (v == nil) {
- return (int)CN1_MAC_DEFAULT_WIDTH;
+ return (int)(cn1MacContentWidthPoints() * cn1MacDefaultScale());
}
// Device pixels, not points: Codename One lays out in pixels and a Retina
// display has two of them per point.
@@ -285,7 +397,7 @@ - (int)displayWidth {
- (int)displayHeight {
NSView *v = _renderingView;
if (v == nil) {
- return (int)CN1_MAC_DEFAULT_HEIGHT;
+ return (int)(cn1MacContentHeightPoints() * cn1MacDefaultScale());
}
return (int)(v.bounds.size.height * CN1AppKitBackingScale(v));
}
diff --git a/Ports/MacPort/nativeSources/CN1MacMenu.h b/Ports/MacPort/nativeSources/CN1MacMenu.h
index 63c1dc15d4b..269a784bd61 100644
--- a/Ports/MacPort/nativeSources/CN1MacMenu.h
+++ b/Ports/MacPort/nativeSources/CN1MacMenu.h
@@ -31,7 +31,8 @@ extern "C" {
/// Builds the application's menu bar.
///
-/// Called from the generated `main` before the run loop starts, because an app
+/// Called from the generated `main` before the run loop starts -- after the main
+/// window and its first frame, which is all the user is waiting for -- because an app
/// with no menu bar has no Quit item, and an app with no Quit item does not pass
/// App Store review and cannot be closed from the keyboard.
///
@@ -41,8 +42,13 @@ extern "C" {
/// XIBs.
void CN1MacInstallMainMenu(void);
-/// Installs the NSApplicationDelegate. Called from the generated main alongside
-/// the menu bar, before [NSApp run].
+/// Lets the menu bar be built, and applies any commands the application
+/// published before it could be. Main thread only; idempotent. See
+/// CN1MacHostSetMenuCommands for why commands are held back until this runs.
+void CN1MacReleaseStartupMenu(void);
+
+/// Installs the NSApplicationDelegate. Called from the generated main before
+/// [NSApp run], ahead of the window and the menu bar.
void CN1MacInstallAppDelegate(void);
#ifdef __cplusplus
diff --git a/Ports/MacPort/nativeSources/CN1MacMenu.m b/Ports/MacPort/nativeSources/CN1MacMenu.m
index ca9c28b077d..fb4a2ced794 100644
--- a/Ports/MacPort/nativeSources/CN1MacMenu.m
+++ b/Ports/MacPort/nativeSources/CN1MacMenu.m
@@ -34,7 +34,21 @@
return name != nil ? name : @"Application";
}
+/// Set once the menu bar exists; see CN1MacInstallMainMenu. Main thread only.
+static BOOL cn1MacMainMenuInstalled = NO;
+
void CN1MacInstallMainMenu(void) {
+ // Idempotent, because it now has two callers that can run in either order.
+ // The generated main installs the menu only after the first frame (see the
+ // comment there), and by then the application may already have published
+ // its own commands -- CN1MacHostSetMenuCommands installs the menu itself
+ // when it finds none and appends to it. Building a second menu over that one
+ // would silently drop every command the application had added.
+ if (cn1MacMainMenuInstalled) {
+ return;
+ }
+ cn1MacMainMenuInstalled = YES;
+ cn1StartupPhase("installMainMenu.enter");
NSString *appName = CN1MacAppName();
NSMenu *mainMenu = [[NSMenu alloc] initWithTitle:@""];
@@ -112,6 +126,16 @@ void CN1MacInstallMainMenu(void) {
[NSApp setHelpMenu:helpMenu];
[NSApp setMainMenu:mainMenu];
+
+ // The menu can be installed after the main window is already on screen, and
+ // a Window menu handed over late does not necessarily list the windows that
+ // existed before it. changeWindowsItem adds the entry when there is none and
+ // only retitles one that is already there, so this is safe either way.
+ for (NSWindow *w in [NSApp windows]) {
+ if (w.isVisible && !w.isExcludedFromWindowsMenu) {
+ [NSApp changeWindowsItem:w title:(w.title != nil ? w.title : @"") filename:NO];
+ }
+ }
}
#endif
diff --git a/Ports/MacPort/nativeSources/CN1MacViewController.m b/Ports/MacPort/nativeSources/CN1MacViewController.m
index a1500771b8e..e9c23bed706 100644
--- a/Ports/MacPort/nativeSources/CN1MacViewController.m
+++ b/Ports/MacPort/nativeSources/CN1MacViewController.m
@@ -52,6 +52,9 @@
/// Defined here because on the UIKit ports it lives in the app delegate, which
/// this port replaces.
BOOL isAppSuspended = NO;
+/// Set on the main thread once the first frame has been handed to the GPU. Read
+/// only by CN1MacBuildMainWindowBeforeRun, on the main thread as well.
+BOOL cn1MacFirstFramePresented = NO;
int mallocWhileSuspended = 0;
static CodenameOne_GLViewController *singletonInstance = nil;
@@ -199,6 +202,8 @@ - (void)drawFrame:(CGRect)rect {
}
- (void)drawFrame:(CGRect)rect allowInactive:(BOOL)allowInactive {
+ static int firstDraw = 1;
+ if (firstDraw) { firstDraw = 0; cn1StartupPhase("firstDrawFrame"); }
METALView *v = (METALView *)[CN1MacHost sharedHost].activeRenderingView;
if (v == nil) {
return;
@@ -281,6 +286,7 @@ - (void)drawFrame:(CGRect)rect allowInactive:(BOOL)allowInactive {
[DrawStringTextureCache flushDeleted];
[v presentFramebuffer];
painted = YES;
+ cn1MacFirstFramePresented = YES;
#ifndef CN1_USE_ARC
[ops release];
#endif
diff --git a/Ports/MacPort/nativeSources/METALView.m b/Ports/MacPort/nativeSources/METALView.m
index c2f85f0ac7e..fc919b57904 100644
--- a/Ports/MacPort/nativeSources/METALView.m
+++ b/Ports/MacPort/nativeSources/METALView.m
@@ -29,6 +29,7 @@
#import "CN1Metalcompat.h"
#include
#import
+#include
/// Three, not two. The window server is not asked when it has finished reading a
/// surface -- there is no such callback -- so the only safe surface to draw into
@@ -199,6 +200,50 @@ @implementation METALView {
/// surface under the layer is never a legal target no matter who owns it,
/// and it stops being current the moment a later frame replaces it.
_Atomic int cn1PresentCurrent;
+
+ /// Whether each surface is currently marked purgeable (volatile).
+ ///
+ /// Memory at rest. Three full-window surfaces exist so a frame always has one
+ /// the window server is not reading, but once the application is idle only
+ /// the surface under the layer is ever looked at again. The other two stay
+ /// allocated, and each is a whole window of BGRA -- two thirds of what this
+ /// path holds, counted in the process footprint for as long as the window is
+ /// open. A volatile surface is not counted, and the kernel may reclaim its
+ /// pages under pressure. Nothing is lost if it does: every frame blits the
+ /// entire framebuffer into the surface it picks, so a surface's previous
+ /// contents are never read. A surface is made non-volatile again before that
+ /// blit, under cn1PresentLock, so it cannot be purged while it is written.
+ BOOL cn1PresentVolatile[CN1_PRESENT_SURFACE_COUNT];
+
+ /// Guards cn1PresentVolatile and the choice of a surface against the idle
+ /// sweep that marks surfaces volatile (cn1RetireIdleSurfaces, on the main
+ /// queue) and against the rebuild that releases them. Without it the sweep
+ /// could mark a surface volatile between the render thread choosing it and
+ /// the blit, or touch a surface the rebuild has just released. Native-side
+ /// state only; held for a handful of flag reads and at most three
+ /// IOSurfaceSetPurgeable calls.
+ os_unfair_lock cn1PresentLock;
+
+ /// Consecutive sweeps that found a released surface still held by the window
+ /// server. Bounds the retries, so a surface that stays in use (it should not)
+ /// cannot keep a timer alive forever.
+ int cn1PresentSweepRetries;
+
+ /// Whether screenTexture is currently marked purgeable, and the frame counter
+ /// that decides when it may be. Both guarded by cn1PresentLock.
+ ///
+ /// At rest the persistent target duplicates the surface under the layer: every
+ /// presented frame blits all of it there. So once a frame's GPU work completes
+ /// and no newer frame has started, the texture is marked volatile and stops
+ /// counting in the footprint. The next user of it -- a frame, or a readback --
+ /// makes it non-volatile first (cn1ScreenTextureResidentFor:), and if the kernel
+ /// did reclaim the pages, copies the surface under the layer back into it
+ /// before anything reads it. The counter is what keeps a late completion from
+ /// marking the texture volatile under a frame that has already begun.
+ BOOL cn1ScreenVolatile;
+ unsigned long long cn1ScreenFrameSeq;
+ /// The counter value of the frame currently being encoded; render thread only.
+ unsigned long long cn1OpenFrameSeq;
}
@synthesize commandQueue;
@@ -521,7 +566,12 @@ - (void)applyPendingFrameBufferSize {
self.layer.contentsScale = CN1AppKitBackingScale(self);
// The surface the window server reads. Replaced rather than resized: an
- // IOSurface is fixed at its creation size.
+ // IOSurface is fixed at its creation size. Under cn1PresentLock, so the idle
+ // sweep never touches a surface while it is being released or replaced.
+ os_unfair_lock_lock(&cn1PresentLock);
+ for (int i = 0; i < CN1_PRESENT_SURFACE_COUNT; i++) {
+ cn1PresentVolatile[i] = NO;
+ }
for (int i = 0; i < CN1_PRESENT_SURFACE_COUNT; i++) {
if (cn1PresentTextures[i] != nil) {
[cn1PresentTextures[i] release];
@@ -578,6 +628,7 @@ - (void)applyPendingFrameBufferSize {
[CATransaction commit];
atomic_store_explicit(&cn1PresentCurrent, 0, memory_order_release);
}
+ os_unfair_lock_unlock(&cn1PresentLock);
// The persistent target. Codename One only queues the operations that
// changed since the previous frame, so it has to survive between them; a
@@ -588,7 +639,10 @@ - (void)applyPendingFrameBufferSize {
desc.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead;
desc.storageMode = MTLStorageModePrivate;
id newScreen = [CN1MetalDevice() newTextureWithDescriptor:desc];
+ os_unfair_lock_lock(&cn1PresentLock);
self.screenTexture = newScreen;
+ cn1ScreenVolatile = NO;
+ os_unfair_lock_unlock(&cn1PresentLock);
#ifndef CN1_USE_ARC
[newScreen release];
#endif
@@ -614,6 +668,15 @@ - (void)applyPendingFrameBufferSize {
width:pw height:ph mipmapped:NO];
stencilDesc.usage = MTLTextureUsageRenderTarget;
stencilDesc.storageMode = MTLStorageModePrivate;
+ // Memoryless where the GPU can: the stencil is cleared at the start of every
+ // pass and never stored (createRenderPassDescriptor), so on an Apple GPU it can
+ // live in tile memory alone and needs no allocation -- a full window of
+ // Stencil8 less at rest. Private elsewhere, where memoryless does not exist.
+ if (@available(macOS 11.0, *)) {
+ if ([CN1MetalDevice() supportsFamily:MTLGPUFamilyApple1]) {
+ stencilDesc.storageMode = MTLStorageModeMemoryless;
+ }
+ }
id newStencil = [CN1MetalDevice() newTextureWithDescriptor:stencilDesc];
self.stencilTexture = newStencil;
#ifndef CN1_USE_ARC
@@ -713,6 +776,57 @@ - (void)createRenderPassDescriptor {
}
}
+/// Makes screenTexture safe to read and draw into for work encoded on {@code cb},
+/// restoring its contents if the kernel reclaimed them while it was volatile
+/// (see cn1ScreenVolatile). Render thread, or readback, before anything touches the
+/// texture on {@code cb}. Returns the frame counter value this use owns.
+- (unsigned long long)cn1ScreenTextureResidentFor:(id)cb {
+ BOOL lost = NO;
+ id source = nil;
+ id target = nil;
+ os_unfair_lock_lock(&cn1PresentLock);
+ unsigned long long seq = ++cn1ScreenFrameSeq;
+ if (cn1ScreenVolatile && self.screenTexture != nil) {
+ MTLPurgeableState old = [self.screenTexture setPurgeableState:MTLPurgeableStateNonVolatile];
+ cn1ScreenVolatile = NO;
+ if (old == MTLPurgeableStateEmpty) {
+ lost = YES;
+ target = self.screenTexture;
+ // The surface under the layer holds exactly what the texture held: the
+ // last presented frame copied all of it there. It is never volatile.
+ int current = atomic_load_explicit(&cn1PresentCurrent, memory_order_acquire);
+ if (current >= 0 && current < CN1_PRESENT_SURFACE_COUNT) {
+ source = cn1PresentTextures[current];
+ }
+#ifndef CN1_USE_ARC
+ [target retain];
+ [source retain];
+#endif
+ }
+ }
+ os_unfair_lock_unlock(&cn1PresentLock);
+ if (lost) {
+ if (source != nil && cb != nil
+ && source.width == target.width && source.height == target.height) {
+ id restore = [cb blitCommandEncoder];
+ [restore copyFromTexture:source sourceSlice:0 sourceLevel:0
+ sourceOrigin:MTLOriginMake(0, 0, 0)
+ sourceSize:MTLSizeMake(target.width, target.height, 1)
+ toTexture:target destinationSlice:0 destinationLevel:0
+ destinationOrigin:MTLOriginMake(0, 0, 0)];
+ [restore endEncoding];
+ } else {
+ // Nothing to copy from: clear, and let the next full repaint fill it.
+ [self invalidateRetainedFramebuffer];
+ }
+#ifndef CN1_USE_ARC
+ [target release];
+ [source release];
+#endif
+ }
+ return seq;
+}
+
- (void)setFramebuffer {
// Tolerates being called more than once per frame, as the UIKit backend
// does: creating a second encoder would discard everything queued against
@@ -724,6 +838,8 @@ - (void)setFramebuffer {
// no encoder exists yet, so nothing can be encoding into what is replaced.
[self applyPendingFrameBufferSize];
self.commandBuffer = [self.commandQueue commandBuffer];
+ // Before the render pass loads it; see cn1ScreenVolatile.
+ cn1OpenFrameSeq = [self cn1ScreenTextureResidentFor:self.commandBuffer];
[self createRenderPassDescriptor];
if (self.renderPassDescriptor == nil) {
self.renderCommandEncoder = nil;
@@ -740,6 +856,39 @@ - (void)setFramebuffer {
framebufferWidth, framebufferHeight);
}
+/// Marks every surface nobody is reading volatile, so an idle window counts one
+/// surface in the footprint rather than three (see cn1PresentVolatile).
+///
+/// Main queue only. A surface is left alone while it is under the layer, while a
+/// blit into it is in flight, or while the window server still holds it; the
+/// last of those clears on its own a composite later, so the sweep retries a
+/// bounded number of times instead of leaving that surface resident.
+- (void)cn1RetireIdleSurfaces {
+ BOOL held = NO;
+ os_unfair_lock_lock(&cn1PresentLock);
+ int current = atomic_load_explicit(&cn1PresentCurrent, memory_order_acquire);
+ for (int i = 0; i < CN1_PRESENT_SURFACE_COUNT; i++) {
+ if (i == current || cn1PresentSurfaces[i] == NULL || cn1PresentVolatile[i]
+ || atomic_load_explicit(&cn1PresentInFlight[i], memory_order_acquire)) {
+ continue;
+ }
+ if (IOSurfaceIsInUse(cn1PresentSurfaces[i])) {
+ held = YES;
+ continue;
+ }
+ IOSurfaceSetPurgeable(cn1PresentSurfaces[i], kIOSurfacePurgeableVolatile, NULL);
+ cn1PresentVolatile[i] = YES;
+ }
+ os_unfair_lock_unlock(&cn1PresentLock);
+ if (held && cn1PresentSweepRetries++ < 40) {
+ METALView *sweepView = self;
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(NSEC_PER_SEC / 20)),
+ dispatch_get_main_queue(), ^{
+ [sweepView cn1RetireIdleSurfaces];
+ });
+ }
+}
+
/// Asks the framework for another frame, if one is owed.
///
/// Clears the owed flag first and only acts if it was set, so the two callers --
@@ -832,6 +981,9 @@ - (BOOL)presentFramebuffer {
// The search starts at the slot after the one used last, so a surface is not
// immediately reused while its own completion handler is still pending.
int presentIdx = -1;
+ // Choosing, reserving and making the surface non-volatile happen together
+ // under the lock: see cn1PresentVolatile.
+ os_unfair_lock_lock(&cn1PresentLock);
for (int i = 0; i < CN1_PRESENT_SURFACE_COUNT; i++) {
int candidate = (cn1PresentIndex + i) % CN1_PRESENT_SURFACE_COUNT;
if (cn1PresentSurfaces[candidate] != NULL
@@ -847,6 +999,19 @@ - (BOOL)presentFramebuffer {
break;
}
}
+ if (presentIdx >= 0) {
+ // Reserved here rather than after the blit is encoded, so the sweep cannot
+ // see it as idle in between.
+ atomic_store_explicit(&cn1PresentInFlight[presentIdx], 1, memory_order_release);
+ if (cn1PresentVolatile[presentIdx]) {
+ // Whatever the kernel did with the old pages does not matter: the blit
+ // below overwrites the whole surface.
+ IOSurfaceSetPurgeable(cn1PresentSurfaces[presentIdx],
+ kIOSurfacePurgeableNonVolatile, NULL);
+ cn1PresentVolatile[presentIdx] = NO;
+ }
+ }
+ os_unfair_lock_unlock(&cn1PresentLock);
if (presentIdx < 0) {
// Every surface is still held. Overwriting one anyway would undo the
// check just made, so drop the frame instead: the command buffer is
@@ -869,6 +1034,7 @@ - (BOOL)presentFramebuffer {
// Selection excludes these now, so this is belt and braces -- but it
// gives up the same way everything else does rather than losing the
// frame, which is what it used to do.
+ atomic_store_explicit(&cn1PresentInFlight[presentIdx], 0, memory_order_release);
return [self cn1DeferFrameAndCommit];
}
id blit = [self.commandBuffer blitCommandEncoder];
@@ -909,6 +1075,7 @@ - (BOOL)presentFramebuffer {
int frameGeneration = atomic_load_explicit(&cn1PresentGeneration, memory_order_acquire);
METALView *presentView = self;
const int completedIdx = presentIdx;
+ const unsigned long long frameSeq = cn1OpenFrameSeq;
[self.commandBuffer addCompletedHandler:^(id cb) {
// One load for both decisions below, so they cannot disagree.
int currentGeneration = atomic_load_explicit(&presentView->cn1PresentGeneration,
@@ -949,6 +1116,16 @@ - (BOOL)presentFramebuffer {
if (currentGeneration == frameGeneration) {
atomic_store_explicit(&presentView->cn1PresentInFlight[completedIdx], 0,
memory_order_release);
+ // The persistent target duplicates what this frame just put under the
+ // layer. Unless a newer frame (or a readback) has started using it since,
+ // it may stop counting in the footprint; see cn1ScreenVolatile.
+ os_unfair_lock_lock(&presentView->cn1PresentLock);
+ if (presentView->cn1ScreenFrameSeq == frameSeq && !presentView->cn1ScreenVolatile
+ && presentView.screenTexture != nil) {
+ [presentView.screenTexture setPurgeableState:MTLPurgeableStateVolatile];
+ presentView->cn1ScreenVolatile = YES;
+ }
+ os_unfair_lock_unlock(&presentView->cn1PresentLock);
// A surface just became free. If a frame was dropped because none
// was, ask the framework to paint again -- the content of that frame
// is still in screenTexture, but only a frame started on the thread
@@ -959,6 +1136,14 @@ - (BOOL)presentFramebuffer {
dispatch_async(dispatch_get_main_queue(), ^{
[presentView cn1PayDeferredPresent];
});
+ // The surface this replaced is idle now, or will be once the window
+ // server lets go of it. Shortly after, not immediately: the server
+ // still holds it until its next composite.
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(NSEC_PER_SEC / 20)),
+ dispatch_get_main_queue(), ^{
+ presentView->cn1PresentSweepRetries = 0;
+ [presentView cn1RetireIdleSurfaces];
+ });
}
CFRelease(presented);
}];
@@ -1003,8 +1188,15 @@ - (void)dealloc {
#endif
- (void)deleteFramebuffer {
+ os_unfair_lock_lock(&cn1PresentLock);
self.screenTexture = nil;
+ cn1ScreenVolatile = NO;
+ os_unfair_lock_unlock(&cn1PresentLock);
self.stencilTexture = nil;
+ os_unfair_lock_lock(&cn1PresentLock);
+ for (int i = 0; i < CN1_PRESENT_SURFACE_COUNT; i++) {
+ cn1PresentVolatile[i] = NO;
+ }
for (int i = 0; i < CN1_PRESENT_SURFACE_COUNT; i++) {
if (cn1PresentTextures[i] != nil) {
[cn1PresentTextures[i] release];
@@ -1026,6 +1218,7 @@ - (void)deleteFramebuffer {
atomic_store_explicit(&cn1PresentDeferred, 0, memory_order_release);
// Nothing is under the layer any more either; the surface it named is gone.
atomic_store_explicit(&cn1PresentCurrent, -1, memory_order_release);
+ os_unfair_lock_unlock(&cn1PresentLock);
framebufferWidth = 0;
framebufferHeight = 0;
}
@@ -1084,6 +1277,8 @@ - (BOOL)readbackInto:(unsigned int *)argb width:(int)w height:(int)h {
return NO;
}
id cb = [self.commandQueue commandBuffer];
+ // The texture may be volatile between frames; see cn1ScreenVolatile.
+ [self cn1ScreenTextureResidentFor:cb];
id blit = [cb blitCommandEncoder];
[blit copyFromTexture:self.screenTexture
sourceSlice:0 sourceLevel:0
diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
index bbfde989e0e..c58721544c5 100644
--- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
+++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
@@ -221,9 +221,29 @@ private static byte[] readFully(InputStream i) throws IOException {
*/
private static WindowsImplementation mainLoopInstance;
+ /// The size the main window opens at, before the user or the window manager
+ /// changes it. Set by the generated bootstrap stub from the `desktop.width` and
+ /// `desktop.height` build hints, BEFORE Display.init: init creates the window,
+ /// and a resize afterwards would lay the first form out twice.
+ private static int defaultWindowWidth = 800;
+ private static int defaultWindowHeight = 600;
+
+ /// Called by the generated bootstrap stub only; see defaultWindowWidth.
+ ///
+ /// @param width the window's initial content width in pixels; ignored unless positive
+ /// @param height the window's initial content height in pixels; ignored unless positive
+ public static void setDefaultWindowSize(int width, int height) {
+ if (width > 0) {
+ defaultWindowWidth = width;
+ }
+ if (height > 0) {
+ defaultWindowHeight = height;
+ }
+ }
+
@Override
public void init(Object m) {
- WindowsNative.initDisplay("Codename One", 800, 600);
+ WindowsNative.initDisplay("Codename One", defaultWindowWidth, defaultWindowHeight);
windowGraphicsPeer = WindowsNative.getWindowGraphics();
windowGraphics = Long.valueOf(windowGraphicsPeer);
defaultFont = Long.valueOf(WindowsNative.getDefaultFont());
@@ -232,7 +252,19 @@ public void init(Object m) {
screenDpi = 96;
}
mainLoopInstance = this;
- installNativeTheme();
+ // Deferred, not installed here: an application that installs its own theme
+ // with setThemeProps -- every Flutter application, and any theme without
+ // @includeNativeBool -- resets the table and discards this one before
+ // anything reads it, and parsing and applying it was about 26ms of every
+ // launch of the transpiled Flutter gallery, all of it thrown away. It is
+ // still installed for an application with no theme of its own, the moment
+ // anything first reads the theme. See UIManager.setDeferredBaseTheme.
+ com.codename1.ui.plaf.UIManager.getInstance().setDeferredBaseTheme(new Runnable() {
+ @Override
+ public void run() {
+ installNativeTheme();
+ }
+ });
}
/**
@@ -302,7 +334,8 @@ public boolean hasNativeTheme() {
* iOSModernTheme.res. An app that loads its own theme layers over this.
* Silently does nothing when the theme resource is absent. This is the
* framework hook (UIManager calls it for {@code @includeNativeBool} themes);
- * init() also calls it so an app with no theme of its own still gets it.
+ * init() defers it (UIManager.setDeferredBaseTheme) so an app with no
+ * theme of its own still gets it, without paying for it when the app installs one.
*/
private boolean installingNativeTheme;
@@ -1930,6 +1963,29 @@ public void drawImage(Object graphics, Object img, int x, int y) {
WindowsNative.drawImage(peer(graphics), peer(img), x, y);
}
+ /// Direct2D scales while it draws (drawImageScaled: the same bitmap, clip and
+ /// linear interpolation as the unscaled draw, into a w x h rectangle), so this
+ /// port can draw an image at any size directly. It used to answer the
+ /// inherited false, and Graphics.drawImage(img, x, y, w, h) then scaled the
+ /// picture first -- which for an EncodedImage means EncodedImage.scaled:
+ /// decode, scale, RE-ENCODE to PNG (or lossy JPEG for an opaque picture),
+ /// decode again, on every paint. The Linux port had the identical gap; the
+ /// transpiled Flutter gallery ran 13 PNG encodes before its first frame there.
+ @Override
+ public boolean isScaledImageDrawingSupported() {
+ return true;
+ }
+
+ @Override
+ public void drawImage(Object graphics, Object img, int x, int y, int w, int h) {
+ // The native builds its destination as x..x+w, y..y+h and has no guard of
+ // its own; a degenerate or inverted rectangle has nothing to draw.
+ if (w <= 0 || h <= 0) {
+ return;
+ }
+ WindowsNative.drawImageScaled(peer(graphics), peer(img), x, y, w, h);
+ }
+
@Override
public void drawRGB(Object graphics, int[] rgbData, int offset, int x, int y, int w, int h, boolean processAlpha) {
WindowsNative.drawRGB(peer(graphics), rgbData, offset, x, y, w, h, processAlpha);
diff --git a/Ports/iOSPort/nativeSources/zoozi18n.bundle/de.lproj/Localizable.strings b/Ports/iOSPort/Zooz/zoozi18n.bundle/de.lproj/Localizable.strings
similarity index 100%
rename from Ports/iOSPort/nativeSources/zoozi18n.bundle/de.lproj/Localizable.strings
rename to Ports/iOSPort/Zooz/zoozi18n.bundle/de.lproj/Localizable.strings
diff --git a/Ports/iOSPort/nativeSources/zoozi18n.bundle/en.lproj/InfoPlist.strings b/Ports/iOSPort/Zooz/zoozi18n.bundle/en.lproj/InfoPlist.strings
similarity index 100%
rename from Ports/iOSPort/nativeSources/zoozi18n.bundle/en.lproj/InfoPlist.strings
rename to Ports/iOSPort/Zooz/zoozi18n.bundle/en.lproj/InfoPlist.strings
diff --git a/Ports/iOSPort/nativeSources/zoozi18n.bundle/en.lproj/Localizable.strings b/Ports/iOSPort/Zooz/zoozi18n.bundle/en.lproj/Localizable.strings
similarity index 100%
rename from Ports/iOSPort/nativeSources/zoozi18n.bundle/en.lproj/Localizable.strings
rename to Ports/iOSPort/Zooz/zoozi18n.bundle/en.lproj/Localizable.strings
diff --git a/Ports/iOSPort/nativeSources/zoozi18n.bundle/es.lproj/Localizable.strings b/Ports/iOSPort/Zooz/zoozi18n.bundle/es.lproj/Localizable.strings
similarity index 100%
rename from Ports/iOSPort/nativeSources/zoozi18n.bundle/es.lproj/Localizable.strings
rename to Ports/iOSPort/Zooz/zoozi18n.bundle/es.lproj/Localizable.strings
diff --git a/Ports/iOSPort/nativeSources/zoozi18n.bundle/fr.lproj/Localizable.strings b/Ports/iOSPort/Zooz/zoozi18n.bundle/fr.lproj/Localizable.strings
similarity index 100%
rename from Ports/iOSPort/nativeSources/zoozi18n.bundle/fr.lproj/Localizable.strings
rename to Ports/iOSPort/Zooz/zoozi18n.bundle/fr.lproj/Localizable.strings
diff --git a/Ports/iOSPort/nativeSources/zoozi18n.bundle/he.lproj/Localizable.strings b/Ports/iOSPort/Zooz/zoozi18n.bundle/he.lproj/Localizable.strings
similarity index 100%
rename from Ports/iOSPort/nativeSources/zoozi18n.bundle/he.lproj/Localizable.strings
rename to Ports/iOSPort/Zooz/zoozi18n.bundle/he.lproj/Localizable.strings
diff --git a/Ports/iOSPort/nativeSources/zoozi18n.bundle/hu.lproj/Localizable.strings b/Ports/iOSPort/Zooz/zoozi18n.bundle/hu.lproj/Localizable.strings
similarity index 100%
rename from Ports/iOSPort/nativeSources/zoozi18n.bundle/hu.lproj/Localizable.strings
rename to Ports/iOSPort/Zooz/zoozi18n.bundle/hu.lproj/Localizable.strings
diff --git a/Ports/iOSPort/nativeSources/zoozi18n.bundle/it.lproj/Localizable.strings b/Ports/iOSPort/Zooz/zoozi18n.bundle/it.lproj/Localizable.strings
similarity index 100%
rename from Ports/iOSPort/nativeSources/zoozi18n.bundle/it.lproj/Localizable.strings
rename to Ports/iOSPort/Zooz/zoozi18n.bundle/it.lproj/Localizable.strings
diff --git a/Ports/iOSPort/nativeSources/zoozi18n.bundle/pt.lproj/Localizable.strings b/Ports/iOSPort/Zooz/zoozi18n.bundle/pt.lproj/Localizable.strings
similarity index 100%
rename from Ports/iOSPort/nativeSources/zoozi18n.bundle/pt.lproj/Localizable.strings
rename to Ports/iOSPort/Zooz/zoozi18n.bundle/pt.lproj/Localizable.strings
diff --git a/Ports/iOSPort/nativeSources/zoozi18n.bundle/tr.lproj/Localizable.strings b/Ports/iOSPort/Zooz/zoozi18n.bundle/tr.lproj/Localizable.strings
similarity index 100%
rename from Ports/iOSPort/nativeSources/zoozi18n.bundle/tr.lproj/Localizable.strings
rename to Ports/iOSPort/Zooz/zoozi18n.bundle/tr.lproj/Localizable.strings
diff --git a/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m b/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m
index f934a3e77f0..ded25c8f63e 100644
--- a/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m
+++ b/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m
@@ -394,7 +394,27 @@ - (CN1MetalGlyphSlot *)slotForGlyph:(CGGlyph)glyph {
CGPoint origin = CGPointMake((CGFloat)CN1_METAL_ATLAS_PADDING - bbox.origin.x,
(CGFloat)CN1_METAL_ATLAS_PADDING - bbox.origin.y);
- CTFontDrawGlyphs(_ctFont, &glyph, &origin, 1, ctx);
+ // Monochrome glyphs go through the CGFont path rather than CTFontDrawGlyphs.
+ // CTFontDrawGlyphs checks every draw for glyphs that overlap in a connected
+ // script, and the first time it does that for a font it loads the font's
+ // design-language metadata: 10-13ms on the main thread inside the first frame
+ // of a macOS launch, measured on every run, for text that has no connected
+ // script in it. CGContextShowGlyphsAtPositions rasterises through the same
+ // renderer without the check -- byte-identical coverage across 27 fonts and
+ // ~3.8M pixels (system weights and italics, Helvetica, Times, Menlo, the
+ // Material icon font, six Google fonts), and the first frame 12ms sooner.
+ // A font with its own matrix (a synthetic oblique) is the one case that did
+ // not match, because the matrix then also moves the glyph origin, so it
+ // keeps CTFontDrawGlyphs, as do colour glyphs.
+ if (!_isColor && CGAffineTransformIsIdentity(CTFontGetMatrix(_ctFont))) {
+ CGFontRef cgFont = CTFontCopyGraphicsFont(_ctFont, NULL);
+ CGContextSetFont(ctx, cgFont);
+ CGContextSetFontSize(ctx, CTFontGetSize(_ctFont));
+ CGContextShowGlyphsAtPositions(ctx, &glyph, &origin, 1);
+ CGFontRelease(cgFont);
+ } else {
+ CTFontDrawGlyphs(_ctFont, &glyph, &origin, 1, ctx);
+ }
CGContextRelease(ctx);
[_texture replaceRegion:MTLRegionMake2D((NSUInteger)slotX, (NSUInteger)slotY,
diff --git a/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m b/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m
index 8ba41398bd0..982e578cad4 100644
--- a/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m
+++ b/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m
@@ -30,6 +30,10 @@
@interface CN1MetalPipelineCache () {
id _device;
id _states[CN1MetalPipelineCount];
+ /// The app's default.metallib, loaded once. newDefaultLibrary opens and
+ /// parses the library file on every call, and each pipeline the first frame
+ /// needed used to call it again -- and, without ARC, leaked the result.
+ id _library;
}
@end
@@ -207,7 +211,10 @@ static void configureStencilWriteOnly(MTLRenderPipelineColorAttachmentDescriptor
if (pipeline < 0 || pipeline >= CN1MetalPipelineCount) return nil;
if (_states[pipeline] != nil) return _states[pipeline];
- id library = [_device newDefaultLibrary];
+ if (_library == nil) {
+ _library = [_device newDefaultLibrary];
+ }
+ id library = _library;
if (library == nil) {
NSLog(@"CN1MetalPipelineCache: device has no default.metallib — is CN1MetalShaders.metal in the Xcode project?");
return nil;
diff --git a/Ports/iOSPort/nativeSources/CN1MetalShaders.metal b/Ports/iOSPort/nativeSources/CN1MetalShaders.metal
index 6601346ca93..64f38a0b485 100644
--- a/Ports/iOSPort/nativeSources/CN1MetalShaders.metal
+++ b/Ports/iOSPort/nativeSources/CN1MetalShaders.metal
@@ -121,6 +121,11 @@ fragment float4 cn1_fs_textured(
// the bitmap first.
//
// params.xy is the destination size in pixels and params.z the corner radius.
+// region is the part of the texture the quad shows -- (u0, v0, du, dv) in the
+// same normalised coordinates as texcoord -- so a picture scaled to OVERFLOW its
+// box (BoxFit.cover) can be drawn as just the box, with the box's corners
+// rounded. The corners are computed over the quad (texcoord), never over the
+// region, which is what makes that work. (0, 0, 1, 1) is the whole texture.
// Coverage comes from a rounded-rectangle signed distance field and is a smooth
// ramp across the last pixel, so the edge is ANTIALIASED. That is the whole
// point of doing it here: a stencil clip of the same shape has a hard edge, and
@@ -131,6 +136,7 @@ fragment float4 cn1_fs_textured_rounded(
VertexOutTextured in [[stage_in]],
constant float4 &tint [[buffer(0)]],
constant float4 ¶ms [[buffer(1)]],
+ constant float4 ®ion [[buffer(2)]],
texture2d tex [[texture(0)]])
{
constexpr sampler s(mag_filter::linear, min_filter::linear, address::clamp_to_edge);
@@ -166,7 +172,7 @@ fragment float4 cn1_fs_textured_rounded(
}
// Coverage scales every channel: the texture pipeline works in
// premultiplied terms, exactly as the tint modulator above it does.
- return tex.sample(s, in.texcoord) * tint * coverage;
+ return tex.sample(s, region.xy + in.texcoord * region.zw) * tint * coverage;
}
// --------- AlphaMask pipeline (Phase 2/4) ---------
diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.h b/Ports/iOSPort/nativeSources/CN1Metalcompat.h
index 57ba8db6ff7..9581af61cc9 100644
--- a/Ports/iOSPort/nativeSources/CN1Metalcompat.h
+++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.h
@@ -349,6 +349,12 @@ void CN1MetalFillGradient(int kind,
id CN1MetalTextureFromUIImage(CN1Image *image);
void CN1MetalDrawImageRounded(id texture, int alpha, int x, int y,
int width, int height, float cornerRadius);
+// As above for the region (u0, v0, du, dv) of the picture, in its normalised
+// coordinates (0,0 top left), with the DESTINATION's corners rounded. A du or dv
+// of zero or less means the whole picture.
+void CN1MetalDrawImageRegionRounded(id texture, int alpha, int x, int y,
+ int width, int height, float cornerRadius,
+ float u0, float v0, float du, float dv);
// Global Metal device (from METALView's command queue); shared by anyone
// who needs to allocate Metal resources.
diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.m b/Ports/iOSPort/nativeSources/CN1Metalcompat.m
index 24bc35555ba..4893718a60d 100644
--- a/Ports/iOSPort/nativeSources/CN1Metalcompat.m
+++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.m
@@ -877,8 +877,32 @@ void CN1MetalDrawImage(id texture, int alpha, int x, int y, int widt
*/
void CN1MetalDrawImageRounded(id texture, int alpha, int x, int y,
int width, int height, float cornerRadius) {
+ CN1MetalDrawImageRegionRounded(texture, alpha, x, y, width, height, cornerRadius,
+ 0.0f, 0.0f, 1.0f, 1.0f);
+}
+
+/**
+ * CN1MetalDrawImageRounded for PART of the texture: the region (u0, v0, du, dv), in
+ * the normalised coordinates of the drawn picture (0,0 top left, 1,1 bottom right),
+ * is stretched over the destination and the DESTINATION's corners are rounded.
+ *
+ *
This is what lets a picture scaled to overflow its box -- BoxFit.cover, the
+ * gallery's cards -- be drawn rounded without a copy. Drawing the whole picture
+ * rounded puts the corners outside the box, where the box's clip cuts them off and
+ * the card shows square; so the caller used to read the pixels back, cut the corners
+ * into them and upload a second image, holding every such picture twice in memory
+ * and twice on the GPU. Showing only the region inside the box, rounded, is the
+ * same picture with no second copy.
+ */
+void CN1MetalDrawImageRegionRounded(id texture, int alpha, int x, int y,
+ int width, int height, float cornerRadius,
+ float u0, float v0, float du, float dv) {
if (texture == nil || width <= 0 || height <= 0) return;
- if (cornerRadius <= 0.0f) {
+ if (du <= 0.0f || dv <= 0.0f) {
+ u0 = 0.0f; v0 = 0.0f; du = 1.0f; dv = 1.0f;
+ }
+ BOOL whole = u0 == 0.0f && v0 == 0.0f && du == 1.0f && dv == 1.0f;
+ if (cornerRadius <= 0.0f && whole) {
CN1MetalDrawImage(texture, alpha, x, y, width, height);
return;
}
@@ -905,8 +929,13 @@ void CN1MetalDrawImageRounded(id texture, int alpha, int x, int y,
uploadMatricesIfChanged(1);
[activeEncoder setVertexBytes:texcoords length:sizeof(float) * 8 atIndex:2];
[activeEncoder setFragmentBytes:&tint length:sizeof(tint) atIndex:0];
- simd_float4 params = (simd_float4){ (float)width, (float)height, cornerRadius, 0.0f };
+ // A zero radius is valid here: the rounded pipeline at r = 0 is a plain
+ // textured draw of the region, which CN1MetalDrawImage cannot do.
+ simd_float4 params = (simd_float4){ (float)width, (float)height,
+ cornerRadius > 0.0f ? cornerRadius : 0.0f, 0.0f };
[activeEncoder setFragmentBytes:¶ms length:sizeof(params) atIndex:1];
+ simd_float4 region = (simd_float4){ u0, v0, du, dv };
+ [activeEncoder setFragmentBytes:®ion length:sizeof(region) atIndex:2];
[activeEncoder setFragmentTexture:texture atIndex:0];
[activeEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4];
}
diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
index ca437f31124..58cbb26bd55 100644
--- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
+++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
@@ -565,6 +565,7 @@ - (BOOL)cn1OpenURL:(UIApplication *)application url:(NSURL *)url sourceApplicati
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
+ { extern void cn1StartupPhase(const char*); cn1StartupPhase("didFinishLaunching"); }
#ifdef CN1_DETECT_JAILBREAK
cn1DetectJailbreakBypassesAndExit();
#endif
diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m
index 9ad3e02314f..4b6ba684b19 100644
--- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m
+++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m
@@ -21,6 +21,8 @@
* need additional information or have any questions.
*/
#import
+#include
+#include
#import
#import "CodenameOne_GLViewController.h"
@@ -3119,7 +3121,8 @@ JAVA_BOOLEAN Java_com_codename1_impl_ios_IOSImplementation_isRoundedImageDrawSup
}
void Java_com_codename1_impl_ios_IOSImplementation_nativeDrawImageRoundedGlobalImpl
-(void* peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius) {
+(void* peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius,
+ float u0, float v0, float du, float dv) {
#ifdef CN1_USE_METAL
if(((BRIDGE_CAST void*)[CodenameOne_GLViewController instance].currentMutableImage) == peer) {
Java_com_codename1_impl_ios_IOSImplementation_finishDrawingOnImageImpl();
@@ -3127,6 +3130,7 @@ JAVA_BOOLEAN Java_com_codename1_impl_ios_IOSImplementation_isRoundedImageDrawSup
DrawImage* f = [[DrawImage alloc] initWithArgs:alpha xpos:x ypos:y i:(BRIDGE_CAST GLUIImage*)peer w:width h:height];
[f setRenderingHints:renderingHints];
[f setCornerRadius:cornerRadius];
+ [f setRegionX:u0 y:v0 w:du h:dv];
[CodenameOne_GLViewController upcoming:f];
#ifndef CN1_USE_ARC
[f release];
@@ -3137,13 +3141,15 @@ JAVA_BOOLEAN Java_com_codename1_impl_ios_IOSImplementation_isRoundedImageDrawSup
}
void Java_com_codename1_impl_ios_IOSImplementation_nativeDrawImageRoundedMutableImpl
-(void* peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius) {
+(void* peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius,
+ float u0, float v0, float du, float dv) {
#ifdef CN1_USE_METAL
GLUIImage *target = [CodenameOne_GLViewController instance].currentMutableImage;
if (target == nil) return;
DrawImage *f = [[DrawImage alloc] initWithArgs:alpha xpos:x ypos:y i:(BRIDGE_CAST GLUIImage*)peer w:width h:height];
[f setRenderingHints:renderingHints];
[f setCornerRadius:cornerRadius];
+ [f setRegionX:u0 y:v0 w:du h:dv];
[f setTarget:target];
[CodenameOne_GLViewController upcoming:f];
#ifndef CN1_USE_ARC
@@ -3228,10 +3234,16 @@ int getResourceSize(const char* name, int nameLen, const char* type, int typeLen
if(path == nil) {
return -1;
}
- NSData* iData = [NSData dataWithContentsOfFile:path];
- int size = [iData length];
- //CN1Log(@"getResourceSize %i finished", size);
- return size;
+ // The size from the file system, not by reading the file. This used to load
+ // the whole resource into an NSData just to take its length, and it runs in
+ // front of every getResourceAsStream -- so each bundled resource was read in
+ // full twice, and the gallery's images, theme and fonts paid for it inside
+ // the first frame of a launch.
+ struct stat st;
+ if (stat([path fileSystemRepresentation], &st) != 0) {
+ return -1;
+ }
+ return st.st_size > INT_MAX ? INT_MAX : (int)st.st_size;
}
diff --git a/Ports/iOSPort/nativeSources/DrawImage.h b/Ports/iOSPort/nativeSources/DrawImage.h
index 831cb0d926b..b574018b0b9 100644
--- a/Ports/iOSPort/nativeSources/DrawImage.h
+++ b/Ports/iOSPort/nativeSources/DrawImage.h
@@ -36,11 +36,18 @@
// ordinary case; anything positive rounds the quad in the fragment shader
// so nobody has to build a rounded copy of the bitmap.
float cornerRadius;
+ // The part of the picture shown, in its normalised coordinates. regionW or
+ // regionH of zero -- the ivars' initial value -- is the whole picture.
+ float regionX;
+ float regionY;
+ float regionW;
+ float regionH;
}
-(id)initWithArgs:(int)a xpos:(int)xpos ypos:(int)ypos i:(GLUIImage*)i w:(int)w h:(int)h;
-(void)execute;
-(void)setRenderingHints:(JAVA_INT)hints;
-(void)setCornerRadius:(float)r;
+-(void)setRegionX:(float)u y:(float)v w:(float)du h:(float)dv;
@end
diff --git a/Ports/iOSPort/nativeSources/DrawImage.m b/Ports/iOSPort/nativeSources/DrawImage.m
index 9816740634d..d7fc7a35429 100644
--- a/Ports/iOSPort/nativeSources/DrawImage.m
+++ b/Ports/iOSPort/nativeSources/DrawImage.m
@@ -48,6 +48,12 @@ -(id)initWithArgs:(int)a xpos:(int)xpos ypos:(int)ypos i:(GLUIImage*)i w:(int)w
-(void)setCornerRadius:(float)r {
cornerRadius = r;
}
+-(void)setRegionX:(float)u y:(float)v w:(float)du h:(float)dv {
+ regionX = u;
+ regionY = v;
+ regionW = du;
+ regionH = dv;
+}
#if TARGET_OS_WATCH
-(void)execute {
@@ -60,7 +66,10 @@ -(void)execute {
#else
-(void)execute {
#ifdef CN1_USE_METAL
- if (cornerRadius > 0.0f) {
+ if (regionW > 0.0f && regionH > 0.0f) {
+ CN1MetalDrawImageRegionRounded([img getMTLTexture], alpha, x, y, width, height, cornerRadius,
+ regionX, regionY, regionW, regionH);
+ } else if (cornerRadius > 0.0f) {
CN1MetalDrawImageRounded([img getMTLTexture], alpha, x, y, width, height, cornerRadius);
} else {
CN1MetalDrawImage([img getMTLTexture], alpha, x, y, width, height);
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index 4091342e0f5..eb0408d27a4 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -662,6 +662,7 @@ void com_codename1_impl_ios_IOSNative_initVM__(CN1_THREAD_STATE_MULTI_ARG JAVA_O
#else
#if !TARGET_OS_WATCH
POOL_BEGIN();
+ cn1StartupPhase("initVM->UIApplicationMain");
int retVal = UIApplicationMain(0, nil, nil, @"CodenameOne_GLAppDelegate");
POOL_END();
#else
@@ -2727,19 +2728,21 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isRoundedImageDrawSupported___R_bo
return Java_com_codename1_impl_ios_IOSImplementation_isRoundedImageDrawSupportedImpl();
}
-void com_codename1_impl_ios_IOSNative_nativeDrawImageRoundedGlobal___long_int_int_int_int_int_int_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG n1, JAVA_INT alpha, JAVA_INT n2, JAVA_INT n3, JAVA_INT n4, JAVA_INT n5, JAVA_INT renderingHints, JAVA_FLOAT cornerRadius)
+// u0, v0, du, dv: the part of the picture shown, normalised; du or dv <= 0 means all of
+// it. See CN1MetalDrawImageRegionRounded.
+void com_codename1_impl_ios_IOSNative_nativeDrawImageRoundedGlobal___long_int_int_int_int_int_int_float_float_float_float_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG n1, JAVA_INT alpha, JAVA_INT n2, JAVA_INT n3, JAVA_INT n4, JAVA_INT n5, JAVA_INT renderingHints, JAVA_FLOAT cornerRadius, JAVA_FLOAT u0, JAVA_FLOAT v0, JAVA_FLOAT du, JAVA_FLOAT dv)
{
POOL_BEGIN();
- extern void Java_com_codename1_impl_ios_IOSImplementation_nativeDrawImageRoundedGlobalImpl(void*, int, int, int, int, int, int, float);
- Java_com_codename1_impl_ios_IOSImplementation_nativeDrawImageRoundedGlobalImpl((void *)n1, alpha, n2, n3, n4, n5, renderingHints, cornerRadius);
+ extern void Java_com_codename1_impl_ios_IOSImplementation_nativeDrawImageRoundedGlobalImpl(void*, int, int, int, int, int, int, float, float, float, float, float);
+ Java_com_codename1_impl_ios_IOSImplementation_nativeDrawImageRoundedGlobalImpl((void *)n1, alpha, n2, n3, n4, n5, renderingHints, cornerRadius, u0, v0, du, dv);
POOL_END();
}
-void com_codename1_impl_ios_IOSNative_nativeDrawImageRoundedMutable___long_int_int_int_int_int_int_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG n1, JAVA_INT alpha, JAVA_INT n2, JAVA_INT n3, JAVA_INT n4, JAVA_INT n5, JAVA_INT renderingHints, JAVA_FLOAT cornerRadius)
+void com_codename1_impl_ios_IOSNative_nativeDrawImageRoundedMutable___long_int_int_int_int_int_int_float_float_float_float_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG n1, JAVA_INT alpha, JAVA_INT n2, JAVA_INT n3, JAVA_INT n4, JAVA_INT n5, JAVA_INT renderingHints, JAVA_FLOAT cornerRadius, JAVA_FLOAT u0, JAVA_FLOAT v0, JAVA_FLOAT du, JAVA_FLOAT dv)
{
POOL_BEGIN();
- extern void Java_com_codename1_impl_ios_IOSImplementation_nativeDrawImageRoundedMutableImpl(void*, int, int, int, int, int, int, float);
- Java_com_codename1_impl_ios_IOSImplementation_nativeDrawImageRoundedMutableImpl((void *)n1, alpha, n2, n3, n4, n5, renderingHints, cornerRadius);
+ extern void Java_com_codename1_impl_ios_IOSImplementation_nativeDrawImageRoundedMutableImpl(void*, int, int, int, int, int, int, float, float, float, float, float);
+ Java_com_codename1_impl_ios_IOSImplementation_nativeDrawImageRoundedMutableImpl((void *)n1, alpha, n2, n3, n4, n5, renderingHints, cornerRadius, u0, v0, du, dv);
POOL_END();
}
@@ -5613,7 +5616,6 @@ void com_codename1_impl_ios_IOSNative_clearRadialGradientPaintMutable__(CN1_THRE
[PaintOp setCurrentMutable:NULL];
}
-
void com_codename1_impl_ios_IOSNative_releasePeer___long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG peer) {
#ifndef CN1_USE_ARC
dispatch_async(dispatch_get_main_queue(), ^{
@@ -13909,7 +13911,7 @@ void com_codename1_impl_ios_IOSNative_registerBundledFont___java_lang_String(CN1
weight = NSFontWeightThin;
} else if ([weightName isEqualToString:@"Light"]) {
weight = NSFontWeightLight;
- } else if ([weightName isEqualToString:@"Regular"]) {
+ } else if ([weightName isEqualToString:@"Regular"] || [weightName isEqualToString:@"Normal"]) {
weight = NSFontWeightRegular;
} else if ([weightName isEqualToString:@"Bold"]) {
weight = NSFontWeightBold;
@@ -13937,6 +13939,13 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_createTruetypeFont___java_lang_String
// Explicit font names continue through the existing shared loader.
fnt = cn1MacSystemFontForAlias(str, pSize);
#endif
+ // The system font at its true regular weight. IOSImplementation maps
+ // native:MainNormal to this sentinel because no HelveticaNeue alias reaches
+ // UIFontWeightRegular -- native:MainRegular is deliberately Medium -- and a
+ // text style asking for weight 400 was therefore rendering one step heavy.
+ if(fnt == nil && [str isEqualToString:@"CN1SystemRegular"]) {
+ fnt = [CN1Font systemFontOfSize:pSize];
+ }
if(fnt == nil && isIOS8_2() && [str hasPrefix:@"HelveticaNeue"]) {
if([str isEqualToString:@"HelveticaNeue-UltraLight"]) {
fnt = [CN1Font systemFontOfSize:pSize weight:UIFontWeightUltraLight];
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index f4aa49985b2..c8af37f80d7 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -2953,8 +2953,11 @@ public Object createImage(String path) throws IOException {
return n;
}
+ /// False for the `custom` theme mode, which says the application ships the only
+ /// theme it uses: the builder packages no native theme for it, so there is none to
+ /// install. The macOS port inherits this.
public boolean hasNativeTheme() {
- return true;
+ return !"custom".equals(nativeThemeMode());
}
private static String iosMode = "auto";
@@ -3019,9 +3022,16 @@ protected String nativeThemeResourceName(String mode) {
}
public void installNativeTheme() {
+ String mode = nativeThemeMode();
+ if ("custom".equals(mode)) {
+ // The application ships its own theme and the builder shipped no native one,
+ // so there is nothing to open. This used to fall through to the pre-flat
+ // iPhoneTheme.res at the bottom of the chain, so a custom application
+ // shipped and installed the legacy iPhone theme under its own.
+ return;
+ }
try {
Resources r;
- String mode = nativeThemeMode();
// A subclass may own a theme this class knows nothing about. The macOS port
// extends this one and ships Aqua, which is not in the list below; without the
// hook it inherited the iOS chain and installed an iPhone theme on a Mac.
@@ -5087,12 +5097,12 @@ long getShapeID(Shape shape, Stroke stroke){
private void nativeDrawImageMutable(long peer, int alpha, int x, int y, int width, int height, int renderingHints) {
nativeInstance.nativeDrawImageMutable(peer, alpha, x, y, width, height, renderingHints);
}
- private void nativeDrawImageRoundedMutable(long peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius) {
- nativeInstance.nativeDrawImageRoundedMutable(peer, alpha, x, y, width, height, renderingHints, cornerRadius);
+ private void nativeDrawImageRoundedMutable(long peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius, float u0, float v0, float du, float dv) {
+ nativeInstance.nativeDrawImageRoundedMutable(peer, alpha, x, y, width, height, renderingHints, cornerRadius, u0, v0, du, dv);
}
- private void nativeDrawImageRoundedGlobal(long peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius) {
- nativeInstance.nativeDrawImageRoundedGlobal(peer, alpha, x, y, width, height, renderingHints, cornerRadius);
+ private void nativeDrawImageRoundedGlobal(long peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius, float u0, float v0, float du, float dv) {
+ nativeInstance.nativeDrawImageRoundedGlobal(peer, alpha, x, y, width, height, renderingHints, cornerRadius, u0, v0, du, dv);
}
private void nativeDrawImageGlobal(long peer, int alpha, int x, int y, int width, int height, int renderingHints) {
@@ -5397,6 +5407,8 @@ public Class getBackgroundLocationListener() {
if (superVal == null && !"".equals(Preferences.get(PREFS_BACKGROUND_LOCATION_LISTENER_CLASS, ""))) {
String backgroundLocationListenerClassName = Preferences.get(PREFS_BACKGROUND_LOCATION_LISTENER_CLASS, "");
try {
+ // ReachabilityCull (FOR_NAME_SITES) keeps only LocationListener
+ // implementations for this forName.
Class backgroundLocationListenerClass = (Class)Class.forName(backgroundLocationListenerClassName);
super.setBackgroundLocationListener(backgroundLocationListenerClass);
} catch (Throwable t) {}
@@ -5522,7 +5534,9 @@ GeofenceListener getGeofenceListener(String id) {
if (geofenceListeners().containsKey(id)) {
Class cls = null;
try {
- cls = Class.forName(geofenceListeners.get(id));
+ // ReachabilityCull (FOR_NAME_SITES) keeps only GeofenceListener
+ // implementations for this forName.
+ cls = Class.forName(geofenceListeners.get(id));
if (cls == null) {
return null;
}
@@ -8097,8 +8111,8 @@ void nativeDrawImage(long peer, int alpha, int x, int y, int width, int height)
nativeDrawImageMutable(peer, alpha, x, y, width, height, renderingHints);
}
- void nativeDrawImageRounded(long peer, int alpha, int x, int y, int width, int height, float cornerRadius) {
- nativeDrawImageRoundedMutable(peer, alpha, x, y, width, height, renderingHints, cornerRadius);
+ void nativeDrawImageRounded(long peer, int alpha, int x, int y, int width, int height, float cornerRadius, float u0, float v0, float du, float dv) {
+ nativeDrawImageRoundedMutable(peer, alpha, x, y, width, height, renderingHints, cornerRadius, u0, v0, du, dv);
}
@@ -8782,8 +8796,8 @@ void nativeDrawImage(long peer, int alpha, int x, int y, int width, int height)
}
@Override
- void nativeDrawImageRounded(long peer, int alpha, int x, int y, int width, int height, float cornerRadius) {
- nativeDrawImageRoundedGlobal(peer, alpha, x, y, width, height, renderingHints, cornerRadius);
+ void nativeDrawImageRounded(long peer, int alpha, int x, int y, int width, int height, float cornerRadius, float u0, float v0, float du, float dv) {
+ nativeDrawImageRoundedGlobal(peer, alpha, x, y, width, height, renderingHints, cornerRadius, u0, v0, du, dv);
}
@Override
@@ -9256,6 +9270,7 @@ public float getDevicePixelRatio() {
return scale > 0 ? scale : super.getDevicePixelRatio();
}
+
@Override
public int getDeviceDensity() {
// IMPORTANT: If you modify this method, you MUST make the equivalent changes
@@ -10588,7 +10603,26 @@ public void drawImageRounded(Object graphics, Object img, int x, int y, int w, i
ng.applyTransform();
ng.applyClip();
NativeImage nm = (NativeImage)img;
- ng.nativeDrawImageRounded(nm.peer, ng.alpha, x, y, w, h, cornerRadius);
+ ng.nativeDrawImageRounded(nm.peer, ng.alpha, x, y, w, h, cornerRadius, 0, 0, 0, 0);
+ }
+
+ @Override
+ public void drawImageRegionRounded(Object graphics, Object img, int x, int y, int w, int h,
+ float cornerRadius, float u0, float v0, float du, float dv) {
+ if (img == null) return;
+ if (!isRoundedImageDrawSupported()) {
+ super.drawImageRegionRounded(graphics, img, x, y, w, h, cornerRadius, u0, v0, du, dv);
+ return;
+ }
+ NativeGraphics ng = (NativeGraphics)graphics;
+ ng.checkControl();
+ ng.applyTransform();
+ ng.applyClip();
+ NativeImage nm = (NativeImage)img;
+ // The corner radius may be zero here: the Metal pipeline then draws the
+ // region square, which is still the region and not the whole picture.
+ ng.nativeDrawImageRounded(nm.peer, ng.alpha, x, y, w, h, cornerRadius > 0 ? cornerRadius : 0,
+ u0, v0, du, dv);
}
@Override
@@ -11358,6 +11392,20 @@ protected String nativeFontName(String fontName) {
if("native:MainRegular".equals(fontName)) {
return "HelveticaNeue-Medium";
}
+ // native:MainNormal is the system font at its TRUE regular weight.
+ //
+ // native:MainRegular has meant HelveticaNeue-Medium since the alias
+ // was introduced, and on iOS 8.2+ that resolves to the system font
+ // at UIFontWeightMedium -- one step heavier than the platform's own
+ // body weight. Changing it would restyle every existing app, so the
+ // honest regular gets a name of its own. Note the macOS branch
+ // already maps native:MainRegular to NSFontWeightRegular, so the two
+ // platforms disagree about the same alias; this name means the same
+ // thing on both.
+ if("native:MainNormal".equals(fontName)) {
+ return "CN1SystemRegular";
+ }
+
if("native:MainBold".equals(fontName)) {
return "HelveticaNeue-Bold";
@@ -15447,6 +15495,8 @@ public static void runBackgroundProcessing(final String id) {
return;
}
try {
+ // ReachabilityCull (FOR_NAME_SITES) keeps only BackgroundWorker
+ // implementations for this forName.
Class> cls = Class.forName(workerClass);
BackgroundWorker worker = (BackgroundWorker) cls.newInstance();
java.util.Map input = new java.util.HashMap();
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
index 44e1946e02f..c877267e520 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
@@ -135,9 +135,10 @@ native void editStringAt(int x, int y, int w, int h, long peer, boolean singleLi
/// the Metal renderer has the shader.
native boolean isRoundedImageDrawSupported();
- native void nativeDrawImageRoundedGlobal(long peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius);
+ // u0/v0/du/dv: the normalised part of the picture shown; du or dv <= 0 is all of it.
+ native void nativeDrawImageRoundedGlobal(long peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius, float u0, float v0, float du, float dv);
- native void nativeDrawImageRoundedMutable(long peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius);
+ native void nativeDrawImageRoundedMutable(long peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius, float u0, float v0, float du, float dv);
native void nativeTileImageGlobal(long peer, int alpha, int x, int y, int width, int height);
native int stringWidthNative(long peer, String str);
native int charWidthNative(long peer, char ch);
diff --git a/docs/demos/common/pom.xml b/docs/demos/common/pom.xml
index 40104bae6ec..fb2175fc417 100644
--- a/docs/demos/common/pom.xml
+++ b/docs/demos/common/pom.xml
@@ -19,6 +19,20 @@
provided
+
+
+ com.codenameone
+ codenameone-flutter-runtime
+ ${cn1.version}
+ provided
+
+
diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java
new file mode 100644
index 00000000000..c1ef0dc077b
--- /dev/null
+++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java
@@ -0,0 +1,69 @@
+/*
+ * 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.flutter;
+
+import com.codename1.flutter.FlutterUI;
+import com.codename1.flutter.widgets.Text;
+import com.codename1.ui.Button;
+import com.codename1.ui.Form;
+import com.codename1.ui.Label;
+import com.codename1.ui.layouts.BoxLayout;
+
+/**
+ * The developer guide's Flutter interop examples, compiled rather than taken
+ * on trust.
+ *
+ *
Both entry points are shown with a {@code Text} widget standing in for a
+ * real one, because the point is where the boundary sits, not what the widget
+ * draws.
+ */
+public final class FlutterInteropSnippets {
+
+ private FlutterInteropSnippets() {
+ }
+
+ /** One Dart screen inside an otherwise ordinary Codename One form. */
+ public static void embedOneScreen() {
+ // tag::flutter-interop-java-001[]
+ Form dashboard = new Form("Dashboard", BoxLayout.y());
+ dashboard.add(new Label("Written in Codename One"));
+
+ // A Dart widget tree, compiled to Java at build time. wrap() returns
+ // an ordinary Container, so it is added like any other component.
+ dashboard.add(FlutterUI.wrap(new Text("Rendered from Dart")));
+
+ dashboard.add(new Button("Also Codename One"));
+ dashboard.show();
+ // end::flutter-interop-java-001[]
+ }
+
+ /** The widget tree IS the application. */
+ public static void runTheWholeApplication() {
+ // tag::flutter-interop-java-002[]
+ // runApp mounts the tree in a Form of its own and shows it. Unlike
+ // wrap(), this installs the Material base theme, because the widget
+ // tree is expected to be the whole UI.
+ FlutterUI.runApp(new Text("The entire application"));
+ // end::flutter-interop-java-002[]
+ }
+}
diff --git a/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.sh b/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.sh
new file mode 100644
index 00000000000..13728e3fed8
--- /dev/null
+++ b/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.sh
@@ -0,0 +1,10 @@
+// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline.
+
+// tag::flutter-interop-bash-001[]
+python3 scripts/flutter-bench/run_bench.py --list
+
+python3 scripts/flutter-bench/run_bench.py --platform macos \
+ --cn1-app /path/to/Bench.app \
+ --flutter-app /path/to/gallery.app \
+ --json out/macos.json --markdown out/macos.md
+// end::flutter-interop-bash-001[]
diff --git a/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.xml b/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.xml
new file mode 100644
index 00000000000..d1b7dadd538
--- /dev/null
+++ b/docs/demos/common/src/main/snippets/developer-guide/flutter-interop.xml
@@ -0,0 +1,9 @@
+// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline.
+
+// tag::flutter-interop-xml-001[]
+
+ com.codenameone
+ codenameone-flutter-runtime
+ ${cn1.version}
+
+// end::flutter-interop-xml-001[]
diff --git a/docs/developer-guide/Flutter-Interop.asciidoc b/docs/developer-guide/Flutter-Interop.asciidoc
new file mode 100644
index 00000000000..66e1987105c
--- /dev/null
+++ b/docs/developer-guide/Flutter-Interop.asciidoc
@@ -0,0 +1,203 @@
+[[flutter-interop]]
+== Running Flutter widget code
+
+Codename One can compile Dart widget source into a Codename One UI at build
+time. The Dart is translated to Java, the Java is compiled by the normal
+toolchain, and the resulting widgets render through Codename One's own
+pipeline on every port the framework supports.
+
+There is no Dart VM in the application, no embedded rendering engine, and no
+platform view. A screen written as Dart widgets becomes ordinary Codename One
+components, so it inherits the theme, the event thread, the accessibility
+tree and the native build at no extra cost -- and it can sit in the same `Form` as
+hand-written Codename One code.
+
+Two things follow from that, and they're the reason to reach for this at all:
+
+* An existing Dart UI can be reused without adopting a second runtime, a
+ second build system or a second set of platform plugins.
+* An existing Codename One application can adopt a Dart screen without being
+ rewritten.
+
+=== How it fits together
+
+The pipeline has three stages, and each is an ordinary build step you can run
+and inspect on its own.
+
+image::img/flutter-pipeline.svg[Dart source through the transpiler to the Codename One ports]
+
+Dart widget source in `src/main/flutter` is read by the `transcode-flutter`
+goal during `generate-sources`. It emits Java into
+`target/generated-sources/flutter`, which the normal compile phase picks up.
+At runtime those generated classes call into the Flutter runtime library,
+which implements the widget, element and render-object model on top of
+Codename One containers.
+
+The key design point is where the boundary sits. The runtime reimplements
+Flutter's *layout and composition* model -- constraints down, sizes up,
+elements reconciled against widgets -- but it doesn't reimplement Flutter's
+*rasterizer*. Painting is Codename One's, which is what makes the output a
+real Codename One component tree rather than a texture.
+
+=== Enabling it in a project
+
+Add the runtime dependency to the `common` module. Projects generated from the
+archetype already contain it, commented out:
+
+[source,xml]
+----
+include::../demos/common/src/main/snippets/developer-guide/flutter-interop.xml[tag=flutter-interop-xml-001,indent=0]
+----
+
+Then put Dart files under `common/src/main/flutter`. The goal is already bound
+in the generated `pom.xml` and is a silent no-op while that directory doesn't
+exist, so nothing changes for projects that don't use it.
+
+No Dart SDK is required. The transpiler is Java and runs as part of the Maven
+build; the Dart source is input, not something that gets executed.
+
+The generated package defaults to `com.codename1.generated.flutter` and the
+paths are configurable:
+
+[cols="1,2", options="header"]
+|===
+|Property |Meaning
+
+|`cn1.flutter.sourceDir`
+|Where the Dart lives. Defaults to `src/main/flutter`.
+
+|`cn1.flutter.outputDir`
+|Where the generated Java is written. Defaults to
+`target/generated-sources/flutter`.
+
+|`cn1.flutter.package`
+|The package the generated classes are emitted into.
+|===
+
+=== Use case: One screen inside an existing application
+
+This is the incremental path, and the one to start with. `FlutterUI.wrap`
+inflates a widget into a plain `Container` that can be added anywhere an
+ordinary component can:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java[tag=flutter-interop-java-001,indent=0]
+----
+
+The wrapped subtree is a component like any other: it participates in the
+parent's layout, scrolls with it, and is styled by the same theme. An embedded
+subtree does *not* install the Material base theme, so dropping a
+widget into an existing screen won't restyle the rest of it.
+
+That makes this the right shape for reusing a piece of design work -- a chart,
+a card, an onboarding pane -- without committing the rest of the application to
+anything.
+
+=== Use case: Porting an application wholesale
+
+When the Dart source *is* the application, `FlutterUI.runApp` mounts the tree
+as the root of a new `Form` and shows it, which is the direct analog of
+Flutter's own `runApp`:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java[tag=flutter-interop-java-002,indent=0]
+----
+
+Unlike `wrap`, this path installs the Material base theme, because the widget
+tree is expected to be the whole UI and to look the way its author intended.
+
+A wholesale port is mostly a question of how much of the Dart language and of
+the Flutter libraries the application actually uses. The transpiler reports
+what it can't handle rather than guessing: a construct outside the supported
+subset fails the build with a milestone code identifying the feature, so the
+gap is a list you can work through rather than a runtime surprise.
+
+At runtime the same principle applies. Widgets that aren't implemented are
+recorded through the error inventory rather than drawing nothing,
+so a screen that's missing something says so.
+
+=== What to expect from the result
+
+The transpiled UI is a Codename One application in every respect that matters
+for shipping it: it builds for the same targets, it's signed and packaged the
+same way, and it has no additional runtime to install on the device.
+
+That last point is what the size and start-up differences come from, and it
+cuts both ways -- a Flutter application carries an engine that's good at
+what it does, while a transpiled one carries Codename One's. The benchmark
+described in <> measures both sides of the same application
+on each platform and publishes the result rather than asserting a direction.
+
+image::img/flutter-parity-textfields.png[The same screen rendered by Flutter and by Codename One]
+
+The screen above is the gallery's text-field demo, rendered by Flutter on the
+left and by the transpiled build on the right, from one Dart source file.
+
+[[flutter-benchmark]]
+=== Measuring it against Flutter
+
+The project keeps a benchmark that builds *one* application both ways -- the
+Flutter toolchain's own release build, and the identical Dart source
+transpiled and built by Codename One -- and measures both on the same machine.
+It lives in `scripts/flutter-bench` and runs in CI, publishing its numbers to
+the pull request and to the port status page.
+
+What it reports, per platform:
+
+[cols="1,3", options="header"]
+|===
+|Metric |What it means
+
+|Installed size
+|The sum of the artifact's file lengths, which is what the user's device gives
+up. Not `du`, which rounds every file up to a block.
+
+|Executable code
+|Every compiled binary in the artifact, added together. Counting only the main
+executable is wrong on iOS, where a Flutter application's own code isn't in
+the executable at all -- it's in the frameworks beside it.
+
+|Download size
+|The artifact compressed, because that's what a store ships.
+
+|Cold start
+|Wall time from launching the process to the first frame being on screen,
+measured from outside so neither runtime is trusted to time itself.
+
+|Memory at rest
+|The platform's own accounting for a settled, idle application.
+|===
+
+Three rules keep the numbers honest, and each exists because the obvious
+alternative produced a flattering result:
+
+* *Interleaved runs, best of N.* One run of each side, alternating. A machine
+ that gets busier halfway through then penalizes both sides equally instead
+ of whichever happened to run second, and the load average is recorded with
+ every result so a surprising ratio can be checked against it.
+
+* *Start-up is a bracket, not a point.* The two runtimes don't expose the
+ same event. Flutter's post-frame callback runs before that frame is
+ rasterized, while Codename One's marker fires once the form is on the
+ screen, so comparing them directly charges one runtime for rasterizing its
+ first screen and not the other. The benchmark therefore reports Flutter's
+ figure as a range and computes the ratio from the end least favorable to
+ Codename One.
+
+* *A platform that can't be measured says so.* iOS start-up and memory are
+ reported as not measured rather than taken from a simulator, because Dart
+ can't compile ahead-of-time for the simulator -- a simulator comparison
+ would time Flutter's debug build against a Codename One release build.
+ iOS sizes come from release device bundles, which need no signing to measure.
+
+To run it locally against builds you already have:
+
+[source,bash]
+----
+include::../demos/common/src/main/snippets/developer-guide/flutter-interop.sh[tag=flutter-interop-bash-001,indent=0]
+----
+
+`--list` also reports which platform adapters have been exercised end to end,
+which isn't the same question as which ones exist.
diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc
index 631206b4dd2..aae6086f8a4 100644
--- a/docs/developer-guide/developer-guide.asciidoc
+++ b/docs/developer-guide/developer-guide.asciidoc
@@ -99,6 +99,8 @@ include::Game-Builder.asciidoc[]
include::Game-Assets.asciidoc[]
+include::Flutter-Interop.asciidoc[]
+
= Data, media and networking
include::io.asciidoc[]
diff --git a/docs/developer-guide/img/flutter-parity-textfields.png b/docs/developer-guide/img/flutter-parity-textfields.png
new file mode 100644
index 00000000000..bfe9d161152
Binary files /dev/null and b/docs/developer-guide/img/flutter-parity-textfields.png differ
diff --git a/docs/developer-guide/img/flutter-pipeline.svg b/docs/developer-guide/img/flutter-pipeline.svg
new file mode 100644
index 00000000000..65781d360f7
--- /dev/null
+++ b/docs/developer-guide/img/flutter-pipeline.svg
@@ -0,0 +1,79 @@
+
+
diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt
index b55c63d062e..566b343df7b 100644
--- a/docs/developer-guide/languagetool-accept.txt
+++ b/docs/developer-guide/languagetool-accept.txt
@@ -404,6 +404,7 @@ toolchain
toolchains
toolset
transferal
+transpiled
transpiler
unix
unlayout
diff --git a/docs/website/layouts/_default/port-status.html b/docs/website/layouts/_default/port-status.html
index c01640aeb8d..12649edc3a5 100644
--- a/docs/website/layouts/_default/port-status.html
+++ b/docs/website/layouts/_default/port-status.html
@@ -4,6 +4,7 @@
{{- $supplement := site.Data.port_status_supplement -}}
{{- $support := site.Data.port_status_support -}}
{{- $environment := site.Data.port_status_environment -}}
+{{- $flutterBench := site.Data.port_status_flutter_benchmark -}}
{{- $snapshotTime := now.UTC -}}
@@ -319,6 +320,58 @@
{{ $support.benchmark.title }}
+ {{- with $flutterBench }}
+
+
+
Flutter compared
+
One Flutter application, built by Flutter and by Codename One
+
+ {{ .app }}. Lower is better for every metric, so a ratio above 1.00x means Codename One is ahead by that factor.
+ {{- with .generated_at }} Measured {{ . }}{{ end }}{{ with .commit }} from commit {{ substr . 0 10 }}{{ end }}.
+
+
+ {{- range $id, $platform := .platforms }}
+
{{ $id }}
+ {{- if eq $platform.status "measured" }}
+
+
+
+
+
Metric
+
Codename One
+
Flutter
+
Ratio
+
+
+
+ {{- range $key, $metric := $platform.metrics }}
+ {{- if $metric.label }}
+
+
+ {{- end }}
+
{{- /* Rendered by scripts/website/build.sh from vm/selfhost/perf-baseline: the ratios
every platform build's performance gate holds ParparVM to, so this table and the
gate cannot disagree. Absent from a bare `hugo` run, hence the guard. */ -}}
diff --git a/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java
index c493544d905..75a6edb5f04 100644
--- a/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java
+++ b/maven/build-engine/src/main/java/com/codename1/builders/AndroidGradleBuilder.java
@@ -4448,6 +4448,31 @@ public void usesClassMethod(String cls, String method) {
throw new BuildException("Failed to extract android port sources from "+androidPortSrcJar, ex);
}
+ // Only the native themes installNativeTheme() can load are shipped. The port
+ // carries three -- the pre-Holo androidTheme alone is half a megabyte -- and
+ // the mode that picks one comes from the build hints the stub copies into
+ // Display properties. android_holo_light always stays: hasNativeTheme()
+ // probes for it. Placed after the port unzip, which overwrites any same-named
+ // file, so only the port's own copies can be removed.
+ try {
+ List droppedThemes = NativeThemes.removeUnusedAndroid(assetsDir,
+ NativeThemes.androidThemesFor(request.getArg("and.themeMode", null),
+ request.getArg("cn1.androidTheme", null),
+ request.getArg("nativeTheme", null),
+ request.getArg("cn1.nativeTheme", null),
+ // and.hololight is a run-time Display property only: no
+ // build hint carries it into the stub, so it is set by the
+ // application or not at all, and removeUnusedAndroid keeps
+ // every theme for an application that names it.
+ null),
+ dummyClassesDir);
+ if (!droppedThemes.isEmpty()) {
+ log("Native themes not used by this build, not shipped: " + droppedThemes);
+ }
+ } catch (IOException ex) {
+ throw new BuildException("Failed to remove the unused native themes", ex);
+ }
+
// Health background-listener bindings: generated rather than
// resolved reflectively, so each listener is reached through a
// direct constructor call that shrinking and obfuscation follow.
diff --git a/maven/build-engine/src/main/java/com/codename1/builders/Executor.java b/maven/build-engine/src/main/java/com/codename1/builders/Executor.java
index 649fd453d7f..f3c77da0cf1 100644
--- a/maven/build-engine/src/main/java/com/codename1/builders/Executor.java
+++ b/maven/build-engine/src/main/java/com/codename1/builders/Executor.java
@@ -572,6 +572,43 @@ protected String databaseLegacyStubProperty(BuildRequest request, boolean usesDa
return " Display.getInstance().setProperty(\"db.legacy\", \"true\");\n";
}
+ /// The `desktop.width` and `desktop.height` build hints, as a call the generated stub makes
+ /// before `Display.init` on the native Linux and Windows ports.
+ ///
+ /// Both hints were declared (`@DesktopBuild`) and honoured only by the JavaSE desktop
+ /// wrapper; the ParparVM desktop ports opened every window at a hard-coded 800x600. It has
+ /// to run before init because init is what creates the window, and resizing it afterwards
+ /// lays the first form out twice.
+ ///
+ /// @param request the build request carrying the hints
+ /// @param implementationClass the port's implementation class, fully qualified
+ /// @return the stub line, or an empty string when neither hint is set
+ protected String desktopWindowSizeStubCall(BuildRequest request, String implementationClass) {
+ int width = positiveIntArg(request, "desktop.width");
+ int height = positiveIntArg(request, "desktop.height");
+ if (width <= 0 && height <= 0) {
+ return "";
+ }
+ return " " + implementationClass + ".setDefaultWindowSize(" + width + ", " + height + ");\n";
+ }
+
+ private int positiveIntArg(BuildRequest request, String name) {
+ String value = request.getArg(name, null);
+ if (value == null || value.trim().length() == 0) {
+ return 0;
+ }
+ try {
+ int v = Integer.parseInt(value.trim());
+ if (v > 0) {
+ return v;
+ }
+ } catch (NumberFormatException ex) {
+ // reported below
+ }
+ log("Invalid " + name + " build hint: '" + value + "'. Ignoring it.");
+ return 0;
+ }
+
/// The `desktop.titleBar` build hint, surfaced to the generated stub as a Display property.
///
/// Without this the hint is INERT on every ParparVM desktop port. `Display.getProperty`
@@ -3514,6 +3551,13 @@ protected boolean isDllResource() {
return false;
}
+ /// True for an application-archive entry that exists only for the build and must
+ /// not reach the installed application. See unzip().
+ static boolean isBuildTimeOnlyEntry(String entryName) {
+ String n = entryName.startsWith("/") ? entryName.substring(1) : entryName;
+ return n.startsWith("META-INF/dart/");
+ }
+
public void unzip(InputStream source, File classesDir, File resDir, File sourceDir, File libsDir, File xmlDir) throws IOException {
try {
BufferedOutputStream dest = null;
@@ -3599,6 +3643,16 @@ public void unzip(InputStream source, File classesDir, File resDir, File sourceD
continue;
}
+ // Build-time metadata, never shipped. META-INF/dart/*.dart are the
+ // Dart API declarations a runtime jar (flutter-runtime) carries for
+ // the transpiler, which reads them from the jar on its own classpath
+ // long before this point. The application jar merges every
+ // dependency, so without this they were copied into every bundle on
+ // every platform -- 22 files of Dart source in a Flutter app.
+ if (isBuildTimeOnlyEntry(entryName)) {
+ continue;
+ }
+
// Entries outside the supported virtual cn1lib namespaces
// retain their original ZIP name, which must be relative and
// remain inside the extraction root.
diff --git a/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java
index a3aeccbba4c..180ae957b74 100644
--- a/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java
+++ b/maven/build-engine/src/main/java/com/codename1/builders/IPhoneBuilder.java
@@ -128,7 +128,6 @@ public class IPhoneBuilder extends Executor {
private File resultDir;
private boolean includePush;
private File tmpFile;
- private File icon57;
private File icon512;
// Bumped from 12.0 to 13.0 to enable NSURLSessionWebSocketTask
// (iOS 13+) used by com.codename1.io.WebSocket's iOS implementation.
@@ -2346,6 +2345,10 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException
// to the FLAT iOS 7 theme -- the exact opposite of what was asked for,
// and silently, because an unrecognised mode is not an error here.
iosMode = "modern";
+ } else if (NativeThemes.isCustom(sharedMode)) {
+ // The application's own theme is the only one: the stub tells the
+ // runtime to install none, and no native theme is packaged.
+ iosMode = "custom";
} else {
iosMode = "auto";
}
@@ -3514,6 +3517,22 @@ public void usesClassMethod(String cls, String method) {
new File(buildinRes, "iOS7Theme.res").delete();
}
+ // Only the native theme the stub's mode will load is shipped. nativeios.jar
+ // carries all four iOS themes, over a megabyte, and installNativeTheme()
+ // installs exactly one: the mode is fixed here at build time (the stub calls
+ // setIosMode / setIosThemeGeneration) and nothing reads it at run time.
+ // btres holds only the port's files -- the application's own resources are
+ // in resDir -- so nothing the developer shipped is removed.
+ try {
+ List droppedThemes = NativeThemes.removeUnusedApple(buildinRes,
+ iosMode, iosThemeGeneration, false, classesDir);
+ if (!droppedThemes.isEmpty()) {
+ log("Native themes not used by this build, not shipped: " + droppedThemes);
+ }
+ } catch (IOException ex) {
+ throw new BuildException("Failed to remove the unused native themes", ex);
+ }
+
// Flip the crypto build toggles in CN1Crypto.h based on what the
// user's bytecode references. Apps that don't touch
// com.codename1.security.* get stub-only versions of the iOS
@@ -17546,10 +17565,6 @@ private File getIconDirectory(BuildRequest request) {
}
- private void copyIcon(String name, File srcDir, File destDir) throws IOException {
- copy(new File(srcDir, name), new File(destDir, name));
- }
-
private String buildLocalizedIconSelectorObjC() {
StringBuilder mapping = new StringBuilder();
mapping.append(" @{ ");
@@ -17637,82 +17652,59 @@ private String buildLocalizedIconSelectorObjC() {
+ " }\n";
}
- private void copyIcons(File srcDir, File destDir, String... icons) throws IOException {
- for (String icon : icons) {
- copyIcon(icon, srcDir, destDir);
- }
- }
-
+ /// The AppIcon asset-catalog images: file name and pixel size. Each is named by
+ /// vm/ByteCodeTranslator/src/Icons.json, the set's Contents.json, which
+ /// IPhoneBuilderIconsTest checks.
+ static final Object[][] APP_ICONS = {
+ {"iPhoneNotification@2x.png", 40}, {"iPhoneNotification@3x.png", 60},
+ {"iPhoneSpotlight.png", 29}, {"iPhoneSpotlight@2x.png", 58}, {"iPhoneSpotlight@3x.png", 87},
+ {"iPhone7Spotlight@2x.png", 80}, {"iPhone7Spotlight@3x.png", 120},
+ {"iPhoneApp.png", 57}, {"iPhoneApp@2x.png", 114},
+ {"iPhone7App@2x.png", 120}, {"iPhone7App@3x.png", 180},
+ {"iPadNotifications.png", 20}, {"iPadNotification@2x.png", 40},
+ {"iPadSettings.png", 29}, {"iPadSettings@2x.png", 58},
+ {"iPadSpotlight7.png", 40}, {"iPadSpotlight7@2x.png", 80},
+ {"iPadSpotlight.png", 50}, {"iPadSpotlight@2x.png", 100},
+ {"iPadApp.png", 72}, {"iPadApp@2x.png", 144},
+ {"iPadApp7.png", 76}, {"iPadApp7@2x.png", 152}, {"iPadPro@2x.png", 167},
+ {"AppStore.png", 1024},
+ };
+
private boolean generateIcons(BuildRequest request) throws Exception {
File iconDirectory = getIconDirectory(request);
File resDir = getResDir();
-
+
BufferedImage iconImage = ImageIO.read(new ByteArrayInputStream(request.getIcon()));
- // Legacy iOS icon files are still copied into the root resources, but should not
- // be placed inside AppIcon.appiconset as they are not referenced by Contents.json.
- icon512 = new File(resDir, "iTunesArtwork");
+ // The icons live in the AppIcon asset catalog and nowhere else. They used to be
+ // copied into the bundle root as well -- iTunesArtwork, Icon.png and every size
+ // above, about 0.6MB -- for the top-level CFBundleIconFiles of iOS 6 and older.
+ // Nothing reads those now: every SDK this builder supports has a deployment
+ // floor of 12 or more, where the system takes the icon from CFBundleIcons /
+ // CFBundleIconName, which actool writes for the catalog. The Info.plist template
+ // no longer lists them either, so App Store validation finds no dangling name.
+ //
+ // The original image is still kept, outside the bundle, as the source of the
+ // macOS icon set.
+ File iconSource = new File(tmpFile, "icon-source");
+ iconSource.mkdirs();
+ icon512 = new File(iconSource, "iTunesArtwork");
createFile(icon512, request.getIcon());
- icon57 = new File(resDir, "Icon.png");
- createIconFile(icon57, iconImage, 57, 57);
- createIconFile(new File(iconDirectory, "iPhoneNotification@2x.png"), iconImage, 40, 40);
- createIconFile(new File(iconDirectory, "iPhoneNotification@3x.png"), iconImage, 60, 60);
- createIconFile(new File(iconDirectory, "iPhoneSpotlight.png"), iconImage, 29, 29);
- createIconFile(new File(iconDirectory, "iPhoneSpotlight@2x.png"), iconImage, 58, 58);
- createIconFile(new File(iconDirectory, "iPhoneSpotlight@3x.png"), iconImage, 87, 87);
- createIconFile(new File(iconDirectory, "iPhone7Spotlight@2x.png"), iconImage, 80, 80);
- createIconFile(new File(iconDirectory, "iPhone7Spotlight@3x.png"), iconImage, 120, 120);
- createIconFile(new File(iconDirectory, "iPhoneApp.png"), iconImage, 57, 57);
- createIconFile(new File(iconDirectory, "iPhoneApp@2x.png"), iconImage, 114, 114);
- createIconFile(new File(iconDirectory, "iPhone7App@2x.png"), iconImage, 120, 120);
- createIconFile(new File(iconDirectory, "iPhone7App@3x.png"), iconImage, 180, 180);
- createIconFile(new File(iconDirectory, "iPadNotifications.png"), iconImage, 20, 20);
- createIconFile(new File(iconDirectory, "iPadNotification@2x.png"), iconImage, 40, 40);
- createIconFile(new File(iconDirectory, "iPadSettings.png"), iconImage, 29, 29);
- createIconFile(new File(iconDirectory, "iPadSettings@2x.png"), iconImage, 58, 58);
- createIconFile(new File(iconDirectory, "iPadSpotlight7.png"), iconImage, 40, 40);
- createIconFile(new File(iconDirectory, "iPadSpotlight7@2x.png"), iconImage, 80, 80);
- createIconFile(new File(iconDirectory, "iPadSpotlight.png"), iconImage, 50, 50);
- createIconFile(new File(iconDirectory, "iPadSpotlight@2x.png"), iconImage, 100, 100);
- createIconFile(new File(iconDirectory, "iPadApp.png"), iconImage, 72, 72);
- createIconFile(new File(iconDirectory, "iPadApp@2x.png"), iconImage, 144, 144);
- createIconFile(new File(iconDirectory, "iPadApp7.png"), iconImage, 76, 76);
- createIconFile(new File(iconDirectory, "iPadApp7@2x.png"), iconImage, 152, 152);
- createIconFile(new File(iconDirectory, "iPadPro@2x.png"), iconImage, 167, 167);
- createIconFile(new File(iconDirectory, "AppStore.png"), iconImage, 1024, 1024);
-
- copyIcons(iconDirectory, resDir,
- "iPhoneNotification@2x.png",
- "iPhoneNotification@3x.png",
- "iPhoneSpotlight.png",
- "iPhoneSpotlight@2x.png",
- "iPhoneSpotlight@3x.png",
- "iPhone7Spotlight@2x.png",
- "iPhone7Spotlight@3x.png",
- "iPhoneApp.png",
- "iPhoneApp@2x.png",
- "iPhone7App@2x.png",
- "iPhone7App@3x.png",
- "iPadNotifications.png",
- "iPadNotification@2x.png",
- "iPadSettings.png",
- "iPadSettings@2x.png",
- "iPadSpotlight7.png",
- "iPadSpotlight7@2x.png",
- "iPadSpotlight.png",
- "iPadSpotlight@2x.png",
- "iPadApp.png",
- "iPadApp@2x.png",
- "iPadApp7.png",
- "iPadApp7@2x.png",
- "iPadPro@2x.png",
- "AppStore.png");
+ writeAppIcons(iconImage, iconDirectory);
processLocalizedIcons(resDir, request);
return true;
}
+ /// Writes every AppIcon asset-catalog image into `iconDirectory`, and nothing else.
+ void writeAppIcons(BufferedImage iconImage, File iconDirectory) throws IOException {
+ for (Object[] icon : APP_ICONS) {
+ int size = ((Integer) icon[1]).intValue();
+ createIconFile(new File(iconDirectory, (String) icon[0]), iconImage, size, size);
+ }
+ }
+
/**
* Scans the resources directory for files named cn1_icon_LANG[_COUNTRY].png
* and registers them as iOS alternate app icons. Each detected locale is
diff --git a/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java
index a575de0f1a8..233c2c6f914 100644
--- a/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java
+++ b/maven/build-engine/src/main/java/com/codename1/builders/LinuxNativeBuilder.java
@@ -252,6 +252,14 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException
// sources), the same mechanism parparvm-compiler.jar uses.
extractJarResource("/LinuxPort.jar", portClasses);
extractJarResource("/nativelinux.jar", nativeSources);
+ // The port installs its bundled theme from init() whenever the file is there,
+ // before any application code runs, so the custom theme mode -- the
+ // application's own theme is the only one -- is honoured by not shipping it.
+ if (NativeThemes.desktopShipsNoTheme(request.getArg("desktop.themeMode", null),
+ request.getArg("nativeTheme", null), request.getArg("cn1.nativeTheme", null))
+ && NativeThemes.removeDesktopTheme(portClasses, "linuxNativeTheme.res")) {
+ log("Native theme not used by this build, not shipped: linuxNativeTheme.res");
+ }
} catch (Exception ex) {
throw new BuildException("Failed to stage the LinuxPort native layer. The codenameone "
+ "maven plugin must provide the codenameone-linux 'bundle' artifact "
@@ -694,6 +702,9 @@ private void writeBootstrapStub(BuildRequest request, File classesDir, File stub
// clients and @OpenTelemetry all compiled here and did nothing at run time.
src.append(routeDispatcherInstallSource(classesDir, " "));
src.append(annotationFrameworksInstallSource(classesDir, " "));
+ // desktop.width/height: before init, which creates the window. See
+ // Executor.desktopWindowSizeStubCall.
+ src.append(desktopWindowSizeStubCall(request, "com.codename1.impl.linux.LinuxImplementation"));
src.append(" Display.init(null);\n");
// The application's identity, which nothing else gives this platform. The stub passes
// null to Display.init, so the implementation never derives a package from an object, and
diff --git a/maven/build-engine/src/main/java/com/codename1/builders/MacOSBuildHints.java b/maven/build-engine/src/main/java/com/codename1/builders/MacOSBuildHints.java
index 1bbbef4eb78..efbdf938ae7 100644
--- a/maven/build-engine/src/main/java/com/codename1/builders/MacOSBuildHints.java
+++ b/maven/build-engine/src/main/java/com/codename1/builders/MacOSBuildHints.java
@@ -773,6 +773,9 @@ public String getThemeMode() {
// the Aqua theme could not be selected at all -- by a hint or by default.
"aqua", "native",
"modern", "liquid", "material", "ios7", "flat", "auto",
+ // custom installs no native theme. Rejected here it became "modern", so an
+ // application that asked for none got the iOS modern theme under its own.
+ "custom",
};
/**
diff --git a/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java
index 0a2772f5601..58b2176a261 100644
--- a/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java
+++ b/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java
@@ -266,6 +266,24 @@ public String get(String key, String defaultValue) {
// dark palette on, so every dark-appearance screen rendered light
// however carefully the application asked for dark. Nothing failed
// to build and nothing was logged.
+ //
+ // Only the theme the stub's mode will load is shipped. nativemac.jar
+ // carries every native theme -- Aqua and four iOS ones, about a
+ // megabyte -- and the runtime installs exactly one of them. Removed
+ // from nativeSources, before staging, so neither the staged copy nor
+ // the translator (which copies every resource on its input path into
+ // the project) picks the others up; the application's own resources
+ // are never touched.
+ List droppedThemes = NativeThemes.removeUnusedApple(nativeSources,
+ hints.getThemeMode(), null, true, classesDir);
+ // MacPort.jar carries a copy too (iPhoneTheme.res, from the iOSPort
+ // sources it shares), and the port classes are translator input as well.
+ droppedThemes.addAll(NativeThemes.removeUnusedApple(portClasses,
+ hints.getThemeMode(), null, true, classesDir));
+ if (!droppedThemes.isEmpty()) {
+ log("Native themes not used by this build, not shipped: "
+ + new java.util.LinkedHashSet(droppedThemes));
+ }
stageThemeResources(nativeSources, buildinRes);
} catch (Exception ex) {
throw new BuildException("Failed to stage the MacPort native layer. The codenameone "
@@ -2352,6 +2370,7 @@ private boolean buildChannel(BuildRequest request, MacOSBuildHints hints, File d
// nobody notices until an Intel user reports it.
cmd.add("ARCHS=" + hints.getArch());
cmd.add("ONLY_ACTIVE_ARCH=NO");
+ cmd.addAll(stripSettings(hints.getConfiguration()));
cmd.add("MACOSX_DEPLOYMENT_TARGET=" + hints.getMinDeploymentTarget());
// The identifier signing and provisioning read. Checked against a
// generated project rather than assumed: neither the macOS template nor
@@ -2743,6 +2762,29 @@ static void stageThemeResources(File nativeSources, File buildinRes)
}
}
+ /// The xcodebuild settings that strip the shipped binary, empty for Debug.
+ ///
+ /// This builder runs `xcodebuild build`, never `archive`, and a plain build
+ /// leaves the symbol table in the product: a universal gallery binary shipped
+ /// 98,805 symbols, 8MB of __LINKEDIT per architecture, about a quarter of the
+ /// file. The iOS cloud build does not have the problem because `archive` sets
+ /// DEPLOYMENT_POSTPROCESSING itself; here it has to be asked for. Xcode strips
+ /// before it signs, so the signature covers the stripped binary -- stripping
+ /// afterwards would break it. dwarf-with-dsym keeps a dSYM beside the app so
+ /// crash reports from a stripped build can still be symbolicated.
+ // Package-visible so a test can assert it without running xcodebuild.
+ static List stripSettings(String configuration) {
+ List out = new ArrayList();
+ if (configuration != null && configuration.trim().equalsIgnoreCase("Debug")) {
+ return out;
+ }
+ out.add("DEPLOYMENT_POSTPROCESSING=YES");
+ out.add("STRIP_INSTALLED_PRODUCT=YES");
+ out.add("STRIP_STYLE=all");
+ out.add("DEBUG_INFORMATION_FORMAT=dwarf-with-dsym");
+ return out;
+ }
+
/// Removes a tree, tolerating one that is not there.
///
/// No symlink following: descending through a link would delete outside the
diff --git a/maven/build-engine/src/main/java/com/codename1/builders/NativeThemes.java b/maven/build-engine/src/main/java/com/codename1/builders/NativeThemes.java
new file mode 100644
index 00000000000..6cb807a4e41
--- /dev/null
+++ b/maven/build-engine/src/main/java/com/codename1/builders/NativeThemes.java
@@ -0,0 +1,293 @@
+/*
+ * 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.builders;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/// Which of the port's native theme resources an application actually ships.
+///
+/// The iOS and macOS ports each carry every native theme -- iPhone, iOS 7, the
+/// two modern generations and, on macOS, Aqua -- about a megabyte between them,
+/// and the builders used to copy all of them into every application. The
+/// runtime loads exactly one: the mode the builder writes into the generated
+/// stub decides it (`IOSImplementation.setIosMode`), nothing reads a theme mode
+/// at run time, so the one it will load is known when the application is built.
+///
+/// The choice here mirrors `IOSImplementation.installNativeTheme()` and
+/// `MacImplementation.nativeThemeResourceName()`. A theme the application's own
+/// classes name -- `Resources.open("/iOS7Theme.res")` -- is kept as well, since
+/// that is a use no build hint can see.
+///
+/// The `custom` mode ships none of them. It says the application's own theme is
+/// the only one it uses -- the theme pairs it with `includeNativeBool: false` --
+/// and every port's runtime answers it by installing nothing, so a platform theme
+/// in the bundle would be dead weight.
+final class NativeThemes {
+
+ /// Every native theme a port jar carries, without the `.res`.
+ static final String[] ALL = {
+ "iPhoneTheme", "iOS7Theme", "iOSModernTheme", "iOSModern27Theme", "MacOSAquaTheme"
+ };
+
+ private NativeThemes() {
+ }
+
+ /// The native theme the runtime will install for `mode`, without the `.res`,
+ /// or null for `custom`, which installs none.
+ ///
+ /// @param mode the theme mode written into the stub; null means the runtime default, "auto"
+ /// @param generation the iOS modern generation, "26" or "27"; null means 26
+ /// @param mac true for the native macOS port, whose Aqua theme answers auto/aqua/native
+ static String themeFor(String mode, String generation, boolean mac) {
+ String m = mode == null ? "auto" : asciiLower(mode.trim());
+ if (isCustom(m)) {
+ return null;
+ }
+ if (mac && ("auto".equals(m) || "aqua".equals(m) || "native".equals(m))) {
+ return "MacOSAquaTheme";
+ }
+ if ("modern".equals(m) || "liquid".equals(m)) {
+ return "27".equals(generation) ? "iOSModern27Theme" : "iOSModernTheme";
+ }
+ if ("ios7".equals(m) || "flat".equals(m) || "auto".equals(m)) {
+ return "iOS7Theme";
+ }
+ // "legacy", "iphone", "material" and anything else fall to the pre-flat
+ // theme in installNativeTheme(), so that is the one they need.
+ return "iPhoneTheme";
+ }
+
+ /// Every native theme the Android port carries, without the `.res`.
+ static final String[] ANDROID_ALL = {"androidTheme", "AndroidMaterialTheme", "android_holo_light"};
+
+ /// The Display properties AndroidImplementation.installNativeTheme() reads the
+ /// mode from. The builder writes them into the stub from the build hints, but an
+ /// application can set them itself before the theme is installed, so one that
+ /// names any of them keeps every Android theme.
+ static final String[] ANDROID_MODE_PROPERTIES = {
+ "and.themeMode", "cn1.androidTheme", "nativeTheme", "cn1.nativeTheme", "and.hololight"
+ };
+
+ /// The Android themes a build needs: the one installNativeTheme() resolves the
+ /// hints to, and always android_holo_light, which hasNativeTheme() probes for on
+ /// every device from API 14 up and without which no native theme is installed.
+ /// None at all for `custom`, which installs no native theme and does not probe.
+ static Set androidThemesFor(String andThemeMode, String cn1AndroidTheme,
+ String nativeTheme, String cn1NativeTheme, String hololight) {
+ String mode = andThemeMode != null ? andThemeMode : cn1AndroidTheme;
+ if (mode == null) {
+ String shared = nativeTheme != null ? nativeTheme : cn1NativeTheme;
+ if ("modern".equalsIgnoreCase(shared) || "native".equalsIgnoreCase(shared)) {
+ mode = "material";
+ } else if (isCustom(shared)) {
+ mode = "custom";
+ } else if ("legacy".equalsIgnoreCase(shared)) {
+ mode = "hololight";
+ } else if ("true".equalsIgnoreCase(hololight)) {
+ mode = "legacy";
+ } else {
+ mode = "hololight";
+ }
+ } else {
+ mode = asciiLower(mode.trim());
+ }
+ Set keep = new LinkedHashSet();
+ if (isCustom(mode)) {
+ return keep;
+ }
+ if ("material".equals(mode) || "modern".equals(mode) || "auto".equals(mode)) {
+ keep.add("AndroidMaterialTheme");
+ } else if (!"hololight".equals(mode) && !"holo".equals(mode)) {
+ keep.add("androidTheme");
+ }
+ keep.add("android_holo_light");
+ return keep;
+ }
+
+ /// Deletes the Android themes `keep` does not name from `dir`, unless the
+ /// application sets the theme mode itself, in which case the choice is made at
+ /// run time and every theme stays.
+ static List removeUnusedAndroid(File dir, Set keep, File appClasses)
+ throws IOException {
+ if (namesAny(appClasses, ANDROID_MODE_PROPERTIES)) {
+ return new ArrayList();
+ }
+ return removeUnused(dir, keep, ANDROID_ALL, appClasses);
+ }
+
+ /// The runtime property an application reads to learn which modern theme the
+ /// port carries, and then installs it itself -- the screenshot suite does, and
+ /// its dark captures all came out light when that theme had been filtered out.
+ /// IOSImplementation answers it with the configured generation's modern theme.
+ static final String NATIVE_THEME_RESOURCE_PROPERTY = "cn1.nativeThemeResource";
+
+ /// Removes the Apple native themes this build does not need from `dir`: every
+ /// one except the theme the stub's mode will install, a theme the application
+ /// names itself, and -- when the application asks the port for its modern
+ /// theme through {@link #NATIVE_THEME_RESOURCE_PROPERTY} -- that modern theme.
+ ///
+ /// @return the names removed, for the build log
+ static List removeUnusedApple(File dir, String mode, String generation, boolean mac,
+ File appClasses) throws IOException {
+ Set keep = new LinkedHashSet();
+ String theme = themeFor(mode, generation, mac);
+ if (theme != null) {
+ keep.add(theme);
+ }
+ if (namesAny(appClasses, new String[] {NATIVE_THEME_RESOURCE_PROPERTY})) {
+ keep.add(themeFor("modern", generation, false));
+ }
+ return removeUnused(dir, keep, ALL, appClasses);
+ }
+
+ /// True for the `custom` theme mode: the application ships the only theme it
+ /// uses, so no platform theme is packaged and none is installed.
+ static boolean isCustom(String mode) {
+ return mode != null && "custom".equalsIgnoreCase(mode.trim());
+ }
+
+ /// The single native theme a native desktop port (Linux, Windows) bundles in its
+ /// port jar, and installs from `init()` whenever the file is present.
+ ///
+ /// Those ports have one theme and no mode to choose between, so the only question
+ /// is whether to ship it: not when `desktop.themeMode` is `custom`, or when it is
+ /// unset and the cross-platform `nativeTheme` (`cn1.nativeTheme`) is. The same
+ /// resolution the JavaSE desktop port runs.
+ ///
+ /// @return true when the port's theme file should be removed before translation
+ static boolean desktopShipsNoTheme(String desktopThemeMode, String nativeTheme,
+ String cn1NativeTheme) {
+ if (desktopThemeMode != null && desktopThemeMode.trim().length() > 0) {
+ return isCustom(desktopThemeMode);
+ }
+ return isCustom(nativeTheme != null ? nativeTheme : cn1NativeTheme);
+ }
+
+ /// Removes `fileName` from `dir` when present, for a desktop port whose theme
+ /// the build does not ship. See [#desktopShipsNoTheme(String, String, String)].
+ ///
+ /// @return true when the file was there and is now gone
+ static boolean removeDesktopTheme(File dir, String fileName) throws IOException {
+ File f = new File(dir, fileName);
+ if (!f.isFile()) {
+ return false;
+ }
+ if (!f.delete()) {
+ throw new IOException("Could not remove the unused theme " + f);
+ }
+ return true;
+ }
+
+ /// Deletes from `dir` every native theme other than `keep` and the ones the
+ /// application names in `appClasses`.
+ ///
+ /// @return the names removed, for the build log
+ static List removeUnused(File dir, String keep, File appClasses) throws IOException {
+ Set k = new LinkedHashSet();
+ k.add(keep);
+ return removeUnused(dir, k, ALL, appClasses);
+ }
+
+ private static List removeUnused(File dir, Set keep, String[] candidates,
+ File appClasses) throws IOException {
+ List removed = new ArrayList();
+ if (dir == null || !dir.isDirectory()) {
+ return removed;
+ }
+ Set referenced = new LinkedHashSet();
+ for (String name : candidates) {
+ if (namesAny(appClasses, new String[] {name + ".res"})) {
+ referenced.add(name);
+ }
+ }
+ for (String name : candidates) {
+ if (keep.contains(name) || referenced.contains(name)) {
+ continue;
+ }
+ File f = new File(dir, name + ".res");
+ if (f.isFile()) {
+ if (!f.delete()) {
+ throw new IOException("Could not remove the unused theme " + f);
+ }
+ removed.add(name);
+ }
+ }
+ return removed;
+ }
+
+ /// True when a class under `classesDir` contains any of `needles` as a string.
+ ///
+ /// The ports' own `com.codename1.impl` packages are skipped, since they name
+ /// every theme and every mode property, and so is `com.codename1.annotations`,
+ /// whose build-hint declarations name the properties without setting them.
+ static boolean namesAny(File classesDir, String[] needles) throws IOException {
+ return classesDir != null && classesDir.isDirectory()
+ && scan(classesDir, classesDir, needles);
+ }
+
+ private static boolean scan(File root, File f, String[] needles) throws IOException {
+ if (f.isDirectory()) {
+ String rel = root.toPath().relativize(f.toPath()).toString().replace('\\', '/');
+ if ("com/codename1/impl".equals(rel) || "com/codename1/annotations".equals(rel)) {
+ return false;
+ }
+ File[] children = f.listFiles();
+ if (children != null) {
+ for (File c : children) {
+ if (scan(root, c, needles)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+ if (!f.getName().endsWith(".class")) {
+ return false;
+ }
+ // The class file's constant pool holds a string literal as modified UTF-8,
+ // which for these ASCII names is the same bytes as the name itself.
+ String body = new String(Files.readAllBytes(f.toPath()), StandardCharsets.ISO_8859_1);
+ for (String needle : needles) {
+ if (body.contains(needle)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String asciiLower(String s) {
+ StringBuilder b = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ b.append(c >= 'A' && c <= 'Z' ? (char) (c + ('a' - 'A')) : c);
+ }
+ return b.toString();
+ }
+}
diff --git a/maven/build-engine/src/main/java/com/codename1/builders/WindowsNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/WindowsNativeBuilder.java
index bc8e9c509b8..9050a837cdb 100644
--- a/maven/build-engine/src/main/java/com/codename1/builders/WindowsNativeBuilder.java
+++ b/maven/build-engine/src/main/java/com/codename1/builders/WindowsNativeBuilder.java
@@ -234,6 +234,14 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException
// C/C++ sources), the same mechanism parparvm-compiler.jar uses.
extractJarResource("/WindowsPort.jar", portClasses);
extractJarResource("/nativewindows.jar", nativeSources);
+ // The port installs its bundled theme from init() whenever the file is there,
+ // before any application code runs, so the custom theme mode -- the
+ // application's own theme is the only one -- is honoured by not shipping it.
+ if (NativeThemes.desktopShipsNoTheme(request.getArg("desktop.themeMode", null),
+ request.getArg("nativeTheme", null), request.getArg("cn1.nativeTheme", null))
+ && NativeThemes.removeDesktopTheme(portClasses, "windowsNativeTheme.res")) {
+ log("Native theme not used by this build, not shipped: windowsNativeTheme.res");
+ }
} catch (Exception ex) {
throw new BuildException("Failed to stage the WindowsPort native layer. The codenameone "
+ "maven plugin must provide the codenameone-windows 'bundle' artifact "
@@ -1250,6 +1258,9 @@ private void writeBootstrapStub(BuildRequest request, File classesDir, File stub
// clients and @OpenTelemetry all compiled here and did nothing at run time.
src.append(routeDispatcherInstallSource(classesDir, " "));
src.append(annotationFrameworksInstallSource(classesDir, " "));
+ // desktop.width/height: before init, which creates the window. See
+ // Executor.desktopWindowSizeStubCall.
+ src.append(desktopWindowSizeStubCall(request, "com.codename1.impl.windows.WindowsImplementation"));
src.append(" Display.init(null);\n");
// The application's identity, which nothing else gives this platform. The stub passes
// null to Display.init, so the implementation never derives a package from an object, and
diff --git a/maven/build-engine/src/test/java/com/codename1/builders/DesktopWindowSizeStubCallTest.java b/maven/build-engine/src/test/java/com/codename1/builders/DesktopWindowSizeStubCallTest.java
new file mode 100644
index 00000000000..7814cf9212c
--- /dev/null
+++ b/maven/build-engine/src/test/java/com/codename1/builders/DesktopWindowSizeStubCallTest.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.builders;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/// desktop.width and desktop.height reach the native Linux and Windows stubs as a call made
+/// BEFORE Display.init, since init is what creates the window. Both hints were declared and read
+/// only by the JavaSE wrapper, so every ParparVM desktop window opened at a hard-coded 800x600.
+class DesktopWindowSizeStubCallTest {
+
+ /// Executor is abstract; none of these hooks are exercised here.
+ private static final class TestExecutor extends Executor {
+ @Override
+ protected String getDeviceIdCode() {
+ return "";
+ }
+
+ @Override
+ protected String generatePeerComponentCreationCode(String methodCallString) {
+ return "";
+ }
+
+ @Override
+ protected String convertPeerComponentToNative(String param) {
+ return "";
+ }
+
+ @Override
+ public boolean build(File sourceZip, BuildRequest request) {
+ return false;
+ }
+ }
+
+ private static final String IMPL = "com.codename1.impl.linux.LinuxImplementation";
+
+ private final TestExecutor executor = new TestExecutor();
+
+ private static BuildRequest size(String width, String height) {
+ BuildRequest r = new BuildRequest();
+ if (width != null) {
+ r.putArgument("desktop.width", width);
+ }
+ if (height != null) {
+ r.putArgument("desktop.height", height);
+ }
+ return r;
+ }
+
+ @Test
+ void noHintKeepsThePortDefault() {
+ assertEquals("", executor.desktopWindowSizeStubCall(size(null, null), IMPL));
+ }
+
+ @Test
+ void bothDimensionsReachTheStub() {
+ assertEquals(" " + IMPL + ".setDefaultWindowSize(1280, 720);\n",
+ executor.desktopWindowSizeStubCall(size("1280", " 720 "), IMPL));
+ }
+
+ @Test
+ void oneDimensionAloneLeavesTheOtherToThePort() {
+ // Zero is "keep the port default" in setDefaultWindowSize.
+ assertEquals(" " + IMPL + ".setDefaultWindowSize(0, 900);\n",
+ executor.desktopWindowSizeStubCall(size(null, "900"), IMPL));
+ }
+
+ @Test
+ void anUnusableValueIsDroppedRatherThanCompiledIntoTheStub() {
+ assertEquals("", executor.desktopWindowSizeStubCall(size("wide", "-3"), IMPL));
+ }
+}
diff --git a/maven/build-engine/src/test/java/com/codename1/builders/ExecutorArchiveExtractionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/ExecutorArchiveExtractionTest.java
index 308c268cdf6..e6e0325d710 100644
--- a/maven/build-engine/src/test/java/com/codename1/builders/ExecutorArchiveExtractionTest.java
+++ b/maven/build-engine/src/test/java/com/codename1/builders/ExecutorArchiveExtractionTest.java
@@ -129,6 +129,26 @@ void preservesSupportedLeadingSlashVirtualEntries() throws Exception {
"javase.lib.tar");
}
+ /**
+ * Dart API declarations a runtime jar carries for the transpiler are build-time
+ * metadata: the application jar merges every dependency, and they used to be
+ * copied into every installed bundle.
+ */
+ @Test
+ void doesNotShipTheTranspilersDartDeclarations() throws Exception {
+ File root = temporaryDirectory.resolve("dart-metadata").toFile();
+ unzipSpecialEntry(root, "META-INF/dart/flutter_material.dart");
+ java.util.List written = new java.util.ArrayList<>();
+ java.nio.file.Files.walk(root.toPath()).filter(java.nio.file.Files::isRegularFile)
+ .forEach(p -> written.add(root.toPath().relativize(p).toString()));
+ assertTrue(written.isEmpty(), "nothing should be extracted, got: " + written);
+
+ File other = temporaryDirectory.resolve("dart-neighbour").toFile();
+ File resources = unzipSpecialEntry(other, "gallery.res");
+ assertTrue(new File(resources, "gallery.res").isFile(),
+ "an ordinary resource must still be extracted");
+ }
+
private static byte[] zipEntry(String name, String contents)
throws IOException {
byte[] payload = contents.getBytes(StandardCharsets.UTF_8);
diff --git a/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderIconsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderIconsTest.java
new file mode 100644
index 00000000000..2d1603de619
--- /dev/null
+++ b/maven/build-engine/src/test/java/com/codename1/builders/IPhoneBuilderIconsTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.builders;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/// The iOS app icon lives only in the AppIcon asset catalog.
+///
+/// The builder used to copy every icon size, Icon.png and iTunesArtwork into the bundle
+/// root as well, for the top-level CFBundleIconFiles of iOS 6 and older -- about 0.6MB
+/// that nothing on a supported iOS reads. These pin the replacement: every catalog image
+/// is written, into the catalog and nowhere else, each is one the catalog's Contents.json
+/// names, and the Info.plist template no longer lists the loose files, which would
+/// otherwise be a dangling reference App Store validation reports.
+public class IPhoneBuilderIconsTest {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ @Test
+ public void everyCatalogIconIsWrittenAtItsSizeAndNothingElse() throws Exception {
+ File dir = tmp.newFolder("AppIcon.appiconset");
+ BufferedImage source = new BufferedImage(1024, 1024, BufferedImage.TYPE_INT_ARGB);
+ new IPhoneBuilder().writeAppIcons(source, dir);
+
+ Set expected = new HashSet();
+ for (Object[] icon : IPhoneBuilder.APP_ICONS) {
+ String name = (String) icon[0];
+ expected.add(name);
+ BufferedImage written = ImageIO.read(new File(dir, name));
+ int size = ((Integer) icon[1]).intValue();
+ assertEquals(name + " width", size, written.getWidth());
+ assertEquals(name + " height", size, written.getHeight());
+ }
+ assertEquals(new HashSet(Arrays.asList(dir.list())), expected);
+ }
+
+ @Test
+ public void everyCatalogIconIsNamedByTheCatalog() throws Exception {
+ String contents = new String(Files.readAllBytes(
+ Paths.get("..", "..", "vm", "ByteCodeTranslator", "src", "Icons.json")), StandardCharsets.UTF_8);
+ Set named = new HashSet();
+ Matcher m = Pattern.compile("\"filename\"\\s*:\\s*\"([^\"]+)\"").matcher(contents);
+ while (m.find()) {
+ named.add(m.group(1));
+ }
+ for (Object[] icon : IPhoneBuilder.APP_ICONS) {
+ assertTrue(icon[0] + " is written but the asset catalog does not use it",
+ named.contains((String) icon[0]));
+ }
+ }
+
+ @Test
+ public void theInfoPlistTemplateNamesNoLooseIcons() throws Exception {
+ String plist = new String(Files.readAllBytes(Paths.get("..", "..", "vm", "ByteCodeTranslator", "src",
+ "template", "template", "template-Info.plist")), StandardCharsets.UTF_8);
+ // CFBundleIcons / CFBundleIconName come from actool for the catalog; the
+ // top-level list is the one that named the loose files.
+ assertFalse("the top-level CFBundleIconFiles names files the bundle no longer carries",
+ plist.contains("CFBundleIconFiles"));
+ for (Object[] icon : IPhoneBuilder.APP_ICONS) {
+ assertFalse(icon[0] + " is still named by the Info.plist template",
+ plist.contains(">" + icon[0] + "<"));
+ }
+ }
+}
diff --git a/maven/build-engine/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java
index 0ebfe6b71ba..b31eeb9753f 100644
--- a/maven/build-engine/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java
+++ b/maven/build-engine/src/test/java/com/codename1/builders/MacOSBuildHintsTest.java
@@ -626,6 +626,9 @@ public void theNativeThemeDefaultsToAquaAndIsConstrainedToKnownModes() {
// whitelist above lets it through unchanged.
assertEquals("native", parse(raw("nativeTheme", "native"), "p").getThemeMode());
assertEquals("native", parse(raw("cn1.nativeTheme", "native"), "p").getThemeMode());
+ // custom installs no native theme; the whitelist used to turn it into "modern".
+ assertEquals("custom", parse(raw("nativeTheme", "custom"), "p").getThemeMode());
+ assertEquals("custom", parse(raw("macos.themeMode", "custom"), "p").getThemeMode());
// The value is interpolated into generated Java source, so it is
// constrained to what the runtime understands rather than passed
diff --git a/maven/build-engine/src/test/java/com/codename1/builders/MacOSStripSettingsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSStripSettingsTest.java
new file mode 100644
index 00000000000..366ac13bc95
--- /dev/null
+++ b/maven/build-engine/src/test/java/com/codename1/builders/MacOSStripSettingsTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.builders;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.Assert.assertEquals;
+
+/// A Release macOS build is stripped by xcodebuild, before it is signed.
+public class MacOSStripSettingsTest {
+
+ @Test
+ public void aReleaseBuildIsStripped() {
+ assertEquals(Arrays.asList("DEPLOYMENT_POSTPROCESSING=YES", "STRIP_INSTALLED_PRODUCT=YES",
+ "STRIP_STYLE=all", "DEBUG_INFORMATION_FORMAT=dwarf-with-dsym"),
+ MacOSNativeBuilder.stripSettings("Release"));
+ assertEquals(MacOSNativeBuilder.stripSettings("Release"), MacOSNativeBuilder.stripSettings(null));
+ }
+
+ @Test
+ public void aDebugBuildKeepsItsSymbols() {
+ assertEquals(Collections.emptyList(), MacOSNativeBuilder.stripSettings("Debug"));
+ assertEquals(Collections.emptyList(), MacOSNativeBuilder.stripSettings(" debug "));
+ }
+}
diff --git a/maven/build-engine/src/test/java/com/codename1/builders/NativeThemesTest.java b/maven/build-engine/src/test/java/com/codename1/builders/NativeThemesTest.java
new file mode 100644
index 00000000000..cf4e14e5cdf
--- /dev/null
+++ b/maven/build-engine/src/test/java/com/codename1/builders/NativeThemesTest.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.builders;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/// The builders ship only the native theme the runtime will install.
+public class NativeThemesTest {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ @Test
+ public void theIosChainPicksWhatInstallNativeThemeLoads() {
+ assertEquals("iOS7Theme", NativeThemes.themeFor(null, null, false));
+ assertEquals("iOS7Theme", NativeThemes.themeFor("auto", "26", false));
+ assertEquals("iOS7Theme", NativeThemes.themeFor("FLAT", "26", false));
+ assertEquals("iOSModernTheme", NativeThemes.themeFor("modern", "26", false));
+ assertEquals("iOSModernTheme", NativeThemes.themeFor("liquid", null, false));
+ assertEquals("iOSModern27Theme", NativeThemes.themeFor("modern", "27", false));
+ assertEquals("iPhoneTheme", NativeThemes.themeFor("legacy", "26", false));
+ assertEquals("iPhoneTheme", NativeThemes.themeFor("material", "26", false));
+ }
+
+ @Test
+ public void macGetsAquaUnlessItAsksForAnIosLook() {
+ assertEquals("MacOSAquaTheme", NativeThemes.themeFor("aqua", null, true));
+ assertEquals("MacOSAquaTheme", NativeThemes.themeFor("auto", null, true));
+ assertEquals("MacOSAquaTheme", NativeThemes.themeFor("native", null, true));
+ // The macOS stub never sets a generation, so the modern theme there is 26.
+ assertEquals("iOSModernTheme", NativeThemes.themeFor("modern", null, true));
+ assertEquals("iOS7Theme", NativeThemes.themeFor("ios7", null, true));
+ }
+
+ @Test
+ public void onlyTheLoadedThemeSurvivesAndOtherFilesAreUntouched() throws IOException {
+ File dir = portDir();
+ List removed = NativeThemes.removeUnused(dir, "MacOSAquaTheme", tmp.newFolder("classes"));
+ assertEquals(Arrays.asList("iPhoneTheme", "iOS7Theme", "iOSModernTheme", "iOSModern27Theme"), removed);
+ assertTrue(new File(dir, "MacOSAquaTheme.res").isFile());
+ assertTrue("not a native theme", new File(dir, "CN1Resource.res").isFile());
+ assertTrue("not a theme at all", new File(dir, "cn1_globals.m").isFile());
+ }
+
+ @Test
+ public void aThemeTheApplicationOpensItselfIsKept() throws IOException {
+ File dir = portDir();
+ File classes = tmp.newFolder("appclasses");
+ File cls = new File(classes, "com/example/App.class");
+ cls.getParentFile().mkdirs();
+ Files.write(cls.toPath(), bytesWith("/iPhoneTheme.res"));
+ NativeThemes.removeUnused(dir, "iOS7Theme", classes);
+ assertTrue(new File(dir, "iPhoneTheme.res").isFile());
+ assertTrue(new File(dir, "iOS7Theme.res").isFile());
+ assertFalse(new File(dir, "iOSModernTheme.res").isFile());
+ }
+
+ @Test
+ public void thePortsOwnReferencesDoNotKeepAnything() throws IOException {
+ // IOSImplementation names every theme; that must not count as a use.
+ File dir = portDir();
+ File classes = tmp.newFolder("merged");
+ File impl = new File(classes, "com/codename1/impl/ios/IOSImplementation.class");
+ impl.getParentFile().mkdirs();
+ Files.write(impl.toPath(), bytesWith("/iPhoneTheme.res /iOSModern27Theme.res"));
+ NativeThemes.removeUnused(dir, "iOS7Theme", classes);
+ assertFalse(new File(dir, "iPhoneTheme.res").isFile());
+ assertFalse(new File(dir, "iOSModern27Theme.res").isFile());
+ }
+
+ @Test
+ public void anAppThatAsksThePortForItsModernThemeKeepsIt() throws IOException {
+ // The screenshot suite installs the theme cn1.nativeThemeResource names,
+ // in an app built in the default auto mode. Filtering by the mode alone
+ // removed the modern theme, and every dark capture rendered light.
+ File dir = portDir();
+ File classes = tmp.newFolder("suite");
+ File cls = new File(classes, "com/example/tests/DualAppearanceBaseTest.class");
+ cls.getParentFile().mkdirs();
+ Files.write(cls.toPath(), bytesWith("cn1.nativeThemeResource"));
+ NativeThemes.removeUnusedApple(dir, "auto", "27", false, classes);
+ assertTrue(new File(dir, "iOS7Theme.res").isFile());
+ assertTrue(new File(dir, "iOSModern27Theme.res").isFile());
+ assertFalse(new File(dir, "iOSModernTheme.res").isFile());
+ assertFalse(new File(dir, "iPhoneTheme.res").isFile());
+ }
+
+ @Test
+ public void anAppThatDoesNotAskKeepsOnlyTheModeTheme() throws IOException {
+ File dir = portDir();
+ NativeThemes.removeUnusedApple(dir, "auto", "27", false, tmp.newFolder("plain"));
+ assertTrue(new File(dir, "iOS7Theme.res").isFile());
+ assertFalse(new File(dir, "iOSModern27Theme.res").isFile());
+ }
+
+ @Test
+ public void androidKeepsTheResolvedThemeAndTheOneHasNativeThemeProbes() {
+ assertEquals(set("android_holo_light"), NativeThemes.androidThemesFor(null, null, null, null, null));
+ assertEquals(set("AndroidMaterialTheme", "android_holo_light"),
+ NativeThemes.androidThemesFor(null, null, "modern", null, null));
+ assertEquals(set("AndroidMaterialTheme", "android_holo_light"),
+ NativeThemes.androidThemesFor("Material", null, null, null, null));
+ assertEquals(set("androidTheme", "android_holo_light"),
+ NativeThemes.androidThemesFor("legacy", null, null, null, null));
+ assertEquals(set("androidTheme", "android_holo_light"),
+ NativeThemes.androidThemesFor(null, null, null, null, "true"));
+ // and.themeMode wins over the cross-platform hint, as it does at run time.
+ assertEquals(set("android_holo_light"),
+ NativeThemes.androidThemesFor("hololight", null, "modern", null, null));
+ }
+
+ @Test
+ public void androidRemovesOnlyThePortThemesItWillNotLoad() throws IOException {
+ File dir = tmp.newFolder("assets");
+ for (String name : NativeThemes.ANDROID_ALL) {
+ Files.write(new File(dir, name + ".res").toPath(), new byte[] {1});
+ }
+ Files.write(new File(dir, "theme.res").toPath(), new byte[] {1});
+ List removed = NativeThemes.removeUnusedAndroid(dir,
+ NativeThemes.androidThemesFor(null, null, "modern", null, null), tmp.newFolder("c1"));
+ assertEquals(Arrays.asList("androidTheme"), removed);
+ assertTrue(new File(dir, "AndroidMaterialTheme.res").isFile());
+ assertTrue(new File(dir, "android_holo_light.res").isFile());
+ assertTrue("the application's own theme", new File(dir, "theme.res").isFile());
+ }
+
+ @Test
+ public void anAndroidAppThatSetsTheModeItselfKeepsEveryTheme() throws IOException {
+ File dir = tmp.newFolder("assets2");
+ for (String name : NativeThemes.ANDROID_ALL) {
+ Files.write(new File(dir, name + ".res").toPath(), new byte[] {1});
+ }
+ File classes = tmp.newFolder("c2");
+ File cls = new File(classes, "com/example/App.class");
+ cls.getParentFile().mkdirs();
+ Files.write(cls.toPath(), bytesWith("and.themeMode"));
+ assertTrue(NativeThemes.removeUnusedAndroid(dir,
+ NativeThemes.androidThemesFor(null, null, null, null, null), classes).isEmpty());
+ assertTrue(new File(dir, "androidTheme.res").isFile());
+ }
+
+ @Test
+ public void aBuildHintDeclarationIsNotAUse() throws IOException {
+ // com.codename1.annotations.buildhints names the properties to document them.
+ File classes = tmp.newFolder("c3");
+ File cls = new File(classes, "com/codename1/annotations/buildhints/Android.class");
+ cls.getParentFile().mkdirs();
+ Files.write(cls.toPath(), bytesWith("and.themeMode nativeTheme"));
+ assertFalse(NativeThemes.namesAny(classes, NativeThemes.ANDROID_MODE_PROPERTIES));
+ }
+
+ @Test
+ public void customShipsNoAppleTheme() throws IOException {
+ assertNull(NativeThemes.themeFor("custom", "26", false));
+ assertNull(NativeThemes.themeFor(" CUSTOM ", null, true));
+ File dir = portDir();
+ List removed = NativeThemes.removeUnusedApple(dir, "custom", "26", false,
+ tmp.newFolder("customClasses"));
+ assertEquals(Arrays.asList(NativeThemes.ALL), removed);
+ assertTrue("not a native theme", new File(dir, "CN1Resource.res").isFile());
+ }
+
+ @Test
+ public void customShipsNoAndroidTheme() throws IOException {
+ assertTrue(NativeThemes.androidThemesFor("custom", null, null, null, null).isEmpty());
+ assertTrue(NativeThemes.androidThemesFor(null, "Custom", null, null, null).isEmpty());
+ assertTrue(NativeThemes.androidThemesFor(null, null, "custom", null, null).isEmpty());
+ assertTrue(NativeThemes.androidThemesFor(null, null, null, "custom", null).isEmpty());
+ // The platform hint outranks the shared one, in both directions.
+ assertEquals(set("AndroidMaterialTheme", "android_holo_light"),
+ NativeThemes.androidThemesFor("modern", null, "custom", null, null));
+ File dir = tmp.newFolder();
+ for (String name : NativeThemes.ANDROID_ALL) {
+ Files.write(new File(dir, name + ".res").toPath(), new byte[] {1});
+ }
+ List removed = NativeThemes.removeUnusedAndroid(dir,
+ NativeThemes.androidThemesFor(null, null, "custom", null, null),
+ tmp.newFolder("customAndroidClasses"));
+ assertEquals(Arrays.asList(NativeThemes.ANDROID_ALL), removed);
+ }
+
+ @Test
+ public void desktopPortsDropTheirThemeOnlyForCustom() throws IOException {
+ assertTrue(NativeThemes.desktopShipsNoTheme("custom", null, null));
+ assertTrue(NativeThemes.desktopShipsNoTheme(null, "custom", null));
+ assertTrue(NativeThemes.desktopShipsNoTheme("", null, "custom"));
+ // desktop.themeMode decides when it is set.
+ assertFalse(NativeThemes.desktopShipsNoTheme("auto", "custom", null));
+ assertFalse(NativeThemes.desktopShipsNoTheme(null, "modern", null));
+ assertFalse(NativeThemes.desktopShipsNoTheme(null, null, null));
+ File dir = tmp.newFolder();
+ File theme = new File(dir, "linuxNativeTheme.res");
+ Files.write(theme.toPath(), new byte[] {1});
+ assertTrue(NativeThemes.removeDesktopTheme(dir, "linuxNativeTheme.res"));
+ assertFalse(theme.exists());
+ assertFalse(NativeThemes.removeDesktopTheme(dir, "linuxNativeTheme.res"));
+ }
+
+ private static java.util.Set set(String... names) {
+ return new java.util.LinkedHashSet(Arrays.asList(names));
+ }
+
+ private File portDir() throws IOException {
+ File dir = tmp.newFolder();
+ for (String name : NativeThemes.ALL) {
+ Files.write(new File(dir, name + ".res").toPath(), new byte[] {1});
+ }
+ Files.write(new File(dir, "CN1Resource.res").toPath(), new byte[] {1});
+ Files.write(new File(dir, "cn1_globals.m").toPath(), new byte[] {1});
+ return dir;
+ }
+
+ private static byte[] bytesWith(String s) {
+ byte[] head = {(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE, 0, 0, 0, 52};
+ byte[] body = s.getBytes(StandardCharsets.US_ASCII);
+ byte[] out = new byte[head.length + body.length];
+ System.arraycopy(head, 0, out, 0, head.length);
+ System.arraycopy(body, 0, out, head.length, body.length);
+ return out;
+ }
+}
diff --git a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
index 9fb192cc5a0..45f0be21ab7 100644
--- a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
@@ -15,11 +15,12 @@
- auto
+ source/target. Java 17 is the default (matching start.codenameone.com);
+ it is also required for Flutter support (src/main/flutter), whose
+ transpiler emits Java 17 source. Pass 8 explicitly for legacy
+ projects, or "auto" to pick 17 on a JDK >= 17 and 8 otherwise via
+ archetype-post-generate.groovy. -->
+ 17
diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml
index dcadd0d70f1..1233abb7455 100644
--- a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml
+++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml
@@ -19,6 +19,18 @@
provided
+
+
+
+ transcode-flutter
+ generate-sources
+
+ transcode-flutter
+
+
generate-gui-sourcesprocess-sources
diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml
index 2074a09cd73..8b87bfb2d95 100644
--- a/maven/codenameone-maven-plugin/pom.xml
+++ b/maven/codenameone-maven-plugin/pom.xml
@@ -130,6 +130,11 @@
codenameone-lottie-transcoder${project.version}
+
+ ${project.groupId}
+ codenameone-dart-transpiler
+ ${project.version}
+ org.apache.mavenmaven-plugin-api
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java
index 57377dd29b3..e5c55492bc8 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java
@@ -66,6 +66,14 @@ public class GenerateAppProjectMojo extends AbstractMojo {
@Parameter(property="artifactId")
private String artifactId;
+ /// The Java release the generated project should target: 8 or 17.
+ ///
+ /// Left unset the archetype chooses, which is what a user normally wants.
+ /// A build that will compile the generated project with a specific JDK has
+ /// to say so, because the two have to agree.
+ @Parameter(property = "javaVersion")
+ private String javaVersion;
+
@Parameter(property="groupId")
private String groupId;
@@ -125,6 +133,17 @@ private void generateProject() throws MojoExecutionException{
"package="+packageName()
};
Properties props = new Properties();
+ // The archetype's javaVersion, when the caller named one.
+ //
+ // This goal invokes archetype:generate as a SEPARATE Maven process, and
+ // that process does not inherit this one's -D properties -- only what is
+ // put in this list reaches it. So a caller passing -DjavaVersion=8 was
+ // silently ignored and got the archetype's default instead, which is 17:
+ // a generated project that a JDK 8 build cannot compile, failing with
+ // "invalid target release: 17" in a module the caller never wrote.
+ if (javaVersion != null && javaVersion.trim().length() > 0) {
+ props.setProperty("javaVersion", javaVersion.trim());
+ }
for (String prop : propsArr) {
int eqpos = prop.indexOf("=");
if (eqpos > 0) {
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SimulatorMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SimulatorMojo.java
index 0ec1fa2114a..9c94d13601b 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SimulatorMojo.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SimulatorMojo.java
@@ -215,6 +215,30 @@ protected void processBuffer(ByteArrayOutputStream buffer) {
+ /**
+ * Hands every {@code cn1.*} system property to the simulator.
+ *
+ *
The simulator runs in a JVM of its own, so {@code mvn cn1:run -Dcn1.something=x}
+ * sets the property on Maven and nowhere the application can see it -- which looks
+ * exactly like the property being ignored. Only {@code ffmpeg.dir} was forwarded, one
+ * key at a time, so anything else needed a code change to become settable at all.
+ *
+ *
Scoped to the {@code cn1.} prefix rather than passing the whole environment:
+ * Maven's own properties (its repository, its offline flag, the JDK's) mean something
+ * different inside a forked JVM, and a few of them would change how it behaves.
+ */
+ private void forwardCn1Properties(Java java) {
+ Properties props = System.getProperties();
+ for (String name : props.stringPropertyNames()) {
+ if (name.startsWith("cn1.")) {
+ Variable v = new Variable();
+ v.setKey(name);
+ v.setValue(props.getProperty(name));
+ java.addSysproperty(v);
+ }
+ }
+ }
+
private Path prepareClasspath(Java java) {
Log log = getLog();
log.debug("Preparing classpath for Simulator");
@@ -225,6 +249,7 @@ private Path prepareClasspath(Java java) {
v.setValue(System.getProperty("ffmpeg.dir"));
java.addSysproperty(v);
}
+ forwardCn1Properties(java);
copyKotlinIncrementalCompileOutputToOutputDir();
for (Artifact artifact : project.getArtifacts()) {
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeFlutterMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeFlutterMojo.java
new file mode 100644
index 00000000000..2dffaba29ae
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeFlutterMojo.java
@@ -0,0 +1,366 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.maven;
+
+import com.codename1.dart.transpiler.api.DartTranspiler;
+import com.codename1.dart.transpiler.api.Diagnostic;
+import com.codename1.dart.transpiler.api.TranspileRequest;
+import com.codename1.dart.transpiler.api.TranspileResult;
+
+import org.apache.maven.model.Dependency;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Transpiles Flutter/Dart sources under {@code src/main/flutter} into Java
+ * source targeting the Codename One Flutter runtime
+ * ({@code codenameone-flutter-runtime}), so Flutter UI code runs as plain
+ * Codename One components at native speed -- no Dart VM or Flutter engine.
+ *
+ *
{@code src/main/flutter/assets/**} and {@code src/main/flutter/packages/**} --
+ * bundled assets, flattened into the
+ * build output (Codename One resources are flat on every port) so
+ * {@code Image.asset(...)} resolves
+ *
+ *
+ *
When the directory does not exist the goal is a silent no-op. When it
+ * exists, generated sources land in {@code target/generated-sources/flutter}
+ * (registered as a compile source root). Generated code is Java 17 source,
+ * so the build must run on JDK 17+ -- checked here with a friendly error.
+ * The transpile is whole-program with a digest-based fast skip; unchanged
+ * outputs are not rewritten, keeping incremental javac warm.
+ */
+@Mojo(name = "transcode-flutter", defaultPhase = LifecyclePhase.GENERATE_SOURCES,
+ requiresDependencyResolution = ResolutionScope.COMPILE)
+public class TranscodeFlutterMojo extends AbstractCN1Mojo {
+
+ private static final String RUNTIME_ARTIFACT = "codenameone-flutter-runtime";
+
+ @Parameter(property = "cn1.flutter.sourceDir", defaultValue = "${project.basedir}/src/main/flutter")
+ private File flutterSourceDir;
+
+ @Parameter(property = "cn1.flutter.outputDir", defaultValue = "${project.build.directory}/generated-sources/flutter")
+ private File flutterOutputDir;
+
+ @Parameter(property = "cn1.flutter.package", defaultValue = "com.codename1.generated.flutter")
+ private String flutterPackage;
+
+ @Parameter(property = "cn1.flutter.stateFile", defaultValue = "${project.build.directory}/flutter-transpiler/state.txt")
+ private File stateFile;
+
+ @Override
+ protected void executeImpl() throws MojoExecutionException, MojoFailureException {
+ if (flutterSourceDir == null || !flutterSourceDir.isDirectory()) {
+ sweepStaleOutput();
+ // Flutter was removed from the project: its assets go with it.
+ removeStaleAssets(new HashSet());
+ return;
+ }
+ checkJdk();
+ checkRuntimeDependency();
+
+ TranspileRequest req = new TranspileRequest()
+ .sourceRoot(flutterSourceDir)
+ .outputDir(flutterOutputDir)
+ .packageName(flutterPackage)
+ .stateFile(stateFile);
+ // runtime dependencies contribute API stubs via META-INF/dart/*.dart
+ for (Object o : project.getArtifacts()) {
+ org.apache.maven.artifact.Artifact a = (org.apache.maven.artifact.Artifact) o;
+ if (a.getFile() != null) {
+ req.stubClasspathEntry(a.getFile());
+ }
+ }
+ TranspileResult result = new DartTranspiler().transpile(req);
+ for (Diagnostic d : result.getDiagnostics()) {
+ String msg = "src/main/flutter/" + d.file + ":[" + d.line + "," + d.col + "] "
+ + d.message + " (dart2java:" + d.code + ")";
+ if (d.severity == Diagnostic.Severity.ERROR) {
+ getLog().error(msg);
+ } else if (d.severity == Diagnostic.Severity.WARNING) {
+ getLog().warn(msg);
+ } else {
+ getLog().info(msg);
+ }
+ }
+ if (result.hasErrors()) {
+ throw new MojoFailureException("Flutter transpilation failed with "
+ + result.errors().size() + " error(s); see log above. "
+ + "Confirm the Dart files pass `dart analyze` -- constructs outside the "
+ + "currently supported subset are reported with a milestone code.");
+ }
+ if (result.isUpToDate()) {
+ getLog().info("Flutter sources are up to date");
+ } else {
+ getLog().info("Transpiled Flutter sources to " + flutterOutputDir);
+ }
+ copyAssets();
+ registerSourceRoot();
+ }
+
+ private void checkJdk() throws MojoFailureException {
+ String spec = System.getProperty("java.specification.version", "1.8");
+ int major;
+ try {
+ major = Integer.parseInt(spec.startsWith("1.") ? spec.substring(2) : spec);
+ } catch (NumberFormatException e) {
+ major = 8;
+ }
+ if (major < 17) {
+ throw new MojoFailureException("Flutter support generates Java 17 source, but this build "
+ + "is running on JDK " + spec + ". Run Maven with JDK 17 or newer "
+ + "(e.g. from https://adoptium.net) to use src/main/flutter.");
+ }
+ }
+
+ private void checkRuntimeDependency() throws MojoFailureException {
+ for (Object o : project.getDependencies()) {
+ Dependency d = (Dependency) o;
+ if (RUNTIME_ARTIFACT.equals(d.getArtifactId())) {
+ return;
+ }
+ }
+ throw new MojoFailureException("src/main/flutter contains Dart sources but the project "
+ + "does not declare the Flutter runtime dependency. Add this to common/pom.xml:\n\n"
+ + " \n"
+ + " com.codenameone\n"
+ + " " + RUNTIME_ARTIFACT + "\n"
+ + " ${cn1.version}\n"
+ + " \n");
+ }
+
+ /**
+ * Copies {@code src/main/flutter/assets} into the build output, flattened.
+ *
+ *
Codename One resources are flat on every port -- {@code getResourceAsStream}
+ * rejects a name containing a {@code '/'} past the leading one -- so the asset
+ * tree cannot be mirrored. Each asset is written to the output root under the
+ * name produced by {@link #flatAssetName}, which the Flutter runtime's
+ * {@code FlutterAssets} recomputes when resolving {@code Image.asset(...)}.
+ */
+ void copyAssets() throws MojoExecutionException {
+ File outDir = new File(project.getBuild().getOutputDirectory());
+ Set written = new HashSet();
+ List keys = new ArrayList();
+ int count = 0;
+ try {
+ // Both roots a pubspec asset key can start with. "assets/" is an app's own
+ // bundle; "packages/" is one it pulls from a dependency, which is how the
+ // gallery ships its artwork (packages/flutter_gallery_assets/assets/...).
+ // Only "assets/" was copied before, so every packaged asset key resolved to
+ // nothing on every port - the images were simply absent.
+ for (String root : ASSET_ROOTS) {
+ File dir = new File(flutterSourceDir, root);
+ if (!dir.isDirectory()) {
+ continue;
+ }
+ Files.createDirectories(outDir.toPath());
+ // the root stays in the Flutter asset key, matching pubspec paths
+ count += flattenInto(dir, root, outDir, written, keys);
+ }
+ if (count > 0) {
+ writeAssetManifest(outDir, keys, written);
+ getLog().info("Flattened " + count + " Flutter asset(s) into the build output");
+ }
+ } catch (IOException e) {
+ throw new MojoExecutionException("Failed copying Flutter assets", e);
+ }
+ removeStaleAssets(written);
+ }
+
+ /**
+ * Deletes the flattened assets an earlier build wrote that this one did not,
+ * then records what this build wrote.
+ *
+ *
Flattening only ever ADDS to {@code target/classes}. An asset renamed or
+ * deleted in {@code src/main/flutter} -- or the whole Flutter tree removed --
+ * therefore kept being packaged by every incremental build until a clean one:
+ * a bigger application, and a stale {@code Image.asset} lookup that still
+ * succeeded against a file the project no longer has.
+ *
+ *
Only names from this plugin's own record are ever deleted. Sweeping every
+ * {@code cn1f_*} file would be simpler, but the output directory is shared with
+ * the application's own resources, and a name this plugin did not write is not
+ * one it should remove.
+ */
+ void removeStaleAssets(Set written) throws MojoExecutionException {
+ File outDir = new File(project.getBuild().getOutputDirectory());
+ File manifest = assetManifest();
+ try {
+ if (manifest.isFile()) {
+ for (String name : Files.readAllLines(manifest.toPath(), java.nio.charset.StandardCharsets.UTF_8)) {
+ name = name.trim();
+ // Defensive: a record entry must name a flat asset in the output
+ // root, never a path, whatever ended up in the file.
+ if (name.startsWith("cn1f_") && name.indexOf('/') < 0 && name.indexOf('\\') < 0
+ && !written.contains(name)) {
+ Files.deleteIfExists(new File(outDir, name).toPath());
+ }
+ }
+ }
+ if (written.isEmpty()) {
+ Files.deleteIfExists(manifest.toPath());
+ } else {
+ List sorted = new ArrayList(written);
+ java.util.Collections.sort(sorted);
+ Files.createDirectories(manifest.getParentFile().toPath());
+ Files.write(manifest.toPath(), sorted, java.nio.charset.StandardCharsets.UTF_8);
+ }
+ } catch (IOException e) {
+ throw new MojoExecutionException("Failed removing stale Flutter assets", e);
+ }
+ }
+
+ /**
+ * The record of flattened asset names. Beside the output rather than inside
+ * the generated-sources directory, which {@link #sweepStaleOutput} deletes.
+ */
+ private File assetManifest() {
+ return new File(project.getBuild().getDirectory(), "cn1-flutter-assets.lst");
+ }
+
+ /** The directory names under {@code src/main/flutter} that hold bundled assets. */
+ private static final String[] ASSET_ROOTS = {"assets", "packages"};
+
+ /**
+ * The bundled list of asset keys, one per line, which the runtime's
+ * {@code FlutterAssets} reads to learn which resolution variants exist. Flutter
+ * builds the same thing (AssetManifest) for the same reason: a variant can sit
+ * in any positive scale directory -- {@code 2.5x/}, or {@code 2x/} as well as
+ * {@code 2.0x/} -- so no fixed probe list finds them all, and one outside it was
+ * bundled and never used. Its name cannot collide with an asset: every asset key
+ * starts with {@code assets/} or {@code packages/}, so every asset's flat name
+ * carries a {@code _s} after that root.
+ */
+ static final String ASSET_MANIFEST = "cn1f_AssetManifest.txt";
+
+ private void writeAssetManifest(File outDir, List keys, Set written) throws IOException {
+ List sorted = new ArrayList(keys);
+ java.util.Collections.sort(sorted);
+ Files.write(new File(outDir, ASSET_MANIFEST).toPath(), sorted, java.nio.charset.StandardCharsets.UTF_8);
+ written.add(ASSET_MANIFEST);
+ }
+
+ private int flattenInto(File dir, String assetPrefix, File outDir, Set written,
+ List keys) throws IOException {
+ File[] children = dir.listFiles();
+ if (children == null) {
+ return 0;
+ }
+ int count = 0;
+ for (File child : children) {
+ String key = assetPrefix + "/" + child.getName();
+ if (child.isDirectory()) {
+ count += flattenInto(child, key, outDir, written, keys);
+ } else {
+ String flat = flatAssetName(key);
+ Files.copy(child.toPath(), new File(outDir, flat).toPath(),
+ StandardCopyOption.REPLACE_EXISTING);
+ written.add(flat);
+ keys.add(key);
+ count++;
+ }
+ }
+ return count;
+ }
+
+ /**
+ * The flat resource name for a Flutter asset key. {@code '_'} is an escape
+ * always followed by one character -- {@code "__"} for an underscore,
+ * {@code "_s"} for the separator -- which is prefix-free, so no two keys
+ * share a name. Doubling underscores and using a single one as the
+ * separator was not: {@code a_/b} and {@code a/_b} both became
+ * {@code a___b}, and one asset was silently written over the other.
+ * Extensions survive for native bundlers.
+ *
+ *
Keep in sync with {@code com.codename1.flutter.FlutterAssets} in
+ * the Flutter runtime -- deliberately duplicated rather than shared, because
+ * this plugin must not depend on the runtime it builds against.
+ */
+ static String flatAssetName(String assetKey) {
+ String p = assetKey;
+ while (p.startsWith("/")) {
+ p = p.substring(1);
+ }
+ StringBuilder sb = new StringBuilder("cn1f_");
+ for (int i = 0; i < p.length(); i++) {
+ char c = p.charAt(i);
+ if (c == '_') {
+ sb.append("__");
+ } else if (c == '/') {
+ sb.append("_s");
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ private void sweepStaleOutput() {
+ if (flutterOutputDir != null && flutterOutputDir.isDirectory()) {
+ deleteRecursive(flutterOutputDir);
+ getLog().debug("Removed stale Flutter generated sources");
+ }
+ }
+
+ private void deleteRecursive(File dir) {
+ File[] children = dir.listFiles();
+ if (children != null) {
+ for (File child : children) {
+ if (child.isDirectory()) {
+ deleteRecursive(child);
+ } else {
+ child.delete();
+ }
+ }
+ }
+ dir.delete();
+ }
+
+ private void registerSourceRoot() {
+ String path = flutterOutputDir.getAbsolutePath();
+ if (!project.getCompileSourceRoots().contains(path)) {
+ project.addCompileSourceRoot(path);
+ getLog().debug("Added compile source root " + path);
+ }
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/TranscodeFlutterAssetsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/TranscodeFlutterAssetsTest.java
new file mode 100644
index 00000000000..0f963a93f38
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/TranscodeFlutterAssetsTest.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.maven;
+
+import org.apache.maven.model.Build;
+import org.apache.maven.project.MavenProject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.util.HashSet;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Flattened Flutter assets follow the source tree across incremental builds:
+ * what disappears from src/main/flutter disappears from the output, and nothing
+ * the plugin did not write is ever removed.
+ */
+public class TranscodeFlutterAssetsTest {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ private TranscodeFlutterMojo mojo;
+ private File flutter;
+ private File classes;
+
+ @Before
+ public void setUp() throws Exception {
+ File target = tmp.newFolder("target");
+ classes = new File(target, "classes");
+ flutter = tmp.newFolder("flutter");
+ MavenProject project = new MavenProject();
+ Build build = new Build();
+ build.setDirectory(target.getAbsolutePath());
+ build.setOutputDirectory(classes.getAbsolutePath());
+ project.setBuild(build);
+ mojo = new TranscodeFlutterMojo();
+ set("project", project);
+ set("flutterSourceDir", flutter);
+ }
+
+ @Test
+ public void aRenamedAssetLeavesNoStaleCopy() throws Exception {
+ File old = asset("assets/icons/old_name.png");
+ asset("assets/kept.png");
+ mojo.copyAssets();
+ File oldFlat = flat("assets/icons/old_name.png");
+ assertTrue(oldFlat.isFile());
+
+ assertTrue(old.delete());
+ asset("assets/icons/new_name.png");
+ mojo.copyAssets();
+
+ assertFalse("the renamed-away asset must not be packaged again", oldFlat.exists());
+ assertTrue(flat("assets/icons/new_name.png").isFile());
+ assertTrue(flat("assets/kept.png").isFile());
+ }
+
+ @Test
+ public void theManifestListsEveryBundledAssetKey() throws Exception {
+ asset("assets/a.png");
+ asset("assets/2.5x/a.png");
+ asset("packages/lib/assets/3x/b.png");
+ mojo.copyAssets();
+ File manifest = new File(classes, TranscodeFlutterMojo.ASSET_MANIFEST);
+ assertTrue(manifest.isFile());
+ assertEquals(java.util.Arrays.asList("assets/2.5x/a.png", "assets/a.png", "packages/lib/assets/3x/b.png"),
+ Files.readAllLines(manifest.toPath(), java.nio.charset.StandardCharsets.UTF_8));
+
+ // Recorded like the assets: gone with them when the Flutter tree is.
+ mojo.removeStaleAssets(new HashSet());
+ assertFalse(manifest.exists());
+ }
+
+ @Test
+ public void removingFlutterRemovesItsAssets() throws Exception {
+ asset("assets/a.png");
+ asset("packages/lib/assets/b.png");
+ mojo.copyAssets();
+ assertTrue(flat("assets/a.png").isFile());
+
+ // What executeImpl does when src/main/flutter is gone.
+ mojo.removeStaleAssets(new HashSet());
+
+ assertFalse(flat("assets/a.png").exists());
+ assertFalse(flat("packages/lib/assets/b.png").exists());
+ }
+
+ @Test
+ public void filesThePluginDidNotWriteAreNeverRemoved() throws Exception {
+ asset("assets/a.png");
+ mojo.copyAssets();
+ // The application's own resource, sharing the output directory and even
+ // the prefix: it is not in the plugin's record, so it is not the plugin's.
+ File own = new File(classes, "cn1f_app_owned.bin");
+ Files.write(own.toPath(), new byte[] {1});
+
+ mojo.removeStaleAssets(new HashSet());
+
+ assertTrue(own.isFile());
+ }
+
+ /**
+ * The same vectors as the runtime's FlutterAssetsTest and the benchmark's
+ * stage_assets.py: the three encoders must agree name for name, or an asset
+ * the build writes is one the runtime never finds.
+ */
+ @Test
+ public void encodingMatchesTheRuntimeAndIsPrefixFree() {
+ assertEquals("cn1f_assets_sstudies_sreply__card.png",
+ TranscodeFlutterMojo.flatAssetName("assets/studies/reply_card.png"));
+ assertEquals("cn1f_a___sb.png", TranscodeFlutterMojo.flatAssetName("a_/b.png"));
+ assertEquals("cn1f_a_s__b.png", TranscodeFlutterMojo.flatAssetName("a/_b.png"));
+ assertFalse(TranscodeFlutterMojo.flatAssetName("a_/b.png")
+ .equals(TranscodeFlutterMojo.flatAssetName("a/_b.png")));
+ }
+
+ private File asset(String key) throws Exception {
+ File f = new File(flutter, key);
+ f.getParentFile().mkdirs();
+ Files.write(f.toPath(), new byte[] {0});
+ return f;
+ }
+
+ private File flat(String key) {
+ return new File(classes, TranscodeFlutterMojo.flatAssetName(key));
+ }
+
+ private void set(String name, Object value) throws Exception {
+ for (Class> c = mojo.getClass(); c != null; c = c.getSuperclass()) {
+ try {
+ Field f = c.getDeclaredField(name);
+ f.setAccessible(true);
+ f.set(mojo, value);
+ return;
+ } catch (NoSuchFieldException keepLooking) {
+ // up the chain
+ }
+ }
+ throw new NoSuchFieldException(name);
+ }
+}
diff --git a/maven/core-unittests/pom.xml b/maven/core-unittests/pom.xml
index aadaa09d05b..d26aa64b089 100644
--- a/maven/core-unittests/pom.xml
+++ b/maven/core-unittests/pom.xml
@@ -29,6 +29,25 @@
+
+
+ maven-jar-plugin
+
+
+
+ test-jar
+
+
+
+ org.apache.maven.pluginsmaven-surefire-plugin
diff --git a/maven/core-unittests/src/test/java/com/codename1/l10n/SimpleDateFormatTest.java b/maven/core-unittests/src/test/java/com/codename1/l10n/SimpleDateFormatTest.java
index c72c820ee62..05eff41c4e8 100644
--- a/maven/core-unittests/src/test/java/com/codename1/l10n/SimpleDateFormatTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/l10n/SimpleDateFormatTest.java
@@ -1,3 +1,26 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
package com.codename1.l10n;
import com.codename1.junit.UITestBase;
@@ -200,4 +223,53 @@ public String getShortMonthName(Date date) {
return "Long";
}
}
+
+ @Test
+ void formatsInTheZoneItIsGiven() {
+ java.util.TimeZone saved = java.util.TimeZone.getDefault();
+ java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("America/New_York"));
+ try {
+ // 2024-03-10 02:30 UTC: the local 02:30 does not exist that morning in New York.
+ java.util.Calendar c = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"));
+ c.clear();
+ c.set(2024, java.util.Calendar.MARCH, 10, 2, 30, 0);
+ SimpleDateFormat f = new SimpleDateFormat("HH:mm");
+ f.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
+ assertEquals("02:30", f.format(c.getTime()));
+ assertEquals("UTC", f.getTimeZone().getID());
+ SimpleDateFormat copy = (SimpleDateFormat) f.clone();
+ assertEquals("02:30", copy.format(c.getTime()), "a clone keeps the zone");
+ assertEquals("America/New_York", new SimpleDateFormat("HH:mm").getTimeZone().getID(),
+ "without one, the device's zone");
+ } finally {
+ java.util.TimeZone.setDefault(saved);
+ }
+ }
+ @Test
+ void parsesInTheZoneItIsGiven() throws Exception {
+ java.util.TimeZone saved = java.util.TimeZone.getDefault();
+ java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("America/New_York"));
+ try {
+ java.util.Calendar c = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"));
+ c.clear();
+ c.set(2024, java.util.Calendar.JANUARY, 1, 0, 0, 0);
+ SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd HH:mm");
+ f.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
+ assertEquals(c.getTime().getTime(), f.parse("2024-01-01 00:00").getTime(),
+ "text without a zone is read in the formatter's zone, not the device's");
+ // Summer, so the device zone's DST cannot leak into a zone without one.
+ c.set(2024, java.util.Calendar.JULY, 1, 12, 0, 0);
+ assertEquals(c.getTime().getTime(), f.parse("2024-07-01 12:00").getTime());
+ assertEquals("2024-07-01 12:00", f.format(f.parse("2024-07-01 12:00")), "round trip");
+
+ java.util.Calendar ny = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("America/New_York"));
+ ny.clear();
+ ny.set(2024, java.util.Calendar.JULY, 1, 12, 0, 0);
+ assertEquals(ny.getTime().getTime(),
+ new SimpleDateFormat("yyyy-MM-dd HH:mm").parse("2024-07-01 12:00").getTime(),
+ "without one, the device's zone");
+ } finally {
+ java.util.TimeZone.setDefault(saved);
+ }
+ }
}
diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java
index d8833551ac4..83d685ab32e 100644
--- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java
+++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java
@@ -1637,6 +1637,16 @@ public void reset() {
incomingConnections.clear();
resourceAsStreams.clear();
deviceDensity = Display.DENSITY_MEDIUM;
+ // Restored for the same reason the density above is, and it is the one
+ // device-shape flag whose leak is SILENT. A test that leaves this false
+ // does not fail; the next test asking for COMMAND_BEHAVIOR_BUTTON_BAR is
+ // rewritten to COMMAND_BEHAVIOR_SOFTKEY by
+ // CodenameOneImplementation.setCommandBehavior, its commands are drawn as
+ // soft buttons, and it fails on a missing button bar with nothing naming
+ // the cause. That is exactly how MenuBarDialogSideMenuTest failed in CI
+ // and passed on every developer machine: surefire's filesystem run order
+ // differs per checkout, so whether the leaking test ran first was luck.
+ touchDevice = true;
displayWidth = 1080;
displayHeight = 1920;
desktopSize = new Dimension(displayWidth, displayHeight);
diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java
index c5e04ee1e3b..ba7ce719730 100644
--- a/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java
@@ -392,4 +392,25 @@ private Button findButton(Container root, Command target) {
}
return null;
}
+
+ @FormTest
+ void aTimedOutDialogIsOnlyDisposedOnTheEdt() throws Exception {
+ final Dialog dlg = new Dialog("Timed");
+ dlg.setTimeout(1);
+ Thread.sleep(5); // the deadline is now in the past
+ // invokeAndBlock's waiting thread polls isDisposed() while a modal show blocks.
+ // It must not act on the deadline there: disposing and deregistering the
+ // dialog's animation from that thread raced the EDT walking the same list.
+ final boolean[] offEdt = new boolean[1];
+ Thread poller = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ offEdt[0] = dlg.isDisposed();
+ }
+ });
+ poller.start();
+ poller.join();
+ assertFalse(offEdt[0], "the waiting thread leaves the deadline to the EDT");
+ assertTrue(dlg.isDisposed(), "on the EDT the passed deadline disposes the dialog");
+ }
}
diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/animations/ContainerTransformNoSourceTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/animations/ContainerTransformNoSourceTest.java
new file mode 100644
index 00000000000..15822848295
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/animations/ContainerTransformNoSourceTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.ui.animations;
+
+import com.codename1.junit.UITestBase;
+import com.codename1.ui.Form;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class ContainerTransformNoSourceTest extends UITestBase {
+
+ @Test
+ void aTransitionWithNoSourceFormDoesNotThrow() {
+ // The first Form shown has nothing to transition from; the Transition contract
+ // allows a null source, and dereferencing it made that first show throw.
+ Form destination = new Form();
+ destination.setWidth(200);
+ destination.setHeight(300);
+ final ContainerTransformTransition t = ContainerTransformTransition.create("anything", 200);
+ t.init(null, destination);
+ assertDoesNotThrow(t::initTransition);
+ assertFalse(t.animate(), "with nothing to transform from there is no animation");
+ }
+
+ @Test
+ void cleanupReleasesTheForms() {
+ // A caller can still hold the transition (Display.getRunningTransition) after
+ // it ends; cleanup must drop both forms and their component trees.
+ Form source = new Form();
+ Form destination = new Form();
+ final ContainerTransformTransition t = ContainerTransformTransition.create("anything", 200);
+ t.init(source, destination);
+ t.cleanup();
+ assertNull(t.getSource());
+ assertNull(t.getDestination());
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/animations/CubicBezierMotionTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/animations/CubicBezierMotionTest.java
index 653aa19a724..6621e1e7d87 100644
--- a/maven/core-unittests/src/test/java/com/codename1/ui/animations/CubicBezierMotionTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/animations/CubicBezierMotionTest.java
@@ -1,3 +1,26 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
package com.codename1.ui.animations;
import com.codename1.junit.UITestBase;
@@ -120,4 +143,20 @@ void worksWithDecreasingRange() {
assertEquals(1000, sample(m, 0));
assertEquals(0, sample(m, 1000));
}
+
+ @Test
+ void aThreePointMotionWithAFlatSegmentStaysContinuousAtTheJoin() {
+ // Midpoint on the bottom edge: the first segment has no vertical extent. Its Y
+ // cannot be normalized, and substituting linear progress there made the value run
+ // up toward t -- about 499 of 1000 just before the join -- and then drop to 0 at
+ // it. The segment's own cubic is flat at 0 all the way.
+ Motion m = Motion.createThreePointCubicMotion(0, 1000, 1000,
+ 0.1f, 0f, 0.4f, 0f,
+ 0.5f, 0f,
+ 0.6f, 0.3f, 0.9f, 1f);
+ assertEquals(0, sample(m, 499), 20, "the flat first segment stays at its level");
+ assertEquals(0, sample(m, 250), 20);
+ int atJoin = sample(m, 500);
+ assertTrue(Math.abs(atJoin - sample(m, 499)) <= 20, "no jump at the join");
+ }
}
diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathRectangleEqualityTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathRectangleEqualityTest.java
new file mode 100644
index 00000000000..ae8c8372cf9
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathRectangleEqualityTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.ui.geom;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A path is equal to a rectangle only when it IS that rectangle.
+ *
+ *
This used to compare bounding boxes, so every non-rectangular path compared equal to
+ * its own bounding box. The clip bookkeeping asks exactly this question before deciding a
+ * setClip is a no-op, and Codename One narrows the clip to a component's bounds before
+ * painting it -- so a shaped clip filling that component arrived with bounds equal to the
+ * current rectangular clip, compared EQUAL, and was DISCARDED. The subtree then painted
+ * square with nothing reported.
+ */
+class GeneralPathRectangleEqualityTest {
+
+ /** A 64-sided polygon inscribed in (x, y, w, h) -- a circle for these purposes. */
+ private static GeneralPath circle(float x, float y, float w, float h) {
+ GeneralPath p = new GeneralPath();
+ float rx = w / 2f;
+ float ry = h / 2f;
+ float cx = x + rx;
+ float cy = y + ry;
+ for (int i = 0; i < 64; i++) {
+ double a = 2 * Math.PI * i / 64;
+ float px = (float) (cx + rx * Math.cos(a));
+ float py = (float) (cy + ry * Math.sin(a));
+ if (i == 0) {
+ p.moveTo(px, py);
+ } else {
+ p.lineTo(px, py);
+ }
+ }
+ p.closePath();
+ return p;
+ }
+
+ private static GeneralPath rectPath(float x, float y, float w, float h) {
+ GeneralPath p = new GeneralPath();
+ p.moveTo(x, y);
+ p.lineTo(x + w, y);
+ p.lineTo(x + w, y + h);
+ p.lineTo(x, y + h);
+ p.closePath();
+ return p;
+ }
+
+ @Test
+ void aCircleIsNotItsBoundingRectangle() {
+ GeneralPath c = circle(10, 20, 100, 100);
+ assertFalse(c.isRectangle(), "a 64-gon is not a rectangle");
+ Rectangle bounds = new Rectangle();
+ c.getBounds(bounds);
+ assertFalse(c.equals(bounds, null),
+ "a circle must not compare equal to the rectangle it is inscribed in");
+ }
+
+ @Test
+ void aTriangleIsNotItsBoundingRectangle() {
+ GeneralPath t = new GeneralPath();
+ t.moveTo(50f, 0f);
+ t.lineTo(100f, 100f);
+ t.lineTo(0f, 100f);
+ t.closePath();
+ Rectangle bounds = new Rectangle();
+ t.getBounds(bounds);
+ assertFalse(t.equals(bounds, null));
+ }
+
+ /// The case the comparison exists for still answers yes.
+ @Test
+ void aRectangularPathStillEqualsThatRectangle() {
+ GeneralPath r = rectPath(10, 20, 100, 50);
+ assertTrue(r.isRectangle());
+ assertTrue(r.equals(new Rectangle(10, 20, 100, 50), null));
+ }
+
+ @Test
+ void aRectangularPathDoesNotEqualADifferentRectangle() {
+ GeneralPath r = rectPath(10, 20, 100, 50);
+ assertFalse(r.equals(new Rectangle(10, 20, 100, 51), null));
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/DefaultLookAndFeelLazyGlyphsTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/DefaultLookAndFeelLazyGlyphsTest.java
new file mode 100644
index 00000000000..d6396060a62
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/DefaultLookAndFeelLazyGlyphsTest.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.
+ */
+package com.codename1.ui.plaf;
+
+import com.codename1.junit.UITestBase;
+import com.codename1.ui.Image;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+/// DefaultLookAndFeel builds its check box, radio button and combo box glyphs the first
+/// time something asks for them instead of on every theme install. What callers see
+/// must not change: after a refresh the getters still return the images the theme
+/// defines, and an image an application sets after a refresh is kept, not replaced by
+/// the late build.
+public class DefaultLookAndFeelLazyGlyphsTest extends UITestBase {
+
+ @Test
+ public void imagesAreThereWhenAskedForAfterARefresh() {
+ DefaultLookAndFeel laf = (DefaultLookAndFeel) UIManager.getInstance().getLookAndFeel();
+ laf.refreshTheme(true);
+ // The test implementation supports TrueType fonts, so the Material glyph
+ // fallback applies and every group has images.
+ assertNotNull(laf.getCheckBoxImages());
+ assertNotNull(laf.getCheckBoxFocusImages());
+ assertNotNull(laf.getRadioButtonImages());
+ assertNotNull(laf.getRadioButtonFocusImages());
+ }
+
+ @Test
+ public void anImageSetAfterARefreshIsNotReplacedByTheLateBuild() {
+ DefaultLookAndFeel laf = (DefaultLookAndFeel) UIManager.getInstance().getLookAndFeel();
+ laf.refreshTheme(true);
+ Image checked = Image.createImage(4, 4, 0xff00ff00);
+ Image unchecked = Image.createImage(4, 4, 0xffff0000);
+ laf.setCheckBoxImages(checked, unchecked, checked, unchecked);
+ laf.setRadioButtonImages(checked, unchecked, checked, unchecked);
+ // The first read after the setter must return the application's images.
+ assertSame(checked, laf.getCheckBoxImages()[1]);
+ assertSame(unchecked, laf.getCheckBoxImages()[0]);
+ assertSame(checked, laf.getRadioButtonImages()[1]);
+ // A new refresh re-derives them from the theme, as it always did.
+ laf.refreshTheme(true);
+ assertNotNull(laf.getCheckBoxImages());
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerDeferredBaseThemeTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerDeferredBaseThemeTest.java
new file mode 100644
index 00000000000..f54b1242143
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerDeferredBaseThemeTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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.ui.plaf;
+
+import com.codename1.junit.UITestBase;
+import org.junit.jupiter.api.Test;
+
+import java.util.Hashtable;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/// A deferred base theme (UIManager.setDeferredBaseTheme) must be invisible in
+/// what an application sees: installed before anything reads the theme, never
+/// installed when a theme replaces it outright, and installed first when a theme is
+/// layered on it. The Linux and Windows ports defer their native theme this way
+/// instead of installing it while the display initializes.
+public class UIManagerDeferredBaseThemeTest extends UITestBase {
+
+ /// Counts its runs and installs a base theme with one recognisable key.
+ private static final class Base implements Runnable {
+ private final UIManager manager;
+ int runs;
+
+ Base(UIManager manager) {
+ this.manager = manager;
+ }
+
+ @Override
+ public void run() {
+ runs++;
+ Hashtable base = new Hashtable();
+ base.put("@deferredBaseMarker", "base");
+ base.put("name", "DeferredBase");
+ base.put("DeferredProbe.fgColor", "112233");
+ manager.setThemeProps(base);
+ }
+ }
+
+ @Test
+ public void aReadInstallsTheBaseThemeOnceBeforeAnswering() {
+ UIManager m = UIManager.createInstance();
+ Base base = new Base(m);
+ m.setDeferredBaseTheme(base);
+ assertEquals(0, base.runs, "registering must not install");
+ assertEquals("base", m.getThemeConstant("deferredBaseMarker", "missing"),
+ "the first read has to see the base theme");
+ assertEquals(0x112233, m.getComponentStyle("DeferredProbe").getFgColor());
+ assertEquals(1, base.runs, "installed exactly once");
+ }
+
+ @Test
+ public void everyKindOfReadTriggersIt() {
+ String[] kinds = {"style", "selected", "constant", "boolean", "image", "laf", "name", "zoom"};
+ for (String kind : kinds) {
+ UIManager m = UIManager.createInstance();
+ Base base = new Base(m);
+ m.setDeferredBaseTheme(base);
+ if ("style".equals(kind)) {
+ m.getComponentStyle("Label");
+ } else if ("selected".equals(kind)) {
+ m.getComponentSelectedStyle("Label");
+ } else if ("constant".equals(kind)) {
+ m.getThemeConstant("x", 0);
+ } else if ("boolean".equals(kind)) {
+ m.isThemeConstant("x", false);
+ } else if ("image".equals(kind)) {
+ m.getThemeImageConstant("x");
+ } else if ("name".equals(kind)) {
+ assertEquals("DeferredBase", m.getThemeName(), "the name of the theme that will be installed");
+ } else if ("zoom".equals(kind)) {
+ // Zooming must scale the base theme, not an empty table it then replaces.
+ m.zoomFonts(1.5f);
+ } else {
+ m.getLookAndFeel();
+ }
+ assertEquals(1, base.runs, "a " + kind + " read must install the deferred base theme");
+ }
+ }
+
+ @Test
+ public void aThemeThatReplacesEverythingSupersedesIt() {
+ UIManager m = UIManager.createInstance();
+ Base base = new Base(m);
+ m.setDeferredBaseTheme(base);
+ Hashtable app = new Hashtable();
+ app.put("@appMarker", "app");
+ m.setThemeProps(app);
+ assertEquals("app", m.getThemeConstant("appMarker", "missing"));
+ assertEquals("missing", m.getThemeConstant("deferredBaseMarker", "missing"),
+ "setThemeProps resets the table, so the base theme must not reappear");
+ assertEquals(0, base.runs, "a superseded base theme is never installed");
+ }
+
+ @Test
+ public void aLayeredThemeGetsTheBaseUnderneath() {
+ UIManager m = UIManager.createInstance();
+ Base base = new Base(m);
+ m.setDeferredBaseTheme(base);
+ Hashtable layer = new Hashtable();
+ layer.put("@layerMarker", "layer");
+ m.addThemeProps(layer);
+ assertEquals(1, base.runs);
+ assertEquals("base", m.getThemeConstant("deferredBaseMarker", "missing"));
+ assertEquals("layer", m.getThemeConstant("layerMarker", "missing"));
+ }
+
+ @Test
+ public void aPendingBaseThemeCountsAsInstalled() {
+ // FullScreenAdService installs the native theme when none was installed;
+ // a deferred one is as good as installed and must not look missing.
+ UIManager m = UIManager.createInstance();
+ Base base = new Base(m);
+ m.setDeferredBaseTheme(base);
+ assertTrue(m.wasThemeInstalled());
+ assertEquals(0, base.runs, "asking must not install");
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/util/ResourcesParseBufferTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/util/ResourcesParseBufferTest.java
new file mode 100644
index 00000000000..f42cf31ab60
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/util/ResourcesParseBufferTest.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.ui.util;
+
+import com.codename1.junit.UITestBase;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.Hashtable;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/// Resources parses a stream that is not a ByteArrayInputStream through a private
+/// read buffer. A source that hands back a few bytes at a time -- as a network or
+/// native file stream may -- must parse to exactly what the whole file in memory
+/// parses to.
+public class ResourcesParseBufferTest extends UITestBase {
+
+ /// Returns at most three bytes per read and skips at most two, so every refill
+ /// and short-read path of the buffer is taken.
+ private static final class TrickleStream extends InputStream {
+ private final byte[] data;
+ private int pos;
+
+ TrickleStream(byte[] data) {
+ this.data = data;
+ }
+
+ @Override
+ public int read() {
+ return pos < data.length ? data[pos++] & 0xff : -1;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ if (pos >= data.length) {
+ return -1;
+ }
+ int n = Math.min(Math.min(len, 3), data.length - pos);
+ System.arraycopy(data, pos, b, off, n);
+ pos += n;
+ return n;
+ }
+
+ @Override
+ public long skip(long n) {
+ int k = (int) Math.min(Math.min(n, 2), data.length - pos);
+ pos += k;
+ return k;
+ }
+ }
+
+ /// A real theme: thousands of keys, fonts and images, which is the file shape the
+ /// buffer was put in for. Read from the checkout; the test fails rather than
+ /// skipping when it is missing.
+ private static byte[] themeFile() throws IOException {
+ java.io.File f = new java.io.File("../../CodenameOneDesigner/src/iPhoneTheme.res");
+ assertTrue(f.isFile(), "fixture " + f.getAbsolutePath());
+ InputStream in = new java.io.FileInputStream(f);
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int n;
+ while ((n = in.read(buf)) > 0) {
+ out.write(buf, 0, n);
+ }
+ return out.toByteArray();
+ } finally {
+ in.close();
+ }
+ }
+
+ @Test
+ public void aTrickledStreamParsesLikeTheWholeFile() throws IOException {
+ byte[] data = themeFile();
+ Resources whole = Resources.open(new ByteArrayInputStream(data));
+ Resources trickled = Resources.open(new TrickleStream(data));
+
+ String[] themes = whole.getThemeResourceNames();
+ assertTrue(themes.length > 0, "the fixture has a theme to compare");
+ assertArrayEquals(themes, trickled.getThemeResourceNames());
+ String[] images = whole.getImageResourceNames();
+ String[] trickledImages = trickled.getImageResourceNames();
+ Arrays.sort(images);
+ Arrays.sort(trickledImages);
+ assertArrayEquals(images, trickledImages);
+ for (String name : themes) {
+ Hashtable a = whole.getTheme(name);
+ Hashtable b = trickled.getTheme(name);
+ assertEquals(a.keySet(), b.keySet(), "theme " + name + " keys");
+ }
+ }
+}
diff --git a/maven/dart-runtime/pom.xml b/maven/dart-runtime/pom.xml
new file mode 100644
index 00000000000..e4b698bbe85
--- /dev/null
+++ b/maven/dart-runtime/pom.xml
@@ -0,0 +1,97 @@
+
+
+
+
+ com.codenameone
+ codenameone
+ 8.0-SNAPSHOT
+
+ 4.0.0
+
+ codenameone-dart-runtime
+ 8.0-SNAPSHOT
+ jar
+ codenameone-dart-runtime
+
+ Java implementation of dart:core / dart:math semantics used by Dart
+ code transpiled to Java by the Codename One Dart transpiler. App-grade
+ code: depends only on codenameone-core and compiles to Java 17
+ bytecode, which all Codename One targets accept.
+
+
+
+ UTF-8
+ 17
+ 17
+
+
+
+
+
+ maven-compiler-plugin
+
+
+ 17
+ 17
+ 17
+
+
+
+
+
+
+
+
+ java17-fork
+
+
+ env.JAVA17_HOME
+
+
+
+
+
+ maven-compiler-plugin
+
+
+ 17
+ 17
+ 17
+ true
+ ${env.JAVA17_HOME}/bin/javac
+
+
+
+ maven-surefire-plugin
+
+ ${env.JAVA17_HOME}/bin/java
+
+
+
+
+
+
+
+
+
+ com.codenameone
+ codenameone-core
+ provided
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
diff --git a/maven/dart-runtime/src/main/java/dart/async/Await.java b/maven/dart-runtime/src/main/java/dart/async/Await.java
new file mode 100644
index 00000000000..259e66cde55
--- /dev/null
+++ b/maven/dart-runtime/src/main/java/dart/async/Await.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package dart.async;
+
+/**
+ * The lowering target of Dart's {@code await} (M3 blocking model).
+ *
+ *
On the EDT the wait runs inside {@code Display.invokeAndBlock}, which
+ * keeps dispatching events — other Dart callbacks, timers and UI input keep
+ * running while this frame is parked, matching Dart's "other events run
+ * during an await" semantics. Off the EDT (background threads, headless
+ * tests) it is a plain monitor wait.
+ */
+public final class Await {
+
+ private Await() {
+ }
+
+ /**
+ * {@code await v} where v is a FutureOr or dynamic: a Future is awaited, anything
+ * else -- {@code await 42}, {@code await null} -- is the value itself, as in Dart.
+ * Only the Future overload existed, so awaiting a non-Future did not compile.
+ */
+ @SuppressWarnings("unchecked")
+ public static Object awaitAny(Object v) {
+ return v instanceof Future ? await$((Future