diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index ca08aa4fe2e..0b0dc941d59 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -14,8 +14,10 @@ on: # sample itself is compiled and tested by wayline.yml, so the demo build # and the figure render have nothing to add for it. - 'scripts/wayline/**' - # The Savor chapters, the same way; savor.yml compiles and tests the sample. + # The Savor and Parley chapters, the same way; savor.yml and parley.yml + # compile and test the samples. - 'scripts/savor/**' + - 'scripts/parley/**' # The API-name check derives the published pages from the javadoc's own # sources, and the guide links into the java.* compatibility classes -- # java/util/List.html among them. A change that renames or deletes one @@ -127,10 +129,11 @@ jobs: - 'Ports/JavaSE/**' - 'maven/core/**' - 'maven/javase/**' - # The Wayline and Savor chapters include these trees by tag, so a + # The Wayline, Savor and Parley chapters include these trees by tag, so a # renamed or deleted tag there has to reach the snippet check. - 'scripts/wayline/**' - 'scripts/savor/**' + - 'scripts/parley/**' workflow: - '.github/workflows/developer-guide-docs.yml' diff --git a/.github/workflows/parley.yml b/.github/workflows/parley.yml new file mode 100644 index 00000000000..7d0e35871b8 --- /dev/null +++ b/.github/workflows/parley.yml @@ -0,0 +1,573 @@ +name: Parley (full-stack sample) + +# scripts/parley is one Maven project holding an application and its server: +# a `shared` module of transfer objects and @RestClient contracts, the +# Codename One client that calls them, and a Codename One Backend that +# implements them. It is also what the initializr offers as a template. +# +# Six things are checked here, because each fails without the others noticing: +# +# backend the server's own tests, on the JVM and compiled by ParparVM +# client-e2e the application in the simulator, driven through its real +# screens against a running server +# web the browser build of the application, hosted by the server +# (cn1:backend-webapp) and driven in a headless browser: it +# loads, signs in and reaches the home screen +# template the Initializr's template is this project (template-sync), and +# a project generated from it under another name builds: its +# server's tests pass and its app compiles (a step of client-e2e, +# which has the artifacts installed) +# android the Android Gradle project builds +# ios the Xcode project generates and builds for the simulator +# +# The project has the minimal layout, with no platform modules: common builds +# the simulator, the desktop, Android, iOS and the browser, and +# build-android-app.sh and build-ios-app.sh find the device projects there. +# +# The two device builds stop at a build: the screens are covered by the +# simulator run, and neither port holds screenshot baselines for this sample. + +on: + pull_request: + paths: + - 'scripts/parley/**' + - 'vm/backend/src/**' + - 'vm/backend/impl/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + - 'maven/build-engine/src/main/java/com/codename1/maven/**' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + # What the messenger is built on: key agreement, calls and their media. + - 'CodenameOne/src/com/codename1/security/**' + - 'CodenameOne/src/com/codename1/call/**' + - 'CodenameOne/src/com/codename1/webrtc/**' + - 'Ports/JavaSE/src/com/codename1/impl/javase/webrtc/**' + - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/rest/**' + # The REST client maps its responses through the mapper registry. + - 'CodenameOne/src/com/codename1/mapping/**' + # The ORM validates every mapping against its migration when the + # server starts, and the backend-database job is where that runs + # against a real database. + - 'CodenameOne/src/com/codename1/impl/orm/**' + - 'CodenameOne/src/com/codename1/orm/**' + - 'scripts/ci/sample-server-tests-on-database.sh' + # What the hosted web app runs on: the JavaScript port, both the + # browser side and the Java the app is compiled with (its network + # connection, its implementation class). The `web` job is the only + # test here that signs in through either. + - 'Ports/JavaScriptPort/src/main/**' + - 'scripts/build-android-app.sh' + - 'scripts/build-ios-app.sh' + - 'scripts/sync-initializr-template.py' + - 'scripts/tests/generate-initializr-fixtures.py' + - 'scripts/tests/GenerateInitializr.java' + # The generator and the template. + - 'scripts/initializr/common/src/**' + - '.github/workflows/parley.yml' + push: + branches: [ master ] + paths: + - 'scripts/parley/**' + - 'vm/backend/src/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + - 'scripts/sync-initializr-template.py' + - 'scripts/initializr/common/src/**' + - '.github/workflows/parley.yml' + workflow_dispatch: + +concurrency: + # Only the latest revision needs validation. PR numbers keep forks isolated; + # event names keep a manual run from cancelling a push or PR run. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + # Required to pull the pr-ci-container image from ghcr.io. + packages: read + +jobs: + template-sync: + name: Initializr template matches the sample + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + # The template's resources are derived from scripts/parley and committed. + # A change to one without the other is a template that is not the project + # this workflow tests. + - name: Compare the template with scripts/parley + run: python3 scripts/sync-initializr-template.py parley + + backend: + name: Server tests (JVM and compiled) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - name: Install native build tools + run: | + bash scripts/ci/apt-get-update.sh + # A server binary links libcurl, OpenSSL and nghttp2; clang alone + # compiles the tests and then fails at the link. + sudo apt-get install -y clang zlib1g-dev libcurl4-openssl-dev libssl-dev libnghttp2-dev + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + # The quality gates run in pr.yml; here they would only slow an install + # whose purpose is to put this checkout's artifacts in the repository. + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on the JVM and compiled + working-directory: scripts/parley + run: | + mvn -B -Dcodename1.platform=backend -pl shared,backend test \ + -Dcn1.backend.compiledTests=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + # A goal that skipped leaves the job green, so the reports are what prove + # that both runners ran. + - name: Check that both runners reported + working-directory: scripts/parley/backend/target/surefire-reports + run: | + jvm=$(find . -maxdepth 1 -name 'TEST-*.xml' ! -name '*-compiled.xml' | wc -l) + compiled=$(find . -maxdepth 1 -name 'TEST-*-compiled.xml' | wc -l) + echo "JVM reports: $jvm, compiled reports: $compiled" + if [ "$jvm" -eq 0 ] || [ "$compiled" -eq 0 ]; then + echo "::error::a runner produced no test reports" + exit 1 + fi + + - name: Upload the test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: parley-backend-tests + path: scripts/parley/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + client-e2e: + name: Application against a running server (simulator) + runs-on: ubuntu-24.04 + container: ghcr.io/codenameone/codenameone/pr-ci-container:latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Install Codename One artifacts (core / javase / css-compiler / plugin / backend) + # Retried only for a failure that is the network's: a compilation + # error has to fail on the first attempt. + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + export JAVA_HOME="${JAVA_HOME_8}" + export PATH="${JAVA_HOME_8}/bin:$PATH" + cd maven + bash ../scripts/ci/retry.sh mvn -B \ + -pl core,javase,css-compiler,codenameone-maven-plugin,backend,backend-test -am install \ + -Plocal-dev-javase -DskipTests -Dmaven.javadoc.skip=true \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true + + - name: Ensure a virtual display (xvfb) for the simulator + run: | + if ! command -v xvfb-run >/dev/null 2>&1; then + (timeout 300 apt-get update && timeout 300 apt-get install -y xvfb) || true + fi + + # Ahead of the simulator run: it is quick, and a template that does not + # build should not wait behind the screens to say so. + - name: Build a project generated from the Initializr template + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/parley/check-template.sh "$RUNNER_TEMP/parley-template" + + # The Initializr's own tests, which cover this template too, run in + # wayline.yml: a change here reaches the generator's resources, which + # trigger it. + + - name: Run the application against the server + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/parley/run-e2e.sh + + - name: Upload the captures and the server log + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: parley-e2e + path: | + scripts/parley/target/e2e-shots/** + scripts/parley/target/e2e-server.log + if-no-files-found: warn + retention-days: 14 + + backend-database: + name: Server tests on ${{ matrix.database }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + # The backend job runs on in-memory SQLite, which accepts any declared + # column type for any value and has no collations to get wrong. PostgreSQL + # and MySQL are what a deployment runs on, and where the ORM's schema + # validation refuses a mapping that disagrees with its migration. + strategy: + fail-fast: false + matrix: + database: [postgresql, mysql] + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: cn1 + POSTGRES_PASSWORD: secret + POSTGRES_DB: cn1 + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cn1" --health-interval 5s + --health-timeout 5s --health-retries 20 + steps: + - uses: actions/checkout@v6 + + # The runner's own MySQL 8.0 rather than a service container: a service + # cannot be given server options, and MySQL 8.4 (mysql:8) turns + # mysql_native_password off. The backend does caching_sha2_password's + # full exchange only over verified TLS, which a throwaway server has no + # certificate for. + - name: Start MySQL + if: matrix.database == 'mysql' + run: | + sudo systemctl start mysql.service + sudo mysql -uroot -proot -e "CREATE USER 'cn1'@'%' IDENTIFIED WITH mysql_native_password BY 'secret'; GRANT ALL ON *.* TO 'cn1'@'%'; CREATE DATABASE cn1;" + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on ${{ matrix.database }} + run: | + if [ "${{ matrix.database }}" = mysql ]; then + export CN1_TEST_DATABASE_URL="mysql://cn1:secret@127.0.0.1:3306/cn1?sslmode=disable" + else + export CN1_TEST_DATABASE_URL="postgres://cn1:secret@localhost:5432/cn1?sslmode=disable" + fi + scripts/ci/sample-server-tests-on-database.sh scripts/parley \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + + - name: Upload the test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: savor-backend-${{ matrix.database }}-tests + path: scripts/parley/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + web: + name: Web app hosted by the server (headless browser) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up Node 20 + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('scripts/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright-chromium- + + - name: Install Playwright Chromium + run: | + cd scripts + npm ci + # Playwright shells out to apt for the OS-level packages, so the + # runner image's third-party sources are pruned first. + bash "$GITHUB_WORKSPACE/scripts/ci/apt-get-update.sh" + npx playwright install-deps chromium + npx playwright install chromium + + # The JavaScript build is the plugin's own: the port and the translator + # travel inside it, so installing the plugin with what it is built from + # is all the browser build needs. + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B \ + -pl core,javase,css-compiler,codenameone-maven-plugin,backend,backend-test \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + # Builds the server and the browser build of the app, starts the server + # with the app staged where it serves it from, and signs in through the + # real screens. + - name: Run the hosted web app in a headless browser + run: scripts/parley/run-web-e2e.sh + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + - name: Upload the screenshots and the logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: parley-web-e2e + path: scripts/parley/target/web-e2e/** + if-no-files-found: warn + retention-days: 14 + + android: + name: Android build + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CN1_APP_DIR: scripts/parley + steps: + - uses: actions/checkout@v6 + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-parley-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2-parley- + ${{ runner.os }}-m2- + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-parley-${{ hashFiles('Ports/Android/build.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle-parley- + ${{ runner.os }}-gradle- + - name: Setup workspace + run: ./scripts/setup-workspace.sh -q -DskipTests + - name: Build Android port + run: ./scripts/build-android-port.sh -q -DskipTests + - name: Build the Android application + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + ./scripts/build-android-app.sh -q -DskipTests + + build-ios-port: + # The reusable workflow scripts-ios.yml depends on; sharing it shares its cache. + uses: ./.github/workflows/_build-ios-port.yml + + ios: + name: iOS build (simulator) + needs: build-ios-port + runs-on: macos-15 + timeout-minutes: 50 + env: + CN1_APP_DIR: scripts/parley + steps: + - uses: actions/checkout@v6 + + - name: Cache CocoaPods and user gems + uses: actions/cache@v5 + with: + path: | + ~/.gem + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-v1-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-pods-v1- + + - name: Ensure CocoaPods tooling + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + set -euo pipefail + GEM_USER_DIR="$(ruby -e 'print Gem.user_dir')" + export PATH="$GEM_USER_DIR/bin:$PATH" + if ! command -v pod >/dev/null 2>&1; then + gem install cocoapods xcodeproj --no-document --user-install + fi + pod --version + + - name: Compute setup-workspace hash + id: setup_hash + run: echo "hash=$(shasum -a 256 scripts/setup-workspace.sh | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Restore cn1-binaries cache + uses: actions/cache@v5 + with: + path: ../cn1-binaries + key: cn1-binaries-${{ runner.os }}-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + cn1-binaries-${{ runner.os }}- + + - name: Download built CN1 + iOS port bundle + uses: actions/download-artifact@v4 + with: + name: cn1-built-bundle + path: ${{ runner.temp }} + - name: Restore built CN1 + iOS port tree + run: | + set -euo pipefail + tar -C / -xf "$RUNNER_TEMP/cn1-built-bundle.tar" + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate the Xcode project + id: build-ios-app + run: ./scripts/build-ios-app.sh -q -DskipTests + timeout-minutes: 25 + + - name: Build the application for the simulator + run: | + set -euo pipefail + WORKSPACE='${{ steps.build-ios-app.outputs.workspace }}' + SCHEME='${{ steps.build-ios-app.outputs.scheme }}' + DERIVED_DATA="${{ runner.temp }}/parley-dd" + rm -rf "$DERIVED_DATA" + # One architecture, the host's: the port's SIMD natives are NEON + # under __aarch64__, and a universal simulator build compiles an + # x86_64 slice that has no use here. + HOST_ARCH="$(uname -m)" + case "$HOST_ARCH" in + arm64|x86_64) BUILD_ARCH="$HOST_ARCH" ;; + *) BUILD_ARCH=arm64 ;; + esac + XCB_CONTAINER_FLAG="-workspace" + if [[ "$WORKSPACE" != *.xcworkspace ]]; then + XCB_CONTAINER_FLAG="-project" + fi + xcodebuild "$XCB_CONTAINER_FLAG" "$WORKSPACE" -scheme "$SCHEME" \ + -sdk iphonesimulator \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$DERIVED_DATA" \ + ARCHS="$BUILD_ARCH" ONLY_ACTIVE_ARCH=YES \ + EXCLUDED_ARCHS="armv7 armv7s" \ + CODE_SIGNING_ALLOWED=NO build + APP_BUNDLE="$(find "$DERIVED_DATA/Build/Products" -maxdepth 3 -type d -name "${SCHEME}.app" | head -n 1)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::no .app bundle under $DERIVED_DATA" + exit 1 + fi + timeout-minutes: 20 diff --git a/.github/workflows/starter-launchers.yml b/.github/workflows/starter-launchers.yml index b9d4697109a..9c8db04fcd3 100644 --- a/.github/workflows/starter-launchers.yml +++ b/.github/workflows/starter-launchers.yml @@ -18,8 +18,9 @@ jobs: # macOS: its bash 3.2 and BSD tools run build.sh too. os: [ubuntu-24.04, windows-latest, macos-latest] runs-on: ${{ matrix.os }} - # macOS runners take ~37 s per generated download (twenty of them). - timeout-minutes: 20 + # macOS runners take ~37 s per generated download, and there are past + # thirty of them now: twenty minutes cancelled a run that was passing. + timeout-minutes: 40 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index eb04ae7fc23..58d897d819a 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -64,6 +64,7 @@ on: # requests: developer-guide-docs.yml already renders the guide for those. - 'scripts/wayline/**' - 'scripts/savor/**' + - 'scripts/parley/**' - 'scripts/website/**' - 'scripts/skindesigner/**' - 'Ports/JavaScriptPort/**' diff --git a/CodenameOne/src/com/codename1/call/package-info.java b/CodenameOne/src/com/codename1/call/package-info.java index d424ff31e8d..24b9d03e9e0 100644 --- a/CodenameOne/src/com/codename1/call/package-info.java +++ b/CodenameOne/src/com/codename1/call/package-info.java @@ -28,12 +28,13 @@ /// OS hands over and takes back, and the caller's name shown for an incoming /// number the address book has never seen. /// -/// **What this is not.** Codename One does not carry the voice. There is no -/// codec, no signalling and no WebRTC here, and none is planned: those are -/// the app's, and there are good libraries for them. What was missing was -/// everything *around* the media -- and without it an app could not ring at -/// all while backgrounded, which is why a Codename One app could not -/// previously be a calling app no matter how good its audio was. +/// **What this is not.** This package does not carry the voice. There is no +/// codec and no signalling here: the media is `com.codename1.webrtc`, which a +/// calling app uses beside this package, and the signalling is the app's own. +/// What this package adds is everything *around* the media -- and without it +/// an app could not ring at all while backgrounded, which is why a Codename +/// One app could not previously be a calling app no matter how good its +/// audio was. /// /// #### The sub-packages are the opt-in /// diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index f44ca682efe..bcc47e4c440 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -7057,6 +7057,17 @@ public com.codename1.call.spi.CallBridge getCallBridge() { return null; } + /// Returns the platform bridge used by the `com.codename1.webrtc` API: the native WebRTC + /// stack, or the simulator's loopback. The default answers `null`, which is the capability + /// query -- `WebRtc.isSupported()` reads it. + /// + /// #### Returns + /// + /// the WebRTC bridge, or null when unsupported + public com.codename1.webrtc.spi.WebRtcBridge getWebRtcBridge() { + return null; + } + /// Returns the platform bridge used by the `com.codename1.vpn` API to install and control VPN /// configurations. Ports supporting VPN management override this; the default answers `null`. /// @@ -13534,6 +13545,20 @@ public byte[][] generateRsaKeyPair(int bits) { throw cryptoUnsupported("generateRsaKeyPair"); } + /// Generates a fresh P-256 key pair. Returns + /// `{publicKeyX509, privateKeyPkcs8}`; the PKCS#8 encoding must carry the + /// public point, as [EcP256Codec#pkcs8FromScalar(byte[], byte[])] writes it, + /// because some providers will not import the private key without it. + public byte[][] generateEcKeyPair(int bits) { + throw cryptoUnsupported("generateEcKeyPair"); + } + + /// Computes the P-256 ECDH shared secret (the 32-byte x coordinate) of a + /// PKCS#8 private key and an X.509 public key. + public byte[] ecdhAgree(byte[] privateKeyPkcs8, byte[] publicKeyX509) { + throw cryptoUnsupported("ecdhAgree"); + } + /// Generates `bytes` of fresh symmetric key material. The default just /// delegates to [#secureRandomBytes(byte[])] (no structure is required /// for AES keys). diff --git a/CodenameOne/src/com/codename1/impl/EcP256Codec.java b/CodenameOne/src/com/codename1/impl/EcP256Codec.java new file mode 100644 index 00000000000..65d8d1fa796 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/EcP256Codec.java @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl; + +/// Converts P-256 keys between the DER encodings the portable security API +/// carries (X.509 SubjectPublicKeyInfo, PKCS#8 PrivateKeyInfo) and the raw +/// forms native providers take: the 65-byte uncompressed point `04||X||Y` and +/// the 32-byte private scalar. +/// +/// The ports whose providers import raw keys -- Security.framework's X9.63 +/// representation on iOS, the BCrypt ECC blobs on Windows -- convert here, in +/// Java, so the ASN.1 lives in one place instead of once per native layer. +/// +/// This is port plumbing, not application API. +public final class EcP256Codec { + /// Length of an uncompressed point: one tag byte and two 32-byte coordinates. + public static final int POINT_LENGTH = 65; + /// Length of a private scalar and of each coordinate. + public static final int SCALAR_LENGTH = 32; + + // SEQUENCE { SEQUENCE { OID ecPublicKey, OID prime256v1 }, BIT STRING (66) } + private static final byte[] SPKI_PREFIX = { + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, (byte) 0x86, 0x48, (byte) 0xce, 0x3d, 0x02, 0x01, + 0x06, 0x08, 0x2a, (byte) 0x86, 0x48, (byte) 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00 + }; + + // The AlgorithmIdentifier alone, as it appears inside a PKCS#8 wrapper. + private static final byte[] ALGORITHM_ID = { + 0x30, 0x13, 0x06, 0x07, 0x2a, (byte) 0x86, 0x48, (byte) 0xce, 0x3d, 0x02, 0x01, + 0x06, 0x08, 0x2a, (byte) 0x86, 0x48, (byte) 0xce, 0x3d, 0x03, 0x01, 0x07 + }; + + private static final byte[] CURVE_OID = { + 0x06, 0x08, 0x2a, (byte) 0x86, 0x48, (byte) 0xce, 0x3d, 0x03, 0x01, 0x07 + }; + + private EcP256Codec() {} + + /// Wraps an uncompressed point as X.509 SubjectPublicKeyInfo DER. + public static byte[] spkiFromPoint(byte[] point) { + checkPoint(point); + byte[] out = new byte[SPKI_PREFIX.length + POINT_LENGTH]; + System.arraycopy(SPKI_PREFIX, 0, out, 0, SPKI_PREFIX.length); + System.arraycopy(point, 0, out, SPKI_PREFIX.length, POINT_LENGTH); + return out; + } + + /// Extracts the uncompressed point from P-256 SubjectPublicKeyInfo DER. + /// Every provider encodes a named-curve P-256 key identically, so anything + /// that is not exactly this structure is refused rather than guessed at. + public static byte[] pointFromSpki(byte[] spki) { + if (spki == null || spki.length != SPKI_PREFIX.length + POINT_LENGTH) { + throw new RuntimeException("not a P-256 SubjectPublicKeyInfo"); + } + for (int i = 0; i < SPKI_PREFIX.length; i++) { + if (spki[i] != SPKI_PREFIX[i]) { + throw new RuntimeException("not a P-256 SubjectPublicKeyInfo"); + } + } + byte[] point = new byte[POINT_LENGTH]; + System.arraycopy(spki, SPKI_PREFIX.length, point, 0, POINT_LENGTH); + checkPoint(point); + return point; + } + + /// Builds PKCS#8 PrivateKeyInfo DER for a scalar and its public point. The + /// point is included (the optional `publicKey` field of SEC 1's + /// ECPrivateKey) because Security.framework and Web Crypto in some browsers + /// will not import a private key without it. + public static byte[] pkcs8FromScalar(byte[] scalar, byte[] point) { + if (scalar == null || scalar.length != SCALAR_LENGTH) { + throw new RuntimeException("P-256 private scalar must be 32 bytes"); + } + checkPoint(point); + byte[] out = new byte[138]; + int p = 0; + out[p++] = 0x30; + out[p++] = (byte) 0x81; + out[p++] = (byte) 0x87; + out[p++] = 0x02; + out[p++] = 0x01; + out[p++] = 0x00; + System.arraycopy(ALGORITHM_ID, 0, out, p, ALGORITHM_ID.length); + p += ALGORITHM_ID.length; + out[p++] = 0x04; + out[p++] = 0x6d; + out[p++] = 0x30; + out[p++] = 0x6b; + out[p++] = 0x02; + out[p++] = 0x01; + out[p++] = 0x01; + out[p++] = 0x04; + out[p++] = 0x20; + System.arraycopy(scalar, 0, out, p, SCALAR_LENGTH); + p += SCALAR_LENGTH; + out[p++] = (byte) 0xa1; + out[p++] = 0x44; + out[p++] = 0x03; + out[p++] = 0x42; + out[p++] = 0x00; + System.arraycopy(point, 0, out, p, POINT_LENGTH); + return out; + } + + /// Returns `{scalar, point}` from P-256 PKCS#8 DER. `point` is null when + /// the encoding omits it, which SEC 1 allows and the JDK sometimes does. + public static byte[][] fromPkcs8(byte[] der) { + Der outer = new Der(der, 0, der == null ? 0 : der.length); + Der info = outer.enter(0x30); + outer.end(); + info.skip(0x02); + Der algorithm = info.enter(0x30); + algorithm.skip(0x06); + if (!algorithm.matches(CURVE_OID)) { + throw new RuntimeException("PKCS#8 key is not on P-256"); + } + Der body = info.enter(0x04); + Der ec = body.enter(0x30); + ec.skip(0x02); + byte[] raw = ec.read(0x04); + if (raw.length > SCALAR_LENGTH) { + throw new RuntimeException("P-256 private scalar too long"); + } + byte[] scalar = new byte[SCALAR_LENGTH]; + System.arraycopy(raw, 0, scalar, SCALAR_LENGTH - raw.length, raw.length); + byte[] point = null; + while (ec.hasMore()) { + int tag = ec.peek(); + if (tag == 0xa1) { + Der wrapped = ec.enter(0xa1); + byte[] bits = wrapped.read(0x03); + if (bits.length != POINT_LENGTH + 1 || bits[0] != 0) { + throw new RuntimeException("malformed P-256 public key in PKCS#8"); + } + point = new byte[POINT_LENGTH]; + System.arraycopy(bits, 1, point, 0, POINT_LENGTH); + checkPoint(point); + } else { + ec.skip(tag); + } + } + return new byte[][] {scalar, point}; + } + + /// Converts a DER ECDSA signature (`SEQUENCE { INTEGER r, INTEGER s }`, the + /// JCA form) to the fixed 64-byte `r||s` form Web Crypto and BCrypt use. + public static byte[] signatureDerToRaw(byte[] der) { + Der outer = new Der(der, 0, der == null ? 0 : der.length); + Der seq = outer.enter(0x30); + byte[] r = seq.read(0x02); + byte[] s = seq.read(0x02); + byte[] out = new byte[64]; + copyInteger(r, out, 0); + copyInteger(s, out, 32); + return out; + } + + /// Converts a 64-byte `r||s` signature to DER. + public static byte[] signatureRawToDer(byte[] raw) { + if (raw == null || raw.length != 64) { + throw new RuntimeException("raw P-256 signature must be 64 bytes"); + } + byte[] r = integer(raw, 0); + byte[] s = integer(raw, 32); + int body = 2 + r.length + 2 + s.length; + byte[] out = new byte[2 + body]; + out[0] = 0x30; + out[1] = (byte) body; + out[2] = 0x02; + out[3] = (byte) r.length; + System.arraycopy(r, 0, out, 4, r.length); + out[4 + r.length] = 0x02; + out[5 + r.length] = (byte) s.length; + System.arraycopy(s, 0, out, 6 + r.length, s.length); + return out; + } + + private static void copyInteger(byte[] value, byte[] out, int offset) { + int start = 0; + while (start < value.length - 1 && value[start] == 0) { + start++; + } + int len = value.length - start; + if (len > 32) { + throw new RuntimeException("ECDSA signature component too long"); + } + System.arraycopy(value, start, out, offset + 32 - len, len); + } + + private static byte[] integer(byte[] raw, int offset) { + int start = offset; + int end = offset + 32; + while (start < end - 1 && raw[start] == 0) { + start++; + } + boolean pad = (raw[start] & 0x80) != 0; + byte[] out = new byte[end - start + (pad ? 1 : 0)]; + System.arraycopy(raw, start, out, pad ? 1 : 0, end - start); + return out; + } + + private static void checkPoint(byte[] point) { + if (point == null || point.length != POINT_LENGTH || point[0] != 0x04) { + throw new RuntimeException("P-256 public point must be 65 bytes starting with 0x04"); + } + } + + /// A cursor over one level of DER. Lengths are definite (DER forbids + /// anything else) and at most two bytes, which bounds every key here. + private static final class Der { + private final byte[] data; + private int pos; + private final int end; + + Der(byte[] data, int pos, int end) { + if (data == null) { + throw new RuntimeException("DER input must not be null"); + } + this.data = data; + this.pos = pos; + this.end = end; + } + + boolean hasMore() { + return pos < end; + } + + int peek() { + if (pos >= end) { + throw new RuntimeException("truncated DER"); + } + return data[pos] & 0xff; + } + + void end() { + if (pos != end) { + throw new RuntimeException("trailing data after DER key"); + } + } + + private int header(int tag) { + if (peek() != tag) { + throw new RuntimeException("unexpected DER tag " + peek() + ", wanted " + tag); + } + pos++; + if (pos >= end) { + throw new RuntimeException("truncated DER"); + } + int len = data[pos++] & 0xff; + if (len == 0x81) { + if (pos >= end) { + throw new RuntimeException("truncated DER"); + } + len = data[pos++] & 0xff; + } else if (len == 0x82) { + if (pos + 1 >= end) { + throw new RuntimeException("truncated DER"); + } + len = ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff); + pos += 2; + } else if (len > 0x7f) { + throw new RuntimeException("unsupported DER length"); + } + if (pos + len > end) { + throw new RuntimeException("truncated DER"); + } + return len; + } + + Der enter(int tag) { + int len = header(tag); + Der inner = new Der(data, pos, pos + len); + pos += len; + return inner; + } + + byte[] read(int tag) { + int len = header(tag); + byte[] out = new byte[len]; + System.arraycopy(data, pos, out, 0, len); + pos += len; + return out; + } + + void skip(int tag) { + // Not "pos += header(tag)": Java reads pos before header() moves it. + int len = header(tag); + pos += len; + } + + boolean matches(byte[] expected) { + if (end - pos != expected.length) { + return false; + } + for (int i = 0; i < expected.length; i++) { + if (data[pos + i] != expected[i]) { + return false; + } + } + return true; + } + } +} diff --git a/CodenameOne/src/com/codename1/io/Util.java b/CodenameOne/src/com/codename1/io/Util.java index 589c0844a95..1452acec50d 100644 --- a/CodenameOne/src/com/codename1/io/Util.java +++ b/CodenameOne/src/com/codename1/io/Util.java @@ -1884,6 +1884,19 @@ public static byte[][] generateRsaKeyPair(int bits) { return cryptoImpl().generateRsaKeyPair(bits); } + /// Generates a P-256 key pair through the platform's crypto provider. + /// Returns `{publicKeyX509, privateKeyPkcs8}`. Use + /// `com.codename1.security.KeyGenerator.ec` instead. + public static byte[][] generateEcKeyPair(int bits) { + return cryptoImpl().generateEcKeyPair(bits); + } + + /// Computes a P-256 ECDH shared secret through the platform's crypto + /// provider. Use `com.codename1.security.KeyAgreement.ecdh` instead. + public static byte[] ecdhAgree(byte[] privateKeyPkcs8, byte[] publicKeyX509) { + return cryptoImpl().ecdhAgree(privateKeyPkcs8, publicKeyX509); + } + /// Generates `bytes` of fresh symmetric key material. public static byte[] generateSymmetricKey(int bytes) { return cryptoImpl().generateSymmetricKey(bytes); diff --git a/CodenameOne/src/com/codename1/security/Hkdf.java b/CodenameOne/src/com/codename1/security/Hkdf.java new file mode 100644 index 00000000000..de8706a7b43 --- /dev/null +++ b/CodenameOne/src/com/codename1/security/Hkdf.java @@ -0,0 +1,126 @@ +/* + * 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.security; + +/// HMAC-based key derivation (HKDF), per RFC 5869. +/// +/// HKDF turns input keying material that is not uniformly random -- the +/// shared secret of a `KeyAgreement`, say -- into one or more keys of any +/// length. It runs in two steps: *extract* concentrates the input into a +/// pseudorandom key, and *expand* stretches that into as many bytes as are +/// asked for, bound to an `info` label so that keys derived for different +/// purposes never coincide. +/// +/// ```java +/// KeyPair mine = KeyGenerator.ec(256); +/// KeyPair theirs = KeyGenerator.ec(256); +/// byte[] shared = KeyAgreement.ecdh(mine.getPrivateKey(), theirs.getPublicKey()); +/// byte[] keys = Hkdf.derive("SHA-256", null, shared, "chat keys".getBytes(), 64); +/// ``` +/// +/// It is pure Java over [Hmac], so it behaves identically on every port and on +/// the server. +@com.codename1.impl.SharedWithBackend +public final class Hkdf { + private Hkdf() {} + + /// The extract step: `HMAC(salt, ikm)`. A null or empty `salt` is + /// replaced by a string of zero bytes the length of the hash, as the RFC + /// specifies. + /// + /// #### Parameters + /// + /// - `algorithm`: the hash, any name [Hmac#create(String, byte[])] accepts + /// + /// - `salt`: optional salt + /// + /// - `ikm`: the input keying material + public static byte[] extract(String algorithm, byte[] salt, byte[] ikm) { + if (ikm == null) { + throw new CryptoException("HKDF input keying material must not be null"); + } + byte[] s = salt; + if (s == null || s.length == 0) { + s = new byte[Hmac.create(algorithm, new byte[0]).tagLength()]; + } + return Hmac.create(algorithm, s).doFinal(ikm); + } + + /// The expand step: derives `length` bytes from a pseudorandom key. + /// + /// #### Parameters + /// + /// - `algorithm`: the hash + /// + /// - `prk`: the pseudorandom key, usually the result of [#extract] + /// + /// - `info`: context label; may be null + /// + /// - `length`: bytes wanted, at most 255 times the hash length + public static byte[] expand(String algorithm, byte[] prk, byte[] info, int length) { + if (prk == null) { + throw new CryptoException("HKDF pseudorandom key must not be null"); + } + Hmac mac = Hmac.create(algorithm, prk); + int hashLength = mac.tagLength(); + if (length < 0 || length > 255 * hashLength) { + throw new CryptoException("HKDF output length out of range: " + length); + } + byte[] out = new byte[length]; + byte[] previous = new byte[0]; + byte[] counter = new byte[1]; + int written = 0; + int block = 1; + while (written < length) { + mac.update(previous); + if (info != null) { + mac.update(info); + } + counter[0] = (byte) block; + mac.update(counter); + previous = mac.doFinal(); + int n = Math.min(previous.length, length - written); + System.arraycopy(previous, 0, out, written, n); + written += n; + block++; + } + return out; + } + + /// Extract then expand in one call. + /// + /// #### Parameters + /// + /// - `algorithm`: the hash, for example `"SHA-256"` + /// + /// - `salt`: optional salt + /// + /// - `ikm`: the input keying material + /// + /// - `info`: context label; may be null + /// + /// - `length`: bytes wanted + public static byte[] derive(String algorithm, byte[] salt, byte[] ikm, byte[] info, int length) { + return expand(algorithm, extract(algorithm, salt, ikm), info, length); + } +} diff --git a/CodenameOne/src/com/codename1/security/KeyAgreement.java b/CodenameOne/src/com/codename1/security/KeyAgreement.java new file mode 100644 index 00000000000..7ca10f0024c --- /dev/null +++ b/CodenameOne/src/com/codename1/security/KeyAgreement.java @@ -0,0 +1,105 @@ +/* + * 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.security; + +import com.codename1.io.Util; + +/// Elliptic-curve Diffie-Hellman key agreement over P-256. +/// +/// Two parties that each hold a key pair from [KeyGenerator#ec(int)] exchange +/// public keys; each combines its own private key with the other's public key +/// and both arrive at the same secret, which no one watching the exchange can +/// compute. That secret is not a key yet -- run it through [Hkdf] first. +/// +/// ```java +/// KeyPair alice = KeyGenerator.ec(256); +/// KeyPair bob = KeyGenerator.ec(256); +/// byte[] a = KeyAgreement.ecdh(alice.getPrivateKey(), bob.getPublicKey()); +/// byte[] b = KeyAgreement.ecdh(bob.getPrivateKey(), alice.getPublicKey()); +/// // a and b hold the same 32 bytes +/// ``` +/// +/// The computation runs in the platform's native crypto provider. +public final class KeyAgreement { + private KeyAgreement() {} + + /// Computes the ECDH shared secret: the 32-byte x coordinate of the + /// product of `mine` and `theirs`, as SEC 1 and the JCA define it. + /// + /// #### Parameters + /// + /// - `mine`: this side's EC private key + /// + /// - `theirs`: the other side's EC public key + /// + /// #### Throws + /// + /// - `CryptoException`: if either key is not a P-256 key, or the public key + /// is not a point on the curve + public static byte[] ecdh(PrivateKey mine, PublicKey theirs) { + if (mine == null || theirs == null) { + throw new CryptoException("ECDH keys must not be null"); + } + if (!PublicKey.EC.equals(mine.getAlgorithm()) || !PublicKey.EC.equals(theirs.getAlgorithm())) { + throw new CryptoException("ECDH needs EC keys, got " + mine.getAlgorithm() + + " and " + theirs.getAlgorithm()); + } + byte[] privateDer = mine.getEncoded(); + byte[] publicDer = theirs.getEncoded(); + // The EC label says nothing about the curve. Some providers would + // agree over an imported P-384 key and others refuse it, so a key that + // does not name P-256 is refused here, the same on every port. + if (!namesP256(privateDer) || !namesP256(publicDer)) { + throw new CryptoException("ECDH supports P-256 keys only"); + } + try { + return Util.ecdhAgree(privateDer, publicDer); + } catch (RuntimeException re) { + throw new CryptoException(re.getMessage(), re); + } + } + + /// The DER encoding of the P-256 curve's object identifier, + /// 1.2.840.10045.3.1.7, which a PKCS#8 or X.509 EC key carries in its + /// algorithm parameters. + private static final byte[] P256_OID = { + 0x06, 0x08, 0x2a, (byte) 0x86, 0x48, (byte) 0xce, 0x3d, 0x03, 0x01, 0x07 + }; + + private static boolean namesP256(byte[] der) { + if (der == null) { + return false; + } + int last = der.length - P256_OID.length; + for (int i = 0; i <= last; i++) { + int j = 0; + while (j < P256_OID.length && der[i + j] == P256_OID[j]) { + j++; + } + if (j == P256_OID.length) { + return true; + } + } + return false; + } +} diff --git a/CodenameOne/src/com/codename1/security/KeyGenerator.java b/CodenameOne/src/com/codename1/security/KeyGenerator.java index 63cf94197d8..68ca667b670 100644 --- a/CodenameOne/src/com/codename1/security/KeyGenerator.java +++ b/CodenameOne/src/com/codename1/security/KeyGenerator.java @@ -33,6 +33,7 @@ /// ```java /// SecretKey aes = KeyGenerator.aes(256); // AES-256 /// KeyPair rsa = KeyGenerator.rsa(2048); // RSA-2048 +/// KeyPair ec = KeyGenerator.ec(256); // P-256, for ECDSA and ECDH /// ``` public final class KeyGenerator { private KeyGenerator() {} @@ -74,4 +75,26 @@ public static KeyPair rsa(int bits) { throw new CryptoException(re.getMessage(), re); } } + + /// Generates a fresh elliptic-curve key pair on NIST P-256 (secp256r1), + /// the curve every platform provider implements. The pair signs with + /// [Signature#SHA256_WITH_ECDSA] and agrees secrets with + /// [KeyAgreement#ecdh(PrivateKey, PublicKey)]. + /// + /// #### Parameters + /// + /// - `bits`: the curve size; only 256 is supported + public static KeyPair ec(int bits) { + if (bits != 256) { + throw new CryptoException("unsupported EC key size: " + bits + " (only 256, P-256, is supported)"); + } + try { + byte[][] pair = Util.generateEcKeyPair(bits); + PublicKey pub = PublicKey.fromX509(PublicKey.EC, pair[0]); + PrivateKey priv = PrivateKey.fromPkcs8(PublicKey.EC, pair[1]); + return new KeyPair(pub, priv); + } catch (RuntimeException re) { + throw new CryptoException(re.getMessage(), re); + } + } } diff --git a/CodenameOne/src/com/codename1/security/PublicKey.java b/CodenameOne/src/com/codename1/security/PublicKey.java index a123c1a1dca..68f7fb75247 100644 --- a/CodenameOne/src/com/codename1/security/PublicKey.java +++ b/CodenameOne/src/com/codename1/security/PublicKey.java @@ -22,6 +22,7 @@ */ package com.codename1.security; +import com.codename1.impl.EcP256Codec; import com.codename1.util.StringUtil; /// A public key -- paired with a [PrivateKey] to form a key pair. Carries the @@ -53,6 +54,37 @@ public static PublicKey rsa(byte[] x509Der) { return fromX509(RSA, x509Der); } + /// Builds a P-256 public key from its uncompressed point, the 65 bytes + /// `04 || X || Y`. That is the compact form protocols put on the wire; + /// [#toEcPoint()] is the inverse. + /// + /// #### Throws + /// + /// - `CryptoException`: if `point` is not 65 bytes starting with `0x04` + public static PublicKey ecFromPoint(byte[] point) { + try { + return fromX509(EC, EcP256Codec.spkiFromPoint(point)); + } catch (RuntimeException re) { + throw new CryptoException(re.getMessage(), re); + } + } + + /// Returns the uncompressed point `04 || X || Y` of a P-256 key. + /// + /// #### Throws + /// + /// - `CryptoException`: if this is not a P-256 key + public byte[] toEcPoint() { + if (!EC.equals(getAlgorithm())) { + throw new CryptoException("not an EC key: " + getAlgorithm()); + } + try { + return EcP256Codec.pointFromSpki(getEncoded()); + } catch (RuntimeException re) { + throw new CryptoException(re.getMessage(), re); + } + } + /// Parses a PEM-encoded public key, determining the algorithm from the key /// itself. This is the form `openssl rsa -pubout` and every backend key /// store hands out: diff --git a/CodenameOne/src/com/codename1/security/Signature.java b/CodenameOne/src/com/codename1/security/Signature.java index 008213c1429..53e146f7d26 100644 --- a/CodenameOne/src/com/codename1/security/Signature.java +++ b/CodenameOne/src/com/codename1/security/Signature.java @@ -46,9 +46,12 @@ public final class Signature { /// `SHA256withECDSA` -- ECDSA with SHA-256 (P-256 curve). public static final String SHA256_WITH_ECDSA = "SHA256withECDSA"; - /// `SHA384withECDSA` -- ECDSA with SHA-384 (P-384 curve). + /// `SHA384withECDSA` -- ECDSA with SHA-384 (P-384 curve). Not on iOS, + /// whose Security framework is given P-256 keys only: there a P-384 key + /// fails with a `CryptoException`. public static final String SHA384_WITH_ECDSA = "SHA384withECDSA"; - /// `SHA512withECDSA` -- ECDSA with SHA-512 (P-521 curve). + /// `SHA512withECDSA` -- ECDSA with SHA-512 (P-521 curve). Not on iOS, + /// for the same reason as [#SHA384_WITH_ECDSA]. public static final String SHA512_WITH_ECDSA = "SHA512withECDSA"; private Signature() {} diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 4fe0dce0e20..61b036691f4 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -6539,6 +6539,17 @@ public com.codename1.call.spi.CallBridge getCallBridge() { return impl.getCallBridge(); } + /// Returns the platform bridge used by the `com.codename1.webrtc` API to carry call media, + /// or null when unsupported on this port. Internal -- application code uses the + /// `com.codename1.webrtc` package rather than this bridge directly. + /// + /// #### Returns + /// + /// the WebRTC bridge, or null + public com.codename1.webrtc.spi.WebRtcBridge getWebRtcBridge() { + return impl.getWebRtcBridge(); + } + /// Returns the platform bridge used by the `com.codename1.vpn` API to manage VPN /// configurations, or null when unsupported on this port. Internal -- application code uses /// the `com.codename1.vpn` packages rather than this bridge directly. diff --git a/CodenameOne/src/com/codename1/webrtc/IceCandidate.java b/CodenameOne/src/com/codename1/webrtc/IceCandidate.java new file mode 100644 index 00000000000..f190f155964 --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/IceCandidate.java @@ -0,0 +1,58 @@ +/* + * 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.webrtc; + +/// A network path one side found to itself, to be tried by the other side. +/// A peer connection gathers these as it goes and hands each to +/// [PeerConnection.Listener#iceCandidate(PeerConnection, IceCandidate)]; the +/// app sends it across and the other side passes it to +/// [PeerConnection#addIceCandidate(IceCandidate)]. +public final class IceCandidate { + private final String sdpMid; + private final int sdpMLineIndex; + private final String candidate; + + public IceCandidate(String sdpMid, int sdpMLineIndex, String candidate) { + if (candidate == null) { + throw new IllegalArgumentException("An ICE candidate needs its candidate line"); + } + this.sdpMid = sdpMid == null ? "" : sdpMid; + this.sdpMLineIndex = sdpMLineIndex; + this.candidate = candidate; + } + + /// The media section this candidate belongs to, by its `a=mid` value. + public String getSdpMid() { + return sdpMid; + } + + /// The media section this candidate belongs to, by its position. + public int getSdpMLineIndex() { + return sdpMLineIndex; + } + + /// The `candidate:` line itself. + public String getCandidate() { + return candidate; + } +} diff --git a/CodenameOne/src/com/codename1/webrtc/IceServer.java b/CodenameOne/src/com/codename1/webrtc/IceServer.java new file mode 100644 index 00000000000..4c437c7d7b3 --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/IceServer.java @@ -0,0 +1,74 @@ +/* + * 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.webrtc; + +/// A STUN or TURN server a [PeerConnection] may use to find a path to the +/// other side. +/// +/// A STUN server only tells a device its public address, so it needs no +/// credentials. A TURN server relays the media when no direct path exists -- +/// behind a carrier NAT or a strict firewall, which is common on mobile +/// networks -- and is normally given short-lived credentials by the app's +/// own server. +public final class IceServer { + private final String[] urls; + private final String username; + private final String credential; + + /// #### Parameters + /// + /// - `urls`: one or more `stun:`, `turn:` or `turns:` URLs for the same + /// server + /// + /// - `username`: the TURN user name, or null for STUN + /// + /// - `credential`: the TURN password, or null for STUN + public IceServer(String[] urls, String username, String credential) { + if (urls == null || urls.length == 0) { + throw new IllegalArgumentException("An ICE server needs at least one URL"); + } + this.urls = new String[urls.length]; + System.arraycopy(urls, 0, this.urls, 0, urls.length); + this.username = username; + this.credential = credential; + } + + /// A server with no credentials, such as a public STUN server. + public static IceServer stun(String url) { + return new IceServer(new String[] {url}, null, null); + } + + public String[] getUrls() { + String[] out = new String[urls.length]; + System.arraycopy(urls, 0, out, 0, urls.length); + return out; + } + + public String getUsername() { + return username; + } + + public String getCredential() { + return credential; + } +} diff --git a/CodenameOne/src/com/codename1/webrtc/LocalMedia.java b/CodenameOne/src/com/codename1/webrtc/LocalMedia.java new file mode 100644 index 00000000000..f8da5e2b229 --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/LocalMedia.java @@ -0,0 +1,55 @@ +/* + * 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.webrtc; + +/// The camera and microphone this device opened with +/// [WebRtc#openUserMedia(boolean, boolean, boolean)]. +public final class LocalMedia { + private final MediaTrack audio; + private final MediaTrack video; + + LocalMedia(MediaTrack audio, MediaTrack video) { + this.audio = audio; + this.video = video; + } + + /// The microphone, or null when none was asked for. + public MediaTrack getAudio() { + return audio; + } + + /// The camera, or null when none was asked for. + public MediaTrack getVideo() { + return video; + } + + /// Stops both. + public void release() { + if (audio != null) { + audio.release(); + } + if (video != null) { + video.release(); + } + } +} diff --git a/CodenameOne/src/com/codename1/webrtc/MediaTrack.java b/CodenameOne/src/com/codename1/webrtc/MediaTrack.java new file mode 100644 index 00000000000..9752a6e5b96 --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/MediaTrack.java @@ -0,0 +1,97 @@ +/* + * 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.webrtc; + +/// One stream of audio or video: the local camera or microphone, or what +/// arrives from the other side. +/// +/// A muted track keeps flowing as silence or black frames, so unmuting is +/// instant and the other side's connection never notices. +public final class MediaTrack { + /// [#getKind()] of a microphone or a remote voice. + public static final String AUDIO = "audio"; + + /// [#getKind()] of a camera or a remote picture. + public static final String VIDEO = "video"; + + private final int handle; + private final String kind; + private final boolean local; + private boolean enabled = true; + private boolean released; + + MediaTrack(int handle, String kind, boolean local) { + this.handle = handle; + this.kind = kind; + this.local = local; + } + + int handle() { + return handle; + } + + public String getKind() { + return kind; + } + + public boolean isVideo() { + return VIDEO.equals(kind); + } + + /// Whether this is this device's own camera or microphone. + public boolean isLocal() { + return local; + } + + public boolean isEnabled() { + return enabled; + } + + /// Mutes (false) or unmutes (true) the track. + public void setEnabled(boolean enabled) { + if (released || enabled == this.enabled) { + return; + } + this.enabled = enabled; + WebRtc.bridge().setTrackEnabled(handle, enabled); + } + + /// Switches a local video track between the front and back cameras. Does + /// nothing for any other track. + public void switchCamera() { + if (!released && local && isVideo()) { + WebRtc.bridge().switchCamera(handle); + } + } + + /// Stops the camera or microphone behind a local track. A track cannot be + /// restarted; open new media instead. Remote tracks end with their + /// connection, so this does nothing for them. + public void release() { + if (released || !local) { + return; + } + released = true; + WebRtc.bridge().releaseTrack(handle); + } +} diff --git a/CodenameOne/src/com/codename1/webrtc/PeerConnection.java b/CodenameOne/src/com/codename1/webrtc/PeerConnection.java new file mode 100644 index 00000000000..bf3b130827f --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/PeerConnection.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.webrtc; + +import com.codename1.util.AsyncResource; + +/// One end of a call: the media this device sends, what it receives, and +/// the network path between the two. +/// +/// The sequence is the standard one. The caller adds its tracks, creates an +/// offer, sets it as its local description and sends it; the callee sets it +/// as its remote description, adds its own tracks, creates an answer, sets +/// that locally and sends it back; the caller sets it as remote. Meanwhile +/// both sides send each other every candidate their listener is given. +/// Carrying the descriptions and candidates is the app's job -- over its own +/// server, a WebSocket, anything -- and nothing here sends them. +/// +/// ```java +/// import com.codename1.io.Log; +/// import com.codename1.webrtc.*; +/// +/// WebRtc.openUserMedia(true, false, false).ready(media -> { +/// PeerConnection pc = WebRtc.createPeerConnection(new PeerConnectionConfig(), +/// new PeerConnection.Listener() { +/// public void iceCandidate(PeerConnection connection, IceCandidate candidate) { +/// Log.p("send to the other side: " + candidate.getCandidate()); +/// } +/// +/// public void stateChanged(PeerConnection connection, String state) { +/// Log.p("state " + state); +/// } +/// +/// public void remoteTrack(PeerConnection connection, MediaTrack track) { +/// } +/// }); +/// pc.addTrack(media.getAudio()); +/// pc.createOffer().ready(offer -> pc.setLocalDescription(offer) +/// .ready(done -> Log.p("send to the other side: " + offer.getSdp()))); +/// }); +/// ``` +public final class PeerConnection { + /// [#getState()] before the two sides found each other. + public static final String NEW = "new"; + /// [#getState()] while a path is being tried. + public static final String CONNECTING = "connecting"; + /// [#getState()] while media flows. + public static final String CONNECTED = "connected"; + /// [#getState()] after the path was lost; it may come back by itself. + public static final String DISCONNECTED = "disconnected"; + /// [#getState()] when no path could be found or it was lost for good. + public static final String FAILED = "failed"; + /// [#getState()] after [#close()]. + public static final String CLOSED = "closed"; + + /// What a peer connection tells the app as the call proceeds. Every + /// method is called on the EDT. + public interface Listener { + /// A path to this device was found; send it to the other side. + void iceCandidate(PeerConnection connection, IceCandidate candidate); + + /// The connection moved to another of the states named on + /// [PeerConnection]. + void stateChanged(PeerConnection connection, String state); + + /// The other side's audio or video arrived. + void remoteTrack(PeerConnection connection, MediaTrack track); + } + + private final int handle; + private final Listener listener; + private String state = NEW; + + PeerConnection(int handle, Listener listener) { + this.handle = handle; + this.listener = listener; + } + + int handle() { + return handle; + } + + Listener listener() { + return listener; + } + + void state(String state) { + this.state = state; + } + + public String getState() { + return state; + } + + /// Sends a local track to the other side. Add every track before + /// creating the offer or the answer. + public void addTrack(MediaTrack track) { + if (track == null || !track.isLocal()) { + throw new IllegalArgumentException("Only a local track can be sent"); + } + if (!CLOSED.equals(state)) { + WebRtc.bridge().addTrack(handle, track.handle()); + } + } + + /// Describes this side for the caller's half of the exchange. + public AsyncResource createOffer() { + AsyncResource out = new AsyncResource(); + WebRtc.bridge().createDescription(handle, WebRtc.request(out), false); + return out; + } + + /// Describes this side in answer to the remote offer already set. + public AsyncResource createAnswer() { + AsyncResource out = new AsyncResource(); + WebRtc.bridge().createDescription(handle, WebRtc.request(out), true); + return out; + } + + public AsyncResource setLocalDescription(SessionDescription description) { + return set(false, description); + } + + public AsyncResource setRemoteDescription(SessionDescription description) { + return set(true, description); + } + + private AsyncResource set(boolean remote, SessionDescription description) { + AsyncResource out = new AsyncResource(); + WebRtc.bridge().setDescription(handle, WebRtc.request(out), remote, + description.getType(), description.getSdp()); + return out; + } + + /// Tries a path the other side found. A candidate that arrives before + /// the remote description is set is held by the port until it is. + public void addIceCandidate(IceCandidate candidate) { + if (!CLOSED.equals(state)) { + WebRtc.bridge().addIceCandidate(handle, candidate.getSdpMid(), + candidate.getSdpMLineIndex(), candidate.getCandidate()); + } + } + + /// Ends the connection. Local tracks stay open: release them separately. + public void close() { + if (CLOSED.equals(state)) { + return; + } + state = CLOSED; + WebRtc.forget(this); + WebRtc.bridge().closePeerConnection(handle); + } +} diff --git a/CodenameOne/src/com/codename1/webrtc/PeerConnectionConfig.java b/CodenameOne/src/com/codename1/webrtc/PeerConnectionConfig.java new file mode 100644 index 00000000000..f87b631d8f0 --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/PeerConnectionConfig.java @@ -0,0 +1,94 @@ +/* + * 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.webrtc; + +import java.util.ArrayList; +import java.util.List; + +/// How a [PeerConnection] looks for a path to the other side. +public final class PeerConnectionConfig { + private final List servers = new ArrayList(); + private boolean relayOnly; + + /// Adds a STUN or TURN server. + public PeerConnectionConfig addIceServer(IceServer server) { + if (server == null) { + throw new IllegalArgumentException("null ICE server"); + } + servers.add(server); + return this; + } + + /// Uses only paths through a TURN relay. The other side then never learns + /// this device's own addresses, at the cost of every call going through + /// the relay. Off by default. + public PeerConnectionConfig setRelayOnly(boolean relayOnly) { + this.relayOnly = relayOnly; + return this; + } + + public boolean isRelayOnly() { + return relayOnly; + } + + public List getIceServers() { + return new ArrayList(servers); + } + + /// The servers in the form [com.codename1.webrtc.spi.WebRtcBridge] takes. + String wire() { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < servers.size(); iter++) { + IceServer each = servers.get(iter); + if (iter > 0) { + out.append('\n'); + } + String[] urls = each.getUrls(); + for (int u = 0; u < urls.length; u++) { + if (u > 0) { + out.append(','); + } + out.append(clean(urls[u])); + } + out.append('\t').append(clean(each.getUsername())); + out.append('\t').append(clean(each.getCredential())); + } + return out.toString(); + } + + // A tab, comma or line break inside a value would split it in the wire + // form. None is legal in a URL, and a TURN credential is base64 or hex. + private static String clean(String value) { + if (value == null) { + return ""; + } + StringBuilder out = new StringBuilder(value.length()); + for (int iter = 0; iter < value.length(); iter++) { + char c = value.charAt(iter); + if (c != '\t' && c != '\n' && c != '\r') { + out.append(c); + } + } + return out.toString(); + } +} diff --git a/CodenameOne/src/com/codename1/webrtc/SessionDescription.java b/CodenameOne/src/com/codename1/webrtc/SessionDescription.java new file mode 100644 index 00000000000..4e3a106e21c --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/SessionDescription.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.webrtc; + +/// One side's description of a session -- the codecs, the media and the +/// transport keys -- in SDP. An offer from the side that calls, an answer +/// from the side that picks up. +/// +/// The app carries it to the other side by whatever signalling it has; the +/// API never sends it anywhere. Its content is opaque to the app. +public final class SessionDescription { + /// The type of the caller's description. + public static final String OFFER = "offer"; + + /// The type of the callee's description. + public static final String ANSWER = "answer"; + + private final String type; + private final String sdp; + + /// #### Parameters + /// + /// - `type`: [#OFFER] or [#ANSWER] + /// + /// - `sdp`: the description, exactly as the other side produced it + public SessionDescription(String type, String sdp) { + if (!OFFER.equals(type) && !ANSWER.equals(type)) { + throw new IllegalArgumentException("Unknown session description type: " + type); + } + if (sdp == null) { + throw new IllegalArgumentException("A session description needs its SDP"); + } + this.type = type; + this.sdp = sdp; + } + + public String getType() { + return type; + } + + public String getSdp() { + return sdp; + } +} diff --git a/CodenameOne/src/com/codename1/webrtc/VideoView.java b/CodenameOne/src/com/codename1/webrtc/VideoView.java new file mode 100644 index 00000000000..89f78564422 --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/VideoView.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.webrtc; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.layouts.BorderLayout; + +/// Draws a video track: the local camera's preview or the other side's +/// picture. A native view on a device, laid out like any component. +/// +/// Call [#release()] when the view is discarded, so the track stops drawing +/// into it. +public final class VideoView extends Container { + private Object peer; + + /// #### Parameters + /// + /// - `track`: a video track + /// + /// - `mirror`: whether to flip it left to right, which is what people + /// expect of their own front camera + public VideoView(MediaTrack track, boolean mirror) { + super(new BorderLayout()); + setUIID("VideoView"); + if (track != null && track.isVideo() && WebRtc.isSupported()) { + peer = WebRtc.bridge().createVideoView(track.handle(), mirror); + if (peer instanceof Component) { + add(BorderLayout.CENTER, (Component) peer); + } + } + } + + /// Whether the track is being drawn: false on a port that cannot draw + /// it, where the view stays empty. + public boolean isRendering() { + return peer instanceof Component; + } + + public void release() { + if (peer != null) { + Object was = peer; + peer = null; + removeAll(); + WebRtc.bridge().releaseVideoView(was); + } + } +} diff --git a/CodenameOne/src/com/codename1/webrtc/WebRtc.java b/CodenameOne/src/com/codename1/webrtc/WebRtc.java new file mode 100644 index 00000000000..c9fec7aef94 --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/WebRtc.java @@ -0,0 +1,278 @@ +/* + * 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.webrtc; + +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; +import com.codename1.webrtc.spi.WebRtcBridge; +import java.util.HashMap; +import java.util.Map; + +/// Real-time audio and video between two devices: the entry point of the +/// package. +/// +/// ```java +/// import com.codename1.webrtc.*; +/// +/// Form form = new Form("Call", new BorderLayout()); +/// if (WebRtc.isSupported()) { +/// WebRtc.openUserMedia(true, true, true).ready(media -> { +/// PeerConnection pc = WebRtc.createPeerConnection( +/// new PeerConnectionConfig().addIceServer(IceServer.stun("stun:stun.example.com")), +/// new PeerConnection.Listener() { +/// public void iceCandidate(PeerConnection connection, IceCandidate candidate) { +/// // send it to the other side over your own channel +/// } +/// +/// public void stateChanged(PeerConnection connection, String state) { +/// form.setTitle(state); +/// } +/// +/// public void remoteTrack(PeerConnection connection, MediaTrack track) { +/// if (track.isVideo()) { +/// form.add(BorderLayout.CENTER, new VideoView(track, false)); +/// form.revalidate(); +/// } +/// } +/// }); +/// pc.addTrack(media.getAudio()); +/// pc.addTrack(media.getVideo()); +/// form.add(BorderLayout.SOUTH, new VideoView(media.getVideo(), true)); +/// form.revalidate(); +/// }); +/// } else { +/// form.add(BorderLayout.CENTER, new Label("No camera calls on this device")); +/// } +/// form.show(); +/// ``` +/// +/// The package carries media. It does not ring, place the call in the +/// system call log, or wake a sleeping phone: that is `com.codename1.call`, +/// which an app uses alongside this one. And it does not signal -- see +/// [PeerConnection]. +/// +/// On Android this is Google's WebRTC library and on iOS Apple-platform +/// builds of the same library; both are added to an app only when it uses +/// this package. In a browser it is the browser's own WebRTC. The simulator +/// connects calls without carrying media: tracks open, descriptions are +/// exchanged and connections reach [PeerConnection#CONNECTED], and the +/// video views show a test pattern, so an app's call flow can be developed +/// and tested end to end on the desktop. +public final class WebRtc { + private static final Map> REQUESTS = + new HashMap>(); + private static final Map PEERS = new HashMap(); + private static int nextRequest = 1; + + private WebRtc() { + } + + static WebRtcBridge bridge() { + WebRtcBridge bridge = Display.getInstance().getWebRtcBridge(); + if (bridge == null) { + throw new IllegalStateException("WebRTC is not supported on this platform"); + } + return bridge; + } + + /// Whether this platform carries WebRTC media. + public static boolean isSupported() { + return Display.getInstance().getWebRtcBridge() != null; + } + + /// Opens the camera and/or the microphone, asking the user's permission + /// where the platform asks for it. Fails when the user refuses or the + /// device has neither. + /// + /// #### Parameters + /// + /// - `frontCamera`: which camera to open first; see + /// [MediaTrack#switchCamera()] + public static AsyncResource openUserMedia(boolean audio, boolean video, + boolean frontCamera) { + AsyncResource out = new AsyncResource(); + if (!audio && !video) { + out.error(new IllegalArgumentException("Ask for audio, video or both")); + return out; + } + bridge().openUserMedia(request(out), audio, video, frontCamera); + return out; + } + + /// Creates a peer connection, which reports to `listener` on the EDT. + public static PeerConnection createPeerConnection(PeerConnectionConfig config, + PeerConnection.Listener listener) { + if (listener == null) { + throw new IllegalArgumentException("A peer connection needs a listener"); + } + PeerConnectionConfig used = config == null ? new PeerConnectionConfig() : config; + int handle = bridge().createPeerConnection(used.wire(), used.isRelayOnly()); + if (handle < 0) { + throw new IllegalStateException("The platform could not create a peer connection"); + } + PeerConnection connection = new PeerConnection(handle, listener); + PEERS.put(Integer.valueOf(handle), connection); + return connection; + } + + /// Routes call audio to the loudspeaker, or with false back to the + /// earpiece or headset. + public static void setSpeakerphone(boolean on) { + bridge().setSpeakerphone(on); + } + + // The registries are touched only on the EDT: the API is called there, and + // every answer from a port is marshalled there by callSerially below. + static int request(AsyncResource pending) { + int id = nextRequest++; + REQUESTS.put(Integer.valueOf(id), pending); + return id; + } + + static void forget(PeerConnection connection) { + PEERS.remove(Integer.valueOf(connection.handle())); + } + + private static AsyncResource take(int requestId) { + return REQUESTS.remove(Integer.valueOf(requestId)); + } + + private static PeerConnection peer(int handle) { + return PEERS.get(Integer.valueOf(handle)); + } + + // ------------------------------------------------- answers from a port + + /// A port's answer to `createDescription`: the description, or a null + /// `sdp` and the reason. + public static void deliverDescription(final int requestId, final String type, + final String sdp, final String error) { + CN.callSerially(new Runnable() { + @Override + public void run() { + AsyncResource pending = take(requestId); + if (pending == null) { + return; + } + @SuppressWarnings("unchecked") + AsyncResource out = (AsyncResource) pending; + if (sdp == null) { + out.error(new IllegalStateException(error == null ? "failed" : error)); + } else { + out.complete(new SessionDescription(type, sdp)); + } + } + }); + } + + /// A port's answer to `openUserMedia`: the track handles, 0 for none, + /// or both 0 and the reason. + public static void deliverTracks(final int requestId, final int audio, final int video, + final String error) { + CN.callSerially(new Runnable() { + @Override + public void run() { + AsyncResource pending = take(requestId); + if (pending == null) { + return; + } + @SuppressWarnings("unchecked") + AsyncResource out = (AsyncResource) pending; + if (audio == 0 && video == 0) { + out.error(new IllegalStateException(error == null + ? "The camera or microphone could not be opened" : error)); + return; + } + out.complete(new LocalMedia( + audio == 0 ? null : new MediaTrack(audio, MediaTrack.AUDIO, true), + video == 0 ? null : new MediaTrack(video, MediaTrack.VIDEO, true))); + } + }); + } + + /// A port's answer to `setDescription`: null for success, else the + /// reason. + public static void deliverResult(final int requestId, final String error) { + CN.callSerially(new Runnable() { + @Override + public void run() { + AsyncResource pending = take(requestId); + if (pending == null) { + return; + } + @SuppressWarnings("unchecked") + AsyncResource out = (AsyncResource) pending; + if (error == null) { + out.complete(Boolean.TRUE); + } else { + out.error(new IllegalStateException(error)); + } + } + }); + } + + /// A candidate a peer connection gathered. + public static void iceCandidate(final int peer, final String sdpMid, final int sdpMLineIndex, + final String candidate) { + CN.callSerially(new Runnable() { + @Override + public void run() { + PeerConnection connection = peer(peer); + if (connection != null && candidate != null) { + connection.listener().iceCandidate(connection, + new IceCandidate(sdpMid, sdpMLineIndex, candidate)); + } + } + }); + } + + /// A peer connection moved to `state`, one of the names on + /// [PeerConnection]. + public static void stateChanged(final int peer, final String state) { + CN.callSerially(new Runnable() { + @Override + public void run() { + PeerConnection connection = peer(peer); + if (connection != null && !state.equals(connection.getState())) { + connection.state(state); + connection.listener().stateChanged(connection, state); + } + } + }); + } + + /// The other side's track arrived on a peer connection. + public static void remoteTrack(final int peer, final int track, final boolean video) { + CN.callSerially(new Runnable() { + @Override + public void run() { + PeerConnection connection = peer(peer); + if (connection != null) { + connection.listener().remoteTrack(connection, new MediaTrack(track, + video ? MediaTrack.VIDEO : MediaTrack.AUDIO, false)); + } + } + }); + } +} diff --git a/CodenameOne/src/com/codename1/webrtc/package-info.java b/CodenameOne/src/com/codename1/webrtc/package-info.java new file mode 100644 index 00000000000..546b4f08619 --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/package-info.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Real-time audio and video calls between devices: WebRTC. +/// +/// [WebRtc] opens the camera and microphone and creates a [PeerConnection]; +/// a [VideoView] draws a video track. The app carries the +/// [SessionDescription] and [IceCandidate] values between the two sides over +/// its own signalling. Ringing, the system call screen and waking a device +/// for an incoming call are `com.codename1.call`, which a calling app uses +/// beside this package. +/// +/// Referencing this package is what adds the native WebRTC library to an +/// Android or iOS build, along with the camera and microphone permissions; +/// an app that does not use it pays for none of that. +package com.codename1.webrtc; diff --git a/CodenameOne/src/com/codename1/webrtc/spi/WebRtcBridge.java b/CodenameOne/src/com/codename1/webrtc/spi/WebRtcBridge.java new file mode 100644 index 00000000000..0cda8aec107 --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/spi/WebRtcBridge.java @@ -0,0 +1,110 @@ +/* + * 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.webrtc.spi; + +/// Internal service-provider interface a port implements to carry the +/// `com.codename1.webrtc` API onto its native WebRTC stack: Google's +/// `org.webrtc` on Android, the WebRTC framework on iOS, the browser's +/// `RTCPeerConnection` in the JavaScript port, and a loopback in the +/// simulator. +/// +/// Application code never touches this interface; the public classes reach it +/// through `com.codename1.ui.Display#getWebRtcBridge()`, and a port that +/// returns `null` there is a port without WebRTC -- `WebRtc.isSupported()` +/// says so and nothing else needs a platform `if`. +/// +/// #### Everything is ids, primitives and strings +/// +/// As with `com.codename1.call.spi.CallBridge`, no method takes or returns a +/// framework type, so a port reached through ParparVM never constructs a +/// Java object from native code. Peer connections and tracks are integer +/// handles the port allocates; structured values cross as strings. +/// +/// #### Every request answers exactly once +/// +/// An operation that can fail takes a `requestId` from the caller and answers +/// it by calling exactly one of `WebRtc.deliverDescription`, +/// `WebRtc.deliverTracks` or `WebRtc.deliverResult`. A request that never +/// answers leaves an `AsyncResource` that never settles, which is worse than +/// one that fails. Unsolicited events -- a gathered candidate, a state +/// change, a remote track -- carry the peer handle instead. Every entry point +/// may be called from any thread; they marshal to the EDT themselves. +public interface WebRtcBridge { + /// Creates a peer connection and answers its handle, or a negative number + /// when it cannot be created. + /// + /// #### Parameters + /// + /// - `iceServers`: one server per line, each line tab-separated as + /// `urls` (comma-separated), `username`, `credential` + /// + /// - `relayOnly`: whether only relayed (TURN) candidates may be used, which + /// keeps the device's own addresses from the other side + int createPeerConnection(String iceServers, boolean relayOnly); + + /// Starts the local camera and/or microphone. Answers with + /// `WebRtc.deliverTracks`: the handles of the audio track and the video + /// track, 0 for one not asked for. + void openUserMedia(int requestId, boolean audio, boolean video, boolean frontCamera); + + /// Sends a local track over a peer connection. + void addTrack(int peer, int track); + + /// Creates an offer, or with `answer` an answer to the remote description + /// already set. Answers with `WebRtc.deliverDescription`. + void createDescription(int peer, int requestId, boolean answer); + + /// Sets a description as the local one, or with `remote` as the remote + /// one. Answers with `WebRtc.deliverResult`. + /// + /// #### Parameters + /// + /// - `type`: `offer` or `answer` + void setDescription(int peer, int requestId, boolean remote, String type, String sdp); + + /// Adds a candidate the other side gathered. + void addIceCandidate(int peer, String sdpMid, int sdpMLineIndex, String candidate); + + /// Closes a peer connection and frees its handle. + void closePeerConnection(int peer); + + /// Mutes or unmutes a track, local or remote. + void setTrackEnabled(int track, boolean enabled); + + /// Switches a local video track between the front and back cameras. + void switchCamera(int track); + + /// Routes call audio to the loudspeaker or back to the earpiece. + void setSpeakerphone(boolean on); + + /// Stops a local track and frees its handle. + void releaseTrack(int track); + + /// A native view drawing a video track: a `com.codename1.ui.PeerComponent`, + /// or any `com.codename1.ui.Component` on a port that draws it itself. + /// Null when the track cannot be drawn. + Object createVideoView(int track, boolean mirror); + + /// Detaches a view from its track, before the view is discarded. + void releaseVideoView(Object view); +} diff --git a/CodenameOne/src/com/codename1/webrtc/spi/package-info.java b/CodenameOne/src/com/codename1/webrtc/spi/package-info.java new file mode 100644 index 00000000000..ff4ab03fba9 --- /dev/null +++ b/CodenameOne/src/com/codename1/webrtc/spi/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// The port side of `com.codename1.webrtc`. Internal: implemented by ports, +/// never called by applications. +package com.codename1.webrtc.spi; diff --git a/Ports/Android/build.xml b/Ports/Android/build.xml index 3630dc0a1fc..8f3c7ea094c 100644 --- a/Ports/Android/build.xml +++ b/Ports/Android/build.xml @@ -130,7 +130,7 @@ a package to one file is not adding it. --> + excludes="com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/webrtc/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java"> diff --git a/Ports/Android/nbproject/project.properties b/Ports/Android/nbproject/project.properties index 2b0451f5250..263aa92f2d3 100644 --- a/Ports/Android/nbproject/project.properties +++ b/Ports/Android/nbproject/project.properties @@ -36,7 +36,7 @@ endorsed.classpath= # excludes= attribute in build.xml. The list lives in three files; the Maven # build is green with only one of them updated, so adding a package here alone # is not adding it. -excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java +excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/webrtc/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java file.reference.android-support-v7-appcompat.jar=../../../cn1-binaries/android/android-support-v7-appcompat.jar file.reference.android-support-v7-cardview.jar=../../../cn1-binaries/android/android-support-v7-cardview.jar file.reference.android-support-v7-gridlayout.jar=../../../cn1-binaries/android/android-support-v7-gridlayout.jar diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 0e8fd939634..c7ef707c053 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -16008,6 +16008,35 @@ public com.codename1.impl.ARImpl createARImpl() { return nearbyBridge; } + private com.codename1.webrtc.spi.WebRtcBridge webRtcBridge; + private boolean webRtcLoaded; + + /// The WebRTC bridge, loaded by name from the optional + /// `com.codename1.impl.android.webrtc` package -- the same way the ar, + /// cipher and nearby backends are reached. The builder deletes that package + /// for an app that never referenced `com.codename1.webrtc`, and then this + /// answers null, which the API reports as unsupported. + @Override + public synchronized com.codename1.webrtc.spi.WebRtcBridge getWebRtcBridge() { + if (!webRtcLoaded) { + webRtcLoaded = true; + Object instance = null; + try { + Class clazz = Class.forName( + "com.codename1.impl.android.webrtc.AndroidWebRtc"); + instance = clazz.getConstructor(android.content.Context.class) + .newInstance(getContext()); + } catch (Throwable t) { + // Expected: the package was not bundled. + instance = null; + } + // Tested rather than cast, so a mismatched class stays inert. + webRtcBridge = instance instanceof com.codename1.webrtc.spi.WebRtcBridge + ? (com.codename1.webrtc.spi.WebRtcBridge) instance : null; + } + return webRtcBridge; + } + private com.codename1.impl.android.call.AndroidCallBridge callBridge; private com.codename1.impl.android.vpn.AndroidVpnBridge vpnBridge; @@ -18989,4 +19018,37 @@ public byte[][] generateRsaKeyPair(int bits) { throw new RuntimeException("RSA keypair generation failed: " + e.getMessage()); } } + + @Override + public byte[][] generateEcKeyPair(int bits) { + try { + java.security.KeyPairGenerator kpg = java.security.KeyPairGenerator.getInstance("EC"); + kpg.initialize(new java.security.spec.ECGenParameterSpec("secp256r1")); + java.security.KeyPair kp = kpg.generateKeyPair(); + byte[] spki = kp.getPublic().getEncoded(); + // The JDK's PKCS#8 omits the public point; re-encode so every port + // hands out the same bytes and providers that need the point get it. + byte[] scalar = com.codename1.impl.EcP256Codec.fromPkcs8(kp.getPrivate().getEncoded())[0]; + byte[] pkcs8 = com.codename1.impl.EcP256Codec.pkcs8FromScalar(scalar, + com.codename1.impl.EcP256Codec.pointFromSpki(spki)); + return new byte[][]{ spki, pkcs8 }; + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("EC keypair generation failed: " + e.getMessage()); + } + } + + @Override + public byte[] ecdhAgree(byte[] privateKeyPkcs8, byte[] publicKeyX509) { + try { + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("EC"); + java.security.PrivateKey priv = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); + java.security.PublicKey pub = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); + javax.crypto.KeyAgreement ka = javax.crypto.KeyAgreement.getInstance("ECDH"); + ka.init(priv); + ka.doPhase(pub, true); + return ka.generateSecret(); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("ECDH failed: " + e.getMessage()); + } + } } diff --git a/Ports/Android/src/com/codename1/impl/android/webrtc/AndroidWebRtc.java b/Ports/Android/src/com/codename1/impl/android/webrtc/AndroidWebRtc.java new file mode 100644 index 00000000000..9fc9c31d918 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/webrtc/AndroidWebRtc.java @@ -0,0 +1,651 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android.webrtc; + +import android.content.Context; +import android.media.AudioManager; +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.ui.PeerComponent; +import com.codename1.webrtc.WebRtc; +import com.codename1.webrtc.spi.WebRtcBridge; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.webrtc.AudioSource; +import org.webrtc.AudioTrack; +import org.webrtc.Camera2Enumerator; +import org.webrtc.CameraVideoCapturer; +import org.webrtc.DataChannel; +import org.webrtc.DefaultVideoDecoderFactory; +import org.webrtc.DefaultVideoEncoderFactory; +import org.webrtc.EglBase; +import org.webrtc.IceCandidate; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStream; +import org.webrtc.MediaStreamTrack; +import org.webrtc.PeerConnection; +import org.webrtc.PeerConnectionFactory; +import org.webrtc.RtpReceiver; +import org.webrtc.RtpTransceiver; +import org.webrtc.SdpObserver; +import org.webrtc.SessionDescription; +import org.webrtc.SurfaceTextureHelper; +import org.webrtc.SurfaceViewRenderer; +import org.webrtc.VideoSource; +import org.webrtc.VideoTrack; + +/// The Android WebRTC bridge, on the `org.webrtc` SDK +/// (`io.github.webrtc-sdk:android`). +/// +/// Optional: the builder deletes this package for every application that +/// never references `com.codename1.webrtc`, and adds the SDK only for the +/// ones that do. `AndroidImplementation` reaches it by name, so the port +/// itself compiles without the SDK. +/// +/// Handles are allocated here, from one counter shared by peers and tracks. +/// All state is guarded by this object's monitor because the SDK answers on +/// its own signalling thread while the API calls in from the EDT. +public class AndroidWebRtc implements WebRtcBridge { + private static final int VIDEO_WIDTH = 1280; + private static final int VIDEO_HEIGHT = 720; + private static final int VIDEO_FPS = 30; + + private final Context context; + private EglBase egl; + private PeerConnectionFactory factory; + private int nextHandle = 1; + private final Map peers = new HashMap(); + private final Map tracks = new HashMap(); + private final Map videos = new HashMap(); + private final Map views = new HashMap(); + private final java.util.Set locals = new java.util.HashSet(); + /// Each local audio track's source, a native object of its own that + /// disposing the track leaves behind. + private final Map audioSources = new HashMap(); + + /// One peer connection and the candidates that arrived before its remote + /// description, which the SDK would otherwise reject. + private static final class Peer { + PeerConnection connection; + boolean remoteSet; + final List pending = new ArrayList(); + /// Handles of the tracks the far side sent, dropped with the peer. + final List remote = new ArrayList(); + } + + /// A local camera track and what feeds it. + private static final class LocalVideo { + CameraVideoCapturer capturer; + SurfaceTextureHelper helper; + VideoSource source; + boolean front; + } + + /// A renderer attached to a video track. + private static final class Sink { + SurfaceViewRenderer renderer; + VideoTrack track; + } + + /// Creates the bridge. + /// + /// #### Parameters + /// + /// - `context`: the application context + public AndroidWebRtc(Context context) { + this.context = context.getApplicationContext(); + } + + private synchronized PeerConnectionFactory factory() { + if (factory == null) { + PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions + .builder(context).createInitializationOptions()); + egl = EglBase.create(); + factory = PeerConnectionFactory.builder() + .setVideoEncoderFactory(new DefaultVideoEncoderFactory( + egl.getEglBaseContext(), true, true)) + .setVideoDecoderFactory(new DefaultVideoDecoderFactory( + egl.getEglBaseContext())) + .createPeerConnectionFactory(); + } + return factory; + } + + private synchronized int allocate() { + return nextHandle++; + } + + private synchronized Peer peer(int handle) { + return peers.get(Integer.valueOf(handle)); + } + + private synchronized MediaStreamTrack track(int handle) { + return tracks.get(Integer.valueOf(handle)); + } + + private synchronized int register(MediaStreamTrack track) { + int handle = nextHandle++; + tracks.put(Integer.valueOf(handle), track); + return handle; + } + + private synchronized int registerLocal(MediaStreamTrack track) { + int handle = register(track); + locals.add(Integer.valueOf(handle)); + return handle; + } + + @Override + public int createPeerConnection(String iceServers, boolean relayOnly) { + try { + List servers = new ArrayList(); + if (iceServers != null) { + String[] lines = iceServers.split("\n"); + for (int iter = 0; iter < lines.length; iter++) { + String line = lines[iter].trim(); + if (line.length() == 0) { + continue; + } + String[] parts = line.split("\t", -1); + List urls = new ArrayList(); + String[] split = parts[0].split(","); + for (int u = 0; u < split.length; u++) { + if (split[u].trim().length() > 0) { + urls.add(split[u].trim()); + } + } + if (urls.isEmpty()) { + continue; + } + PeerConnection.IceServer.Builder b = PeerConnection.IceServer.builder(urls); + if (parts.length > 1 && parts[1].length() > 0) { + b.setUsername(parts[1]); + } + if (parts.length > 2 && parts[2].length() > 0) { + b.setPassword(parts[2]); + } + servers.add(b.createIceServer()); + } + } + PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(servers); + config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; + config.iceTransportsType = relayOnly + ? PeerConnection.IceTransportsType.RELAY : PeerConnection.IceTransportsType.ALL; + final int handle = allocate(); + PeerConnection connection = factory().createPeerConnection(config, + new Observer(handle)); + if (connection == null) { + return -1; + } + Peer p = new Peer(); + p.connection = connection; + synchronized (this) { + peers.put(Integer.valueOf(handle), p); + } + inCall(true); + return handle; + } catch (Throwable t) { + return -1; + } + } + + @Override + public void openUserMedia(final int requestId, final boolean audio, final boolean video, + final boolean frontCamera) { + // Asked on the calling thread: checkForPermission prompts through the + // port's usual flow and blocks until the user answers. + if (audio && !AndroidImplementation.checkForPermission("android.permission.RECORD_AUDIO", + "The microphone is needed for the call")) { + WebRtc.deliverTracks(requestId, 0, 0, "Microphone permission denied"); + return; + } + if (video && !AndroidImplementation.checkForPermission("android.permission.CAMERA", + "The camera is needed for the call")) { + WebRtc.deliverTracks(requestId, 0, 0, "Camera permission denied"); + return; + } + new Thread(new Runnable() { + public void run() { + int audioHandle = 0; + int videoHandle = 0; + String error = null; + try { + PeerConnectionFactory f = factory(); + if (audio) { + AudioSource source = f.createAudioSource(new MediaConstraints()); + AudioTrack t = f.createAudioTrack("a" + allocate(), source); + audioHandle = registerLocal(t); + synchronized (AndroidWebRtc.this) { + audioSources.put(Integer.valueOf(audioHandle), source); + } + } + if (video) { + videoHandle = openCamera(f, frontCamera); + if (videoHandle == 0) { + error = "No camera could be opened"; + } + } + } catch (Throwable t) { + error = String.valueOf(t.getMessage()); + } + if (error != null && audioHandle != 0) { + releaseTrack(audioHandle); + audioHandle = 0; + } + WebRtc.deliverTracks(requestId, audioHandle, videoHandle, error); + } + }, "CN1 WebRTC media").start(); + } + + private int openCamera(PeerConnectionFactory f, boolean front) { + Camera2Enumerator enumerator = new Camera2Enumerator(context); + String name = pickCamera(enumerator, front); + if (name == null) { + return 0; + } + LocalVideo lv = new LocalVideo(); + lv.front = enumerator.isFrontFacing(name); + lv.capturer = enumerator.createCapturer(name, null); + if (lv.capturer == null) { + return 0; + } + lv.helper = SurfaceTextureHelper.create("CN1 WebRTC capture", egl.getEglBaseContext()); + lv.source = f.createVideoSource(false); + lv.capturer.initialize(lv.helper, context, lv.source.getCapturerObserver()); + lv.capturer.startCapture(VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS); + VideoTrack t = f.createVideoTrack("v" + allocate(), lv.source); + int handle = registerLocal(t); + synchronized (this) { + videos.put(Integer.valueOf(handle), lv); + } + return handle; + } + + private static String pickCamera(Camera2Enumerator enumerator, boolean front) { + String[] names = enumerator.getDeviceNames(); + for (int iter = 0; iter < names.length; iter++) { + if (enumerator.isFrontFacing(names[iter]) == front) { + return names[iter]; + } + } + return names.length > 0 ? names[0] : null; + } + + @Override + public void addTrack(int peer, int track) { + Peer p = peer(peer); + MediaStreamTrack t = track(track); + if (p != null && t != null) { + List streams = new ArrayList(); + streams.add("cn1"); + p.connection.addTrack(t, streams); + } + } + + @Override + public void createDescription(int peer, final int requestId, boolean answer) { + Peer p = peer(peer); + if (p == null) { + WebRtc.deliverDescription(requestId, null, null, "Unknown peer connection"); + return; + } + SdpObserver observer = new SdpObserver() { + public void onCreateSuccess(SessionDescription desc) { + WebRtc.deliverDescription(requestId, typeName(desc.type), desc.description, null); + } + + public void onCreateFailure(String error) { + WebRtc.deliverDescription(requestId, null, null, error); + } + + public void onSetSuccess() { + } + + public void onSetFailure(String error) { + } + }; + if (answer) { + p.connection.createAnswer(observer, new MediaConstraints()); + } else { + p.connection.createOffer(observer, new MediaConstraints()); + } + } + + private static String typeName(SessionDescription.Type type) { + switch (type) { + case OFFER: + return "offer"; + case PRANSWER: + return "pranswer"; + case ROLLBACK: + return "rollback"; + default: + return "answer"; + } + } + + @Override + public void setDescription(int peer, final int requestId, final boolean remote, String type, + String sdp) { + final Peer p = peer(peer); + if (p == null) { + WebRtc.deliverResult(requestId, "Unknown peer connection"); + return; + } + SessionDescription.Type t; + if ("offer".equals(type)) { + t = SessionDescription.Type.OFFER; + } else if ("answer".equals(type)) { + t = SessionDescription.Type.ANSWER; + } else if ("pranswer".equals(type)) { + t = SessionDescription.Type.PRANSWER; + } else { + WebRtc.deliverResult(requestId, "Unknown description type " + type); + return; + } + SdpObserver observer = new SdpObserver() { + public void onCreateSuccess(SessionDescription desc) { + } + + public void onCreateFailure(String error) { + } + + public void onSetSuccess() { + if (remote) { + flushCandidates(p); + } + WebRtc.deliverResult(requestId, null); + } + + public void onSetFailure(String error) { + WebRtc.deliverResult(requestId, error == null ? "failed" : error); + } + }; + SessionDescription desc = new SessionDescription(t, sdp); + if (remote) { + p.connection.setRemoteDescription(observer, desc); + } else { + p.connection.setLocalDescription(observer, desc); + } + } + + private void flushCandidates(Peer p) { + List held; + synchronized (this) { + p.remoteSet = true; + held = new ArrayList(p.pending); + p.pending.clear(); + } + for (int iter = 0; iter < held.size(); iter++) { + p.connection.addIceCandidate(held.get(iter)); + } + } + + @Override + public void addIceCandidate(int peer, String sdpMid, int sdpMLineIndex, String candidate) { + Peer p = peer(peer); + if (p == null || candidate == null) { + return; + } + IceCandidate c = new IceCandidate(sdpMid, sdpMLineIndex, candidate); + synchronized (this) { + if (!p.remoteSet) { + p.pending.add(c); + return; + } + } + p.connection.addIceCandidate(c); + } + + @Override + public void closePeerConnection(int peer) { + Peer p; + synchronized (this) { + p = peers.remove(Integer.valueOf(peer)); + if (p != null) { + // The SDK disposes remote tracks with their connection, and + // releaseTrack leaves them alone, so their handles go here. + for (int iter = 0; iter < p.remote.size(); iter++) { + tracks.remove(p.remote.get(iter)); + } + } + } + if (p != null) { + p.connection.dispose(); + } + boolean last; + synchronized (this) { + last = peers.isEmpty(); + } + if (last) { + inCall(false); + } + } + + /// The audio mode before the first call, restored after the last; -1 + /// outside a call. + private int modeBeforeCall = -1; + + /// A call keeps the audio system in communication mode -- earpiece + /// routing, echo cancellation -- for its whole length, whichever way the + /// speaker is switched, and hands back the mode it found when it ends. + private void inCall(boolean on) { + Object service = context.getSystemService(Context.AUDIO_SERVICE); + if (!(service instanceof AudioManager)) { + return; + } + AudioManager am = (AudioManager) service; + if (on && modeBeforeCall < 0) { + modeBeforeCall = am.getMode(); + am.setMode(AudioManager.MODE_IN_COMMUNICATION); + } else if (!on && modeBeforeCall >= 0) { + am.setSpeakerphoneOn(false); + am.setMode(modeBeforeCall); + modeBeforeCall = -1; + } + } + + @Override + public void setTrackEnabled(int track, boolean enabled) { + MediaStreamTrack t = track(track); + if (t != null) { + t.setEnabled(enabled); + } + } + + @Override + public void switchCamera(int track) { + LocalVideo lv; + synchronized (this) { + lv = videos.get(Integer.valueOf(track)); + } + if (lv != null) { + lv.capturer.switchCamera(null); + lv.front = !lv.front; + } + } + + @Override + public void setSpeakerphone(boolean on) { + Object service = context.getSystemService(Context.AUDIO_SERVICE); + if (service instanceof AudioManager) { + AudioManager am = (AudioManager) service; + am.setSpeakerphoneOn(on); + } + } + + @Override + public void releaseTrack(int track) { + MediaStreamTrack t; + LocalVideo lv; + AudioSource audioSource; + boolean local; + synchronized (this) { + Integer key = Integer.valueOf(track); + t = tracks.remove(key); + audioSource = audioSources.remove(key); + lv = videos.remove(key); + local = locals.remove(key); + } + if (t != null && local) { + // Remote tracks belong to their receiver and are disposed with the + // peer connection; only the local ones are ours to free. + t.dispose(); + } + if (audioSource != null) { + audioSource.dispose(); + } + if (lv != null) { + try { + lv.capturer.stopCapture(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + lv.capturer.dispose(); + lv.source.dispose(); + lv.helper.dispose(); + } + } + + @Override + public Object createVideoView(int track, final boolean mirror) { + MediaStreamTrack t = track(track); + if (!(t instanceof VideoTrack)) { + return null; + } + factory(); + final Sink sink = new Sink(); + sink.track = (VideoTrack) t; + AndroidImplementation.runOnUiThreadAndBlock(new Runnable() { + public void run() { + SurfaceViewRenderer r = new SurfaceViewRenderer(AndroidImplementation.getActivity()); + r.init(egl.getEglBaseContext(), null); + r.setMirror(mirror); + r.setEnableHardwareScaler(true); + sink.renderer = r; + } + }); + sink.track.addSink(sink.renderer); + PeerComponent peer = PeerComponent.create(sink.renderer); + synchronized (this) { + views.put(peer, sink); + } + return peer; + } + + @Override + public void releaseVideoView(Object view) { + final Sink sink; + synchronized (this) { + sink = views.remove(view); + } + if (sink == null) { + return; + } + sink.track.removeSink(sink.renderer); + AndroidImplementation.getActivity().runOnUiThread(new Runnable() { + public void run() { + sink.renderer.release(); + } + }); + } + + private static String stateName(PeerConnection.PeerConnectionState state) { + switch (state) { + case NEW: + return "new"; + case CONNECTING: + return "connecting"; + case CONNECTED: + return "connected"; + case DISCONNECTED: + return "disconnected"; + case FAILED: + return "failed"; + default: + return "closed"; + } + } + + /// Forwards one peer connection's events to the API. + private final class Observer implements PeerConnection.Observer { + private final int handle; + + Observer(int handle) { + this.handle = handle; + } + + public void onSignalingChange(PeerConnection.SignalingState state) { + } + + public void onIceConnectionChange(PeerConnection.IceConnectionState state) { + } + + public void onConnectionChange(PeerConnection.PeerConnectionState state) { + WebRtc.stateChanged(handle, stateName(state)); + } + + public void onIceConnectionReceivingChange(boolean receiving) { + } + + public void onIceGatheringChange(PeerConnection.IceGatheringState state) { + } + + public void onIceCandidate(IceCandidate candidate) { + WebRtc.iceCandidate(handle, candidate.sdpMid, candidate.sdpMLineIndex, candidate.sdp); + } + + public void onIceCandidatesRemoved(IceCandidate[] candidates) { + } + + public void onAddStream(MediaStream stream) { + } + + public void onRemoveStream(MediaStream stream) { + } + + public void onDataChannel(DataChannel channel) { + } + + public void onRenegotiationNeeded() { + } + + public void onAddTrack(RtpReceiver receiver, MediaStream[] streams) { + } + + public void onTrack(RtpTransceiver transceiver) { + MediaStreamTrack t = transceiver.getReceiver().track(); + if (t != null) { + int trackHandle = register(t); + synchronized (AndroidWebRtc.this) { + Peer p = peers.get(Integer.valueOf(handle)); + if (p != null) { + p.remote.add(Integer.valueOf(trackHandle)); + } + } + WebRtc.remoteTrack(handle, trackHandle, t instanceof VideoTrack); + } + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 320e70034fc..6b5368286c5 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -18075,6 +18075,23 @@ public static void setSimulatedContinuity( com.codename1.continuity.Continuity.refreshBridge(); } + private com.codename1.impl.javase.webrtc.LoopbackWebRtc webRtc; + + /// The simulator carries calls through every step but the media: see + /// [com.codename1.impl.javase.webrtc.LoopbackWebRtc]. A desktop build has + /// no WebRTC, so it answers null and the app sees calls as unsupported + /// rather than connecting calls that carry nothing. + @Override + public com.codename1.webrtc.spi.WebRtcBridge getWebRtcBridge() { + if (!isSimulator()) { + return null; + } + if (webRtc == null) { + webRtc = new com.codename1.impl.javase.webrtc.LoopbackWebRtc(); + } + return webRtc; + } + /// The VPN bridge for the simulator and desktop builds: a simulated /// configuration store that tunnels nothing. /// @@ -23962,4 +23979,37 @@ public byte[][] generateRsaKeyPair(int bits) { throw new RuntimeException("RSA keypair generation failed: " + e.getMessage()); } } + + @Override + public byte[][] generateEcKeyPair(int bits) { + try { + java.security.KeyPairGenerator kpg = java.security.KeyPairGenerator.getInstance("EC"); + kpg.initialize(new java.security.spec.ECGenParameterSpec("secp256r1")); + java.security.KeyPair kp = kpg.generateKeyPair(); + byte[] spki = kp.getPublic().getEncoded(); + // The JDK's PKCS#8 omits the public point; re-encode so every port + // hands out the same bytes and providers that need the point get it. + byte[] scalar = com.codename1.impl.EcP256Codec.fromPkcs8(kp.getPrivate().getEncoded())[0]; + byte[] pkcs8 = com.codename1.impl.EcP256Codec.pkcs8FromScalar(scalar, + com.codename1.impl.EcP256Codec.pointFromSpki(spki)); + return new byte[][]{ spki, pkcs8 }; + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("EC keypair generation failed: " + e.getMessage()); + } + } + + @Override + public byte[] ecdhAgree(byte[] privateKeyPkcs8, byte[] publicKeyX509) { + try { + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("EC"); + java.security.PrivateKey priv = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); + java.security.PublicKey pub = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); + javax.crypto.KeyAgreement ka = javax.crypto.KeyAgreement.getInstance("ECDH"); + ka.init(priv); + ka.doPhase(pub, true); + return ka.generateSecret(); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("ECDH failed: " + e.getMessage()); + } + } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/webrtc/LoopbackWebRtc.java b/Ports/JavaSE/src/com/codename1/impl/javase/webrtc/LoopbackWebRtc.java new file mode 100644 index 00000000000..464800a5117 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/webrtc/LoopbackWebRtc.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase.webrtc; + +import com.codename1.ui.Component; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.webrtc.WebRtc; +import com.codename1.webrtc.spi.WebRtcBridge; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The simulator's WebRTC: every step of a call without the media. +/// +/// Tracks open at once, descriptions are generated with one `m=` line per +/// track a connection sends, a candidate is gathered when a local +/// description is set, and once both descriptions are in the connection +/// reports connected and hands over one remote track per `m=` line of the +/// other side's description. Nothing crosses the network: the two sides of a +/// call may be two simulators or a simulator and a test that answers through +/// the app's own signalling, and the flow is identical either way. A video +/// view paints a test pattern naming its track. +public final class LoopbackWebRtc implements WebRtcBridge { + private static final int[] BARS = {0xc0c0c0, 0xc0c000, 0x00c0c0, 0x00c000, 0xc000c0, 0xc00000, + 0x0000c0}; + + private final Map peers = new HashMap(); + private final Map tracks = new HashMap(); + private int nextPeer; + private int nextTrack; + + private static final class Peer { + final List sending = new ArrayList(); + String remote; + boolean local; + boolean connected; + int session; + } + + public synchronized int createPeerConnection(String iceServers, boolean relayOnly) { + int id = ++nextPeer; + Peer peer = new Peer(); + peer.session = 1000 + id; + peers.put(Integer.valueOf(id), peer); + return id; + } + + public synchronized void openUserMedia(int requestId, boolean audio, boolean video, + boolean frontCamera) { + int a = 0; + int v = 0; + if (audio) { + a = ++nextTrack; + tracks.put(Integer.valueOf(a), "audio"); + } + if (video) { + v = ++nextTrack; + tracks.put(Integer.valueOf(v), "video"); + } + WebRtc.deliverTracks(requestId, a, v, null); + } + + public synchronized void addTrack(int peer, int track) { + Peer p = peers.get(Integer.valueOf(peer)); + String kind = tracks.get(Integer.valueOf(track)); + if (p != null && kind != null) { + p.sending.add(kind); + } + } + + public synchronized void createDescription(int peer, int requestId, boolean answer) { + Peer p = peers.get(Integer.valueOf(peer)); + if (p == null) { + WebRtc.deliverDescription(requestId, null, null, "closed"); + return; + } + if (answer && p.remote == null) { + WebRtc.deliverDescription(requestId, null, null, "No remote offer to answer"); + return; + } + StringBuilder sdp = new StringBuilder(); + sdp.append("v=0\r\no=- ").append(p.session).append(" 2 IN IP4 127.0.0.1\r\n") + .append("s=-\r\nt=0 0\r\n"); + for (int iter = 0; iter < p.sending.size(); iter++) { + String kind = p.sending.get(iter); + sdp.append("m=").append(kind).append(" 9 UDP/TLS/RTP/SAVPF ") + .append("audio".equals(kind) ? "111" : "96").append("\r\n") + .append("a=mid:").append(iter).append("\r\na=sendrecv\r\n"); + } + WebRtc.deliverDescription(requestId, answer ? "answer" : "offer", sdp.toString(), null); + } + + public void setDescription(int peer, int requestId, boolean remote, String type, String sdp) { + boolean connect = false; + List arriving = null; + synchronized (this) { + Peer p = peers.get(Integer.valueOf(peer)); + if (p == null) { + WebRtc.deliverResult(requestId, "closed"); + return; + } + if (remote) { + p.remote = sdp == null ? "" : sdp; + } else { + p.local = true; + } + if (p.local && p.remote != null && !p.connected) { + p.connected = true; + connect = true; + arriving = kinds(p.remote); + } + } + WebRtc.deliverResult(requestId, null); + if (!remote) { + WebRtc.iceCandidate(peer, "0", 0, + "candidate:1 1 udp 2122260223 127.0.0.1 9 typ host"); + } + if (connect) { + WebRtc.stateChanged(peer, "connecting"); + for (int iter = 0; iter < arriving.size(); iter++) { + String kind = arriving.get(iter); + int track; + synchronized (this) { + track = ++nextTrack; + tracks.put(Integer.valueOf(track), kind); + } + WebRtc.remoteTrack(peer, track, "video".equals(kind)); + } + WebRtc.stateChanged(peer, "connected"); + } + } + + private static List kinds(String sdp) { + List out = new ArrayList(); + int at = 0; + while (at < sdp.length()) { + int end = sdp.indexOf('\n', at); + if (end < 0) { + end = sdp.length(); + } + String line = sdp.substring(at, end).trim(); + if (line.startsWith("m=audio")) { + out.add("audio"); + } else if (line.startsWith("m=video")) { + out.add("video"); + } + at = end + 1; + } + return out; + } + + public void addIceCandidate(int peer, String sdpMid, int sdpMLineIndex, String candidate) { + // Nothing to try: the loopback is connected by its descriptions. + } + + public synchronized void closePeerConnection(int peer) { + peers.remove(Integer.valueOf(peer)); + } + + public void setTrackEnabled(int track, boolean enabled) { + } + + public void switchCamera(int track) { + } + + public void setSpeakerphone(boolean on) { + } + + public synchronized void releaseTrack(int track) { + tracks.remove(Integer.valueOf(track)); + } + + public Object createVideoView(final int track, final boolean mirror) { + Component view = new Component() { + @Override + public void paint(Graphics g) { + int w = getWidth(); + int h = getHeight(); + int bar = Math.max(1, w / BARS.length); + for (int iter = 0; iter < BARS.length; iter++) { + int color = BARS[mirror ? BARS.length - 1 - iter : iter]; + g.setColor(color); + g.fillRect(getX() + iter * bar, getY(), iter == BARS.length - 1 + ? w - iter * bar : bar, h); + } + g.setColor(0xffffff); + g.drawString("track " + track, getX() + 8, getY() + 8); + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(320, 240); + } + }; + view.setName("videoTrack-" + track); + return view; + } + + public void releaseVideoView(Object view) { + } +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index b2f782ff8f4..8780ed28cf1 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -8672,6 +8672,19 @@ public com.codename1.home.spi.HomeBridge getHomeBridge() { } } + private HTML5WebRtc webRtc; + + /// The browser's WebRTC, where the browser has one. + @Override + public com.codename1.webrtc.spi.WebRtcBridge getWebRtcBridge() { + synchronized (HTML5Implementation.class) { + if (webRtc == null && HTML5WebRtc.nativeRtcAvailable()) { + webRtc = new HTML5WebRtc(); + } + return webRtc; + } + } + /// The nearby bridge for the JavaScript port: a simulated /// implementation rather than no implementation, for the same reason /// [#getHomeBridge()] carries one. diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5WebRtc.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5WebRtc.java new file mode 100644 index 00000000000..7d39c893d75 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5WebRtc.java @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.html5; + +import com.codename1.html5.js.JSBody; +import com.codename1.html5.js.JSFunctor; +import com.codename1.html5.js.JSObject; +import com.codename1.html5.js.dom.HTMLElement; +import com.codename1.webrtc.WebRtc; +import com.codename1.webrtc.spi.WebRtcBridge; + +/// The browser's own WebRTC behind the bridge. +/// +/// On the ParparVM JavaScript port the translated application runs in a +/// worker, which has no `RTCPeerConnection`, no `navigator.mediaDevices` and no +/// DOM. Every `nativeRtc*` method is therefore bound in `port.js` to the +/// `__cn1_webrtc__` host native in `browser_bridge.js`, which keeps the peer +/// connections and tracks on the main thread in tables keyed by the integer +/// handles the API speaks in, streams asynchronous outcomes back through the +/// worker-callback channel into [#dispatch], and hands a video view back as an +/// opaque `