diff --git a/.github/workflows/dart-flutter-tests.yml b/.github/workflows/dart-flutter-tests.yml
new file mode 100644
index 00000000000..ce00879af61
--- /dev/null
+++ b/.github/workflows/dart-flutter-tests.yml
@@ -0,0 +1,145 @@
+name: Dart and Flutter tests
+
+# Runs the unit and behavior tests of the Dart transpiler and the two runtimes
+# it targets (maven/dart-transpiler, maven/dart-runtime, maven/flutter-runtime).
+#
+# Nothing else does. pr.yml runs core-unittests only, and flutter-bench.yml
+# builds these modules with -DskipTests. The transpiler's BehaviorTest and
+# CompileGeneratedTest are also SKIPPED, not failed, when JAVA17_HOME or the
+# runtime jars are missing, so a leg that merely forgot JAVA17_HOME would stay
+# green having executed nothing. CN1_DART_TESTS_REQUIRED=1 below turns those
+# skips into failures, and the last step checks the reports independently.
+
+on:
+ pull_request:
+ paths:
+ - 'maven/dart-transpiler/**'
+ - 'maven/dart-runtime/**'
+ - 'maven/flutter-runtime/**'
+ # Everything those modules compile against: the runtimes depend on core,
+ # whose sources are CodenameOne/src, and -am builds factory with it.
+ - 'CodenameOne/src/**'
+ - 'Factory/src/**'
+ - 'maven/core/**'
+ - 'maven/factory/**'
+ - 'maven/pom.xml'
+ - 'scripts/ci/retry.sh'
+ - '.github/workflows/dart-flutter-tests.yml'
+ push:
+ branches:
+ - master
+ paths:
+ - 'maven/dart-transpiler/**'
+ - 'maven/dart-runtime/**'
+ - 'maven/flutter-runtime/**'
+ - 'CodenameOne/src/**'
+ - 'Factory/src/**'
+ - 'maven/core/**'
+ - 'maven/factory/**'
+ - 'maven/pom.xml'
+ - 'scripts/ci/retry.sh'
+ - '.github/workflows/dart-flutter-tests.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ # The pr-ci-container image lives on ghcr; without this the job dies in
+ # "Initialize containers" with a bare "denied".
+ packages: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ dart-flutter-tests:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ container: ghcr.io/codenameone/codenameone/pr-ci-container:latest
+ env:
+ # The behavior tests write UTF-8 sources and read UTF-8 output.
+ LANG: C.UTF-8
+ LC_ALL: C.UTF-8
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Check the container's JDK 17
+ run: |
+ if [ -z "${JAVA_HOME_17:-}" ] || [ ! -x "$JAVA_HOME_17/bin/javac" ]; then
+ echo "JAVA_HOME_17 is unset or has no javac: '${JAVA_HOME_17:-}'" >&2
+ exit 1
+ fi
+ echo "JAVA_HOME=$JAVA_HOME_17" >> "$GITHUB_ENV"
+ echo "JAVA17_HOME=$JAVA_HOME_17" >> "$GITHUB_ENV"
+ echo "$JAVA_HOME_17/bin" >> "$GITHUB_PATH"
+
+ - name: Cache Maven dependencies
+ uses: actions/cache@v5
+ with:
+ path: ~/.m2
+ key: ${{ runner.os }}-m2-dart-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-m2-dart-
+ ${{ runner.os }}-m2
+
+ # JDK 17, because the runtimes compile at release 17: maven/pom.xml adds
+ # them to the reactor only on 17+ (flutter-modules), and the transpiler
+ # only gets them on its test classpath there (transpiler-tests).
+ #
+ # verify rather than test: the transpiler's harness links the generated
+ # Java against the runtime JARS in each module's target/, which test never
+ # packages. Javadoc and the static analysis gates are skipped -- they are
+ # not what this job is for, and the core's run in pr.yml.
+ - name: Run the transpiler and runtime tests
+ env:
+ CN1_DART_TESTS_REQUIRED: '1'
+ # Retry only a dependency-resolution failure, never a failing test.
+ RETRY_ONLY_MATCHING: transient
+ run: |
+ cd maven
+ bash ../scripts/ci/retry.sh mvn -B verify \
+ -pl core,dart-runtime,flutter-runtime,dart-transpiler -am \
+ -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \
+ -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true
+
+ # Independent of the switch above: a module whose tests silently stopped
+ # running (a profile that no longer activates, a surefire include that
+ # matches nothing) produces no report at all, which no test can notice.
+ - name: Check that every suite ran
+ run: |
+ python3 - <<'EOF'
+ import glob, os, sys
+ import xml.etree.ElementTree as ET
+
+ def totals(pattern):
+ tests = skipped = 0
+ files = glob.glob(pattern)
+ for f in files:
+ r = ET.parse(f).getroot()
+ tests += int(r.get("tests", 0))
+ skipped += int(r.get("skipped", 0))
+ return len(files), tests, skipped
+
+ failed = False
+ for module in ("dart-runtime", "flutter-runtime", "dart-transpiler"):
+ n, tests, skipped = totals("maven/%s/target/surefire-reports/TEST-*.xml" % module)
+ print("%s: %d reports, %d tests, %d skipped" % (module, n, tests, skipped))
+ if tests == 0:
+ print("::error::%s ran no tests" % module)
+ failed = True
+
+ harness = "maven/dart-transpiler/target/surefire-reports/TEST-com.codename1.dart.transpiler.harness.%s.xml"
+ cases = [d for d in os.listdir("maven/dart-transpiler/src/test/resources/behavior")
+ if os.path.isdir(os.path.join("maven/dart-transpiler/src/test/resources/behavior", d))]
+ for name, expected in (("BehaviorTest", len(cases)), ("CompileGeneratedTest", 2)):
+ n, tests, skipped = totals(harness % name)
+ print("%s: %d tests, %d skipped (expected %d)" % (name, tests, skipped, expected))
+ if n == 0 or tests < expected or skipped != 0:
+ print("::error::%s did not run every case: %d run, %d skipped, %d expected"
+ % (name, tests, skipped, expected))
+ failed = True
+ sys.exit(1 if failed else 0)
+ EOF
diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml
index c59dc002c23..095d5e43067 100644
--- a/.github/workflows/developer-guide-docs.yml
+++ b/.github/workflows/developer-guide-docs.yml
@@ -151,7 +151,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,backend-test \
-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 d22e99c8574..83d25fa1c71 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 7072a1ca1df..1f5dd964b1b 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
@@ -180,34 +190,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 63bbb442b56..4f1a333f37c 100644
--- a/.github/workflows/parparvm-selfhost.yml
+++ b/.github/workflows/parparvm-selfhost.yml
@@ -49,6 +49,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 6a880ffc4ab..f4da51d76be 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 f24feaba7d8..cb71ff533a0 100644
--- a/.github/workflows/parparvm-tests.yml
+++ b/.github/workflows/parparvm-tests.yml
@@ -122,6 +122,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 2d9178257a2..10700db588e 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,20 +54,34 @@ 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: |
+ # A pull_request run checks out GitHub's merge of the pull request into
+ # the CURRENT base branch, while base.sha can be an older base commit.
+ # Diffed from that, every base/ row the nightly fold wrote since was
+ # charged to this pull request. The merge commit's first parent is the
+ # base branch it was merged into, so HEAD^1...HEAD is exactly the
+ # pull request's own change.
+ if git rev-parse -q --verify HEAD^2 >/dev/null; then
+ BASE_SHA=$(git rev-parse HEAD^1)
+ fi
+ # Both, even when the first fails, so one comment shows every problem.
+ status=0
if [ -n "${BASE_SHA}" ]; then
- # checkout uses the synthetic merge, which can target a newer master
- # than event.pull_request.base.sha. Compare against its actual base
- # parent so master's nightly folds are not attributed to this PR.
- MERGE_BASE_SHA="$(git rev-parse HEAD^1)"
- python3 vm/selfhost/perf_baseline.py check --base "${MERGE_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'
@@ -91,14 +110,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 eb37d5d8d02..94b89935c0e 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -157,6 +157,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 37960a445f9..d77d6cc632c 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 f55f56bd844..aa37facb171 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 79b25f02119..376d6c0884b 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.
- 'scripts/hellocodenameone/backend/**'
+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 dc3ad571469..b949de98655 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 a6304d15f17..e71870a826e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -199,5 +199,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 a821dfed112..f31c57a5466 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 6edda93b5de..7fdcc89c853 100644
--- a/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java
+++ b/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java
@@ -183,14 +183,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 343aaae12ac..bf62c3caa69 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 cloud builds only, Pro plan and above. Profile-guided optimization:
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 545ad9b28e5..98b0f69163f 100644
--- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
+++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
@@ -1189,6 +1189,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
@@ -5424,6 +5475,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 2eabb92f0f2..3f7e623b33a 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() {
@@ -220,7 +222,20 @@ public boolean equals(Object o) {
return super.equals(o) &&
(dateFormatSymbols == null ? that.dateFormatSymbols == null : dateFormatSymbols.equals(that.dateFormatSymbols)) &&
(pattern == null ? that.pattern == null : pattern.equals(that.pattern)) &&
- (patternTokens == null ? that.patternTokens == null : patternTokens.equals(that.patternTokens));
+ (patternTokens == null ? that.patternTokens == null : patternTokens.equals(that.patternTokens)) &&
+ // The zone decides the fields an instant formats to, so it is part of the value.
+ // Compared as the effective zone: no zone means the device's.
+ sameZone(getTimeZone(), that.getTimeZone());
+ }
+
+ /// Two zones format alike when they share the ID, the raw offset and whether they observe
+ /// daylight time. A custom zone can reuse a standard ID with other rules, so the ID alone is
+ /// not enough. The transition dates themselves are not compared: neither device runtime
+ /// offers `TimeZone.hasSameRules`, and `hashCode` hashes the ID, which equal zones share.
+ private static boolean sameZone(TimeZone a, TimeZone b) {
+ return a.getID().equals(b.getID())
+ && a.getRawOffset() == b.getRawOffset()
+ && a.useDaylightTime() == b.useDaylightTime();
}
/// {@inheritDoc}
@@ -228,6 +243,7 @@ public boolean equals(Object o) {
public int hashCode() {
int result = super.hashCode();
result = 31 * result + (pattern != null ? pattern.hashCode() : 0);
+ result = 31 * result + getTimeZone().getID().hashCode();
return result;
}
@@ -240,9 +256,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 +312,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 +601,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 +711,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 e1883d4393b..fa76ae7c1bb 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..f1e92976805 100644
--- a/CodenameOne/src/com/codename1/ui/Graphics.java
+++ b/CodenameOne/src/com/codename1/ui/Graphics.java
@@ -1030,6 +1030,66 @@ 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);
+ // pushClip()/popClip() restore the exact clip that was in force,
+ // shape and all. getClip()/setClip(int,int,int,int) only round-trip
+ // the current clip's bounding rectangle, so a caller painting
+ // through a nonrectangular clip (a shape clip set via
+ // #setClip(Shape)) would have it permanently replaced by its
+ // bounding box once this fallback ran.
+ pushClip();
+ try {
+ clipRect(x, y, w, h);
+ drawImage(img, fx, fy, fw, fh);
+ } finally {
+ popClip();
+ }
+ }
+
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 32c6a77ebc2..b9689ce29ab 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());
@@ -987,6 +1012,7 @@ public Dimension getButtonPreferredSize(Button b) {
/// {@inheritDoc}
@Override
public Dimension getCheckBoxPreferredSize(Button cb) {
+ ensureCheckBoxImages();
if (cb.isToggle()) {
return getButtonPreferredSize(cb);
}
@@ -1204,6 +1230,7 @@ private Dimension getListPreferredSizeImpl(List l) {
/// {@inheritDoc}
@Override
public Dimension getRadioButtonPreferredSize(Button rb) {
+ ensureRadioButtonImages();
if (rb.isToggle()) {
return getButtonPreferredSize(rb);
}
@@ -1813,6 +1840,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) {
@@ -2625,8 +2653,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 {
@@ -2636,10 +2681,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..9b6cf3214ce 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], a `Long` duration
+/// in milliseconds 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 adafaae440f..0e4ab8df955 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
@@ -5993,6 +5993,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 {
@@ -6013,50 +6019,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)) {
@@ -6979,7 +6993,95 @@ public boolean shouldNavigate(String url) {
}
}
+ /// Java types Chromium passes as arguments when it calls back into Java during
+ /// a navigation (NavigationHandle.initialize and friends). JNI looks these up by
+ /// name, so R8 keeps them unobfuscated in every WebView build.
+ private static final String[] WEBVIEW_JNI_ARGUMENT_TYPES = {
+ "org.chromium.url.GURL",
+ "org.chromium.url.Origin",
+ "org.chromium.content_public.browser.NavigationHandle"
+ };
+
+ private static boolean webViewJniTypesPreloaded;
+
+ /// Loads [#WEBVIEW_JNI_ARGUMENT_TYPES] through the WebView provider's class
+ /// loader, so they are resolved before the first navigation calls back into
+ /// Java with them.
+ ///
+ /// This works around a bug in ART's CheckJNI, which is forced on for every
+ /// debuggable process. CheckMethodArguments (art/runtime/reflection.cc, which
+ /// carries a "compaction bug" TODO for exactly this) resolves each parameter
+ /// type of a JNI upcall while it holds the raw argument pointers. Resolving a
+ /// class that was never loaded suspends the thread. When a concurrent
+ /// mark-compact GC moves the arguments in that window, the check dereferences
+ /// a stale one and the whole app dies with a SIGSEGV at fault address 0xc in
+ /// CheckMethodArguments, under WebView.loadDataWithBaseURL. Only the first
+ /// navigation in a process is exposed: a browser-initiated load passes a null
+ /// Origin, so nothing has loaded that class yet. Once the types are loaded,
+ /// resolving them is a table lookup that cannot suspend. Release builds run
+ /// without CheckJNI and never take this path.
+ ///
+ /// Returns the names that did not resolve, or null when the platform has no
+ /// public WebView class loader (API 27 and older). The device suite asserts the
+ /// list is empty, so a rename on Chromium's side fails CI instead of silently
+ /// reopening the window.
+ public static List preloadWebViewJniArgumentTypes() {
+ ClassLoader cl;
+ try {
+ // WebView.getWebViewClassLoader is API 28; the port compiles against an
+ // older android.jar.
+ cl = (ClassLoader) WebView.class.getMethod("getWebViewClassLoader").invoke(null);
+ } catch (Throwable t) {
+ return null;
+ }
+ if (cl == null) {
+ return null;
+ }
+ List missing = new ArrayList();
+ for (String name : WEBVIEW_JNI_ARGUMENT_TYPES) {
+ try {
+ Class.forName(name, false, cl);
+ } catch (Throwable t) {
+ missing.add(name);
+ }
+ }
+ return missing;
+ }
+
+ /// Runs [#preloadWebViewJniArgumentTypes] once per process, and only when the
+ /// application is debuggable, the only case where CheckJNI is on.
+ static void preloadWebViewJniArgumentTypesIfDebuggable(android.content.Context ctx) {
+ if (webViewJniTypesPreloaded) {
+ return;
+ }
+ webViewJniTypesPreloaded = true;
+ if ((ctx.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) == 0) {
+ return;
+ }
+ List missing = preloadWebViewJniArgumentTypes();
+ if (missing != null && !missing.isEmpty()) {
+ Log.w("CodenameOne", "WebView JNI argument types not found: " + missing);
+ }
+ }
+
class AndroidBrowserComponent extends AndroidImplementation.AndroidPeer {
+ /// Set on the UI thread when destroy() runs, and read only there. A WebView
+ /// used after destroy() is undefined per the Android documentation, so every
+ /// call into the WebView checks it. (The NavigationControllerImpl SIGSEGV
+ /// once blamed on this is ART's CheckJNI; see preloadWebViewJniArgumentTypes.)
+ private boolean webDestroyed;
+
+ /// Runs `r` on the UI thread unless the WebView has been destroyed by then.
+ private void onWebThread(final Runnable r) {
+ act.runOnUiThread(new Runnable() {
+ public void run() {
+ if (!webDestroyed) {
+ r.run();
+ }
+ }
+ });
+ }
+
private Activity act;
private WebView web;
@@ -6999,6 +7101,8 @@ public AndroidBrowserComponent(final WebView web, Activity act, Object p) {
}
parent = (BrowserComponent) p;
this.web = web;
+ // Before any load call; see preloadWebViewJniArgumentTypes.
+ preloadWebViewJniArgumentTypesIfDebuggable(act);
layerType = web.getLayerType();
web.getSettings().setJavaScriptEnabled(true);
web.getSettings().setSupportZoom(parent.isPinchToZoomEnabled());
@@ -7271,7 +7375,7 @@ public void run() {
@Override
protected void initComponent() {
if(android.os.Build.VERSION.SDK_INT == 21 && web.getLayerType() != layerType){
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
@Override
public void run() {
web.setLayerType(layerType, null); //setting layer type to original state
@@ -7290,7 +7394,7 @@ protected Image generatePeerImage() {
final Bitmap nativeBuffer = Bitmap.createBitmap(
getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
Image image = new AndroidImplementation.NativeImage(nativeBuffer);
- getActivity().runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
@Override
public void run() {
try {
@@ -7324,7 +7428,7 @@ protected void setLightweightMode(boolean l) {
public void setScrollingEnabled(final boolean enabled){
this.scrollingEnabled = enabled;
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
web.setHorizontalScrollBarEnabled(enabled);
web.setVerticalScrollBarEnabled(enabled);
@@ -7350,7 +7454,7 @@ public boolean isScrollingEnabled(){
}
public void setProperty(final String key, final Object value) {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
WebSettings s = web.getSettings();
if(key.equalsIgnoreCase("useragent")) {
@@ -7383,6 +7487,9 @@ public String getTitle() {
act.runOnUiThread(new Runnable() {
public void run() {
try {
+ if (webDestroyed) {
+ return;
+ }
retVal[0] = web.getTitle();
} finally {
@@ -7412,6 +7519,9 @@ public String getURL() {
act.runOnUiThread(new Runnable() {
public void run() {
try {
+ if (webDestroyed) {
+ return;
+ }
retVal[0] = web.getUrl();
} finally {
complete[0] = true;
@@ -7435,7 +7545,7 @@ public void run() {
}
public void setURL(final String url, final Map headers) {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
if(headers != null) {
web.loadUrl(url, headers);
@@ -7447,7 +7557,7 @@ public void run() {
}
public void reload() {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
web.reload();
}
@@ -7461,6 +7571,9 @@ public boolean hasBack() {
act.runOnUiThread(new Runnable() {
public void run() {
try {
+ if (webDestroyed) {
+ return;
+ }
retVal[0] = web.canGoBack();
} finally {
complete[0] = true;
@@ -7490,6 +7603,9 @@ public boolean hasForward() {
act.runOnUiThread(new Runnable() {
public void run() {
try {
+ if (webDestroyed) {
+ return;
+ }
retVal[0] = web.canGoForward();
} finally {
complete[0] = true;
@@ -7514,7 +7630,7 @@ public void run() {
}
public void back() {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
web.goBack();
}
@@ -7522,7 +7638,7 @@ public void run() {
}
public void forward() {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
web.goForward();
}
@@ -7530,7 +7646,7 @@ public void run() {
}
public void clearHistory() {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
web.clearHistory();
}
@@ -7538,7 +7654,7 @@ public void run() {
}
public void stop() {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
web.stopLoading();
}
@@ -7548,13 +7664,17 @@ public void run() {
public void destroy() {
act.runOnUiThread(new Runnable() {
public void run() {
+ if (webDestroyed) {
+ return;
+ }
+ webDestroyed = true;
web.destroy();
}
});
}
public void setPage(final String html, final String baseUrl) {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
web.loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null);
}
@@ -7562,7 +7682,7 @@ public void run() {
}
public void exposeInJavaScript(final Object o, final String name) {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
web.addJavascriptInterface(o, name);
}
@@ -7570,7 +7690,7 @@ public void run() {
}
public void setPinchZoomEnabled(final boolean e) {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
public void run() {
web.getSettings().setSupportZoom(e);
web.getSettings().setBuiltInZoomControls(e);
@@ -7580,7 +7700,7 @@ public void run() {
@Override
protected void deinitialize() {
- act.runOnUiThread(new Runnable() {
+ onWebThread(new Runnable() {
@Override
public void run() {
if(android.os.Build.VERSION.SDK_INT == 21) { // bugfix for Android 5.0.x
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 3f5bfb49eca..1466929478d 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();
@@ -14569,7 +14640,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);
@@ -14614,6 +14685,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..a86b757845b 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,9 @@ 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 then runs on the EDT, so {@code afterInit} always runs first, and this
+ * call returns only once {@code start} has returned.
*
* @param lifecycle the application lifecycle
* @param afterInit code to run after {@code Display.init} and before the lifecycle starts; may be null
@@ -61,7 +62,23 @@ 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.
+ //
+ // It must still be WAITED for here, not just queued. The runtime reports the app
+ // as started when this (main) thread finishes -- parparvm_runtime.js posts the
+ // ``lifecycle``/``started`` message from its drain loop, and browser_bridge.js
+ // turns that into the page's ``window.cn1Started``. Queuing and returning let the
+ // main thread finish before init() and start() had even run, so cn1Started went
+ // true while the first form was not yet shown: the Playground editor-input check
+ // clicked into an empty display and the click was lost. Blocking until the
+ // lifecycle has run keeps "the main thread finished" meaning "start() returned",
+ // as it did when run() was called inline. run() catches everything, so the
+ // wrapper always completes and this wait always ends.
+ Display.getInstance().callSeriallyAndWait(bootstrap);
}
// ``window.cn1Initialized = true`` lands on the worker's global
@@ -84,7 +101,7 @@ public static void bootstrap(Lifecycle lifecycle, Runnable afterInit) {
// in-page invocations from the JavaScript-port simulator.
@JSBody(params = {}, script = ""
+ "window.cn1Started = true;"
- + "var __cn1LifecycleMsg = {type: 'lifecycle', phase: 'started'};"
+ + "var __cn1LifecycleMsg = {type: 'lifecycle', phase: 'started', source: 'bootstrap'};"
+ "if (typeof parentPort !== 'undefined' && parentPort && typeof parentPort.postMessage === 'function') {"
+ " parentPort.postMessage(__cn1LifecycleMsg);"
+ "} else if (typeof self !== 'undefined' && self !== this && typeof self.postMessage === 'function') {"
diff --git a/Ports/JavaScriptPort/src/main/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js
index af6718baa8d..ea53ca4e0ad 100644
--- a/Ports/JavaScriptPort/src/main/webapp/port.js
+++ b/Ports/JavaScriptPort/src/main/webapp/port.js
@@ -3615,92 +3615,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";
@@ -5861,8 +5781,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 4a49a0371a2..62829655a14 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 a73399a623e..d1314a6ecc5 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 baea4113f90..88c68d27628 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/nativeSources/cn1_debugger.m b/Ports/iOSPort/nativeSources/cn1_debugger.m
index 6b14cdf5781..e8a7bb0df4e 100644
--- a/Ports/iOSPort/nativeSources/cn1_debugger.m
+++ b/Ports/iOSPort/nativeSources/cn1_debugger.m
@@ -714,8 +714,17 @@ static void suspendCurrent(struct ThreadLocalData* tsd) {
memcpy(argsCopy, s->invokeArgs, sizeof(argsCopy));
// Re-activate the thread while running the thunk so any
// allocations / GC interaction it triggers proceed normally;
- // we'll re-park before going back to wait.
- tsd->threadActive = JAVA_TRUE;
+ // we'll re-park before going back to wait. Through the GC's
+ // resume handshake, never a bare store: this thread is parked, so
+ // the collector may be holding it, and running Java while it is held
+ // is running under a stop-the-world cycle that believes it stopped.
+ do {
+ while (tsd->threadBlockedByGC) {
+ pthread_mutex_unlock(&s->mu);
+ usleep(1000);
+ pthread_mutex_lock(&s->mu);
+ }
+ } while (!cn1GcTryResumeActive(tsd));
pthread_mutex_unlock(&s->mu);
cn1_invoke_result r;
r.type = 'V';
@@ -743,13 +752,15 @@ static void suspendCurrent(struct ThreadLocalData* tsd) {
}
pthread_cond_wait(&s->cv, &s->mu);
}
- // GC may have parked us; wait for it to finish before resuming.
- while (tsd->threadBlockedByGC) {
- pthread_mutex_unlock(&s->mu);
- usleep(1000);
- pthread_mutex_lock(&s->mu);
- }
- tsd->threadActive = JAVA_TRUE;
+ // GC may have parked us; wait for it to finish before resuming, and go
+ // active through its handshake (cn1GcTryResumeActive in cn1_globals.h).
+ do {
+ while (tsd->threadBlockedByGC) {
+ pthread_mutex_unlock(&s->mu);
+ usleep(1000);
+ pthread_mutex_lock(&s->mu);
+ }
+ } while (!cn1GcTryResumeActive(tsd));
s->tsd = NULL;
pthread_mutex_unlock(&s->mu);
}
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..a4cea576b31
--- /dev/null
+++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java
@@ -0,0 +1,187 @@
+/*
+ * 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.platform.EventChannel;
+import com.codename1.flutter.platform.MethodCall;
+import com.codename1.flutter.platform.MethodChannel;
+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[]
+ }
+
+ /**
+ * A Java handler answering a Dart {@code MethodChannel.invokeMethod}
+ * call, written exactly like a classic Flutter plugin's Android side --
+ * {@code implements MethodChannel.MethodCallHandler}, {@code
+ * result.success(...)} -- just importing {@code com.codename1.flutter
+ * .platform} instead of {@code io.flutter.plugin.common}.
+ */
+ public static void answerDartWithAJavaHandler() {
+ // tag::flutter-interop-java-003[]
+ MethodChannel channel = new MethodChannel("com.example.app/battery");
+ channel.setMethodCallHandler(new MethodChannel.MethodCallHandler() {
+ @Override
+ public void onMethodCall(MethodCall call, MethodChannel.Result result) {
+ if ("getBatteryLevel".equals(call.method())) {
+ result.success(87);
+ } else {
+ result.notImplemented();
+ }
+ }
+ });
+ // end::flutter-interop-java-003[]
+ }
+
+ /**
+ * Java calling a handler the Dart side registered with {@code
+ * MethodChannel.setMethodCallHandler}. {@code result} may complete
+ * later -- a loaded resource, a native callback -- the call does not
+ * require an answer before it returns.
+ */
+ public static void invokeADartRegisteredHandler() {
+ // tag::flutter-interop-java-004[]
+ MethodChannel channel = new MethodChannel("com.example.app/battery");
+ channel.invokeMethod("refreshBatteryLevel", null, new MethodChannel.Result() {
+ @Override
+ public void success(Object result) {
+ System.out.println("Dart replied: " + result);
+ }
+
+ @Override
+ public void error(String errorCode, String errorMessage, Object errorDetails) {
+ System.out.println("Dart reported an error: " + errorCode);
+ }
+
+ @Override
+ public void notImplemented() {
+ System.out.println("no Dart handler is registered");
+ }
+ });
+ // end::flutter-interop-java-004[]
+ }
+
+ /**
+ * A Java {@code EventChannel.StreamHandler} pushing events to whatever
+ * Dart listens on {@code receiveBroadcastStream()}. {@code onListen}
+ * runs on the first listener, {@code onCancel} on the last cancel; the
+ * {@code EventSink} it is handed stays valid for pushing events in
+ * between, from wherever the real readings arrive.
+ */
+ public static void pushEventsFromAJavaStreamHandler() {
+ // tag::flutter-interop-java-005[]
+ EventChannel channel = new EventChannel("com.example.app/sensor");
+ final EventChannel.EventSink[] activeSink = new EventChannel.EventSink[1];
+ channel.setStreamHandler(new EventChannel.StreamHandler() {
+ @Override
+ public void onListen(Object arguments, EventChannel.EventSink events) {
+ activeSink[0] = events;
+ }
+
+ @Override
+ public void onCancel(Object arguments) {
+ activeSink[0] = null;
+ }
+ });
+
+ // Called whenever a new reading arrives, from wherever that is.
+ EventChannel.EventSink sink = activeSink[0];
+ if (sink != null) {
+ sink.success(42.5);
+ }
+ // end::flutter-interop-java-005[]
+ }
+
+ /**
+ * Calling a transpiled Dart top-level function directly from Java. A
+ * Dart file {@code lib/greeting.dart} declaring
+ * {@code String greet(String name) => 'Hello, $name!';} transpiles to a
+ * public static method on a generated {@code LibGreetingLib} class --
+ * {@link LibGreetingLib} here stands in for that generated class, shaped
+ * exactly like the transpiler's own output, so this method is real code
+ * that compiles and runs rather than a description of the convention.
+ */
+ public static void callATranspiledDartFunction() {
+ // tag::flutter-interop-java-006[]
+ String greeting = LibGreetingLib.greet("World");
+ System.out.println(greeting);
+ // end::flutter-interop-java-006[]
+ }
+
+ /**
+ * Stands in for the transpiler's generated output of
+ * {@code lib/greeting.dart}'s {@code greet}, so
+ * {@link #callATranspiledDartFunction()} above is real, compiling code:
+ * a top-level Dart function becomes a public static method on a class
+ * named after its file ({@code lib/greeting.dart} -> {@code
+ * LibGreetingLib}).
+ */
+ private static final class LibGreetingLib {
+ private LibGreetingLib() {
+ }
+
+ static String greet(String name) {
+ return "Hello, " + name + "!";
+ }
+ }
+}
diff --git a/docs/demos/common/src/main/snippets/developer-guide/flutter-channels.dart b/docs/demos/common/src/main/snippets/developer-guide/flutter-channels.dart
new file mode 100644
index 00000000000..c9f83e387ea
--- /dev/null
+++ b/docs/demos/common/src/main/snippets/developer-guide/flutter-channels.dart
@@ -0,0 +1,42 @@
+// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline.
+//
+// The Dart side of the same channels FlutterInteropSnippets.java answers
+// from Java -- package:flutter/services.dart, compiled unchanged.
+
+import 'package:flutter/services.dart';
+
+// tag::flutter-interop-dart-001[]
+// Calls the Java handler registered with MethodChannel.setMethodCallHandler
+// on the platform side (FlutterInteropSnippets.answerDartWithAJavaHandler).
+final batteryChannel = MethodChannel('com.example.app/battery');
+
+Future getBatteryLevel() async {
+ final level = await batteryChannel.invokeMethod('getBatteryLevel');
+ return level as int;
+}
+// end::flutter-interop-dart-001[]
+
+// tag::flutter-interop-dart-002[]
+// Answers calls the Java side makes with MethodChannel.invokeMethod
+// (FlutterInteropSnippets.invokeADartRegisteredHandler).
+void installBatteryRefreshHandler() {
+ batteryChannel.setMethodCallHandler((MethodCall call) async {
+ if (call.method == 'refreshBatteryLevel') {
+ return getBatteryLevel();
+ }
+ throw MissingPluginException();
+ });
+}
+// end::flutter-interop-dart-002[]
+
+// tag::flutter-interop-dart-003[]
+// Listens to events the Java side pushes through an EventSink
+// (FlutterInteropSnippets.pushEventsFromAJavaStreamHandler).
+final sensorChannel = EventChannel('com.example.app/sensor');
+
+StreamSubscription listenToSensorReadings(void Function(double value) onReading) {
+ return sensorChannel.receiveBroadcastStream().listen((event) {
+ onReading(event as double);
+ });
+}
+// end::flutter-interop-dart-003[]
diff --git a/docs/demos/common/src/main/snippets/developer-guide/flutter-import.sh b/docs/demos/common/src/main/snippets/developer-guide/flutter-import.sh
new file mode 100644
index 00000000000..7acbcde9d26
--- /dev/null
+++ b/docs/demos/common/src/main/snippets/developer-guide/flutter-import.sh
@@ -0,0 +1,5 @@
+// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline.
+
+// tag::flutter-import-bash-001[]
+mvn cn1:import-flutter-project -Dcn1.flutter.import=/path/to/flutter_app
+// end::flutter-import-bash-001[]
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..f5f9a0b0226
--- /dev/null
+++ b/docs/developer-guide/Flutter-Interop.asciidoc
@@ -0,0 +1,410 @@
+[[flutter-interop]]
+== Running Flutter applications
+
+Codename One builds a Flutter application from its Dart source. The Dart is
+translated to Java at build time, and the result is an ordinary Codename One
+application: no Dart VM, no embedded rendering engine and no platform view
+ship with it. It builds for every platform Codename One targets, and it comes
+out smaller, quicker to start and lighter on memory than the same application
+built by Flutter.
+
+IMPORTANT: Dart support is experimental. Most widgets render, but some callbacks
+and parameters are accepted and then ignored, and the pipeline has so far been
+verified against one large sample application. Test every interaction your
+screens depend on before shipping.
+
+[[flutter-benchmark-results]]
+=== Smaller, faster and lighter than Flutter
+
+The charts below come from one application, the Flutter Gallery, built both
+ways from the same Dart source: once by the Flutter toolchain's own release
+build, and once by Codename One. Both builds are measured on the same machine,
+in interleaved rounds, on every change to the project. Lower is better
+throughout.
+
+image::img/flutter-bench-install.svg["Installed size of the Flutter Gallery per platform: Codename One against Flutter"]
+
+image::img/flutter-bench-code.svg["Executable code of the Flutter Gallery per platform: Codename One against Flutter"]
+
+image::img/flutter-bench-cold-start.svg["Cold start to the first frame per platform: Codename One against Flutter"]
+
+image::img/flutter-bench-idle-memory.svg["Memory at rest per platform: Codename One against Flutter"]
+
+The comparison is tilted *toward* Flutter. The application is Flutter's own
+showcase, written by the Flutter team in Flutter's idioms, and Codename One
+runs it as it is. To do that the Codename One build carries a runtime that
+reimplements Flutter's widget, element and layout model on top of Codename One
+components, which is work an application written for Codename One never pays
+for. The same screens written directly against Codename One's own components
+would be smaller and faster again. The numbers above are what a team gets on
+the day it moves its Flutter code over, before changing a line of it.
+
+Two platforms aren't measured in full, and the charts leave them out rather than
+estimate them:
+
+* *Start-up and memory on iOS.* Flutter can't compile Dart ahead of time for
+ the simulator, so a simulator run would time Flutter's debug engine against a
+ Codename One release build. iOS sizes come from release device bundles.
+* *Start-up and memory on the web.* Timing a browser build needs a driver for
+ the browser's developer tools, which the benchmark doesn't have, so it
+ reports web sizes only.
+
+Android start-up and memory come from an emulator rather than a phone. They
+compare the two builds on one machine; they aren't phone timings.
+<> explains how each metric is taken.
+
+=== What it looks like
+
+The Flutter Gallery's demos, rendered by Flutter on the left and by Codename One
+on the right from the same Dart source:
+
+image::img/flutter-gallery-home.png[The Flutter Gallery home screen rendered by Flutter and by Codename One]
+
+image::img/flutter-gallery-card.jpg[The Cards demo rendered by Flutter and by Codename One]
+
+image::img/flutter-gallery-text-field.png[The Text fields demo rendered by Flutter and by Codename One]
+
+image::img/flutter-gallery-bottom-navigation.png[The Bottom navigation demo rendered by Flutter and by Codename One]
+
+image::img/flutter-gallery-colors.png[The Colors demo rendered by Flutter and by Codename One]
+
+image::img/flutter-gallery-button.png[The Text button demo rendered by Flutter and by Codename One]
+
+image::img/flutter-gallery-cupertino-buttons.png[The Cupertino buttons demo rendered by Flutter and by Codename One]
+
+image::img/flutter-gallery-shrine.png[The Shrine study's login screen rendered by Flutter and by Codename One]
+
+image::img/flutter-gallery-reply.png[The Reply study's inbox rendered by Flutter and by Codename One]
+
+The Codename One side is a real Codename One component tree, not a picture of
+one: it uses the theme, the event dispatch thread, the accessibility tree and
+the native build like any other Codename One screen, and it can share a `Form`
+with hand-written Codename One code.
+
+[[flutter-import]]
+=== Importing a Flutter application
+
+One command brings a Flutter project into a Codename One project:
+
+[source,bash]
+----
+include::../demos/common/src/main/snippets/developer-guide/flutter-import.sh[tag=flutter-import-bash-001,indent=0]
+----
+
+Run it in a project generated from the Codename One archetype or
+https://start.codenameone.com[the initializr]. It does what wiring Flutter in
+by hand would take:
+
+* Copies the Dart source in `lib` into `common/src/main/flutter`.
+* Copies every asset and font `pubspec.yaml` declares, including whole asset
+ directories and the assets of the packages the application depends on, which
+ it finds through the project's `.dart_tool/package_config.json` or the pub
+ cache.
+* Adds the Flutter runtime to `common/pom.xml` and makes sure the build
+ translates the Dart.
+* Writes the application's entry class so that it runs the Dart `main()`. A
+ class that was already there is saved beside it with a `.pre-flutter-import`
+ suffix.
+* Carries the application name and version over from `pubspec.yaml`.
+
+It then lists the application's pub dependencies and says which of them
+Codename One provides. The transpiler doesn't compile a package's Dart; a
+package works when the Flutter runtime implements its API, as it does for
+`provider`, `intl`, `animations` and other common ones. A dependency listed as
+not covered is the first thing to look at if a screen misbehaves.
+
+Re-running the command later picks up changes made on the Flutter side. A file
+you edited after the earlier import is left as you edited it, and a file the
+Flutter project no longer has is removed.
+
+No Dart or Flutter SDK is needed to build the result. The transpiler is Java and
+runs as part of the normal Maven build. The command reads the pub cache only to
+find package assets, so run `flutter pub get` once in the Flutter project if it
+reports a package it can't find.
+
+=== Migrating an entire application
+
+After the import, the Dart *is* the application: the entry class calls the
+Dart `main()`, whose `runApp` mounts the widget tree as the root of a `Form`.
+Build and run it like any Codename One application, in the simulator or for a
+device.
+
+`FlutterUI.runApp` is the same call from Java, the analog of Flutter's own
+`runApp`. It installs the Material base theme, because the widget tree is
+expected to be the whole UI and to look the way its author intended:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java[tag=flutter-interop-java-002,indent=0]
+----
+
+A port is mostly a question of how much of the Dart language and of the Flutter
+libraries the application uses. The transpiler reports what it can't handle
+rather than guessing: a construct outside the supported subset fails the build
+with a code naming the feature, so the gaps are a list to work through rather
+than a runtime surprise. At runtime, a widget that isn't implemented is
+recorded through the error inventory instead of drawing nothing.
+
+=== Migrating a single form
+
+The same build can carry Dart screens inside an application otherwise written
+in Codename One, so a migration can go one screen at a time in either
+direction.
+
+Put the Dart files under `common/src/main/flutter`; the import command does
+this, or copy them by hand. Each Dart widget class becomes a Java class of the
+same name in `com.codename1.generated.flutter`, with its parameters as
+constructor arguments in declaration order and the widget key first. A Dart
+`MyHomePage({super.key, required this.title})` is created from Java as
+`new MyHomePage(null, "Inbox")`.
+
+`FlutterUI.wrap` inflates the widget into a plain `Container`, which goes
+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 widget is a component like any other: it takes part in the parent's
+layout, scrolls with it and is styled by the same theme. It doesn't install the
+Material base theme, so a Dart screen dropped into an existing form won't
+restyle the rest of it.
+
+A wrapped widget isn't inside a navigator, so it has no `Overlay` of its own.
+Dart code that calls `Overlay.of(context)` from inside it gets the same error
+it would get outside a `Navigator` on Flutter; show such a screen as a route
+of its own instead.
+
+[[flutter-java-dart-calls]]
+=== Calling between Java and Dart
+
+Java and Dart run in the same application, on the same thread, so calls between
+them are ordinary method calls. There are three ways to make them, and the
+first is the one a Flutter developer already knows.
+
+==== Platform channels
+
+A Flutter application talks to its platform code through `MethodChannel` and
+`EventChannel` from `package:flutter/services.dart`. Those work unchanged: the
+Dart side compiles unchanged, and the platform side, which is Kotlin or Swift in
+a Flutter project, becomes Java in the Codename One project. The Java classes
+in `com.codename1.flutter.platform` mirror Flutter's Android
+`io.flutter.plugin.common` API, so Android handler code ports by changing its
+imports.
+
+*Dart calling Java.* The Dart side invokes a method on a named channel:
+
+[source,dart]
+----
+include::../demos/common/src/main/snippets/developer-guide/flutter-channels.dart[tag=flutter-interop-dart-001,indent=0]
+----
+
+A Java handler registered under the same channel name answers it:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java[tag=flutter-interop-java-003,indent=0]
+----
+
+`result.error(...)` reaches Dart as a `PlatformException`, and
+`result.notImplemented()`, or no handler at all, as a
+`MissingPluginException`, as on Flutter. The handler doesn't have to answer
+before it returns: keep the `Result` and complete it when the answer arrives.
+
+*Java calling Dart.* The direction reverses with the same two classes. Dart
+registers a handler on the channel:
+
+[source,dart]
+----
+include::../demos/common/src/main/snippets/developer-guide/flutter-channels.dart[tag=flutter-interop-dart-002,indent=0]
+----
+
+Java invokes it and gets the reply through a callback:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java[tag=flutter-interop-java-004,indent=0]
+----
+
+*Events from Java to Dart.* An `EventChannel` carries a stream of events: a
+sensor reading, a download's progress, a message from a server. Java pushes
+them through the `EventSink` it receives when Dart starts listening:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java[tag=flutter-interop-java-005,indent=0]
+----
+
+Dart listens to the stream as it would on Flutter:
+
+[source,dart]
+----
+include::../demos/common/src/main/snippets/developer-guide/flutter-channels.dart[tag=flutter-interop-dart-003,indent=0]
+----
+
+`onListen` runs when the first listener subscribes and `onCancel` when the last
+one cancels, so a sensor or a connection is only active while something is
+listening.
+
+Values cross the channel in the types each side expects, as Flutter's message
+codec does:
+
+[cols="1,1", options="header"]
+|===
+|Dart |Java
+
+|`int`
+|`Long`. An `Integer`, `Short` or `Byte` sent from Java arrives as `int`.
+
+|`double`
+|`Double`. A `Float` sent from Java arrives as `double`.
+
+|`String`, `bool`, `null`
+|`String`, `Boolean`, `null`
+
+|`List`, `Map`
+|`java.util.List`, `java.util.Map`, converted element by element
+|===
+
+Every handler runs on the event dispatch thread, and a reply is always
+delivered asynchronously, never inside the call that asked for it, as on
+Flutter.
+
+==== Calling Dart functions directly
+
+Without a channel, Java can call transpiled Dart directly. Each Dart file
+becomes a class named after it, with `Lib` appended: `lib/greeting.dart`
+becomes `LibGreetingLib`, and its top-level functions are public static methods
+of that class. Dart's `main` is renamed `main$`.
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/flutter/FlutterInteropSnippets.java[tag=flutter-interop-java-006,indent=0]
+----
+
+This is the quickest way to reach Dart logic that has no UI, a parser or a
+pricing rule, from Java. Channels are the better boundary for code that a
+Flutter build must also keep running, because the Dart side doesn't change.
+
+==== Sharing state and callbacks
+
+A `ValueNotifier` or `ChangeNotifier` is an ordinary Java object on the
+Codename One side. Java code that holds one can set its value or call
+`notifyListeners()`, and every Dart widget listening to it, through
+`ValueListenableBuilder` or `AnimatedBuilder`, rebuilds.
+Callback parameters work the other way: an `onPressed` or `onChanged` that a
+Java caller passes into a Dart widget's constructor is a Java lambda, and the
+Dart code calls it.
+
+=== 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.
+
+The goal is bound in every project generated from the archetype and does
+nothing while `src/main/flutter` doesn't exist. To wire a project by hand
+instead of importing, add the runtime dependency to the `common` module, where
+generated projects already carry it commented out:
+
+[source,xml]
+----
+include::../demos/common/src/main/snippets/developer-guide/flutter-interop.xml[tag=flutter-interop-xml-001,indent=0]
+----
+
+The generated package 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. Defaults to
+`com.codename1.generated.flutter`.
+|===
+
+[[flutter-benchmark]]
+=== How the numbers are measured
+
+The benchmark lives in `scripts/flutter-bench`. It builds the Flutter Gallery
+both ways on each platform and measures both builds on the same machine, and
+continuous integration runs it on every change, publishing the 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 the 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 rounds.* One launch of each build, alternating, repeated. A
+ machine that gets busier halfway through then penalizes both builds equally
+ instead of whichever happened to run second, and start-up and memory are
+ judged round by round: the ratio is the median of the per-round ratios.
+
+* *Start-up is a bracket where the two runtimes report different events.* On
+ the web, Flutter's earliest marker fires before its first frame is
+ rasterized while Codename One's fires once the frame is on the screen, so the
+ benchmark takes 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, for the reason
+ given above.
+
+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. The charts in
+<> are rendered from the benchmark's result files by
+`scripts/flutter-bench/render_doc_charts.py`.
diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc
index fd86060c6aa..594ba2af6c5 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-bench-code.svg b/docs/developer-guide/img/flutter-bench-code.svg
new file mode 100644
index 00000000000..20f2592da76
--- /dev/null
+++ b/docs/developer-guide/img/flutter-bench-code.svg
@@ -0,0 +1,45 @@
+
diff --git a/docs/developer-guide/img/flutter-bench-cold-start.svg b/docs/developer-guide/img/flutter-bench-cold-start.svg
new file mode 100644
index 00000000000..3c07d2a9fea
--- /dev/null
+++ b/docs/developer-guide/img/flutter-bench-cold-start.svg
@@ -0,0 +1,33 @@
+
diff --git a/docs/developer-guide/img/flutter-bench-idle-memory.svg b/docs/developer-guide/img/flutter-bench-idle-memory.svg
new file mode 100644
index 00000000000..7ac44835bdf
--- /dev/null
+++ b/docs/developer-guide/img/flutter-bench-idle-memory.svg
@@ -0,0 +1,33 @@
+
diff --git a/docs/developer-guide/img/flutter-bench-install.svg b/docs/developer-guide/img/flutter-bench-install.svg
new file mode 100644
index 00000000000..1deef3b1415
--- /dev/null
+++ b/docs/developer-guide/img/flutter-bench-install.svg
@@ -0,0 +1,45 @@
+
diff --git a/docs/developer-guide/img/flutter-gallery-bottom-navigation.png b/docs/developer-guide/img/flutter-gallery-bottom-navigation.png
new file mode 100644
index 00000000000..e91bd7d3627
Binary files /dev/null and b/docs/developer-guide/img/flutter-gallery-bottom-navigation.png differ
diff --git a/docs/developer-guide/img/flutter-gallery-button.png b/docs/developer-guide/img/flutter-gallery-button.png
new file mode 100644
index 00000000000..0347075135d
Binary files /dev/null and b/docs/developer-guide/img/flutter-gallery-button.png differ
diff --git a/docs/developer-guide/img/flutter-gallery-card.jpg b/docs/developer-guide/img/flutter-gallery-card.jpg
new file mode 100644
index 00000000000..2f66cd3fc23
Binary files /dev/null and b/docs/developer-guide/img/flutter-gallery-card.jpg differ
diff --git a/docs/developer-guide/img/flutter-gallery-colors.png b/docs/developer-guide/img/flutter-gallery-colors.png
new file mode 100644
index 00000000000..e0354687ef0
Binary files /dev/null and b/docs/developer-guide/img/flutter-gallery-colors.png differ
diff --git a/docs/developer-guide/img/flutter-gallery-cupertino-buttons.png b/docs/developer-guide/img/flutter-gallery-cupertino-buttons.png
new file mode 100644
index 00000000000..2af7e505eff
Binary files /dev/null and b/docs/developer-guide/img/flutter-gallery-cupertino-buttons.png differ
diff --git a/docs/developer-guide/img/flutter-gallery-home.png b/docs/developer-guide/img/flutter-gallery-home.png
new file mode 100644
index 00000000000..3dc6f87cf72
Binary files /dev/null and b/docs/developer-guide/img/flutter-gallery-home.png differ
diff --git a/docs/developer-guide/img/flutter-gallery-reply.png b/docs/developer-guide/img/flutter-gallery-reply.png
new file mode 100644
index 00000000000..357f1e6b985
Binary files /dev/null and b/docs/developer-guide/img/flutter-gallery-reply.png differ
diff --git a/docs/developer-guide/img/flutter-gallery-shrine.png b/docs/developer-guide/img/flutter-gallery-shrine.png
new file mode 100644
index 00000000000..c6dcefbd824
Binary files /dev/null and b/docs/developer-guide/img/flutter-gallery-shrine.png differ
diff --git a/docs/developer-guide/img/flutter-gallery-text-field.png b/docs/developer-guide/img/flutter-gallery-text-field.png
new file mode 100644
index 00000000000..2461828e870
Binary files /dev/null and b/docs/developer-guide/img/flutter-gallery-text-field.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 c71b484ddbf..5cb3347550a 100644
--- a/docs/developer-guide/languagetool-accept.txt
+++ b/docs/developer-guide/languagetool-accept.txt
@@ -408,6 +408,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/spotbugs-exclude.xml b/maven/build-engine/spotbugs-exclude.xml
index 25d77303053..55b7f0088e9 100644
--- a/maven/build-engine/spotbugs-exclude.xml
+++ b/maven/build-engine/spotbugs-exclude.xml
@@ -291,4 +291,12 @@
+
+
+
+
+
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..d5b64104877 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.
@@ -5685,17 +5710,14 @@ && watchModuleName(request) != null && legacyGplayServicesMode) {
}
File manifestFile = new File(projectDir + "/src/main", "AndroidManifest.xml");
+ int intVersion = computeVersionCode(request);
+ // android:versionName (below, at the "versionName" build.gradle line) wants the
+ // raw float, independent of the android.versionCode override that intVersion
+ // may have taken -- unchanged from before computeVersionCode was extracted.
float version = 1.0f;
- int intVersion = 1;
try {
version = Float.parseFloat(request.getVersion());
- String vcOverride = request.getArg("android.versionCode", null);
- if (vcOverride != null && vcOverride.length() > 0) {
- intVersion = Integer.parseInt(vcOverride);
- } else {
- intVersion = Math.round(100 * version);
- }
- } catch (Throwable thrown) {
+ } catch (Throwable notAFloat) {
}
String locationServices = "\n"
@@ -9722,6 +9744,38 @@ private void generateWearModule(BuildRequest request, File studioProjectDir, Str
}
+ /**
+ * The phone APK's {@code android:versionCode}: the {@code android.versionCode}
+ * build hint (BuildHintsAndroid) when set, otherwise {@code Math.round(100 *
+ * Float.parseFloat(request.getVersion()))}, or {@code 1} when neither parses.
+ *
+ *
The override and the version-derived default used to share one
+ * {@code try}/{@code catch (Throwable)}: {@code request.getVersion()} failing
+ * {@link Float#parseFloat} -- any 3-segment semver such as {@code "1.0.0"}, or a
+ * Flutter {@code pubspec.yaml}'s {@code "marketing+buildNumber"} after the build
+ * number is stripped -- threw on the FIRST statement, before the override was
+ * even read. A developer-supplied {@code android.versionCode} was therefore
+ * silently discarded too, and {@code intVersion} fell back to the hardcoded
+ * default of 1 regardless of what the override said. Reading the override first,
+ * independent of whether the project's version string is float-parseable, fixes
+ * that: the two failure modes no longer share a catch block.
+ */
+ static int computeVersionCode(BuildRequest request) {
+ String vcOverride = request.getArg("android.versionCode", null);
+ if (vcOverride != null && vcOverride.length() > 0) {
+ try {
+ return Integer.parseInt(vcOverride);
+ } catch (NumberFormatException invalidOverride) {
+ // Not a whole number: fall through to the version-derived default.
+ }
+ }
+ try {
+ return Math.round(100 * Float.parseFloat(request.getVersion()));
+ } catch (Throwable notAFloat) {
+ return 1;
+ }
+ }
+
/**
* The Wear artifact's version code, which must outrank the phone's.
*
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/JavaScriptBuildHints.java b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java
index 2b4aae29fbd..746b00ba83f 100644
--- a/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java
+++ b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java
@@ -385,7 +385,7 @@ static void scanBytes(byte[] data, Set found) {
/// Whether `text` holds `value` as a whole constant-pool string: a CONSTANT_Utf8 entry is a
/// tag byte 1 and a two-byte length before its bytes. "nativeTheme" must not match inside
/// "nativeThemeResource" or another longer name.
- private static boolean containsConstant(String text, String value) {
+ static boolean containsConstant(String text, String value) {
int len = value.length();
int from = 0;
while (true) {
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..3d7dbda08a6
--- /dev/null
+++ b/maven/build-engine/src/main/java/com/codename1/builders/NativeThemes.java
@@ -0,0 +1,316 @@
+/*
+ * 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;
+import java.util.function.Predicate;
+
+/// 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 (referencesTheme(appClasses, name)) {
+ 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, final String[] needles) throws IOException {
+ return classesDir != null && classesDir.isDirectory()
+ && scan(classesDir, classesDir, new Predicate() {
+ @Override
+ public boolean test(String body) {
+ for (String needle : needles) {
+ if (body.contains(needle)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ });
+ }
+
+ /// Whether the application names the theme `name`: as "name.res" anywhere (a path such as
+ /// "/name.res" included), or as the bare name in a whole constant, because
+ /// `Resources.openLayered("/name")` appends the extension itself and its argument never
+ /// contains ".res". The JavaScript build's pruning matches the same way
+ /// (`JavaScriptBuildHints.scanBytes`).
+ static boolean referencesTheme(File classesDir, final String name) throws IOException {
+ return classesDir != null && classesDir.isDirectory()
+ && scan(classesDir, classesDir, new Predicate() {
+ @Override
+ public boolean test(String body) {
+ return body.contains(name + ".res")
+ || JavaScriptBuildHints.containsConstant(body, name)
+ || JavaScriptBuildHints.containsConstant(body, "/" + name);
+ }
+ });
+ }
+
+ private static boolean scan(File root, File f, Predicate matches) 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, matches)) {
+ 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);
+ return matches.test(body);
+ }
+
+ 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/main/java/com/codename1/maven/FlutterProjectImporter.java b/maven/build-engine/src/main/java/com/codename1/maven/FlutterProjectImporter.java
new file mode 100644
index 00000000000..9981934c0b1
--- /dev/null
+++ b/maven/build-engine/src/main/java/com/codename1/maven/FlutterProjectImporter.java
@@ -0,0 +1,857 @@
+/*
+ * 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.maven;
+
+import com.codename1.build.Log;
+import com.codename1.builders.BuildException;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/// Imports a Flutter project into a Codename One application: copies its
+/// `lib` tree into `src/main/flutter` (where `cn1:transcode-flutter` picks it
+/// up), copies the assets and fonts its `pubspec.yaml` declares, points the
+/// Codename One entry point at the transpiled Dart `main()`, and reports
+/// which `pubspec.yaml` dependencies the Flutter runtime's stubs cover.
+public final class FlutterProjectImporter {
+
+ /// Pub package names the transpiler's stub set gives Dart API for, by
+ /// package name. Unlike [AndroidProjectImporter#COVERED] (a stable,
+ /// curated Gradle coordinate list) this can only ever be the handful of
+ /// pub packages the runtime actually implements signatures for -- most
+ /// pub packages have no Codename One counterpart at all.
+ static final Map COVERED = new LinkedHashMap();
+
+ static {
+ COVERED.put("flutter", "Material and Cupertino widget stubs (codenameone-flutter-runtime)");
+ COVERED.put("flutter_localizations", "localization delegate scaffolding only; strings are not translated");
+ COVERED.put("cupertino_icons", "CupertinoIcons constants (flutter-runtime stub); "
+ + "the font file itself still needs its own pubspec.yaml fonts: entry to be copied");
+ // The rest are verified against the actual stub sources (maven/flutter-runtime's
+ // META-INF/dart/*.dart and the transpiler's embedded stubs), not guessed at: each
+ // entry names the real stub class/function backing it, so a pub package that is
+ // merely MENTIONED in a code comment (package:collection's case) does not count.
+ COVERED.put("provider", "Provider/ChangeNotifierProvider/Consumer stubs (flutter-runtime)");
+ COVERED.put("animations", "OpenContainer/PageTransitionSwitcher/SharedAxisTransition stubs");
+ COVERED.put("intl", "DateFormat/NumberFormat/Intl stubs");
+ COVERED.put("google_fonts", "GoogleFonts stub (bundled/asset fonts only, no network fetch)");
+ COVERED.put("url_launcher", "launchUrl/launchUrlString stubs");
+ COVERED.put("scoped_model", "ScopedModel/ScopedModelDescendant stubs");
+ COVERED.put("vector_math", "Matrix4/Vector3 stubs");
+ COVERED.put("flutter_localized_locales", "LocaleNames/LocaleNamesLocalizationsDelegate stubs");
+ COVERED.put("adaptive_breakpoints", "AdaptiveBreakpoints.getWindowType only; "
+ + "isDisplayDesktop/isDisplaySmallDesktop/isDisplayMobile are not stubbed");
+ // NOT covered, checked and confirmed absent: "collection" (no ListEquality/MapEquality/
+ // SetEquality stub -- the one hit on that name is a comment, not an implementation),
+ // "flutter_staggered_grid_view" and "nested" (provider's internal composition helper,
+ // never imported directly by application code).
+ }
+
+ /// Pub dependencies that only matter to the Dart tooling and tests, never
+ /// reached by a transpiled build.
+ static final String[] IGNORED = {"flutter_test", "flutter_driver", "integration_test", "test", "build_runner"};
+
+ /// flutter_tools' own resolution-variant directory name, matched exactly
+ /// (`asset.dart`'s `_assetVariantDirectoryRegExp`): one or more digits,
+ /// optionally a `.` and zero or more further digits, then `x` --
+ /// `"2x"`, `"3.0x"`, `"1.5x"`, even the edge case `"2.x"` that regex also
+ /// accepts. A pubspec asset key naming a single file never lists these
+ /// directories itself; Flutter still bundles whichever of them hold a
+ /// same-named file beside it, and [#copyAssetVariants] replicates that.
+ private static final Pattern VARIANT_DIR = Pattern.compile("\\d+(\\.\\d*)?x");
+
+ public static final class Result {
+ public final List covered = new ArrayList();
+ public final List uncovered = new ArrayList();
+ /// A pubspec `assets:`/`fonts:` entry that does not exist under the
+ /// Flutter project -- typically a `packages//...` key
+ /// whose bytes live in that package's own checkout or pub cache, not
+ /// in this project.
+ public final List missingAssets = new ArrayList();
+ /// A pub package name a `packages//...` asset key needed, that
+ /// neither `.dart_tool/package_config.json` nor the pub cache could
+ /// resolve a directory for.
+ public final List unresolvedPackages = new ArrayList();
+ public String appName;
+ public String appVersion;
+ public int copiedFiles;
+ public int copiedAssets;
+ }
+
+ private final Log log;
+
+ public FlutterProjectImporter(Log log) {
+ this.log = log;
+ }
+
+ /// Imports `source` (a Flutter project: `pubspec.yaml` beside a `lib`
+ /// directory) into `commonDir`.
+ public Result importProject(File source, File commonDir, String mainPackage, String mainClass)
+ throws BuildException {
+ File pubspecFile = new File(source, "pubspec.yaml");
+ File libDir = new File(source, "lib");
+ if (!pubspecFile.isFile()) {
+ throw new BuildException("No pubspec.yaml under " + source + " -- point cn1.flutter.import at a "
+ + "Flutter project's root");
+ }
+ if (!libDir.isDirectory()) {
+ throw new BuildException("No lib/ under " + source + " -- not a Flutter project");
+ }
+ Result r = new Result();
+ File target = new File(commonDir, "src/main/flutter");
+ try {
+ Pubspec pubspec = Pubspec.parse(new String(Files.readAllBytes(pubspecFile.toPath()),
+ Charset.forName("UTF-8")));
+ r.appName = pubspec.name;
+ r.appVersion = pubspec.version;
+
+ Map previous = readImportRecord(target);
+ Map imported = new TreeMap();
+
+ File[] libChildren = libDir.listFiles();
+ if (libChildren != null) {
+ for (File child : libChildren) {
+ r.copiedFiles += copy(child, new File(target, child.getName()), child.getName(), previous,
+ imported);
+ }
+ }
+
+ // A `packages//...` asset key points into a pub dependency's own
+ // checkout, not this project: resolve it the same way `flutter pub get`
+ // left it resolvable, via .dart_tool/package_config.json first and the
+ // pub cache (by the version pubspec.lock locked) otherwise.
+ Map packageConfig = readPackageConfig(source);
+ Map lockVersions = readLockVersions(source);
+ Map resolvedPackageLibDirs = new HashMap();
+ Set packagesWithCopiedAssets = new LinkedHashSet();
+ // Every declared key that resolved to real content, recorded for
+ // TranscodeFlutterMojo -- see ASSET_KEYS_RECORD.
+ Set declaredAssetKeys = new TreeSet();
+
+ Set assetKeys = new LinkedHashSet();
+ assetKeys.addAll(pubspec.assets);
+ assetKeys.addAll(pubspec.fontAssets);
+ for (String key : assetKeys) {
+ String clean = key.endsWith("/") ? key.substring(0, key.length() - 1) : key;
+ if (clean.length() == 0 || clean.contains("..")) {
+ continue;
+ }
+ File assetSrc = new File(source, clean.replace('/', File.separatorChar));
+ String pkgName = null;
+ if (!assetSrc.exists() && clean.startsWith("packages/")) {
+ int nameEnd = clean.indexOf('/', "packages/".length());
+ pkgName = nameEnd > 0 ? clean.substring("packages/".length(), nameEnd) : null;
+ String rest = nameEnd > 0 ? clean.substring(nameEnd + 1) : "";
+ File pkgLibDir;
+ if (resolvedPackageLibDirs.containsKey(pkgName)) {
+ pkgLibDir = resolvedPackageLibDirs.get(pkgName);
+ } else {
+ pkgLibDir = pkgName == null ? null
+ : resolvePackageLibDir(pkgName, packageConfig, lockVersions);
+ resolvedPackageLibDirs.put(pkgName, pkgLibDir);
+ }
+ if (pkgLibDir == null) {
+ if (pkgName != null && !r.unresolvedPackages.contains(pkgName)) {
+ r.unresolvedPackages.add(pkgName);
+ log.warn("Flutter package '" + pkgName + "' is not resolvable from "
+ + ".dart_tool/package_config.json or the pub cache; run `flutter pub get` "
+ + "once in " + source + ", or set PUB_CACHE if it is not at ~/.pub-cache");
+ }
+ r.missingAssets.add(key);
+ continue;
+ }
+ assetSrc = new File(pkgLibDir, rest.replace('/', File.separatorChar));
+ }
+ if (!assetSrc.exists()) {
+ r.missingAssets.add(key);
+ continue;
+ }
+ declaredAssetKeys.add(clean);
+ int before = r.copiedAssets;
+ boolean singleFile = assetSrc.isFile();
+ r.copiedAssets += copy(assetSrc, new File(target, clean.replace('/', File.separatorChar)), clean,
+ previous, imported);
+ if (singleFile) {
+ // A directory key ("assets/icons/") already recurses through any
+ // "x/" sibling on its own, via #copy; only a single-file key
+ // ("assets/logo.png") needs its resolution variants found
+ // separately, because Flutter bundles them without a second
+ // pubspec entry.
+ r.copiedAssets += copyAssetVariants(assetSrc, clean, target, previous, imported,
+ declaredAssetKeys);
+ }
+ if (r.copiedAssets == before) {
+ // A declared directory with nothing in it yet; not an error.
+ log.debug("No files found for Flutter asset entry " + key);
+ } else if (pkgName != null) {
+ packagesWithCopiedAssets.add(pkgName);
+ }
+ }
+
+ removeStaleImports(target, previous, imported);
+ writeImportRecord(target, imported);
+ writeDeclaredAssetKeys(target, declaredAssetKeys);
+
+ if (mainPackage != null && mainClass != null) {
+ writeEntryPoint(new File(commonDir, "src/main/java"), mainPackage, mainClass);
+ }
+ updateSettings(commonDir, pubspec);
+
+ // A package with no Dart sources at all is pure artwork/fonts (flutter_gallery_assets,
+ // rally_assets, shrine_images are exactly this for the gallery): once its declared
+ // assets copied, there is nothing further for the runtime to "support" -- unlike an
+ // API package, which is only covered when flutter-runtime actually stubs its classes.
+ Map assetOnlyCovered = new LinkedHashMap();
+ for (String pkgName : packagesWithCopiedAssets) {
+ if (COVERED.containsKey(pkgName)) {
+ continue;
+ }
+ File pkgLibDir = resolvedPackageLibDirs.get(pkgName);
+ if (pkgLibDir != null && !containsDartSource(pkgLibDir)) {
+ assetOnlyCovered.put(pkgName, "asset/font package; files copied from " + pkgLibDir);
+ }
+ }
+
+ for (String dep : pubspec.dependencies) {
+ boolean ignored = false;
+ for (String i : IGNORED) {
+ if (i.equals(dep)) {
+ ignored = true;
+ break;
+ }
+ }
+ if (ignored) {
+ continue;
+ }
+ String note = COVERED.get(dep);
+ if (note == null) {
+ note = assetOnlyCovered.get(dep);
+ }
+ if (note != null) {
+ r.covered.add(dep + " (" + note + ")");
+ } else {
+ r.uncovered.add(dep);
+ }
+ }
+ } catch (IOException e) {
+ throw new BuildException("Import failed: " + e.getMessage(), e);
+ }
+ return r;
+ }
+
+ /// Rewrites `codename1.version`/`codename1.displayName` in
+ /// `commonDir/codenameone_settings.properties` from the pubspec, but only
+ /// when the project already declares that key -- this never introduces a
+ /// setting the developer did not already have, the same caution
+ /// [AndroidProjectImporter] applies to `codename1.packageName`.
+ ///
+ /// Flutter's `version:` is `marketing[+buildNumber]` (its own template
+ /// ships `1.0.0+1`), and the two halves mean different things to the two
+ /// native builders: AndroidGradleBuilder parses `codename1.version` with
+ /// `Float.parseFloat` for its default `versionCode`, and IPhoneBuilder
+ /// writes it straight into `CFBundleShortVersionString` (the marketing
+ /// string App Review shows). Copying `"1.0.0+1"` there whole is wrong
+ /// twice over: the `+1` is not `Float.parseFloat`-compatible --
+ /// `AndroidGradleBuilder.computeVersionCode` falls back to a hardcoded
+ /// `1` -- and `CFBundleShortVersionString` must not contain a `+` at
+ /// all. The marketing half (before `+`) goes to `codename1.version`
+ /// alone; the build number (after `+`) goes to the hints the builders
+ /// actually read for their OWN build-number concept --
+ /// `android.versionCode` (`BuildHintsAndroid`) and `ios.bundleVersion`
+ /// (`CodenameOne/src/.../buildhints/Ios.java`, which backs
+ /// `CFBundleVersion`) -- added rather than updated-if-present, since the
+ /// archetype's settings file never declares either by default.
+ private void updateSettings(File commonDir, Pubspec pubspec) throws IOException {
+ File settings = new File(commonDir, "codenameone_settings.properties");
+ if (!settings.isFile()) {
+ return;
+ }
+ String s = new String(Files.readAllBytes(settings.toPath()), Charset.forName("UTF-8"));
+ String updated = s;
+ if (pubspec.version != null) {
+ int plus = pubspec.version.indexOf('+');
+ String marketing = plus < 0 ? pubspec.version : pubspec.version.substring(0, plus);
+ updated = setProperty(updated, "codename1.version", marketing);
+ if (plus >= 0 && plus < pubspec.version.length() - 1) {
+ String buildNumber = pubspec.version.substring(plus + 1);
+ updated = setOrAppendProperty(updated, "codename1.arg.android.versionCode", buildNumber);
+ updated = setOrAppendProperty(updated, "codename1.arg.ios.bundleVersion", buildNumber);
+ }
+ }
+ if (pubspec.name != null) {
+ updated = setProperty(updated, "codename1.displayName", pubspec.name);
+ }
+ if (!updated.equals(s)) {
+ Files.write(settings.toPath(), updated.getBytes(Charset.forName("UTF-8")));
+ log.info("Set codename1.version/codename1.displayName from pubspec.yaml");
+ }
+ }
+
+ /// Replaces the value of a `key=value` line in a `.properties` file,
+ /// leaving every other line untouched; a `key` the file does not already
+ /// declare is left alone rather than appended.
+ static String setProperty(String properties, String key, String value) {
+ Matcher m = Pattern.compile("(?m)^" + Pattern.quote(key) + "\\s*=.*$").matcher(properties);
+ if (!m.find()) {
+ return properties;
+ }
+ return m.replaceFirst(Matcher.quoteReplacement(key + "=" + value));
+ }
+
+ /// Like [#setProperty], but appends `key=value` as a new line when the
+ /// file does not already declare `key`, instead of leaving it alone --
+ /// used for the `android.versionCode`/`ios.bundleVersion` build hints,
+ /// which (unlike `codename1.version`/`codename1.displayName`) the
+ /// archetype's settings file never declares by default, so "update only
+ /// if present" would silently drop the pubspec build number every time.
+ static String setOrAppendProperty(String properties, String key, String value) {
+ Matcher m = Pattern.compile("(?m)^" + Pattern.quote(key) + "\\s*=.*$").matcher(properties);
+ if (m.find()) {
+ return m.replaceFirst(Matcher.quoteReplacement(key + "=" + value));
+ }
+ String separator = properties.length() == 0 || properties.endsWith("\n") ? "" : "\n";
+ return properties + separator + key + "=" + value + "\n";
+ }
+
+ private void writeEntryPoint(File javaDir, String pkg, String cls) throws IOException {
+ File f = new File(javaDir, pkg.replace('.', File.separatorChar) + File.separator + cls + ".java");
+ f.getParentFile().mkdirs();
+ String src = "package " + pkg + ";\n\n"
+ + "/// The Codename One entry point: starts the imported Flutter application's Dart `main()`.\n"
+ + "public class " + cls + " extends com.codename1.system.Lifecycle {\n"
+ + " @Override\n"
+ + " public void runApp() {\n"
+ + " com.codename1.generated.flutter.FlutterRegistry.invokeMain();\n"
+ + " }\n"
+ + "}\n";
+ byte[] bytes = src.getBytes(Charset.forName("UTF-8"));
+ if (f.isFile()) {
+ File backup = new File(f.getPath() + ".pre-flutter-import");
+ boolean unchanged = java.util.Arrays.equals(bytes, Files.readAllBytes(f.toPath()));
+ if (backup.exists()) {
+ // A repeated import: the backup already holds the application's
+ // original class, and overwriting it would lose the only copy.
+ if (!unchanged) {
+ log.warn(f.getName() + " was changed after an earlier import and was left as it is; "
+ + "delete it and import again to regenerate it");
+ return;
+ }
+ } else if (!unchanged) {
+ Files.copy(f.toPath(), backup.toPath());
+ log.info("Saved the previous " + f.getName() + " as " + backup.getName());
+ }
+ }
+ Files.write(f.toPath(), bytes);
+ }
+
+ /// The manifest-free minimal record: one `` line per file
+ /// this import copied, `path` relative to `src/main/flutter`, `/`
+ /// separated. Same semantics as [AndroidProjectImporter#IMPORT_RECORD]:
+ /// a file the developer edited since the earlier import is kept with its
+ /// earlier hash so the next import still recognizes the edit, and a file
+ /// the earlier import copied that is no longer declared is removed.
+ static final String IMPORT_RECORD = ".flutter-import-files";
+
+ private static Map readImportRecord(File target) throws IOException {
+ Map out = new TreeMap();
+ File f = new File(target, IMPORT_RECORD);
+ if (!f.isFile()) {
+ return out;
+ }
+ for (String line : Files.readAllLines(f.toPath(), Charset.forName("UTF-8"))) {
+ int sp = line.indexOf(' ');
+ if (sp > 0 && sp < line.length() - 1) {
+ out.put(line.substring(sp + 1), line.substring(0, sp));
+ }
+ }
+ return out;
+ }
+
+ private static void writeImportRecord(File target, Map imported) throws IOException {
+ StringBuilder b = new StringBuilder();
+ for (Map.Entry e : imported.entrySet()) {
+ b.append(e.getValue()).append(' ').append(e.getKey()).append('\n');
+ }
+ target.mkdirs();
+ Files.write(new File(target, IMPORT_RECORD).toPath(), b.toString().getBytes(Charset.forName("UTF-8")));
+ }
+
+ /// File (beside [#IMPORT_RECORD], directly under `src/main/flutter`)
+ /// listing every pubspec `assets:`/`fonts:` key this import resolved to
+ /// real content, one per line, `/` separated -- exactly the path each
+ /// key was copied to under `src/main/flutter`, whether its source was
+ /// the Flutter project itself or a resolved pub package.
+ ///
+ /// `TranscodeFlutterMojo.copyAssets()` reads this to flatten keys
+ /// outside the two roots it otherwise scans (`assets/`, `packages/`):
+ /// Flutter allows an asset or font declared anywhere project-relative
+ /// (`fonts/MyFont.ttf`, `images/logo.png`), and without this record the
+ /// mojo had no way to learn such a key exists, so it silently packaged
+ /// nothing for it on every port. Rewritten whole on every import, even
+ /// to empty, so a key removed from pubspec.yaml stops being flattened
+ /// too. A hand-wired project (no import, so no such file) is unaffected
+ /// -- the mojo falls back to its existing assets/+packages scan alone.
+ static final String ASSET_KEYS_RECORD = ".flutter-asset-keys";
+
+ private static void writeDeclaredAssetKeys(File target, Set keys) throws IOException {
+ StringBuilder b = new StringBuilder();
+ for (String k : keys) {
+ b.append(k).append('\n');
+ }
+ target.mkdirs();
+ Files.write(new File(target, ASSET_KEYS_RECORD).toPath(), b.toString().getBytes(Charset.forName("UTF-8")));
+ }
+
+ /// Copies `src` to `dest` (recursing when `src` is a directory), recording
+ /// each copied file's path (relative to `src/main/flutter`, `/`
+ /// separated) and content hash in `imported`.
+ private int copy(File src, File dest, String path, Map previous, Map imported)
+ throws IOException {
+ if (src.isDirectory()) {
+ int n = 0;
+ File[] files = src.listFiles();
+ if (files != null) {
+ for (File f : files) {
+ n += copy(f, new File(dest, f.getName()), path + "/" + f.getName(), previous, imported);
+ }
+ }
+ return n;
+ }
+ String recorded = previous.get(path);
+ if (recorded != null && dest.isFile() && !recorded.equals(sha256(dest))
+ && !java.util.Arrays.equals(Files.readAllBytes(src.toPath()), Files.readAllBytes(dest.toPath()))) {
+ log.warn("Kept " + dest + ": it was changed after the earlier import, so the Flutter project's copy "
+ + "was not imported; delete it and import again to take that copy");
+ imported.put(path, recorded);
+ return 0;
+ }
+ dest.getParentFile().mkdirs();
+ Files.copy(src.toPath(), dest.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ imported.put(path, sha256(dest));
+ return 1;
+ }
+
+ /// Copies every resolution-specific sibling of a single-file asset --
+ /// `logo.png`'s `2.0x/logo.png`, `3.0x/logo.png` and so on -- that Flutter
+ /// bundles without a second pubspec entry (flutter_tools' own rule,
+ /// `asset.dart`'s `_AssetDirectoryCache.variantsFor`: any sibling directory
+ /// matching [#VARIANT_DIR] holding a same-named file). `assetSrc` is the
+ /// already-resolved main file, from either the Flutter project itself or a
+ /// pub package's `lib/`; `cleanKey` is the asset key it was declared under
+ /// (`assets/logo.png`, or `packages//assets/logo.png`), and each
+ /// variant's own key is built from it the way flutter_tools names the
+ /// variant file -- `assets/2.0x/logo.png`.
+ ///
+ /// Every variant's key is added to `declaredAssetKeys` too, unconditionally
+ /// once the variant file is found to exist -- the same rule [#importProject]
+ /// applies to the main key itself. A key rooted at `assets/`/`packages/`
+ /// does not strictly need that (`TranscodeFlutterMojo` already rescans those
+ /// two roots whole, so a variant copied under them is found regardless), but
+ /// any other project-relative key does: the mojo flattens such a key one
+ /// file at a time from exactly this record, with no scan of its own to find
+ /// the variant otherwise.
+ private int copyAssetVariants(File assetSrc, String cleanKey, File target, Map previous,
+ Map imported, Set declaredAssetKeys) throws IOException {
+ File dir = assetSrc.getParentFile();
+ File[] siblings = dir == null ? null : dir.listFiles();
+ if (siblings == null) {
+ return 0;
+ }
+ String fileName = assetSrc.getName();
+ int slash = cleanKey.lastIndexOf('/');
+ String keyDir = slash < 0 ? "" : cleanKey.substring(0, slash + 1);
+ int copied = 0;
+ for (File sibling : siblings) {
+ if (!sibling.isDirectory() || !VARIANT_DIR.matcher(sibling.getName()).matches()) {
+ continue;
+ }
+ File variantFile = new File(sibling, fileName);
+ if (!variantFile.isFile()) {
+ continue;
+ }
+ String variantKey = keyDir + sibling.getName() + "/" + fileName;
+ declaredAssetKeys.add(variantKey);
+ copied += copy(variantFile, new File(target, variantKey.replace('/', File.separatorChar)), variantKey,
+ previous, imported);
+ }
+ return copied;
+ }
+
+ /// Removes what an earlier import copied and this one did not -- a Dart
+ /// file or asset renamed or removed upstream -- so it does not stay
+ /// behind to be transpiled or packaged. Only a file listed in the earlier
+ /// import's record is a candidate, and only while it still holds exactly
+ /// the bytes that import wrote; a file the developer added, or edited
+ /// since, is kept. Directories the removal empties go too.
+ private void removeStaleImports(File target, Map previous, Map imported)
+ throws IOException {
+ for (Map.Entry e : previous.entrySet()) {
+ String path = e.getKey();
+ if (imported.containsKey(path) || path.contains("..")) {
+ continue;
+ }
+ File f = new File(target, path.replace('/', File.separatorChar));
+ if (!f.isFile()) {
+ continue;
+ }
+ if (!e.getValue().equals(sha256(f))) {
+ log.warn("Kept " + f + ": it is no longer in the Flutter project, but it was changed after "
+ + "the earlier import");
+ continue;
+ }
+ Files.delete(f.toPath());
+ log.info("Removed " + f + ": it is no longer in the Flutter project");
+ File dir = f.getParentFile();
+ while (dir != null && !dir.equals(target)) {
+ String[] left = dir.list();
+ if (left == null || left.length > 0 || !dir.delete()) {
+ break;
+ }
+ dir = dir.getParentFile();
+ }
+ }
+ }
+
+ private static String sha256(File f) throws IOException {
+ try {
+ byte[] d = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(f.toPath()));
+ StringBuilder b = new StringBuilder(d.length * 2);
+ for (byte x : d) {
+ b.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16));
+ }
+ return b.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IOException("SHA-256 is unavailable", e);
+ }
+ }
+
+ /// A `"key": "value"` line inside `.dart_tool/package_config.json`, which
+ /// `flutter pub get` always writes one field per line.
+ private static final Pattern JSON_FIELD = Pattern.compile("\"([A-Za-z]+)\"\\s*:\\s*\"([^\"]*)\"\\s*,?");
+
+ /// Maps each pub dependency `flutter pub get` resolved to its `lib/`
+ /// directory, read from the Flutter project's
+ /// `.dart_tool/package_config.json`. Empty when the file is absent or
+ /// unreadable -- the caller falls back to the pub cache.
+ private static Map readPackageConfig(File flutterProjectDir) {
+ Map out = new HashMap();
+ File f = new File(flutterProjectDir, ".dart_tool" + File.separator + "package_config.json");
+ if (!f.isFile()) {
+ return out;
+ }
+ try {
+ String json = new String(Files.readAllBytes(f.toPath()), Charset.forName("UTF-8"));
+ URI base = new File(flutterProjectDir, ".dart_tool").toURI();
+ boolean inPackages = false;
+ String name = null;
+ String rootUri = null;
+ String packageUri = "lib/";
+ for (String rawLine : json.split("\n", -1)) {
+ String line = rawLine.trim();
+ if (!inPackages) {
+ if (line.startsWith("\"packages\"")) {
+ inPackages = true;
+ }
+ continue;
+ }
+ if (line.startsWith("]")) {
+ break;
+ }
+ if (line.equals("{")) {
+ name = null;
+ rootUri = null;
+ packageUri = "lib/";
+ continue;
+ }
+ if (line.startsWith("}")) {
+ if (name != null && rootUri != null) {
+ putPackageLibDir(out, base, name, rootUri, packageUri);
+ }
+ continue;
+ }
+ Matcher m = JSON_FIELD.matcher(line);
+ if (!m.matches()) {
+ continue;
+ }
+ String key = m.group(1);
+ if ("name".equals(key)) {
+ name = m.group(2);
+ } else if ("rootUri".equals(key)) {
+ rootUri = m.group(2);
+ } else if ("packageUri".equals(key)) {
+ packageUri = m.group(2);
+ }
+ }
+ } catch (IOException e) {
+ // Unreadable package_config.json: resolved via the pub cache instead.
+ }
+ return out;
+ }
+
+ /// Resolves one `package_config.json` entry's `lib/` directory and adds
+ /// it to `out`. `rootUri` is a `file:` URI, absolute or (for a path
+ /// dependency) relative to `.dart_tool/`; `packageUri` is conventionally
+ /// `lib/` but is read rather than assumed. A malformed URI for one
+ /// package is skipped rather than failing every other one.
+ private static void putPackageLibDir(Map out, URI dartToolDir, String name, String rootUri,
+ String packageUri) {
+ try {
+ URI root = dartToolDir.resolve(rootUri);
+ String rootStr = root.toString();
+ if (!rootStr.endsWith("/")) {
+ // A relative reference resolved against a URI with no trailing
+ // slash replaces its last segment instead of extending it --
+ // the package's own directory name, here -- so packageUri must
+ // be resolved against a URI that unambiguously names a directory.
+ rootStr = rootStr + "/";
+ }
+ URI rootDir = URI.create(rootStr);
+ URI lib = packageUri.length() == 0 ? rootDir : rootDir.resolve(packageUri);
+ out.put(name, new File(lib));
+ } catch (IllegalArgumentException e) {
+ // Skip just this entry.
+ }
+ }
+
+ /// A pub package's locked version, read from `pubspec.lock`'s own small
+ /// line-based scan (see [Pubspec]). Used only when
+ /// `.dart_tool/package_config.json` does not resolve a package -- a
+ /// project that has never run `flutter pub get` has neither.
+ private static Map readLockVersions(File flutterProjectDir) {
+ Map out = new HashMap();
+ File f = new File(flutterProjectDir, "pubspec.lock");
+ if (!f.isFile()) {
+ return out;
+ }
+ try {
+ String yaml = new String(Files.readAllBytes(f.toPath()), Charset.forName("UTF-8"));
+ boolean inPackages = false;
+ String currentPkg = null;
+ for (String rawLine : yaml.split("\n", -1)) {
+ String trimmed = rawLine.trim();
+ if (trimmed.length() == 0 || trimmed.charAt(0) == '#') {
+ continue;
+ }
+ int indent = 0;
+ while (indent < rawLine.length() && rawLine.charAt(indent) == ' ') {
+ indent++;
+ }
+ if (indent == 0) {
+ inPackages = "packages:".equals(trimmed);
+ currentPkg = null;
+ continue;
+ }
+ if (!inPackages) {
+ continue;
+ }
+ if (indent == 2) {
+ Matcher m = Pattern.compile("^([A-Za-z0-9_.-]+):\\s*$").matcher(trimmed);
+ currentPkg = m.matches() ? m.group(1) : null;
+ continue;
+ }
+ if (currentPkg != null && indent == 4 && trimmed.startsWith("version:")) {
+ Matcher v = Pattern.compile("^version:\\s*(.+)$").matcher(trimmed);
+ if (v.matches()) {
+ out.put(currentPkg, Pubspec.unquote(v.group(1).trim()));
+ }
+ }
+ }
+ } catch (IOException e) {
+ // Unreadable pubspec.lock: the package stays unresolved.
+ }
+ return out;
+ }
+
+ /// `name`'s `lib/` directory: from `package_config`, or failing that
+ /// from the pub cache at the version `lockVersions` locked
+ /// (`$PUB_CACHE`, or `~/.pub-cache`, under `hosted/pub.dev/-/lib`).
+ /// Null when neither resolves it.
+ private static File resolvePackageLibDir(String name, Map packageConfig,
+ Map lockVersions) {
+ File fromConfig = packageConfig.get(name);
+ if (fromConfig != null) {
+ return fromConfig;
+ }
+ String version = lockVersions.get(name);
+ if (version == null) {
+ return null;
+ }
+ String pubCacheEnv = System.getenv("PUB_CACHE");
+ File cacheRoot = pubCacheEnv != null && pubCacheEnv.length() > 0 ? new File(pubCacheEnv)
+ : new File(System.getProperty("user.home"), ".pub-cache");
+ File lib = new File(new File(new File(cacheRoot, "hosted" + File.separator + "pub.dev"),
+ name + "-" + version), "lib");
+ return lib.isDirectory() ? lib : null;
+ }
+
+ /// True when `dir` (or a subdirectory) holds at least one `.dart` file --
+ /// the test for "this pub package is Dart API, not just artwork". Stops
+ /// at the first one found.
+ private static boolean containsDartSource(File dir) {
+ File[] files = dir.listFiles();
+ if (files == null) {
+ return false;
+ }
+ for (File f : files) {
+ if (f.isDirectory() ? containsDartSource(f) : f.getName().endsWith(".dart")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /// The handful of `pubspec.yaml` keys the importer needs, read with a
+ /// small line-based scanner rather than a YAML library: build-engine has
+ /// none, and `name:`/`version:`/`dependencies:`/`flutter: assets:` and
+ /// `flutter: fonts:` are all that matters here. It is not a general YAML
+ /// reader -- block scalars, anchors and flow collections are not
+ /// recognized, and a pubspec using them for these particular keys (rare
+ /// in practice) will simply not contribute to the list it misses.
+ static final class Pubspec {
+ String name;
+ String version;
+ final List assets = new ArrayList();
+ final List fontAssets = new ArrayList();
+ final List dependencies = new ArrayList();
+
+ private static final Pattern TOP_KEY = Pattern.compile("^([A-Za-z0-9_.-]+):\\s*(.*)$");
+ private static final Pattern DEP_KEY = Pattern.compile("^([A-Za-z0-9_.-]+):.*$");
+ private static final Pattern FONT_ASSET = Pattern.compile("^-?\\s*asset:\\s*(.+)$");
+
+ static Pubspec parse(String yaml) {
+ Pubspec p = new Pubspec();
+ String currentTop = null;
+ int depItemIndent = -1;
+ boolean inAssetsList = false;
+ int assetsBulletIndent = -1;
+ for (String rawLine : yaml.split("\n", -1)) {
+ String line = rawLine.length() > 0 && rawLine.charAt(rawLine.length() - 1) == '\r'
+ ? rawLine.substring(0, rawLine.length() - 1) : rawLine;
+ String trimmed = line.trim();
+ if (trimmed.length() == 0 || trimmed.charAt(0) == '#') {
+ continue;
+ }
+ int indent = 0;
+ while (indent < line.length() && line.charAt(indent) == ' ') {
+ indent++;
+ }
+ if (indent == 0) {
+ Matcher top = TOP_KEY.matcher(trimmed);
+ if (top.matches()) {
+ currentTop = top.group(1);
+ if ("name".equals(currentTop)) {
+ p.name = unquote(top.group(2));
+ } else if ("version".equals(currentTop)) {
+ p.version = unquote(top.group(2));
+ }
+ } else {
+ currentTop = null;
+ }
+ depItemIndent = -1;
+ inAssetsList = false;
+ assetsBulletIndent = -1;
+ continue;
+ }
+ if ("dependencies".equals(currentTop)) {
+ Matcher dep = DEP_KEY.matcher(trimmed);
+ if (dep.matches()) {
+ if (depItemIndent == -1) {
+ depItemIndent = indent;
+ }
+ if (indent == depItemIndent) {
+ p.dependencies.add(dep.group(1));
+ }
+ }
+ continue;
+ }
+ if (!"flutter".equals(currentTop)) {
+ continue;
+ }
+ if ("assets:".equals(trimmed)) {
+ inAssetsList = true;
+ assetsBulletIndent = -1;
+ continue;
+ }
+ if (inAssetsList && trimmed.startsWith("- ")) {
+ if (assetsBulletIndent == -1) {
+ assetsBulletIndent = indent;
+ }
+ if (indent == assetsBulletIndent) {
+ p.assets.add(unquote(trimmed.substring(2).trim()));
+ continue;
+ }
+ }
+ inAssetsList = false;
+ Matcher font = FONT_ASSET.matcher(trimmed);
+ if (font.matches()) {
+ p.fontAssets.add(unquote(font.group(1).trim()));
+ }
+ }
+ return p;
+ }
+
+ /// A plain scalar: a trailing ` # comment` dropped (YAML starts a comment
+ /// only at a `#` preceded by whitespace, so `a#b` keeps its `#`), then one
+ /// pair of matching quotes removed. A quoted value keeps a `#` inside the
+ /// quotes. The gallery's `version: 2.10.2+021002 # See README.md` came
+ /// through whole before this.
+ static String unquote(String s) {
+ String v = s.trim();
+ if (!v.startsWith("\"") && !v.startsWith("'")) {
+ for (int i = 1; i < v.length(); i++) {
+ if (v.charAt(i) == '#' && Character.isWhitespace(v.charAt(i - 1))) {
+ v = v.substring(0, i).trim();
+ break;
+ }
+ }
+ } else {
+ char q = v.charAt(0);
+ int close = v.indexOf(q, 1);
+ if (close > 0) {
+ v = v.substring(0, close + 1);
+ }
+ }
+ if (v.length() >= 2 && ((v.charAt(0) == '"' && v.charAt(v.length() - 1) == '"')
+ || (v.charAt(0) == '\'' && v.charAt(v.length() - 1) == '\''))) {
+ v = v.substring(1, v.length() - 1);
+ }
+ return v;
+ }
+ }
+}
diff --git a/maven/build-engine/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java b/maven/build-engine/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java
index 2374b923aed..d6c99d6f159 100644
--- a/maven/build-engine/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java
+++ b/maven/build-engine/src/test/java/com/codename1/builders/AndroidGradleBuilderVersionTest.java
@@ -310,4 +310,46 @@ void typedPushReplaysColdStartMessagesBeforeTheListenerIsInstalled() {
+ "com.codename1.impl.CodenameOneImplementation.getPushCallback(), this)"));
assertFalse(legacyReplay.contains("PushClient.dispatch"));
}
+
+ @Test
+ void versionCodeOverrideWinsEvenWhenTheProjectVersionIsNotAFloat() {
+ // The override and the version-derived default used to share one
+ // try/catch(Throwable): Float.parseFloat("1.0.0") throws on the FIRST
+ // statement, before android.versionCode was even read, so the override
+ // was silently discarded and intVersion fell back to the hardcoded
+ // default of 1 regardless of what the developer asked for.
+ BuildRequest threeSegment = new BuildRequest();
+ threeSegment.setVersion("1.0.0");
+ threeSegment.putArgument("android.versionCode", "42");
+ assertEquals(42, AndroidGradleBuilder.computeVersionCode(threeSegment),
+ "an explicit android.versionCode must be honoured even when codename1.version "
+ + "is not Float.parseFloat-compatible");
+
+ // Same failure mode from a Flutter import: pubspec's "marketing+build"
+ // convention left codename1.version with a '+' after splitting at the
+ // marketing part was skipped, but the marketing part itself can still be
+ // a 3-segment semver.
+ BuildRequest fromFlutterImport = new BuildRequest();
+ fromFlutterImport.setVersion("2.10.2");
+ fromFlutterImport.putArgument("android.versionCode", "021002");
+ assertEquals(21002, AndroidGradleBuilder.computeVersionCode(fromFlutterImport));
+
+ // No override: falls back to the version-derived default, unchanged.
+ BuildRequest noOverride = new BuildRequest();
+ noOverride.setVersion("1.5");
+ assertEquals(150, AndroidGradleBuilder.computeVersionCode(noOverride));
+
+ // Neither a usable override nor a float-parseable version: the
+ // long-standing default of 1, not an exception.
+ BuildRequest neither = new BuildRequest();
+ neither.setVersion("1.0.0");
+ assertEquals(1, AndroidGradleBuilder.computeVersionCode(neither));
+
+ // An override that is not itself a whole number falls back to the
+ // version-derived default rather than propagating.
+ BuildRequest malformedOverride = new BuildRequest();
+ malformedOverride.setVersion("1.5");
+ malformedOverride.putArgument("android.versionCode", "not-a-number");
+ assertEquals(150, AndroidGradleBuilder.computeVersionCode(malformedOverride));
+ }
}
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..4abe3c05650
--- /dev/null
+++ b/maven/build-engine/src/test/java/com/codename1/builders/NativeThemesTest.java
@@ -0,0 +1,285 @@
+/*
+ * 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 aThemeOpenedByItsBareLayeredNameIsKept() throws IOException {
+ // Resources.openLayered("/iOSModernTheme") appends ".res" itself, so the class file
+ // only holds the bare name, as a whole constant-pool string.
+ File dir = portDir();
+ File classes = tmp.newFolder("layeredclasses");
+ File cls = new File(classes, "com/example/App.class");
+ cls.getParentFile().mkdirs();
+ Files.write(cls.toPath(), bytesWith(utf8Constant("/iOSModernTheme")));
+ NativeThemes.removeUnused(dir, "iOS7Theme", classes);
+ assertTrue(new File(dir, "iOSModernTheme.res").isFile());
+ assertFalse(new File(dir, "iPhoneTheme.res").isFile());
+ }
+
+ @Test
+ public void aLongerIdentifierStartingWithAThemeNameIsNotAUse() throws IOException {
+ File dir = portDir();
+ File classes = tmp.newFolder("prefixclasses");
+ File cls = new File(classes, "com/example/App.class");
+ cls.getParentFile().mkdirs();
+ Files.write(cls.toPath(), bytesWith(utf8Constant("iOSModernThemeSettings")));
+ NativeThemes.removeUnused(dir, "iOS7Theme", classes);
+ 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;
+ }
+
+ /// A CONSTANT_Utf8 entry as a class file stores it: tag 1, a two-byte length, the bytes.
+ private static String utf8Constant(String value) {
+ return "\u0001\u0000" + (char) value.length() + value;
+ }
+
+ 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/build-engine/src/test/java/com/codename1/maven/FlutterProjectImporterTest.java b/maven/build-engine/src/test/java/com/codename1/maven/FlutterProjectImporterTest.java
new file mode 100644
index 00000000000..8213538e84b
--- /dev/null
+++ b/maven/build-engine/src/test/java/com/codename1/maven/FlutterProjectImporterTest.java
@@ -0,0 +1,442 @@
+/*
+ * 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.maven;
+
+import org.junit.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class FlutterProjectImporterTest {
+
+ private static final String PUBSPEC = "name: demo_app\n"
+ + "description: A demo.\n"
+ + "version: 1.2.3+4\n"
+ + "\n"
+ + "environment:\n"
+ + " sdk: ^3.0.0\n"
+ + "\n"
+ + "dependencies:\n"
+ + " flutter:\n"
+ + " sdk: flutter\n"
+ + " cupertino_icons: ^1.0.2\n"
+ + " some_uncovered_pkg: ^1.0.0\n"
+ + "\n"
+ + "dev_dependencies:\n"
+ + " flutter_test:\n"
+ + " sdk: flutter\n"
+ + "\n"
+ + "flutter:\n"
+ + " uses-material-design: true\n"
+ + " assets:\n"
+ + " - assets/images/logo.png\n"
+ + " - assets/icons/\n"
+ + " fonts:\n"
+ + " - family: Custom\n"
+ + " fonts:\n"
+ + " - asset: assets/fonts/Custom-Regular.ttf\n";
+
+ @Test
+ public void parsesNameVersionDependenciesAssetsAndFonts() {
+ FlutterProjectImporter.Pubspec p = FlutterProjectImporter.Pubspec.parse(PUBSPEC);
+ assertEquals("demo_app", p.name);
+ assertEquals("1.2.3+4", p.version);
+ assertEquals(java.util.Arrays.asList("flutter", "cupertino_icons", "some_uncovered_pkg"), p.dependencies);
+ assertFalse("dev_dependencies must not be reported as app dependencies",
+ p.dependencies.contains("flutter_test"));
+ assertEquals(java.util.Arrays.asList("assets/images/logo.png", "assets/icons/"), p.assets);
+ assertEquals(java.util.Arrays.asList("assets/fonts/Custom-Regular.ttf"), p.fontAssets);
+ }
+
+ @Test
+ public void setPropertyReplacesOnlyAnExistingKey() {
+ String props = "codename1.version=1.0\ncodename1.displayName=Hi World\n";
+ String updated = FlutterProjectImporter.setProperty(props, "codename1.version", "2.0");
+ assertTrue(updated, updated.contains("codename1.version=2.0"));
+ assertTrue(updated, updated.contains("codename1.displayName=Hi World"));
+ // A key the file never declared is left alone, not appended.
+ assertEquals(props, FlutterProjectImporter.setProperty(props, "codename1.secondaryTitle", "x"));
+ }
+
+ @Test
+ public void importsLibTreeAssetsAndFontsAndReportsDependencies() throws Exception {
+ File root = Files.createTempDirectory("flutter-import").toFile();
+ File flutterProject = new File(root, "demo");
+ write(new File(flutterProject, "pubspec.yaml"), PUBSPEC);
+ write(new File(flutterProject, "lib/main.dart"), "void main() {}\n");
+ write(new File(flutterProject, "lib/pages/home.dart"), "class Home {}\n");
+ write(new File(flutterProject, "assets/images/logo.png"), "PNGDATA");
+ write(new File(flutterProject, "assets/icons/a.png"), "ICONA");
+ write(new File(flutterProject, "assets/icons/b.png"), "ICONB");
+ write(new File(flutterProject, "assets/fonts/Custom-Regular.ttf"), "FONTDATA");
+
+ File common = new File(root, "common");
+ FlutterProjectImporter importer = new FlutterProjectImporter(new com.codename1.build.SystemStreamLog());
+ FlutterProjectImporter.Result r = importer.importProject(flutterProject, common, "com.example", "MyApp");
+
+ assertEquals("demo_app", r.appName);
+ assertEquals("1.2.3+4", r.appVersion);
+ assertTrue(r.covered.toString(), r.covered.get(0).startsWith("flutter ("));
+ assertTrue(r.covered.toString(), r.covered.get(1).startsWith("cupertino_icons ("));
+ assertEquals(java.util.Collections.singletonList("some_uncovered_pkg"), r.uncovered);
+ assertTrue(r.missingAssets.toString(), r.missingAssets.isEmpty());
+
+ File flutterDir = new File(common, "src/main/flutter");
+ assertTrue(new File(flutterDir, "main.dart").isFile());
+ assertTrue(new File(flutterDir, "pages/home.dart").isFile());
+ assertTrue(new File(flutterDir, "assets/images/logo.png").isFile());
+ assertTrue(new File(flutterDir, "assets/icons/a.png").isFile());
+ assertTrue(new File(flutterDir, "assets/icons/b.png").isFile());
+ assertTrue(new File(flutterDir, "assets/fonts/Custom-Regular.ttf").isFile());
+
+ File entry = new File(common, "src/main/java/com/example/MyApp.java");
+ assertTrue(entry.isFile());
+ assertTrue(read(entry).contains("FlutterRegistry.invokeMain()"));
+ }
+
+ @Test
+ public void missingAssetIsReportedNotFatal() throws Exception {
+ File root = Files.createTempDirectory("flutter-import-missing").toFile();
+ File flutterProject = new File(root, "demo");
+ write(new File(flutterProject, "pubspec.yaml"), "name: demo\nversion: 1.0.0\n"
+ + "dependencies:\n flutter:\n sdk: flutter\n"
+ + "flutter:\n assets:\n - packages/some_pkg/logo.png\n");
+ write(new File(flutterProject, "lib/main.dart"), "void main() {}\n");
+ File common = new File(root, "common");
+ FlutterProjectImporter.Result r = new FlutterProjectImporter(new com.codename1.build.SystemStreamLog())
+ .importProject(flutterProject, common, null, null);
+ assertEquals(java.util.Collections.singletonList("packages/some_pkg/logo.png"), r.missingAssets);
+ // Neither .dart_tool/package_config.json nor pubspec.lock exist here, so the
+ // package itself -- not just the one asset key -- could not be resolved at all.
+ assertEquals(java.util.Collections.singletonList("some_pkg"), r.unresolvedPackages);
+ }
+
+ /// `packages//...` asset keys are the common case in a real app
+ /// (flutter_gallery_assets, cupertino_icons' own font, ...): the bytes
+ /// live in the dependency's own checkout, found via
+ /// `.dart_tool/package_config.json` exactly as `flutter pub get` left it,
+ /// never a manual step.
+ @Test
+ public void resolvesPackageAssetsViaPackageConfigJson() throws Exception {
+ File root = Files.createTempDirectory("flutter-pkg-assets").toFile();
+ File flutterProject = new File(root, "demo");
+ write(new File(flutterProject, "pubspec.yaml"), "name: demo\nversion: 1.0.0\n"
+ + "dependencies:\n flutter:\n sdk: flutter\n my_pkg: 1.0.0\n"
+ + "flutter:\n assets:\n - packages/my_pkg/logo.png\n - packages/my_pkg/images/\n");
+ write(new File(flutterProject, "lib/main.dart"), "void main() {}\n");
+
+ // Stands in for the pub cache: a package checkout with no .dart source,
+ // pure artwork, the same shape as flutter_gallery_assets.
+ File pkgRoot = new File(root, "fake-pub-cache/my_pkg-1.0.0");
+ write(new File(pkgRoot, "lib/logo.png"), "LOGO");
+ write(new File(pkgRoot, "lib/images/a.png"), "A");
+ write(new File(pkgRoot, "lib/images/b.png"), "B");
+
+ File dartTool = new File(flutterProject, ".dart_tool");
+ write(new File(dartTool, "package_config.json"), "{\n \"configVersion\": 2,\n \"packages\": [\n"
+ + " {\n \"name\": \"my_pkg\",\n \"rootUri\": \"" + pkgRoot.toURI()
+ + "\",\n \"packageUri\": \"lib/\"\n }\n ]\n}\n");
+
+ File common = new File(root, "common");
+ FlutterProjectImporter.Result r = new FlutterProjectImporter(new com.codename1.build.SystemStreamLog())
+ .importProject(flutterProject, common, null, null);
+
+ assertTrue(r.missingAssets.toString(), r.missingAssets.isEmpty());
+ assertTrue(r.unresolvedPackages.toString(), r.unresolvedPackages.isEmpty());
+ File flutterDir = new File(common, "src/main/flutter");
+ assertTrue(new File(flutterDir, "packages/my_pkg/logo.png").isFile());
+ assertTrue(new File(flutterDir, "packages/my_pkg/images/a.png").isFile());
+ assertTrue(new File(flutterDir, "packages/my_pkg/images/b.png").isFile());
+ // A package with no Dart source at all (pure artwork) is reported as
+ // covered once its declared assets are actually copied.
+ assertEquals(2, r.covered.size());
+ assertTrue(r.covered.toString(), r.covered.get(1).startsWith("my_pkg (asset"));
+ assertTrue(r.uncovered.toString(), r.uncovered.isEmpty());
+ }
+
+ @Test
+ public void reimportKeepsACustomizedEntryPointAndRemovesStaleAssets() throws Exception {
+ File root = Files.createTempDirectory("flutter-reimport").toFile();
+ File flutterProject = new File(root, "demo");
+ write(new File(flutterProject, "pubspec.yaml"), "name: demo\nversion: 1.0.0\n"
+ + "dependencies:\n flutter:\n sdk: flutter\n");
+ write(new File(flutterProject, "lib/main.dart"), "void main() {}\n");
+ File oldAsset = new File(flutterProject, "assets/old.png");
+ // The asset is declared now and dropped before the second import.
+ write(new File(flutterProject, "pubspec.yaml"), "name: demo\nversion: 1.0.0\n"
+ + "dependencies:\n flutter:\n sdk: flutter\n"
+ + "flutter:\n assets:\n - assets/old.png\n");
+ write(oldAsset, "OLD");
+ File common = new File(root, "common");
+ // A project generated from the archetype already has its main class; the
+ // first import saves it as the backup, as the Android importer does.
+ File entry = new File(common, "src/main/java/com/example/MyApp.java");
+ write(entry, "package com.example; public class MyApp {}");
+ FlutterProjectImporter importer = new FlutterProjectImporter(new com.codename1.build.SystemStreamLog());
+ importer.importProject(flutterProject, common, "com.example", "MyApp");
+
+ String generated = read(entry);
+ String customized = generated.replace("invokeMain();", "invokeMain(); // customized");
+ write(entry, customized);
+
+ // The asset is dropped from pubspec and the import re-run.
+ write(new File(flutterProject, "pubspec.yaml"), "name: demo\nversion: 1.0.0\n"
+ + "dependencies:\n flutter:\n sdk: flutter\n");
+ importer.importProject(flutterProject, common, "com.example", "MyApp");
+
+ assertEquals("the customized entry point was overwritten", customized, read(entry));
+ File backup = new File(entry.getPath() + ".pre-flutter-import");
+ assertEquals("package com.example; public class MyApp {}", read(backup));
+ assertFalse("a stale asset dropped from pubspec.yaml stayed behind",
+ new File(common, "src/main/flutter/assets/old.png").exists());
+ }
+
+ @Test
+ public void setOrAppendPropertyAddsAMissingKeyButUpdatesAnExistingOneInPlace() {
+ String props = "codename1.version=1.0\n";
+ // Not declared yet (the archetype's settings file never ships either build-number
+ // hint by default) -- so it is appended, unlike setProperty's "leave it alone".
+ String withNew = FlutterProjectImporter.setOrAppendProperty(props, "codename1.arg.android.versionCode", "4");
+ assertTrue(withNew, withNew.contains("codename1.arg.android.versionCode=4"));
+ assertTrue(withNew, withNew.contains("codename1.version=1.0"));
+
+ // Declared now: re-running (as a second import would) updates it in place,
+ // never appending a second line for the same key.
+ String updated = FlutterProjectImporter.setOrAppendProperty(withNew, "codename1.arg.android.versionCode",
+ "9");
+ assertEquals(2, updated.split("codename1\\.arg\\.android\\.versionCode=", -1).length);
+ assertTrue(updated, updated.contains("codename1.arg.android.versionCode=9"));
+ }
+
+ @Test
+ public void importSplitsPubspecVersionIntoMarketingAndBuildNumberHints() throws Exception {
+ // Flutter's own template ships "1.0.0+1": PUBSPEC's "1.2.3+4" is the same
+ // marketing+buildNumber shape. Copied in whole, "+4" is not
+ // Float.parseFloat-compatible (AndroidGradleBuilder's default versionCode)
+ // and is invalid inside CFBundleShortVersionString (IPhoneBuilder).
+ File root = Files.createTempDirectory("flutter-version-split").toFile();
+ File flutterProject = new File(root, "demo");
+ write(new File(flutterProject, "pubspec.yaml"), PUBSPEC);
+ write(new File(flutterProject, "lib/main.dart"), "void main() {}\n");
+ write(new File(flutterProject, "assets/images/logo.png"), "PNGDATA");
+ write(new File(flutterProject, "assets/icons/a.png"), "ICONA");
+ write(new File(flutterProject, "assets/fonts/Custom-Regular.ttf"), "FONTDATA");
+
+ File common = new File(root, "common");
+ write(new File(common, "codenameone_settings.properties"),
+ "codename1.version=1.0\ncodename1.displayName=Old Name\n");
+
+ new FlutterProjectImporter(new com.codename1.build.SystemStreamLog())
+ .importProject(flutterProject, common, null, null);
+
+ String settings = read(new File(common, "codenameone_settings.properties"));
+ assertTrue(settings, settings.contains("codename1.version=1.2.3"));
+ assertFalse("the marketing version must not carry the build number",
+ settings.contains("codename1.version=1.2.3+4"));
+ assertTrue(settings, settings.contains("codename1.arg.android.versionCode=4"));
+ assertTrue(settings, settings.contains("codename1.arg.ios.bundleVersion=4"));
+ assertTrue(settings, settings.contains("codename1.displayName=demo_app"));
+ }
+
+ @Test
+ public void importWithNoBuildNumberLeavesTheVersionHintsUnset() throws Exception {
+ File root = Files.createTempDirectory("flutter-version-no-build-number").toFile();
+ File flutterProject = new File(root, "demo");
+ write(new File(flutterProject, "pubspec.yaml"), "name: demo\nversion: 1.0.0\n"
+ + "dependencies:\n flutter:\n sdk: flutter\n");
+ write(new File(flutterProject, "lib/main.dart"), "void main() {}\n");
+
+ File common = new File(root, "common");
+ write(new File(common, "codenameone_settings.properties"), "codename1.version=1.0\n");
+
+ new FlutterProjectImporter(new com.codename1.build.SystemStreamLog())
+ .importProject(flutterProject, common, null, null);
+
+ String settings = read(new File(common, "codenameone_settings.properties"));
+ assertTrue(settings, settings.contains("codename1.version=1.0.0"));
+ assertFalse(settings, settings.contains("android.versionCode"));
+ assertFalse(settings, settings.contains("ios.bundleVersion"));
+ }
+
+ /// Flutter allows an asset or font declared anywhere project-relative, not
+ /// only under `assets/` or `packages/` -- `fonts/MyFont.ttf` is exactly the
+ /// shape a font-only pubspec entry takes. The importer must both copy the
+ /// file (already true) and record the key in ASSET_KEYS_RECORD, which is
+ /// what lets TranscodeFlutterMojo find it outside the two roots it scans.
+ @Test
+ public void recordsDeclaredAssetKeysOutsideTheLegacyRootsForTheMojoToFlatten() throws Exception {
+ File root = Files.createTempDirectory("flutter-declared-asset-keys").toFile();
+ File flutterProject = new File(root, "demo");
+ write(new File(flutterProject, "pubspec.yaml"), "name: demo\nversion: 1.0.0\n"
+ + "dependencies:\n flutter:\n sdk: flutter\n"
+ + "flutter:\n assets:\n - images/logo.png\n"
+ + " fonts:\n - family: Custom\n fonts:\n - asset: fonts/Custom-Regular.ttf\n");
+ write(new File(flutterProject, "lib/main.dart"), "void main() {}\n");
+ write(new File(flutterProject, "images/logo.png"), "PNGDATA");
+ write(new File(flutterProject, "fonts/Custom-Regular.ttf"), "FONTDATA");
+
+ File common = new File(root, "common");
+ new FlutterProjectImporter(new com.codename1.build.SystemStreamLog())
+ .importProject(flutterProject, common, null, null);
+
+ File flutterDir = new File(common, "src/main/flutter");
+ assertTrue(new File(flutterDir, "images/logo.png").isFile());
+ assertTrue(new File(flutterDir, "fonts/Custom-Regular.ttf").isFile());
+
+ File record = new File(flutterDir, FlutterProjectImporter.ASSET_KEYS_RECORD);
+ assertTrue(record.isFile());
+ assertEquals(java.util.Arrays.asList("fonts/Custom-Regular.ttf", "images/logo.png"),
+ Files.readAllLines(record.toPath(), java.nio.charset.StandardCharsets.UTF_8));
+ }
+
+ /// Flutter bundles a single-file asset's resolution-aware siblings --
+ /// `2.0x/logo.png`, `3.0x/logo.png` -- without a separate pubspec entry
+ /// for each. The importer must find and copy them beside the declared
+ /// file, and record each one's own key so `TranscodeFlutterMojo` packages
+ /// it; a sibling directory that is not a `x` variant, or a same-named
+ /// file in a directory belonging to some other asset, must not be swept
+ /// in by mistake.
+ @Test
+ public void copiesResolutionVariantSiblingsOfASingleFileAssetKeyAndRecordsThem() throws Exception {
+ File root = Files.createTempDirectory("flutter-asset-variants").toFile();
+ File flutterProject = new File(root, "demo");
+ write(new File(flutterProject, "pubspec.yaml"), "name: demo\nversion: 1.0.0\n"
+ + "dependencies:\n flutter:\n sdk: flutter\n"
+ + "flutter:\n assets:\n - assets/logo.png\n");
+ write(new File(flutterProject, "lib/main.dart"), "void main() {}\n");
+ write(new File(flutterProject, "assets/logo.png"), "LOGO-1X");
+ write(new File(flutterProject, "assets/2.0x/logo.png"), "LOGO-2X");
+ write(new File(flutterProject, "assets/3.0x/logo.png"), "LOGO-3X");
+ // A same-named file sitting in a directory that is not a "x" variant
+ // name must not be treated as a resolution variant.
+ write(new File(flutterProject, "assets/notaratio/logo.png"), "LOGO-NOT-A-VARIANT");
+ // A different file inside a real variant directory belongs to some
+ // other (undeclared) asset here, not to "logo.png".
+ write(new File(flutterProject, "assets/2.0x/other.png"), "OTHER-2X");
+
+ File common = new File(root, "common");
+ new FlutterProjectImporter(new com.codename1.build.SystemStreamLog())
+ .importProject(flutterProject, common, null, null);
+
+ File flutterDir = new File(common, "src/main/flutter");
+ assertEquals("LOGO-1X", read(new File(flutterDir, "assets/logo.png")));
+ assertEquals("LOGO-2X", read(new File(flutterDir, "assets/2.0x/logo.png")));
+ assertEquals("LOGO-3X", read(new File(flutterDir, "assets/3.0x/logo.png")));
+ assertFalse("a sibling directory that is not a resolution-variant name must be ignored",
+ new File(flutterDir, "assets/notaratio/logo.png").exists());
+ assertFalse("a different file in a variant directory belongs to another asset",
+ new File(flutterDir, "assets/2.0x/other.png").exists());
+
+ File record = new File(flutterDir, FlutterProjectImporter.ASSET_KEYS_RECORD);
+ assertEquals(java.util.Arrays.asList("assets/2.0x/logo.png", "assets/3.0x/logo.png", "assets/logo.png"),
+ Files.readAllLines(record.toPath(), java.nio.charset.StandardCharsets.UTF_8));
+ }
+
+ /// The same bundling rule applies to a package asset resolved through
+ /// `.dart_tool/package_config.json`: the variant lives beside the file in
+ /// the package's own `lib/`, not in the importing app's tree.
+ @Test
+ public void copiesResolutionVariantSiblingsOfAPackageAsset() throws Exception {
+ File root = Files.createTempDirectory("flutter-pkg-asset-variants").toFile();
+ File flutterProject = new File(root, "demo");
+ write(new File(flutterProject, "pubspec.yaml"), "name: demo\nversion: 1.0.0\n"
+ + "dependencies:\n flutter:\n sdk: flutter\n my_pkg: 1.0.0\n"
+ + "flutter:\n assets:\n - packages/my_pkg/logo.png\n");
+ write(new File(flutterProject, "lib/main.dart"), "void main() {}\n");
+
+ File pkgRoot = new File(root, "fake-pub-cache/my_pkg-1.0.0");
+ write(new File(pkgRoot, "lib/logo.png"), "PKG-LOGO-1X");
+ write(new File(pkgRoot, "lib/2.0x/logo.png"), "PKG-LOGO-2X");
+
+ File dartTool = new File(flutterProject, ".dart_tool");
+ write(new File(dartTool, "package_config.json"), "{\n \"configVersion\": 2,\n \"packages\": [\n"
+ + " {\n \"name\": \"my_pkg\",\n \"rootUri\": \"" + pkgRoot.toURI()
+ + "\",\n \"packageUri\": \"lib/\"\n }\n ]\n}\n");
+
+ File common = new File(root, "common");
+ new FlutterProjectImporter(new com.codename1.build.SystemStreamLog())
+ .importProject(flutterProject, common, null, null);
+
+ File flutterDir = new File(common, "src/main/flutter");
+ assertEquals("PKG-LOGO-1X", read(new File(flutterDir, "packages/my_pkg/logo.png")));
+ assertEquals("PKG-LOGO-2X", read(new File(flutterDir, "packages/my_pkg/2.0x/logo.png")));
+
+ File record = new File(flutterDir, FlutterProjectImporter.ASSET_KEYS_RECORD);
+ assertEquals(java.util.Arrays.asList("packages/my_pkg/2.0x/logo.png", "packages/my_pkg/logo.png"),
+ Files.readAllLines(record.toPath(), java.nio.charset.StandardCharsets.UTF_8));
+ }
+
+ /// A variant dropped from the Flutter project (not just the main asset)
+ /// must stop being bundled on the next import, the same way a dropped
+ /// main asset already does.
+ @Test
+ public void reimportRemovesAStaleResolutionVariantNoLongerPresent() throws Exception {
+ File root = Files.createTempDirectory("flutter-reimport-variant").toFile();
+ File flutterProject = new File(root, "demo");
+ write(new File(flutterProject, "pubspec.yaml"), "name: demo\nversion: 1.0.0\n"
+ + "dependencies:\n flutter:\n sdk: flutter\n"
+ + "flutter:\n assets:\n - assets/logo.png\n");
+ write(new File(flutterProject, "lib/main.dart"), "void main() {}\n");
+ write(new File(flutterProject, "assets/logo.png"), "LOGO-1X");
+ File variant = new File(flutterProject, "assets/2.0x/logo.png");
+ write(variant, "LOGO-2X");
+
+ File common = new File(root, "common");
+ FlutterProjectImporter importer = new FlutterProjectImporter(new com.codename1.build.SystemStreamLog());
+ importer.importProject(flutterProject, common, null, null);
+
+ File flutterDir = new File(common, "src/main/flutter");
+ assertTrue(new File(flutterDir, "assets/2.0x/logo.png").isFile());
+
+ assertTrue(variant.delete());
+ importer.importProject(flutterProject, common, null, null);
+
+ assertFalse("the dropped variant must not stay behind",
+ new File(flutterDir, "assets/2.0x/logo.png").exists());
+ File record = new File(flutterDir, FlutterProjectImporter.ASSET_KEYS_RECORD);
+ assertEquals(java.util.Collections.singletonList("assets/logo.png"),
+ Files.readAllLines(record.toPath(), java.nio.charset.StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void aTrailingCommentIsNotPartOfAPubspecValue() {
+ assertEquals("2.10.2+021002", FlutterProjectImporter.Pubspec.unquote("2.10.2+021002 # See README.md"));
+ assertEquals("a#b", FlutterProjectImporter.Pubspec.unquote("a#b"));
+ assertEquals("x # y", FlutterProjectImporter.Pubspec.unquote("\"x # y\" # z"));
+ assertEquals("plain", FlutterProjectImporter.Pubspec.unquote("'plain'"));
+ }
+
+ private static void write(File f, String content) throws IOException {
+ f.getParentFile().mkdirs();
+ Files.write(f.toPath(), content.getBytes("UTF-8"));
+ }
+
+ private static String read(File f) throws IOException {
+ return new String(Files.readAllBytes(f.toPath()), "UTF-8");
+ }
+}
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 ac1531e8c1a..9d8fe0161de 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
@@ -18,10 +18,12 @@
+ source/target. The literal "auto" (default) lets
+ archetype-post-generate.groovy pick 17 when running on a JDK >= 17
+ and 8 otherwise, so a project generated on JDK 8 still compiles there.
+ Flutter support (src/main/flutter) needs 17 (its transpiler emits
+ Java 17 source), and a Flutter project is built on a JDK >= 17, where
+ "auto" already resolves to 17; pass 17 explicitly to force it. -->
auto
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 eba76774886..933a531135a 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
+
+
diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml
index a1ada47b0ab..975ad351539 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/AndroidPomUpdater.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AndroidPomUpdater.java
index 27a576ea4aa..cb989e10860 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AndroidPomUpdater.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AndroidPomUpdater.java
@@ -74,6 +74,14 @@ final class AndroidPomUpdater {
AndroidPomUpdater(String pom, boolean kotlin) {
String p = pom;
if (p.indexOf("codenameone-android-compat") < 0) {
+ // Unlike FlutterPomUpdater, this never does a bare
+ // indexOf("") to splice a dependency into whichever
+ // element happens to come first in the file (which
+ // can be 's own, nested one -- the bug
+ // FlutterPomUpdater had). The whole PROFILE block, including its
+ // own self-contained , is inserted as one unit
+ // right before , so there is no "which "
+ // question to get wrong here.
int profiles = p.lastIndexOf("");
if (profiles < 0) {
manual.add("the codenameone-android-compat dependency (scope provided), e.g. as this profile:\n" + PROFILE);
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/FlutterPomUpdater.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/FlutterPomUpdater.java
new file mode 100644
index 00000000000..cd8a19ce3aa
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/FlutterPomUpdater.java
@@ -0,0 +1,220 @@
+/*
+ * 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.maven;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/// Wires Flutter support into an existing application's common `pom.xml`.
+///
+/// A project generated from the current archetype already has the
+/// `transcode-flutter` execution bound unconditionally (it is a no-op while
+/// `src/main/flutter` does not exist), and the `codenameone-flutter-runtime`
+/// dependency present but **commented out** -- see the archetype's own
+/// `common/pom.xml`. So, unlike [AndroidPomUpdater], there are two different
+/// "not wired yet" shapes to recognize: the archetype's own commented-out
+/// dependency (uncomment it in place) and a pom that predates the feature
+/// entirely, or that had the block deleted (insert it, or the execution,
+/// fresh). Either way the pom is edited as text, at anchors every
+/// archetype-generated common pom has; a pom without them is reported with
+/// the snippets to add by hand rather than guessed at.
+final class FlutterPomUpdater {
+
+ static final String DEPENDENCY = " \n"
+ + " com.codenameone\n"
+ + " codenameone-flutter-runtime\n"
+ + " ${cn1.version}\n"
+ + " \n";
+
+ static final String EXECUTION = " \n"
+ + " \n"
+ + " transcode-flutter\n"
+ + " generate-sources\n"
+ + " \n"
+ + " transcode-flutter\n"
+ + " \n"
+ + " \n";
+
+ private static final String RUNTIME_ARTIFACT = "codenameone-flutter-runtime";
+ private static final String TRANSCODE_GOAL = "transcode-flutter";
+ private static final String PLUGIN = "codenameone-maven-plugin";
+
+ /// Matches the archetype's own commented-out dependency block, capturing
+ /// the dependency itself (group 1) without the `` wrapper
+ /// around it, so uncommenting it is a plain substring replace.
+ private static final Pattern COMMENTED_DEPENDENCY = Pattern.compile(
+ "");
+
+ /// The updated pom, or the same text when it is already wired.
+ final String pom;
+ /// What could not be added automatically, each with the XML to add.
+ final List manual = new ArrayList();
+ boolean changed;
+
+ FlutterPomUpdater(String pom) {
+ String p = pom;
+ if (activeOccurrence(p, RUNTIME_ARTIFACT) < 0) {
+ Matcher commented = COMMENTED_DEPENDENCY.matcher(p);
+ if (commented.find()) {
+ // The archetype ships this dependency commented out with
+ // instructions to uncomment it; do exactly that; strip just
+ // the comment markers so the explanatory text above it stays.
+ p = p.substring(0, commented.start()) + commented.group(1) + p.substring(commented.end());
+ changed = true;
+ } else {
+ // Issue: a plain indexOf("") matches whichever
+ // element comes FIRST in the file -- and a pom
+ // that pins transitive versions commonly declares
+ // (its own nested )
+ // before its real, top-level one. The runtime dependency was
+ // then spliced into dependencyManagement, which only sets a
+ // default version/scope for a dependency declared elsewhere;
+ // it never puts the artifact on the classpath, so Maven could
+ // not find the Flutter runtime at all. Locate the
+ // that is a direct child of instead.
+ int at = topLevelDependenciesInsertionPoint(p);
+ if (at < 0) {
+ manual.add("the codenameone-flutter-runtime dependency:\n" + DEPENDENCY);
+ } else {
+ p = p.substring(0, at) + "\n" + DEPENDENCY.substring(0, DEPENDENCY.length() - 1)
+ + p.substring(at);
+ changed = true;
+ }
+ }
+ }
+ if (activeOccurrence(p, TRANSCODE_GOAL) < 0) {
+ int plugin = p.indexOf(PLUGIN);
+ // Only this plugin's own executions block: a later plugin's would
+ // otherwise receive a goal it does not have.
+ int pluginEnd = plugin < 0 ? -1 : p.indexOf("", plugin);
+ int executions = pluginEnd < 0 ? -1 : p.indexOf("", plugin);
+ if (executions > pluginEnd) {
+ executions = -1;
+ }
+ if (executions < 0) {
+ manual.add("an execution of transcode-flutter in the codenameone-maven-plugin:\n" + EXECUTION);
+ } else {
+ int at = executions + "".length();
+ p = p.substring(0, at) + "\n" + EXECUTION.substring(0, EXECUTION.length() - 1) + p.substring(at);
+ changed = true;
+ }
+ }
+ this.pom = p;
+ }
+
+ /// `needle`'s first position outside an XML comment, or -1. Unlike a
+ /// plain `indexOf`, this is not fooled by the archetype's own
+ /// commented-out dependency block, which contains the exact artifact id
+ /// text while contributing nothing to the build.
+ private static int activeOccurrence(String xml, String needle) {
+ return stripComments(xml).indexOf(needle);
+ }
+
+ private static String stripComments(String xml) {
+ return xml.replaceAll("(?s)", "");
+ }
+
+ /// The index right after the opening tag of the `` element
+ /// whose immediate parent is `` -- i.e. the project's own
+ /// top-level dependency list, never one nested inside
+ /// ``, ``, `` or `` -- or
+ /// -1 when the pom has none. A light XML-aware scan of element nesting
+ /// depth (tags only; attributes, comments, CDATA and PIs are skipped
+ /// without being parsed), kept deliberately small rather than pulling in
+ /// a real XML parser for one lookup.
+ static int topLevelDependenciesInsertionPoint(String xml) {
+ Deque stack = new ArrayDeque();
+ int i = 0;
+ int n = xml.length();
+ while (i < n) {
+ int lt = xml.indexOf('<', i);
+ if (lt < 0) {
+ break;
+ }
+ if (xml.startsWith("", lt + 4);
+ i = end < 0 ? n : end + 3;
+ continue;
+ }
+ if (xml.startsWith("", lt + 9);
+ i = end < 0 ? n : end + 3;
+ continue;
+ }
+ if (xml.startsWith("", lt)) {
+ int end = xml.indexOf("?>", lt + 2);
+ i = end < 0 ? n : end + 2;
+ continue;
+ }
+ if (xml.startsWith("', lt + 2);
+ i = end < 0 ? n : end + 1;
+ continue;
+ }
+ int gt = xml.indexOf('>', lt + 1);
+ if (gt < 0) {
+ break;
+ }
+ String tag = xml.substring(lt + 1, gt);
+ boolean closing = tag.startsWith("/");
+ boolean selfClosing = tag.endsWith("/");
+ if (closing) {
+ String name = tag.substring(1).trim();
+ if (!stack.isEmpty() && stack.peek().equals(name)) {
+ stack.pop();
+ }
+ } else {
+ String body = selfClosing ? tag.substring(0, tag.length() - 1) : tag;
+ String name = tagName(body);
+ if ("dependencies".equals(name) && stack.size() == 1 && "project".equals(stack.peek())) {
+ return gt + 1;
+ }
+ if (!selfClosing) {
+ stack.push(name);
+ }
+ }
+ i = gt + 1;
+ }
+ return -1;
+ }
+
+ /// The element name at the start of an opening tag's body (everything
+ /// between `<`/`` and `>`/`/>`), stopping at the first whitespace so a
+ /// tag carrying attributes (``) is still named
+ /// correctly.
+ private static String tagName(String tagBody) {
+ int end = 0;
+ while (end < tagBody.length() && !Character.isWhitespace(tagBody.charAt(end))) {
+ end++;
+ }
+ return tagBody.substring(0, end);
+ }
+}
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 c6f217739ed..71513db032e 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;
@@ -142,6 +150,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/ImportFlutterProjectMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ImportFlutterProjectMojo.java
new file mode 100644
index 00000000000..015fd04ca2b
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ImportFlutterProjectMojo.java
@@ -0,0 +1,116 @@
+/*
+ * 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.maven;
+
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+import java.io.File;
+
+/// Imports a Flutter project into this Codename One application.
+///
+/// ```
+/// mvn cn1:import-flutter-project -Dcn1.flutter.import=/path/to/flutter_app
+/// ```
+///
+/// Copies the project's `lib` tree into `common/src/main/flutter`, where
+/// `cn1:transcode-flutter` transpiles it at build time, copies the assets and
+/// fonts its `pubspec.yaml` declares, makes the Codename One main class start
+/// the Dart `main()`, and lists the project's pub dependencies the Flutter
+/// runtime's stubs do and do not cover. Build and run as usual afterwards --
+/// no Dart SDK is needed, but the build must run on JDK 17+.
+@Mojo(name = "import-flutter-project", requiresProject = true, aggregator = true)
+public class ImportFlutterProjectMojo extends AbstractCN1Mojo {
+
+ @Parameter(property = "cn1.flutter.import", required = true)
+ private File source;
+
+ @Override
+ protected void executeImpl() throws MojoExecutionException, MojoFailureException {
+ File common = getCN1ProjectDir();
+ if (common == null) {
+ throw new MojoExecutionException("Run this from a Codename One application project");
+ }
+ wireFlutterRuntime(common);
+ String pkg = properties == null ? null : properties.getProperty("codename1.packageName");
+ String main = properties == null ? null : properties.getProperty("codename1.mainName");
+ FlutterProjectImporter.Result r;
+ try {
+ r = new FlutterProjectImporter(MavenLog.of(getLog())).importProject(source, common, pkg, main);
+ } catch (com.codename1.builders.BuildException e) {
+ throw new MojoFailureException(e.getMessage(), e);
+ }
+ getLog().info("Imported " + r.copiedFiles + " Dart file(s) and " + r.copiedAssets + " asset(s)/font(s) into "
+ + new File(common, "src/main/flutter"));
+ if (r.appName != null) {
+ getLog().info("pubspec.yaml: " + r.appName + (r.appVersion != null ? " " + r.appVersion : ""));
+ }
+ for (String c : r.covered) {
+ getLog().info(" supported: " + c);
+ }
+ for (String u : r.uncovered) {
+ getLog().warn(" NOT supported by the Flutter runtime: " + u);
+ }
+ for (String a : r.missingAssets) {
+ getLog().warn(" asset declared in pubspec.yaml but not found: " + a);
+ }
+ if (!r.unresolvedPackages.isEmpty()) {
+ // Each one was already warned individually, with the remedy, as it was hit.
+ getLog().warn(" " + r.unresolvedPackages.size() + " pub package(s) could not be resolved: "
+ + r.unresolvedPackages);
+ }
+ }
+
+ /// A project generated before Flutter support existed, or whose archetype
+ /// pom still has the runtime dependency commented out, has no working
+ /// wiring for the imported sources. Adds it to the common pom, or stops
+ /// before copying anything, naming what to add.
+ private void wireFlutterRuntime(File common) throws MojoExecutionException, MojoFailureException {
+ File pomFile = new File(common, "pom.xml");
+ String pom;
+ try {
+ pom = new String(java.nio.file.Files.readAllBytes(pomFile.toPath()), "UTF-8");
+ } catch (java.io.IOException e) {
+ throw new MojoExecutionException("Cannot read " + pomFile, e);
+ }
+ FlutterPomUpdater u = new FlutterPomUpdater(pom);
+ if (!u.manual.isEmpty()) {
+ StringBuilder sb = new StringBuilder("Nothing was imported: " + pomFile
+ + " needs the Flutter runtime wired in by hand. Add:\n");
+ for (String m : u.manual) {
+ sb.append("\n- ").append(m);
+ }
+ throw new MojoFailureException(sb.toString());
+ }
+ if (u.changed) {
+ try {
+ java.nio.file.Files.write(pomFile.toPath(), u.pom.getBytes("UTF-8"));
+ } catch (java.io.IOException e) {
+ throw new MojoExecutionException("Cannot write " + pomFile, e);
+ }
+ getLog().info("Wired the Flutter runtime into " + pomFile);
+ }
+ }
+}
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 d358da09411..df24c108fe7 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
@@ -217,6 +217,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");
@@ -227,6 +251,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..d127c695651
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeFlutterMojo.java
@@ -0,0 +1,447 @@
+/*
+ * 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
+ *
Any other pubspec {@code assets:}/{@code fonts:} key --
+ * {@code fonts/MyFont.ttf}, {@code images/logo.png} -- is flattened the
+ * same way, but only when {@code cn1:import-flutter-project} recorded it
+ * declared (see {@code FlutterProjectImporter.ASSET_KEYS_RECORD}); a
+ * hand-wired tree with no such record relies on the two roots above
+ * alone
+ *
+ *
+ *
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);
+ }
+ // Flutter allows an asset or font declared anywhere project-relative --
+ // fonts/MyFont.ttf, images/logo.png -- not only under assets/ or
+ // packages/, so the scan above missed every one of those: the key
+ // resolved to nothing on every port. cn1:import-flutter-project records
+ // exactly which keys it copied, in FlutterProjectImporter.ASSET_KEYS_RECORD;
+ // a hand-wired project (no import, so no such record) is unaffected --
+ // it keeps relying on the scan above alone, as before.
+ for (String key : readDeclaredAssetKeys()) {
+ if (ASSET_ROOTS_SET.contains(topSegment(key))) {
+ continue; // already copied by the scan above
+ }
+ Files.createDirectories(outDir.toPath());
+ count += flattenDeclaredAsset(key, 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);
+ }
+
+ /// The `FlutterProjectImporter.ASSET_KEYS_RECORD` keys a previous
+ /// `cn1:import-flutter-project` wrote beside `src/main/flutter`, or
+ /// empty when the project was hand-wired (never imported) or predates
+ /// the record. `FlutterProjectImporter` is package-private but lives in
+ /// `build-engine`, which this plugin already depends on and already
+ /// invokes directly (`ImportFlutterProjectMojo`) -- so its record
+ /// filename is read from that one constant rather than duplicated here.
+ private List readDeclaredAssetKeys() throws IOException {
+ File f = new File(flutterSourceDir, FlutterProjectImporter.ASSET_KEYS_RECORD);
+ if (!f.isFile()) {
+ return java.util.Collections.emptyList();
+ }
+ List out = new ArrayList();
+ for (String line : Files.readAllLines(f.toPath(), java.nio.charset.StandardCharsets.UTF_8)) {
+ line = line.trim();
+ if (line.length() > 0) {
+ out.add(line);
+ }
+ }
+ return out;
+ }
+
+ private static String topSegment(String key) {
+ int slash = key.indexOf('/');
+ return slash < 0 ? key : key.substring(0, slash);
+ }
+
+ /// Flattens one declared asset key outside {@link #ASSET_ROOTS}: a file,
+ /// or a directory (pubspec allows declaring a whole directory), rooted
+ /// at {@code flutterSourceDir}. Missing on disk is not an error here --
+ /// {@code FlutterProjectImporter} already warned about that key when it
+ /// could not resolve it.
+ private int flattenDeclaredAsset(String key, File outDir, Set written, List keys)
+ throws IOException {
+ File node = new File(flutterSourceDir, key.replace('/', File.separatorChar));
+ if (node.isDirectory()) {
+ return flattenInto(node, key, outDir, written, keys);
+ }
+ if (!node.isFile()) {
+ return 0;
+ }
+ String flat = flatAssetName(key);
+ Files.copy(node.toPath(), new File(outDir, flat).toPath(), StandardCopyOption.REPLACE_EXISTING);
+ written.add(flat);
+ keys.add(key);
+ return 1;
+ }
+
+ /**
+ * 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"};
+ private static final Set ASSET_ROOTS_SET =
+ new HashSet(java.util.Arrays.asList(ASSET_ROOTS));
+
+ /**
+ * 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.
+ *
+ *
The name is outside the namespace {@link #flatAssetName} can produce: the
+ * encoder writes every {@code '_'} of a key as {@code "__"} and every {@code '/'}
+ * as {@code "_s"}, so after the {@code cn1f_} prefix a single underscore is always
+ * followed by another underscore or an {@code s}. {@code "cn1f__A..."} -- an
+ * underscore followed by {@code A} -- is therefore no asset's flat name. The
+ * plain {@code cn1f_AssetManifest.txt} used to be, once {@link #readDeclaredAssetKeys}
+ * began flattening project-relative keys outside {@code assets/} and
+ * {@code packages/}: a declared {@code AssetManifest.txt} flattened to it and
+ * {@link #writeAssetManifest} overwrote the developer's asset. The runtime reads
+ * the same literal ({@code FlutterAssets.MANIFEST}); the two must change
+ * together.
+ */
+ 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/FlutterPomUpdaterTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/FlutterPomUpdaterTest.java
new file mode 100644
index 00000000000..cdbaadb7200
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/FlutterPomUpdaterTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.maven;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.nio.file.Files;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/// cn1:import-flutter-project in a project generated by the current
+/// archetype, which already carries the `transcode-flutter` execution active
+/// but the runtime dependency commented out, and in one that predates the
+/// feature entirely.
+public class FlutterPomUpdaterTest {
+
+ private static String read(String path) throws Exception {
+ return new String(Files.readAllBytes(new File(path).toPath()), "UTF-8");
+ }
+
+ @Test
+ public void uncommentsTheCurrentArchetypesDependency() throws Exception {
+ String current = read("../cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml");
+ assertTrue(current.contains("codenameone-flutter-runtime"),
+ "the archetype must still ship the commented-out dependency this test targets");
+ FlutterPomUpdater u = new FlutterPomUpdater(current);
+ assertTrue(u.manual.isEmpty(), u.manual.toString());
+ assertTrue(u.changed);
+ assertFalse(u.pom.replaceAll("(?s)", "").indexOf("codenameone-flutter-runtime") < 0,
+ "the dependency must be active, not still commented out");
+ assertTrue(u.pom.contains("transcode-flutter"));
+
+ // Wired once: a second import changes nothing further.
+ FlutterPomUpdater again = new FlutterPomUpdater(u.pom);
+ assertFalse(again.changed);
+ assertTrue(again.manual.isEmpty());
+ assertEquals(u.pom, again.pom);
+ }
+
+ @Test
+ public void wiresALegacyPomWithNeitherAnchorPresent() {
+ String legacy = "\n"
+ + " \n"
+ + " com.codenameone\n"
+ + " codenameone-maven-plugin\n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + "\n"
+ + "\n"
+ + "\n"
+ + "\n";
+ FlutterPomUpdater u = new FlutterPomUpdater(legacy);
+ assertTrue(u.manual.isEmpty(), u.manual.toString());
+ assertTrue(u.changed);
+ assertTrue(u.pom.contains("codenameone-flutter-runtime"));
+ assertTrue(u.pom.contains("transcode-flutter"));
+
+ FlutterPomUpdater again = new FlutterPomUpdater(u.pom);
+ assertFalse(again.changed);
+ assertTrue(again.manual.isEmpty());
+ }
+
+ @Test
+ public void neverWiresTheGoalIntoALaterPlugin() {
+ String pom = "\n"
+ + " \n"
+ + " com.codenameone\n"
+ + " codenameone-maven-plugin\n"
+ + " \n"
+ + " \n"
+ + " maven-antrun-plugin\n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + "\n";
+ FlutterPomUpdater u = new FlutterPomUpdater(pom);
+ assertFalse(u.pom.contains("transcode-flutter"), u.pom);
+ boolean named = false;
+ for (String m : u.manual) {
+ named |= m.contains("transcode-flutter");
+ }
+ assertTrue(named, u.manual.toString());
+ }
+
+ @Test
+ public void namesWhatAPomWithoutTheAnchorsNeeds() {
+ FlutterPomUpdater u = new FlutterPomUpdater("");
+ assertFalse(u.changed);
+ assertEquals(2, u.manual.size(), u.manual.toString());
+ }
+
+ /// A pom that pins transitive versions declares
+ /// (with its own nested ) before its real, top-level
+ /// -- legal, and common. The runtime dependency must land
+ /// in the top-level one: a plain indexOf("") matches
+ /// whichever comes first in the file, which spliced it into
+ /// dependencyManagement, where Maven never adds it to the classpath.
+ @Test
+ public void insertsIntoTopLevelDependenciesNotDependencyManagement() {
+ String pom = "\n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " other\n"
+ + " other-artifact\n"
+ + " 1.0\n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " com.codenameone\n"
+ + " codenameone-maven-plugin\n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + " \n"
+ + "\n";
+ FlutterPomUpdater u = new FlutterPomUpdater(pom);
+ assertTrue(u.manual.isEmpty(), u.manual.toString());
+ assertTrue(u.changed);
+
+ int dependencyManagementEnd = u.pom.indexOf("");
+ int runtimeDependency = u.pom.indexOf("codenameone-flutter-runtime");
+ assertTrue(dependencyManagementEnd > 0 && runtimeDependency > 0, u.pom);
+ assertTrue(runtimeDependency > dependencyManagementEnd,
+ "the runtime dependency must land in the top-level , "
+ + "after closes, not inside it:\n" + u.pom);
+
+ String managementBlock = u.pom.substring(u.pom.indexOf(""), dependencyManagementEnd);
+ assertFalse(managementBlock.contains("codenameone-flutter-runtime"),
+ "dependencyManagement must be left untouched:\n" + managementBlock);
+ assertTrue(managementBlock.contains("other-artifact"));
+ }
+}
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..238b880a624
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/TranscodeFlutterAssetsTest.java
@@ -0,0 +1,248 @@
+/*
+ * 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());
+ }
+
+ /**
+ * Flutter allows an asset or font declared anywhere project-relative, not
+ * only under {@code assets/} or {@code packages/} -- {@code fonts/MyFont.ttf}
+ * is exactly the shape a font-only pubspec entry takes. Only
+ * {@code cn1:import-flutter-project} ever writes
+ * {@code FlutterProjectImporter.ASSET_KEYS_RECORD}: a hand-wired tree with no
+ * such record must keep relying on the two legacy roots alone, unchanged.
+ */
+ @Test
+ public void declaredAssetKeysOutsideTheLegacyRootsAreFlattenedToo() throws Exception {
+ File fontFile = new File(flutter, "fonts/MyFont.ttf");
+ fontFile.getParentFile().mkdirs();
+ Files.write(fontFile.toPath(), new byte[] {9});
+ declareAssetKeys("fonts/MyFont.ttf");
+
+ mojo.copyAssets();
+
+ assertTrue("a declared key outside assets/+packages must still be flattened",
+ flat("fonts/MyFont.ttf").isFile());
+ }
+
+ @Test
+ public void aDeclaredDirectoryKeyIsFlattenedRecursively() throws Exception {
+ asset("images/logo.png");
+ asset("images/2x/logo.png");
+ declareAssetKeys("images");
+
+ mojo.copyAssets();
+
+ assertTrue(flat("images/logo.png").isFile());
+ assertTrue(flat("images/2x/logo.png").isFile());
+ }
+
+ @Test
+ public void handWiredProjectsWithNoDeclaredAssetKeysRecordAreUnaffected() throws Exception {
+ // No ASSET_KEYS_RECORD file at all -- never imported -- so a file outside
+ // assets/+packages must stay invisible to the mojo, exactly as before.
+ asset("fonts/MyFont.ttf");
+
+ mojo.copyAssets();
+
+ assertFalse(flat("fonts/MyFont.ttf").exists());
+ }
+
+ private void declareAssetKeys(String... keys) throws Exception {
+ File record = new File(flutter, FlutterProjectImporter.ASSET_KEYS_RECORD);
+ StringBuilder b = new StringBuilder();
+ for (String k : keys) {
+ b.append(k).append('\n');
+ }
+ Files.write(record.toPath(), b.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ }
+
+ /**
+ * A root-declared asset (outside {@code assets/}/{@code packages/}) named exactly
+ * {@code AssetManifest.txt} used to flatten onto the generated manifest's name, and
+ * {@code writeAssetManifest} then overwrote it, so {@code Image.asset("AssetManifest.txt")}
+ * loaded the key list. The manifest's name is now outside the flattening namespace:
+ * both files are written, each with its own content.
+ */
+ @Test
+ public void aRootAssetNamedLikeTheManifestIsKeptBesideTheManifest() throws Exception {
+ File colliding = new File(flutter, "AssetManifest.txt");
+ Files.write(colliding.toPath(), "this is the developer's own asset".getBytes(
+ java.nio.charset.StandardCharsets.UTF_8));
+ declareAssetKeys("AssetManifest.txt");
+
+ mojo.copyAssets();
+
+ File asset = new File(classes, TranscodeFlutterMojo.flatAssetName("AssetManifest.txt"));
+ assertEquals("this is the developer's own asset", new String(Files.readAllBytes(asset.toPath()),
+ java.nio.charset.StandardCharsets.UTF_8));
+ File manifestFile = new File(classes, TranscodeFlutterMojo.ASSET_MANIFEST);
+ assertTrue("the generated manifest is written under its own name", manifestFile.isFile());
+ assertFalse(asset.getName().equals(manifestFile.getName()));
+ }
+
+ /**
+ * 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/io/NetworkTracerQueueTest.java b/maven/core-unittests/src/test/java/com/codename1/io/NetworkTracerQueueTest.java
index 0c6452795c2..e716505c345 100644
--- a/maven/core-unittests/src/test/java/com/codename1/io/NetworkTracerQueueTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/io/NetworkTracerQueueTest.java
@@ -192,6 +192,10 @@ protected void readResponse(java.io.InputStream input) {
request.setUrl("http://queue.test/done");
request.setPost(false);
NetworkManager.getInstance().addToQueueAndWait(request);
+ // The clear is queued on the EDT behind the request's listener callbacks
+ // (a listener may still retry()), so addToQueueAndWait can return before it
+ // has run. Without this flush the assertions raced it.
+ flushSerialCalls();
assertEquals(null, request.tracerParent);
assertEquals(null, request.tracerParentOwner);
assertEquals(null, request.tracerLastAttempt);
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 19f9cc2908a..83eded99e0a 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
@@ -249,4 +249,89 @@ public String getShortMonthName(Date date) {
return "Long";
}
}
+
+ @Test
+ void zonesSharingAnIdButNotTheirRulesAreNotEqual() {
+ SimpleDateFormat a = new SimpleDateFormat("HH:mm");
+ a.setTimeZone(new java.util.SimpleTimeZone(0, "Custom"));
+ SimpleDateFormat b = new SimpleDateFormat("HH:mm");
+ b.setTimeZone(new java.util.SimpleTimeZone(3600000, "Custom"));
+ assertNotEquals(a, b, "same ID, different raw offset");
+ SimpleDateFormat c = new SimpleDateFormat("HH:mm");
+ c.setTimeZone(new java.util.SimpleTimeZone(0, "Custom"));
+ assertEquals(a, c);
+ assertEquals(a.hashCode(), c.hashCode());
+ }
+
+ @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);
+ }
+ }
+
+ @Test
+ void equalityCountsTheConfiguredZone() {
+ SimpleDateFormat utc = new SimpleDateFormat("yyyy-MM-dd HH:mm");
+ utc.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
+ SimpleDateFormat tokyo = new SimpleDateFormat("yyyy-MM-dd HH:mm");
+ tokyo.setTimeZone(java.util.TimeZone.getTimeZone("Asia/Tokyo"));
+ assertNotEquals(utc, tokyo, "they format one instant differently, so they are not equal");
+ SimpleDateFormat utc2 = new SimpleDateFormat("yyyy-MM-dd HH:mm");
+ utc2.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
+ assertEquals(utc, utc2);
+ assertEquals(utc.hashCode(), utc2.hashCode());
+ java.util.TimeZone saved = java.util.TimeZone.getDefault();
+ java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("Asia/Tokyo"));
+ try {
+ SimpleDateFormat device = new SimpleDateFormat("yyyy-MM-dd HH:mm");
+ assertEquals(tokyo, device, "the device zone is the zone a formatter without one uses");
+ assertEquals(tokyo.hashCode(), device.hashCode());
+ assertNotEquals(utc, device);
+ } 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 1127d4dc071..46572e36345 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
@@ -1653,6 +1653,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);
@@ -2756,17 +2766,32 @@ public int getClipHeight(Object graphics) {
@Override
public void setClip(Object graphics, Shape shape) {
lastClipShape = shape;
+ TestGraphics g = (TestGraphics) graphics;
if (shape == null) {
- setClip(graphics, 0, 0, getDisplayWidth(), getDisplayHeight());
+ g.clipShape = null;
+ g.clipX = 0;
+ g.clipY = 0;
+ g.clipWidth = getDisplayWidth();
+ g.clipHeight = getDisplayHeight();
return;
}
+ // Track the shape itself (not just its bounds) so pushClip/popClip
+ // can round-trip a nonrectangular clip exactly, the way a real
+ // shape-clip-capable port does. A plain rectangular clip operation
+ // (setClip(int...) / clipRect) abandons this -- it is only ever
+ // brought back by popClip restoring a pushed state.
+ g.clipShape = shape;
Rectangle bounds = shape.getBounds();
- setClip(graphics, bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight());
+ g.clipX = bounds.getX();
+ g.clipY = bounds.getY();
+ g.clipWidth = bounds.getWidth();
+ g.clipHeight = bounds.getHeight();
}
@Override
public void setClip(Object graphics, int x, int y, int width, int height) {
TestGraphics g = (TestGraphics) graphics;
+ g.clipShape = null;
g.clipX = x;
g.clipY = y;
g.clipWidth = width;
@@ -2776,6 +2801,7 @@ public void setClip(Object graphics, int x, int y, int width, int height) {
@Override
public void clipRect(Object graphics, int x, int y, int width, int height) {
TestGraphics g = (TestGraphics) graphics;
+ g.clipShape = null;
int newX = Math.max(g.clipX, x);
int newY = Math.max(g.clipY, y);
int newW = Math.max(0, Math.min(g.clipX + g.clipWidth, x + width) - newX);
@@ -2786,6 +2812,34 @@ public void clipRect(Object graphics, int x, int y, int width, int height) {
g.clipHeight = newH;
}
+ @Override
+ public void pushClip(Object graphics) {
+ TestGraphics g = (TestGraphics) graphics;
+ g.clipStack.push(new TestClipState(g.clipX, g.clipY, g.clipWidth, g.clipHeight, g.clipShape));
+ }
+
+ @Override
+ public void popClip(Object graphics) {
+ TestGraphics g = (TestGraphics) graphics;
+ if (g.clipStack.isEmpty()) {
+ return;
+ }
+ TestClipState state = g.clipStack.pop();
+ g.clipX = state.x;
+ g.clipY = state.y;
+ g.clipWidth = state.width;
+ g.clipHeight = state.height;
+ g.clipShape = state.shape;
+ }
+
+ /// The current effective clip shape for `graphics`, as tracked by
+ /// `#pushClip`/`#popClip`: non-null only when the last operation to
+ /// touch the clip was `#setClip(Object, Shape)` (directly, or restored
+ /// by `popClip`); a plain `setClip(int...)` or `clipRect` abandons it.
+ public Shape getClipShapeState(Object graphics) {
+ return ((TestGraphics) graphics).clipShape;
+ }
+
@Override
public boolean isShapeSupported(Object nativeGraphics) {
return shapeSupported;
@@ -5289,6 +5343,24 @@ public String toString() {
}
}
+ /// One clip state saved by `#pushClip(Object)`, restored by
+ /// `#popClip(Object)`.
+ private static final class TestClipState {
+ final int x;
+ final int y;
+ final int width;
+ final int height;
+ final Shape shape;
+
+ TestClipState(int x, int y, int width, int height, Shape shape) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ this.shape = shape;
+ }
+ }
+
public static final class TestGraphics {
int color = 0x000000;
int alpha = 0xff;
@@ -5296,6 +5368,8 @@ public static final class TestGraphics {
int clipY;
int clipWidth;
int clipHeight;
+ Shape clipShape;
+ final Deque clipStack = new ArrayDeque();
int translateX;
int translateY;
final TestTransform transform = new TestTransform();
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/GraphicsTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/GraphicsTest.java
index c5443ddf9a1..cf81929658f 100644
--- a/maven/core-unittests/src/test/java/com/codename1/ui/GraphicsTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/GraphicsTest.java
@@ -1,7 +1,31 @@
+/*
+ * 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;
import com.codename1.junit.FormTest;
import com.codename1.junit.UITestBase;
+import com.codename1.ui.geom.GeneralPath;
import com.codename1.ui.geom.Rectangle;
import com.codename1.ui.geom.Rectangle2D;
import com.codename1.ui.geom.Shape;
@@ -167,6 +191,31 @@ void testClipRectShrinksExistingClip() {
assertEquals(5, implementation.getClipHeight(nativeGraphics));
}
+ @Test
+ void testDrawImageRegionRoundedFallbackPreservesNonRectangularClip() throws Exception {
+ // isRoundedImageDrawSupported() is false by default in the test
+ // implementation, so drawImageRegionRounded always takes its square
+ // "clip to the destination" fallback. A nonrectangular clip (an
+ // L-shaped path, the kind a caller sets up for a rounded-corner
+ // paint) must survive that fallback unchanged.
+ GeneralPath clipShape = new GeneralPath();
+ clipShape.moveTo(0, 0);
+ clipShape.lineTo(20, 0);
+ clipShape.lineTo(20, 20);
+ clipShape.lineTo(10, 20);
+ clipShape.lineTo(10, 10);
+ clipShape.lineTo(0, 10);
+ clipShape.closePath();
+ graphics.setClip(clipShape);
+ assertSame(clipShape, implementation.getClipShapeState(nativeGraphics));
+
+ Image img = Image.createImage(8, 8);
+ graphics.drawImageRegionRounded(img, 0, 0, 8, 8, 2, 2, 10, 10, 4f);
+
+ assertSame(clipShape, implementation.getClipShapeState(nativeGraphics),
+ "fallback must restore the exact clip (pushClip/popClip), not just its bounding rectangle");
+ }
+
@Test
void testDrawShapeDelegatesWhenSupported() {
implementation.setShapeSupported(true);
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