diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index c59dc002c23..9357fbb2b1f 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -7,6 +7,13 @@ on: - 'docs/developer-guide/**' - 'docs/demos/**' - 'scripts/developer-guide/**' + # The Wayline chapters include the sample's own sources by tag, so moving + # or deleting a tagged region there changes the guide without touching + # docs/. Only the trigger, not the `docs` filter below: the Asciidoctor + # lint runs on every trigger and fails on a tag it cannot find, and the + # 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 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 diff --git a/.github/workflows/parparvm-tests.yml b/.github/workflows/parparvm-tests.yml index f24feaba7d8..9e3d6140375 100644 --- a/.github/workflows/parparvm-tests.yml +++ b/.github/workflows/parparvm-tests.yml @@ -181,6 +181,10 @@ jobs: # The full suite and serial GC benchmarks took 69 and 47 minutes in run # 38355108612. Both passed, but setup plus reporting exceeded two hours. # Leave reporting headroom; each GC workload retains its own timeout. + # + # The tests of the runtime library no longer run once per JDK + # (CompilerHelper.getBookendCompilers), which gives back some of that + # time; the limit stays where a slow runner still fits inside it. timeout-minutes: 150 # The two database engines the backend speaks a wire protocol to. # diff --git a/.github/workflows/wayline.yml b/.github/workflows/wayline.yml new file mode 100644 index 00000000000..2eef7f25286 --- /dev/null +++ b/.github/workflows/wayline.yml @@ -0,0 +1,479 @@ +name: Wayline (full-stack sample) + +# scripts/wayline 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/wayline/**' + - '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/**' + - 'CodenameOne/src/com/codename1/maps/**' + - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/rest/**' + # 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-wayline.py' + - 'scripts/tests/generate-initializr-fixtures.py' + - 'scripts/tests/GenerateInitializr.java' + # The generator, the template, and the generator's own tests, which this + # workflow is the only one to run. + - 'scripts/initializr/common/src/**' + - '.github/workflows/wayline.yml' + push: + branches: [ master ] + paths: + - 'scripts/wayline/**' + - 'vm/backend/src/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + - 'scripts/sync-initializr-wayline.py' + - 'scripts/initializr/common/src/**' + - '.github/workflows/wayline.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/wayline 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/wayline + run: python3 scripts/sync-initializr-wayline.py + + 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/wayline + 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/wayline/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: wayline-backend-tests + path: scripts/wayline/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/wayline/check-template.sh "$RUNNER_TEMP/wayline-template" + + # The generator's matrix of templates, layouts and IDEs, and the screens + # that offer them. No other workflow runs these tests. + - name: Run the Initializr's tests + working-directory: scripts/initializr + run: | + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + xvfb-run -a mvn -B -Dcn1.localWorkspace=true -Dcodename1.platform=javase -Ptest verify + + - 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/wayline/run-e2e.sh + + - name: Upload the captures and the server log + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: wayline-e2e + path: | + scripts/wayline/target/e2e-shots/** + scripts/wayline/target/e2e-server.log + 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/wayline/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: wayline-web-e2e + path: scripts/wayline/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/wayline + 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-wayline-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2-wayline- + ${{ runner.os }}-m2- + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-wayline-${{ hashFiles('Ports/Android/build.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle-wayline- + ${{ 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/wayline + 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 }}/wayline-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/website-docs.yml b/.github/workflows/website-docs.yml index 3849516c30c..9978768ed55 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -59,6 +59,10 @@ on: - 'docs/developer-guide/**' - 'scripts/cn1playground/**' - 'scripts/initializr/**' + # The developer guide's Wayline chapters include the sample's sources by + # tag, so a merged change there changes the published guide. Not on pull + # requests: developer-guide-docs.yml already renders the guide for those. + - 'scripts/wayline/**' - 'scripts/website/**' - 'scripts/skindesigner/**' - 'Ports/JavaScriptPort/**' @@ -310,7 +314,7 @@ jobs: # on a build where clicking any class left the site: Pages redirects # /x.html to /x before an asset is considered, so the page published at # the extension was one nobody could reach. - scripts/website/check-javadoc-urls.sh docs/website/public 8789 + scripts/website/check-javadoc-urls.sh docs/website/public - name: Validate Port Status page output run: | @@ -393,38 +397,27 @@ jobs: - name: Validate redirects against local Pages runtime run: | set -euo pipefail - PORT=8788 + # Its own process group on a free port, stopped as a group. With a + # fixed 8788 and `kill $!` (npx only), build.sh's javadoc URL check + # left a workerd on 8788 that answered this step instead of the + # server it started; see scripts/website/pages-dev-server.sh. + . scripts/website/pages-dev-server.sh + trap pages_dev_stop EXIT + # From docs/website, so wrangler compiles docs/website/functions. cd docs/website - npx --yes "wrangler@${WRANGLER_VERSION}" pages dev public \ - --port "${PORT}" \ - --ip 127.0.0.1 \ - --compatibility-date=2026-02-18 > /tmp/wrangler-pages-dev.log 2>&1 & - WRANGLER_PID=$! - cleanup() { - kill "${WRANGLER_PID}" >/dev/null 2>&1 || true - } - trap cleanup EXIT + pages_dev_start public /tmp/wrangler-pages-dev.log "" \ + --compatibility-date=2026-02-18 + cd ../.. print_log_on_error() { echo "wrangler pages dev log (tail):" >&2 tail -n 200 /tmp/wrangler-pages-dev.log >&2 || true } trap print_log_on_error ERR - for i in $(seq 1 60); do - if curl -sSf "http://127.0.0.1:${PORT}/" >/dev/null; then - break - fi - sleep 1 - done - if ! curl -sSf "http://127.0.0.1:${PORT}/" >/dev/null; then - echo "wrangler pages dev did not become ready on port ${PORT}" >&2 - exit 1 - fi - cd ../.. python3 scripts/website/test_redirects.py \ - --base-url "http://127.0.0.1:${PORT}" \ + --base-url "${PAGES_DEV_URL}" \ --redirects-file docs/website/public/_redirects \ --skip-auto-cases - node scripts/website/test_search.mjs "http://127.0.0.1:${PORT}" + node scripts/website/test_search.mjs "${PAGES_DEV_URL}" - name: Validate internal links and images uses: lycheeverse/lychee-action@v2 diff --git a/CodenameOne/src/com/codename1/annotations/Column.java b/CodenameOne/src/com/codename1/annotations/Column.java index 481d523f31f..2d7ec073a41 100644 --- a/CodenameOne/src/com/codename1/annotations/Column.java +++ b/CodenameOne/src/com/codename1/annotations/Column.java @@ -28,15 +28,17 @@ import java.lang.annotation.Target; /// Renames or constrains an `@Entity` field's column. Optional -- when absent, -/// the column name defaults to the field name and the type is inferred from -/// the Java field type (`String -> TEXT`, `int/long -> INTEGER`, +/// the column name defaults to the field name (in snake case on the backend) +/// and the type is inferred from the Java field type (`String -> TEXT`, `int/long -> INTEGER`, /// `float/double -> REAL`, `boolean -> INTEGER`, `byte[] -> BLOB`, /// `java.util.Date -> INTEGER`). @Retention(RetentionPolicy.CLASS) @Target(ElementType.FIELD) @com.codename1.impl.SharedWithBackend public @interface Column { - /// Column name. Defaults to the field name when blank. + /// Column name. When blank the app uses the field name and the backend + /// the field name in snake case (`fareCents` becomes `fare_cents`), the + /// default Spring Boot applies. String name() default ""; /// When false the column gets a `NOT NULL` constraint at table-create time. diff --git a/CodenameOne/src/com/codename1/annotations/Entity.java b/CodenameOne/src/com/codename1/annotations/Entity.java index 90ac34de53a..34b36820dd2 100644 --- a/CodenameOne/src/com/codename1/annotations/Entity.java +++ b/CodenameOne/src/com/codename1/annotations/Entity.java @@ -62,14 +62,19 @@ /// GENERATES from it: against the core the processor emits a dao over /// `com.codename1.db.Database`, against the backend one over /// `com.codename1.backend.Database`, whose SQL is built for whichever engine the -/// connection turns out to be. On the server the class needs public fields and a -/// public no-arg constructor, because the generated dao reads and writes them -/// directly. +/// connection turns out to be. The class needs a public no-arg constructor. +/// +/// Every field that is not `static` or `transient` is persistent, whatever its +/// access; `@DbTransient` leaves one out. A non-public field is read and written +/// through a static accessor the build weaves into the class that declares it, +/// so the generated dao needs no reflection on any platform. @Retention(RetentionPolicy.CLASS) @Target(ElementType.TYPE) @com.codename1.impl.SharedWithBackend public @interface Entity { - /// SQL table name. Defaults to the simple class name when blank. + /// SQL table name. When blank the app names the table after the simple + /// class name, and the backend after the same name in snake case + /// (`RideEvent` becomes `ride_event`), as Spring Boot does. String table() default ""; /// Additional indexes created with the entity table. /// @return index declarations, empty by default diff --git a/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java b/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java index 2c270879895..61e8b81ddd1 100644 --- a/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java +++ b/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java @@ -137,7 +137,9 @@ private T checked(Object value) { } return (T) value; } - /// Bulk DML bypasses cascades/callbacks and clears the context after execution. + /// Bulk DML bypasses cascades/callbacks. Managed entities stay managed with the + /// values they had, as JPA's executeUpdate leaves the persistence context; a + /// repository method asks for a clear with `@Modifying(clearAutomatically = true)`. @Override public int executeUpdate() { if (!plan.mutation) { @@ -149,9 +151,7 @@ public int executeUpdate() { Bound bound = arguments(); session.requireTransaction(); session.flush(); - int changed = session.write(bound.sql, bound.args); - session.clear(); - return changed; + return session.write(bound.sql, bound.args); } private static final class Bound { final String sql; diff --git a/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java b/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java index 08ba6a963db..c74d31ec82c 100644 --- a/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java +++ b/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java @@ -195,8 +195,7 @@ public int executeUpdate() { Object[] args = arguments(); session.requireTransaction(); session.flush(); - int changed = session.write(sql, args); - session.clear(); - return changed; + // Like JPQL, the session is left as it is; see JpqlQueryImpl.executeUpdate. + return session.write(sql, args); } } diff --git a/CodenameOne/src/com/codename1/location/LocationManager.java b/CodenameOne/src/com/codename1/location/LocationManager.java index bf5b8a09bd7..dbc1128df4c 100644 --- a/CodenameOne/src/com/codename1/location/LocationManager.java +++ b/CodenameOne/src/com/codename1/location/LocationManager.java @@ -222,8 +222,7 @@ public Location getCurrentLocationSync(long timeout) { /// - `req`: @param req provide the settings in which we are interested to get updates /// to the Listener. public void setLocationListener(final LocationListener l, LocationRequest req) { - setLocationListener(l); - request = req; + install(l, req, true); } /// Adds a geo fence listener to gets an event once the device is in/out of @@ -272,6 +271,15 @@ protected LocationListener getLocationListener() { /// - `l`: @param l a LocationListener or null to stop the current listener /// from getting updates public void setLocationListener(final LocationListener l) { + install(l, null, false); + } + + /// Installs `l`, and with `withRequest` the request it is installed with. + /// The request is in place BEFORE bindListener() runs: the ports read + /// getRequest() while binding (the priority on iOS and Android, the + /// accuracy in the browser), and assigning it afterwards made every port + /// bind the first listener with the default settings. + private void install(final LocationListener l, LocationRequest req, boolean withRequest) { synchronized (LISTENER_LOCK) { // Every install gets a number, and an LL is told its own. That is // how a timed-out getCurrentLocationSync knows whether the listener @@ -290,6 +298,9 @@ public void setLocationListener(final LocationListener l) { status = TEMPORARILY_UNAVAILABLE; } listener = l; + if (withRequest) { + request = req; + } if (l == null) { return; } diff --git a/CodenameOne/src/com/codename1/maps/MapView.java b/CodenameOne/src/com/codename1/maps/MapView.java index 1e154e3a11f..88749afe573 100644 --- a/CodenameOne/src/com/codename1/maps/MapView.java +++ b/CodenameOne/src/com/codename1/maps/MapView.java @@ -35,6 +35,7 @@ import com.codename1.ui.FontImage; import com.codename1.ui.Graphics; import com.codename1.ui.Stroke; +import com.codename1.ui.TopLevelContainer; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.geom.GeneralPath; import com.codename1.ui.geom.Point; @@ -80,6 +81,49 @@ public class MapView extends Container implements MapSurface { private int lastTapX; private int lastTapY; + // How long a thrown map takes to lose all but a third of its speed, and how + // long a zoom takes to close all but a third of the way to where it is + // going. Both motions are decays rather than fixed-length tweens, so one + // that is added to while it runs -- a second notch of the wheel -- simply + // carries on from where it is. + private static final double GLIDE_MILLIS = 325; + private static final double ZOOM_MILLIS = 90; + // The longest step one frame may take. A frame that was held up must not + // then throw the map across the gap in one move. + private static final int LONGEST_STEP_MILLIS = 100; + // The part of a drag its release is judged by. + private static final int THROW_WINDOW_MILLIS = 120; + // A finger that rested this long before lifting put the map down. + private static final int THROW_REST_MILLIS = 80; + private static final int SAMPLES = 8; + + private final long[] sampleTime = new long[SAMPLES]; + private final int[] sampleX = new int[SAMPLES]; + private final int[] sampleY = new int[SAMPLES]; + private int sampleCount; + // Pixels a millisecond. + private double glideX; + private double glideY; + private boolean gliding; + private boolean zooming; + private double zoomTarget; + private int zoomFocusX; + private int zoomFocusY; + private long lastStep; + private boolean moving; + private TopLevelContainer movingOn; + private boolean wheelZoom; + // Where the pointer was last seen over the map, for a gesture that says + // how much to zoom and not where. + private int pointerX; + private int pointerY; + private boolean pointerSeen; + private int pinchX; + private int pinchY; + private boolean pinchPlaced; + private int pinchLastX; + private int pinchLastY; + /// Creates a map showing the free, keyless OpenFreeMap vector basemap (real /// OpenStreetMap data) centered on the equator at a low zoom. public MapView() { @@ -179,6 +223,7 @@ public CameraPosition getCameraPosition() { /// {@inheritDoc} @Override public void setCameraPosition(CameraPosition position) { + settle(); engine.setCenter(position.getTarget()); engine.setZoom(position.getZoom()); repaint(); @@ -188,6 +233,7 @@ public void setCameraPosition(CameraPosition position) { /// {@inheritDoc} @Override public void moveCamera(LatLng target, double zoom) { + settle(); engine.setCenter(target); engine.setZoom(zoom); repaint(); @@ -203,6 +249,7 @@ public double getZoom() { /// {@inheritDoc} @Override public void setZoom(double zoom) { + settle(); engine.setZoom(zoom); repaint(); fireCameraChanged(); @@ -229,6 +276,7 @@ public LatLng getCenter() { /// {@inheritDoc} @Override public void setCenter(LatLng center) { + settle(); engine.setCenter(center); repaint(); fireCameraChanged(); @@ -243,6 +291,7 @@ public MapBounds getVisibleRegion() { /// {@inheritDoc} @Override public void fitBounds(MapBounds bounds, int paddingPixels) { + settle(); engine.setViewport(getWidth(), getHeight()); engine.fitBounds(bounds, paddingPixels); repaint(); @@ -628,9 +677,180 @@ private GeneralPath buildPath(List points, boolean close) { /// {@inheritDoc} @Override public void pointerPressed(int x, int y) { + // A finger put down on a moving map stops it there. + boolean wasMoving = gliding || zooming; + settle(); + if (wasMoving) { + fireCameraChanged(); + } lastX = x; lastY = y; dragDistance = 0; + sampleCount = 0; + sample(x, y); + seen(x, y); + } + + /// Whether the wheel zooms the map about the pointer instead of panning it. + public boolean isWheelZoom() { + return wheelZoom; + } + + /// Makes the wheel zoom the map about the pointer, the way a map that fills + /// its window is expected to answer, instead of panning it. + /// + /// Panning is the default because a map is as often one item on a page + /// that scrolls: there the wheel belongs to the page, and a map that took + /// it to zoom would stop the page under the pointer wherever the two met. + /// A wheel at the end of the zoom range is passed on either way. + /// + /// #### Parameters + /// + /// - `wheelZoom`: true to zoom with the wheel, false to pan with it + public void setWheelZoom(boolean wheelZoom) { + this.wheelZoom = wheelZoom; + } + + /// {@inheritDoc} + @Override + public void pointerHover(int[] x, int[] y) { + if (x.length > 0) { + seen(x[0], y[0]); + } + super.pointerHover(x, y); + } + + private void seen(int x, int y) { + pointerX = x - getAbsoluteX(); + pointerY = y - getAbsoluteY(); + pointerSeen = true; + } + + private void sample(int x, int y) { + if (sampleCount == SAMPLES) { + System.arraycopy(sampleTime, 1, sampleTime, 0, SAMPLES - 1); + System.arraycopy(sampleX, 1, sampleX, 0, SAMPLES - 1); + System.arraycopy(sampleY, 1, sampleY, 0, SAMPLES - 1); + sampleCount--; + } + sampleTime[sampleCount] = System.currentTimeMillis(); + sampleX[sampleCount] = x; + sampleY[sampleCount] = y; + sampleCount++; + } + + // ---- Motion that outlasts the gesture -------------------------------- + + // Stops the map where it is. Whoever moves the camera next -- a finger, or + // the application -- is not to be argued with by a glide still running. + private void settle() { + gliding = false; + zooming = false; + } + + // Takes the map off the top level it registered with -- the one remembered, + // not the one it is on now, which is none once it has been removed. + private void stopAnimating() { + if (moving) { + moving = false; + if (movingOn != null) { + movingOn.deregisterAnimated(this); + movingOn = null; + } + } + } + + private void move() { + lastStep = System.currentTimeMillis(); + if (!moving) { + // The top level, not the form: getComponentForm() is null by design + // inside a desktop Window, and a map there would never be animated -- + // wheel zoom, double-tap zoom and the glide after a fling all did + // nothing. Remembered here rather than through Component's + // registerForAnimation(), whose single slot Component uses itself. + TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + top.registerAnimated(this); + movingOn = top; + moving = true; + } + } + repaint(); + } + + // Starts, or redirects, a zoom towards `target` that keeps the point under + // the given pixel where it is. + private void zoomTowards(double target, int focusX, int focusY) { + gliding = false; + zoomTarget = Math.max(engine.getMinZoom(), Math.min(engine.getMaxZoom(), target)); + zoomFocusX = focusX; + zoomFocusY = focusY; + zooming = true; + move(); + } + + /// {@inheritDoc} + @Override + public boolean animate() { + boolean other = super.animate(); + if (!gliding && !zooming) { + stopAnimating(); + return other; + } + long now = System.currentTimeMillis(); + long elapsed = Math.min(LONGEST_STEP_MILLIS, now - lastStep); + if (elapsed <= 0) { + return other; + } + lastStep = now; + if (zooming) { + double zoom = engine.getZoom(); + double left = zoomTarget - zoom; + double next = zoomTarget - left * MathUtil.exp(-elapsed / ZOOM_MILLIS); + if (Math.abs(zoomTarget - next) < 0.004) { + next = zoomTarget; + zooming = false; + } + engine.zoomAround(next, zoomFocusX, zoomFocusY); + } + if (gliding) { + double keep = MathUtil.exp(-elapsed / GLIDE_MILLIS); + // The distance covered while the speed fell from what it was to + // what it is, not the speed at either end times the time. + double reach = GLIDE_MILLIS * (1 - keep); + LatLng before = engine.getCenter(); + engine.panPixels(glideX * reach, glideY * reach); + glideX *= keep; + glideY *= keep; + LatLng after = engine.getCenter(); + boolean stuck = Double.compare(before.getLatitude(), after.getLatitude()) == 0 + && Double.compare(before.getLongitude(), after.getLongitude()) == 0; + if (stuck || glideX * glideX + glideY * glideY < slowest() * slowest()) { + gliding = false; + } + } + if (!gliding && !zooming) { + // The camera has come to rest: this is the end of the gesture that + // set it going, which is when the listeners hear of a drag too. + fireCameraChanged(); + } + return true; + } + + // The speed, in pixels a millisecond, under which a glide is over: about + // one logical pixel a frame. + private double slowest() { + return 0.03 * engine.getPixelRatio(); + } + + /// {@inheritDoc} + @Override + protected void deinitialize() { + settle(); + // Not left to animate(): the form stops asking once this is gone from + // it, or would go on asking a map it no longer shows. + stopAnimating(); + super.deinitialize(); } /// {@inheritDoc} @@ -643,6 +863,30 @@ protected boolean mouseWheel(com.codename1.ui.events.WheelEvent ev) { if (ev.getDeltaX() == 0 && ev.getDeltaY() == 0) { return false; } + seen(ev.getX(), ev.getY()); + if (wheelZoom) { + if (ev.getDeltaY() == 0) { + return false; + } + // Two hundred logical pixels of wheel to a zoom level: half a level + // for a notch of a mouse wheel, and a trackpad, which reports a few + // pixels at a time, zooms as far as the fingers travel. + // + // Added, not subtracted: a positive deltaY is the wheel turned up + // (see WheelEvent; the ports negate the browser's and AWT's sign), + // and a wheel turned up zooms in, as it does on every web map. + double from = zooming ? zoomTarget : engine.getZoom(); + double target = from + ev.getDeltaY() / (200 * engine.getPixelRatio()); + target = Math.max(engine.getMinZoom(), Math.min(engine.getMaxZoom(), target)); + if (Double.compare(target, from) == 0) { + // At the end of the range there is nothing to do with it, and + // what cannot move passes the wheel on. + return false; + } + zoomTowards(target, pointerX, pointerY); + return true; + } + settle(); // Web Mercator stops at its latitude limits, and panPixels clamps the centre back // to where it already was. Claiming the wheel for a pan that did not happen traps // scrolling on a page that holds a map: what cannot move passes the wheel on, the @@ -678,6 +922,8 @@ public void pointerDragged(int x, int y) { lastX = x; lastY = y; dragDistance += Math.abs(dx) + Math.abs(dy); + sample(x, y); + seen(x, y); engine.panPixels(dx, dy); repaint(); } @@ -696,20 +942,91 @@ public void pointerReleased(int x, int y) { long now = System.currentTimeMillis(); if (now - lastTapTime < 300 && Math.abs(x - lastTapX) < 30 && Math.abs(y - lastTapY) < 30) { lastTapTime = 0; - engine.zoomAround(engine.getZoom() + 1, lx, ly); - repaint(); - fireCameraChanged(); + // Glided to rather than jumped to: a map that is suddenly a + // different map leaves the eye to find its place again. The + // listeners hear when it gets there. + zoomTowards(engine.getZoom() + 1, lx, ly); } else { lastTapTime = now; lastTapX = x; lastTapY = y; handleTap(lx, ly); } - } else { + } else if (!thrown()) { fireCameraChanged(); } } + // Lets a map that was moving when it was let go carry on, slowing: a drag + // that stops dead under a lifting finger is the one thing about a map that + // feels like software. The speed is that of the last tenth of a second, so + // a drag that ended slowly is not thrown by how it began. + private boolean thrown() { + if (sampleCount < 2) { + return false; + } + int last = sampleCount - 1; + long now = System.currentTimeMillis(); + if (now - sampleTime[last] > THROW_REST_MILLIS) { + return false; + } + int first = last; + while (first > 0 && sampleTime[last] - sampleTime[first - 1] <= THROW_WINDOW_MILLIS) { + first--; + } + long took = sampleTime[last] - sampleTime[first]; + if (took <= 0) { + return false; + } + double vx = (sampleX[last] - sampleX[first]) / (double) took; + double vy = (sampleY[last] - sampleY[first]) / (double) took; + // Slower than a sixth of a logical pixel a millisecond is a map being + // put down, not thrown. + double least = 0.16 * engine.getPixelRatio(); + if (vx * vx + vy * vy < least * least) { + return false; + } + glideX = vx; + glideY = vy; + gliding = true; + move(); + return true; + } + + /// {@inheritDoc} + /// + /// Remembers where the fingers are, so that the zoom that follows keeps + /// the ground between them between them. + @Override + protected boolean pinch(int[] x, int[] y) { + if (x.length > 1) { + pinchX = (x[0] + x[1]) / 2 - getAbsoluteX(); + pinchY = (y[0] + y[1]) / 2 - getAbsoluteY(); + pinchPlaced = true; + } + return false; + } + + /// {@inheritDoc} + /// + /// The end of a pinch that leaves no pointer behind: a trackpad's, which + /// never presses or releases anything. Without it the map stayed mid-pinch, + /// and the next pinch began from the zoom the last one had started at. + @Override + protected void pinchReleased(int x, int y) { + if (!pinching) { + return; + } + pinching = false; + // A finger still down carries on as a drag from where it is, and is + // neither a tap nor a throw when it lifts. + lastX = x; + lastY = y; + dragDistance = Math.max(dragDistance, 10); + sampleCount = 0; + fireCameraChanged(); + } + /// {@inheritDoc} @Override public void longPointerPress(int x, int y) { @@ -723,12 +1040,37 @@ public void longPointerPress(int x, int y) { @Override protected boolean pinch(float scale) { + settle(); + // Between the fingers; for a gesture that has none on the screen, under + // the pointer; and failing both, the middle of the map. + int fx = getWidth() / 2; + int fy = getHeight() / 2; + if (pinchPlaced) { + fx = pinchX; + fy = pinchY; + } else if (pointerSeen) { + fx = pointerX; + fy = pointerY; + } + pinchPlaced = false; if (!pinching) { pinching = true; pinchStartZoom = engine.getZoom(); + pinchLastX = fx; + pinchLastY = fy; } + // The scale is since the gesture began, so it is applied to the zoom it + // began at. A producer with no gesture phases (a desktop trackpad's + // magnify steps) delimits each step as a gesture of its own, see + // JavaSEPort.fireMagnify, so this never sees steps as one gesture. double nz = pinchStartZoom + MathUtil.log(scale) / MathUtil.log(2); - engine.zoomAround(nz, getWidth() / 2, getHeight() / 2); + // Zoomed about where the fingers were and then carried to where they + // are, so two fingers that travel as they spread take the map along. + engine.zoomAround(nz, pinchLastX, pinchLastY); + engine.panPixels(fx - pinchLastX, fy - pinchLastY); + pinchLastX = fx; + pinchLastY = fy; + sampleCount = 0; repaint(); return true; } diff --git a/CodenameOne/src/com/codename1/maps/vector/LabelCandidate.java b/CodenameOne/src/com/codename1/maps/vector/LabelCandidate.java index 30a9d166781..d86369f2bde 100644 --- a/CodenameOne/src/com/codename1/maps/vector/LabelCandidate.java +++ b/CodenameOne/src/com/codename1/maps/vector/LabelCandidate.java @@ -22,6 +22,8 @@ */ package com.codename1.maps.vector; +import com.codename1.ui.Font; + /// A single label to draw, captured at tile-decode time. Its anchor is stored /// in integer-zoom world pixels (256px tiles) so the engine can convert it to /// the screen at any fractional camera zoom without re-walking the tile. @@ -42,6 +44,31 @@ final class LabelCandidate { /// for a label placed at a point. [#worldX]/[#worldY] is its midpoint. final double[] path; + // What [LabelEngine] measured this text as, and the font it measured it + // in: every label in view is measured on every frame, most of them only + // to be found in the way of another. + private Font measuredFont; + private int measuredHeight; + int textWidth; + boolean perGlyph; + /// The widths the text is laid along a line by, or null when it has only + /// been measured whole. + int[] cellWidths; + + /// Whether the measurements here are of this text in `font`, while it + /// answered the height `height`. + boolean measured(Font font, int height) { + return measuredFont == font && measuredHeight == height; //NOPMD CompareObjectsWithEquals + } + + void remember(Font font, int height, int width, boolean glyphs, int[] widths) { + measuredFont = font; + measuredHeight = height; + textWidth = width; + perGlyph = glyphs; + cellWidths = widths; + } + LabelCandidate(String text, double worldX, double worldY, int tileZoom, int textColor, int haloColor, double sizePx) { this(text, worldX, worldY, tileZoom, textColor, haloColor, sizePx, null); diff --git a/CodenameOne/src/com/codename1/maps/vector/LabelEngine.java b/CodenameOne/src/com/codename1/maps/vector/LabelEngine.java index efbd458c3ae..37b6a733b47 100644 --- a/CodenameOne/src/com/codename1/maps/vector/LabelEngine.java +++ b/CodenameOne/src/com/codename1/maps/vector/LabelEngine.java @@ -34,7 +34,7 @@ /// Places and draws text labels with greedy collision avoidance: a label is /// only drawn when its bounding box does not overlap one already placed this -/// frame. Each label is rendered with a one-pixel halo for legibility over +/// frame. Each label is rendered with a halo (see setHalo) for legibility over /// busy map content. This is a lightweight stand-in for a full label engine, /// adequate for place names on a basemap. /// @@ -51,15 +51,181 @@ final class LabelEngine { // a single rotated string and keeps the font's kerning. private static final double STRAIGHT_TOLERANCE = 0.035; - private final List occupied = new ArrayList(); + // The side of a cell of the grid the placed boxes are filed under, as a + // power of two: a label is a few cells wide and one high. + private static final int CELL_SHIFT = 6; + + // A box over more cells than this is kept in a short list of its own + // instead of being filed under every one of them. + private static final int MAX_CELLS = 32; + + // The boxes placed this frame, four ints each: x, y, width, height. + private int[] boxes = new int[1024]; + private int boxCount; + // The grid. A cell hashes to a bucket; a bucket is a chain of entries, + // each naming a box. Two cells sharing a bucket only means a box that is + // nowhere near is compared as well, and found not to overlap. + private final int[] buckets = new int[2048]; + private int[] entryBox = new int[2048]; + private int[] entryNext = new int[2048]; + private int entryCount; + private int[] largeBoxes = new int[16]; + private int largeCount; + private final Map placedAnchors = new HashMap(); - private final Map fontCache = new HashMap(); + // The fonts in use, one for each size asked for -- a handful, the sizes + // of a style times one density -- and the size each was made for. + private static final int FONTS = 8; + private final Font[] fonts = new Font[FONTS]; + private final int[] fontSizes = new int[FONTS]; + private int fontCount; + // The height of each of them, asked for once a frame; -1 before. + private final int[] fontHeights = new int[FONTS]; + // How far the outline of a label reaches past its strokes, in pixels. + private int halo = 1; + // The part of the screen a label has to be wholly inside, if any. + private boolean bounded; + private int boundLeft; + private int boundTop; + private int boundRight; + private int boundBottom; private Transform scratch; + LabelEngine() { + clearBuckets(); + } + /// Clears placements at the start of a frame. void reset() { - occupied.clear(); + boxCount = 0; + entryCount = 0; + largeCount = 0; + for (int i = 0; i < FONTS; i++) { + fontHeights[i] = -1; + } + clearBuckets(); placedAnchors.clear(); + bounded = false; + } + + /// As [#reset], for a frame whose labels must each fit inside the given + /// rectangle. A name cut off by the edge of the map is not a name: "ay + /// Street" tells the reader less than nothing. + void reset(int left, int top, int right, int bottom) { + reset(); + bounded = true; + boundLeft = left; + boundTop = top; + boundRight = right; + boundBottom = bottom; + } + + /// How far the outline around a label reaches, in device pixels. One pixel + /// is an outline on a display of one pixel to the point and nothing at all + /// on a phone, where it is a third of the thinnest stroke of a letter. + void setHalo(int pixels) { + halo = Math.max(1, pixels); + } + + // How far past its text a label is drawn: the offset of the outline's + // copies in draw(). What a label reserves and what has to fit in the view + // are measured with it, or a wide outline on a dense display would paint + // over a neighbour accepted as clear, or past the edge of the map. + private int reach() { + return halo > 1 ? Math.max(1, (halo * 3 + 2) / 4) : 1; + } + + private void clearBuckets() { + for (int i = 0; i < buckets.length; i++) { + buckets[i] = -1; + } + } + + private int bucketOf(int cellX, int cellY) { + return ((cellX * 73856093) ^ (cellY * 19349663)) & (buckets.length - 1); + } + + // Whether the box overlaps one placed this frame. + // + // Every label in view asks this, and most of them are turned away, so it + // is what a frame of a busy map spends its time on. Walking every placed + // box for each of them grew with the square of the labels in view: a + // desktop-sized map of a city centre compared millions of boxes a frame. + // Only the boxes filed under the cells this one touches are compared. + private boolean collides(int x, int y, int w, int h) { + for (int i = 0; i < largeCount; i++) { + if (overlaps(largeBoxes[i], x, y, w, h)) { + return true; + } + } + int x0 = x >> CELL_SHIFT; + int y0 = y >> CELL_SHIFT; + int x1 = (x + w) >> CELL_SHIFT; + int y1 = (y + h) >> CELL_SHIFT; + if ((long) (x1 - x0 + 1) * (y1 - y0 + 1) > MAX_CELLS) { + for (int i = 0; i < boxCount; i++) { + if (overlaps(i, x, y, w, h)) { + return true; + } + } + return false; + } + for (int cy = y0; cy <= y1; cy++) { + for (int cx = x0; cx <= x1; cx++) { + for (int e = buckets[bucketOf(cx, cy)]; e >= 0; e = entryNext[e]) { + if (overlaps(entryBox[e], x, y, w, h)) { + return true; + } + } + } + } + return false; + } + + private boolean overlaps(int box, int x, int y, int w, int h) { + int at = box * 4; + return intersects(x, y, w, h, boxes[at], boxes[at + 1], boxes[at + 2], boxes[at + 3]); + } + + private void occupy(int x, int y, int w, int h) { + if (boxCount * 4 == boxes.length) { + boxes = grow(boxes); + } + int box = boxCount++; + int at = box * 4; + boxes[at] = x; + boxes[at + 1] = y; + boxes[at + 2] = w; + boxes[at + 3] = h; + int x0 = x >> CELL_SHIFT; + int y0 = y >> CELL_SHIFT; + int x1 = (x + w) >> CELL_SHIFT; + int y1 = (y + h) >> CELL_SHIFT; + if ((long) (x1 - x0 + 1) * (y1 - y0 + 1) > MAX_CELLS) { + if (largeCount == largeBoxes.length) { + largeBoxes = grow(largeBoxes); + } + largeBoxes[largeCount++] = box; + return; + } + for (int cy = y0; cy <= y1; cy++) { + for (int cx = x0; cx <= x1; cx++) { + if (entryCount == entryBox.length) { + entryBox = grow(entryBox); + entryNext = grow(entryNext); + } + int bucket = bucketOf(cx, cy); + entryBox[entryCount] = box; + entryNext[entryCount] = buckets[bucket]; + buckets[bucket] = entryCount++; + } + } + } + + private static int[] grow(int[] from) { + int[] grown = new int[from.length * 2]; + System.arraycopy(from, 0, grown, 0, from.length); + return grown; } /// Lays `text` along the polyline `pts` (interleaved screen `x,y`), @@ -78,6 +244,21 @@ void reset() { /// Returns true when at least one copy was drawn. boolean placeAlongLine(Graphics g, String text, double sizePx, int textColor, int haloColor, double[] pts, double repeat, int left, int top, int right, int bottom) { + return placeAlongLine(g, null, text, sizePx, textColor, haloColor, pts, repeat, left, top, + right, bottom); + } + + /// [#placeAlongLine] for a label of the map, whose measurements are kept + /// on `candidate` from one frame to the next. + boolean placeAlongLine(Graphics g, LabelCandidate candidate, double[] pts, double sizePx, + double repeat, int left, int top, int right, int bottom) { + return placeAlongLine(g, candidate, candidate.text, sizePx, candidate.textColor, + candidate.haloColor, pts, repeat, left, top, right, bottom); + } + + private boolean placeAlongLine(Graphics g, LabelCandidate candidate, String text, double sizePx, + int textColor, int haloColor, double[] pts, double repeat, + int left, int top, int right, int bottom) { if (text == null || text.length() == 0 || pts == null || pts.length < 4) { return false; } @@ -102,29 +283,49 @@ boolean placeAlongLine(Graphics g, String text, double sizePx, int textColor, in return false; } double[] p = pointAt(pts, cum, at); - return place(g, text, sizePx, textColor, haloColor, round(p[0]), round(p[1])); + return place(g, candidate, text, sizePx, textColor, haloColor, round(p[0]), round(p[1])); } Font font = fontFor(sizePx); - int h = font.getHeight(); - boolean perGlyph = drawsCharByChar(text); + int h = heightOf(font); + boolean perGlyph; int[] widths; int textW; - if (perGlyph) { - int len = text.length(); - widths = new int[len]; - textW = 0; - for (int i = 0; i < len; i++) { - widths[i] = font.charWidth(text.charAt(i)); - textW += widths[i]; - } + if (candidate != null && candidate.measured(font, h) && candidate.cellWidths != null) { + perGlyph = candidate.perGlyph; + widths = candidate.cellWidths; + textW = candidate.textWidth; } else { - // Shaped text is measured and drawn whole. The cells only sample - // the road under it, for the bend check and the collision boxes. - textW = font.stringWidth(text); - int cells = Math.max(1, (textW + h - 1) / Math.max(1, h)); - widths = new int[cells]; - for (int i = 0; i < cells; i++) { - widths[i] = textW * (i + 1) / cells - textW * i / cells; + perGlyph = drawsCharByChar(text); + if (perGlyph) { + // Each glyph's share of the name as it is drawn, not its width + // on its own. A stretch of the name is drawn as one string, + // and a port that measures in whole pixels rounds every + // letter measured singly, so the letters do not add up to the + // string: on macOS they came short, and where a name turned a + // corner one stretch was drawn over the start of the next. + // This does not close the small gap a bend itself opens on + // its outer side, which is the geometry of two straight + // pieces meeting at an angle. + int len = text.length(); + widths = new int[len]; + textW = 0; + for (int i = 0; i < len; i++) { + int upTo = i == len - 1 ? font.stringWidth(text) : font.stringWidth(text.substring(0, i + 1)); + widths[i] = Math.max(0, upTo - textW); + textW += widths[i]; + } + } else { + // Shaped text is measured and drawn whole. The cells only sample + // the road under it, for the bend check and the collision boxes. + textW = font.stringWidth(text); + int cells = Math.max(1, (textW + h - 1) / Math.max(1, h)); + widths = new int[cells]; + for (int i = 0; i < cells; i++) { + widths[i] = textW * (i + 1) / cells - textW * i / cells; + } + } + if (candidate != null) { + candidate.remember(font, h, textW, perGlyph, widths); } } if (textW <= 0 || total < textW + h) { @@ -133,20 +334,38 @@ boolean placeAlongLine(Graphics g, String text, double sizePx, int textColor, in if (repeat < textW + h) { repeat = textW + h; } + // The stretch of the road that is on screen, less half a line at the + // edges: the whole name has to fit on it. The middle of the road is + // where a name goes while it fits there, and the middle of a road + // that runs off the screen is as often as not at the edge, which is + // how a street came to be called "Hyde Stre". + double[] run = visibleRun(pts, cum, left + h / 2, top + h / 2, right - h / 2, bottom - h / 2); + if (run == null) { + return false; + } + double from = Math.max(run[0], 0) + textW / 2.0; + double to = Math.min(run[1], total) - textW / 2.0; + if (to < from) { + return false; + } + // The places a copy may go are fixed to the road -- its middle, and + // steps of about half a name either way that include every whole + // repeat -- and not to the screen, so a name stays where it is on the + // ground while the map is dragged, and moves along only when the + // place it had would cut it off. + int parts = Math.max(1, (int) (repeat / Math.max(1.0, textW / 2.0))); + double step = repeat / parts; + int lo = (int) Math.ceil((from - mid) / step - 1e-9); + int hi = (int) Math.floor((to - mid) / step + 1e-9); boolean placedAny = false; - for (int k = 0; mid - k * repeat - textW / 2.0 >= 0; k++) { - for (int sign = 1; sign >= -1; sign -= 2) { - if (k == 0 && sign < 0) { - continue; - } - double d = mid + sign * k * repeat; - if (d - textW / 2.0 < 0 || d + textW / 2.0 > total) { - continue; - } - if (placeCopy(g, text, font, widths, textW, h, perGlyph, textColor, haloColor, pts, - cum, d, repeat, left, top, right, bottom)) { - placedAny = true; - } + int up = lo > 0 ? lo : Math.min(hi, 0); + int down = up - 1; + while (up <= hi || down >= lo) { + boolean takeUp = up <= hi && (down < lo || Math.abs(up) <= Math.abs(down)); + int j = takeUp ? up++ : down--; + if (placeCopy(g, text, font, widths, textW, h, perGlyph, textColor, haloColor, pts, + cum, mid + j * step, repeat, left, top, right, bottom)) { + placedAny = true; } } return placedAny; @@ -177,16 +396,20 @@ private boolean placeCopy(Graphics g, String text, Font font, int[] widths, int // walk it backwards and turn every glyph half a revolution. boolean reverse = end[0] < start[0]; int len = widths.length; + double[] angles = new double[cum.length]; + for (int i = 0; i < angles.length; i++) { + angles[i] = Double.NaN; + } double[] gx = new double[len]; double[] gy = new double[len]; double[] ga = new double[len]; - int[][] boxes = new int[len][]; + int[][] glyphBoxes = new int[len][]; double advance = 0; for (int i = 0; i < len; i++) { double offset = advance + widths[i] / 2.0; advance += widths[i]; double at = reverse ? d + textW / 2.0 - offset : d - textW / 2.0 + offset; - double[] p = pointAt(pts, cum, at); + double[] p = pointAt(pts, cum, at, angles); double angle = p[2]; if (reverse) { angle += Math.PI; @@ -199,16 +422,13 @@ private boolean placeCopy(Graphics g, String text, Font font, int[] widths, int ga[i] = angle; double c = Math.abs(Math.cos(angle)); double s = Math.abs(Math.sin(angle)); - int ex = (int) Math.ceil(c * widths[i] / 2.0 + s * h / 2.0) + 1; - int ey = (int) Math.ceil(s * widths[i] / 2.0 + c * h / 2.0) + 1; + int ex = (int) Math.ceil(c * widths[i] / 2.0 + s * h / 2.0) + reach(); + int ey = (int) Math.ceil(s * widths[i] / 2.0 + c * h / 2.0) + reach(); int[] box = new int[]{round(p[0]) - ex, round(p[1]) - ey, ex * 2, ey * 2}; - for (Object occItem : occupied) { - int[] o = (int[]) occItem; - if (intersects(box[0], box[1], box[2], box[3], o[0], o[1], o[2], o[3])) { - return false; - } + if (collides(box[0], box[1], box[2], box[3])) { + return false; } - boxes[i] = box; + glyphBoxes[i] = box; } // Drawn whole, the text is a straight line from its first cell to its // last; keep it only where the road stays within half a line of that, @@ -227,8 +447,8 @@ private boolean placeCopy(Graphics g, String text, Font font, int[] widths, int } } } - for (int[] box : boxes) { - occupied.add(box); + for (int[] box : glyphBoxes) { + occupy(box[0], box[1], box[2], box[3]); } if (anchors == null) { anchors = new ArrayList(); @@ -313,14 +533,34 @@ private void drawAlong(Graphics g, String text, Font font, int[] widths, int h, } g.setFont(font); try { - for (int i = 0; i < ga.length; i++) { - int px = round(gx[i]); - int py = round(gy[i]); + // Not a glyph at a time: a road is a few straight stretches, and + // the glyphs on one of them share its direction, so each stretch + // is drawn as one piece of the name. Drawn singly, a name of a + // dozen letters on a road with one kink in it was sixty strings + // with their outlines, on every frame -- three quarters of all the + // text a busy map drew. + int from = 0; + while (from < ga.length) { + int to = from + 1; + while (to < ga.length && Math.abs(normalize(ga[to] - ga[from])) <= STRAIGHT_TOLERANCE) { + to++; + } + int width = 0; + for (int i = from; i < to; i++) { + width += widths[i]; + } + // The middle of the stretch: half way between the middles of + // its first and last glyphs, put right for their widths. + double angle = ga[from]; + double shift = (widths[from] - widths[to - 1]) / 4.0; + int px = round((gx[from] + gx[to - 1]) / 2 - shift * Math.cos(angle)); + int py = round((gy[from] + gy[to - 1]) / 2 - shift * Math.sin(angle)); scratch.setTransform(saved); - scratch.rotate((float) ga[i], px, py); + scratch.rotate((float) angle, px, py); g.setTransform(scratch); - draw(g, String.valueOf(text.charAt(i)), font, textColor, haloColor, - px - widths[i] / 2, py - h / 2); + draw(g, to - from == text.length() ? text : text.substring(from, to), font, textColor, + haloColor, px - width / 2, py - h / 2); + from = to; } } finally { g.setTransform(saved); @@ -332,6 +572,14 @@ private void drawAlong(Graphics g, String text, Font font, int[] widths, int h, // the rectangle (Liang-Barsky); touching clipped pieces join into one run. private static double visibleMiddle(double[] pts, double[] cum, int left, int top, int right, int bottom) { + double[] run = visibleRun(pts, cum, left, top, right, bottom); + return run == null ? -1 : (run[0] + run[1]) / 2; + } + + // The longest run of the line inside the rectangle, as the distances along + // the line it starts and ends at, or null when no part of it is inside. + private static double[] visibleRun(double[] pts, double[] cum, int left, int top, int right, + int bottom) { double bestStart = -1; double bestEnd = -1; double runStart = -1; @@ -376,12 +624,20 @@ private static double visibleMiddle(double[] pts, double[] cum, int left, int to bestEnd = runEnd; } } - return bestStart < 0 ? -1 : (bestStart + bestEnd) / 2; + return bestStart < 0 ? null : new double[]{bestStart, bestEnd}; } // The point at distance `d` along the line, and the direction of the // segment it falls on: {x, y, angle}. private static double[] pointAt(double[] pts, double[] cum, double d) { + return pointAt(pts, cum, d, null); + } + + // `angles` holds the direction of each segment once it has been worked + // out, NaN before: the glyphs of a name mostly share a segment, and an + // arc tangent for each of them, for every road in view, was a twentieth + // of a frame. Null leaves the direction out of the answer. + private static double[] pointAt(double[] pts, double[] cum, double d, double[] angles) { int n = cum.length; int seg = 1; while (seg < n - 1 && cum[seg] < d) { @@ -398,11 +654,17 @@ private static double[] pointAt(double[] pts, double[] cum, double d) { } else if (t > 1) { t = 1; } - double angle; - if (length > 0) { - angle = MathUtil.atan2(y1 - y0, x1 - x0); - } else { - angle = directionNear(pts, cum, seg); + double angle = 0; + if (angles != null) { + angle = angles[seg]; + if (Double.isNaN(angle)) { + if (length > 0) { + angle = MathUtil.atan2(y1 - y0, x1 - x0); + } else { + angle = directionNear(pts, cum, seg); + } + angles[seg] = angle; + } } return new double[]{x0 + t * (x1 - x0), y0 + t * (y1 - y0), angle}; } @@ -442,25 +704,54 @@ private static int round(double v) { /// Attempts to draw `text` centered at `cx,cy`. Returns false (drawing /// nothing) when it would collide with an already placed label. boolean place(Graphics g, String text, double sizePx, int textColor, int haloColor, int cx, int cy) { + return place(g, null, text, sizePx, textColor, haloColor, cx, cy); + } + + /// [#place] for a label of the map, whose width is kept on `candidate` + /// from one frame to the next. + boolean place(Graphics g, LabelCandidate candidate, double sizePx, int cx, int cy) { + return place(g, candidate, candidate.text, sizePx, candidate.textColor, candidate.haloColor, + cx, cy); + } + + // Measuring is the other thing a frame did for every label in view, drawn + // or not: a name that loses its place to a neighbour was measured first, + // on every frame of a drag. A label keeps its measurements for as long as + // the font it was measured in answers the same height, which follows the + // display's density. + private boolean place(Graphics g, LabelCandidate candidate, String text, double sizePx, + int textColor, int haloColor, int cx, int cy) { if (text == null || text.length() == 0) { return false; } Font font = fontFor(sizePx); - int w = font.stringWidth(text); - int h = font.getHeight(); + int h = heightOf(font); + int w; + // Not what a road name was measured as for laying along its road: + // that is the sum of its glyphs, which is not the width of the string. + if (candidate != null && candidate.measured(font, h) && candidate.cellWidths == null) { + w = candidate.textWidth; + } else { + w = font.stringWidth(text); + if (candidate != null) { + candidate.remember(font, h, w, false, null); + } + } int x = cx - w / 2; int y = cy - h / 2; - int bx = x - 2; - int by = y - 2; - int bw = w + 4; - int bh = h + 4; - for (Object occItem : occupied) { - int[] o = (int[]) occItem; - if (intersects(bx, by, bw, bh, o[0], o[1], o[2], o[3])) { - return false; - } + int r = reach(); + int bx = x - r - 1; + int by = y - r - 1; + int bw = w + 2 * r + 2; + int bh = h + 2 * r + 2; + if (bounded && (x - r < boundLeft || y - r < boundTop || x + w + r > boundRight + || y + h + r > boundBottom)) { + return false; } - occupied.add(new int[]{bx, by, bw, bh}); + if (collides(bx, by, bw, bh)) { + return false; + } + occupy(bx, by, bw, bh); draw(g, text, font, textColor, haloColor, x, y); return true; } @@ -472,10 +763,23 @@ private void draw(Graphics g, String text, Font font, int textColor, int haloCol if (haloA > 0) { g.setAlpha(haloA); g.setColor(haloColor & 0xffffff); - g.drawString(text, x - 1, y); - g.drawString(text, x + 1, y); - g.drawString(text, x, y - 1); - g.drawString(text, x, y + 1); + if (halo > 1) { + // Further out than a pixel the four copies go on the diagonals, + // where each covers two sides at once: on the axes they are + // four ghosts of the name with the corners between them bare. + // Still four, though -- the outline is most of what a label + // costs to draw, and it is drawn for every label every frame. + int d = reach(); + g.drawString(text, x - d, y - d); + g.drawString(text, x + d, y - d); + g.drawString(text, x - d, y + d); + g.drawString(text, x + d, y + d); + } else { + g.drawString(text, x - 1, y); + g.drawString(text, x + 1, y); + g.drawString(text, x, y - 1); + g.drawString(text, x, y + 1); + } } int textA = (textColor >>> 24) & 0xff; if (textA == 0) { @@ -487,25 +791,60 @@ private void draw(Graphics g, String text, Font font, int textColor, int haloCol g.setAlpha(prevAlpha); } + // Every label asks the height of its font, and a port may work it out + // each time it is asked. It can change -- with the display's density -- + // but not during a frame. + private int heightOf(Font font) { + for (int i = 0; i < fontCount; i++) { + if (fonts[i] == font) { //NOPMD CompareObjectsWithEquals + if (fontHeights[i] < 0) { + fontHeights[i] = font.getHeight(); + } + return fontHeights[i]; + } + } + return font.getHeight(); + } + + // The font for text of `sizePx` device pixels. + // + // At that size, where the platform can make one. The three sizes of the + // system font are the platform's idea of small, medium and large text, not + // a number of pixels: asked for thirteen logical pixels on a phone of three + // pixels to the point, the old lookup read 39 as "large" and set every + // street name in the largest type the device has, three times the size the + // style asked for. private Font fontFor(double sizePx) { - int bucket; - int sizeConst; - if (sizePx <= 12) { - bucket = 0; - sizeConst = Font.SIZE_SMALL; - } else if (sizePx <= 17) { - bucket = 1; - sizeConst = Font.SIZE_MEDIUM; - } else { - bucket = 2; - sizeConst = Font.SIZE_LARGE; + int size = Math.max(1, (int) Math.floor(sizePx + 0.5)); + for (int i = 0; i < fontCount; i++) { + if (fontSizes[i] == size) { + return fonts[i]; + } + } + Font f = null; + if (Font.isNativeFontSchemeSupported()) { + Font face = Font.createTrueTypeFont("native:MainRegular", "native:MainRegular"); + if (face != null) { + f = face.derive(size, Font.STYLE_PLAIN); + } } - Integer k = Integer.valueOf(bucket); - Font f = (Font) fontCache.get(k); if (f == null) { + int sizeConst = Font.SIZE_LARGE; + if (size <= 12) { + sizeConst = Font.SIZE_SMALL; + } else if (size <= 17) { + sizeConst = Font.SIZE_MEDIUM; + } f = Font.createSystemFont(Font.FACE_PROPORTIONAL, Font.STYLE_PLAIN, sizeConst); - fontCache.put(k, f); } + if (fontCount == FONTS) { + // More sizes than a style has: start over rather than grow. + fontCount = 0; + } + fonts[fontCount] = f; + fontSizes[fontCount] = size; + fontHeights[fontCount] = -1; + fontCount++; return f; } diff --git a/CodenameOne/src/com/codename1/maps/vector/MapStyle.java b/CodenameOne/src/com/codename1/maps/vector/MapStyle.java index 01e7b30325f..caf093f95b0 100644 --- a/CodenameOne/src/com/codename1/maps/vector/MapStyle.java +++ b/CodenameOne/src/com/codename1/maps/vector/MapStyle.java @@ -70,61 +70,159 @@ List getLayers() { // ---- Built-in styles -------------------------------------------------- - /// A clean light basemap (sensible default for most apps). Settlement - /// labels are joined by street and park names from zoom 12 and points of - /// interest from zoom 14, when the tile source supplies those names. + /// A clean light basemap (sensible default for most apps): muted land, + /// soft water and parks, and roads that read by their importance -- main + /// roads wider and faintly tinted, every road edged so that it stands off + /// the land, side streets and buildings only once the map is close enough + /// for them to mean something. Settlement names are joined by the names of + /// main roads and parks from zoom 12, side streets from zoom 14 and points + /// of interest from zoom 16, when the tile source supplies those names. /// /// Every colour falls back to the value baked in here but is overridable /// through a theme constant (a CSS color string) so an app can recolour the /// map without supplying a whole style: `mapLightWaterColor`, /// `mapLightLandcoverColor`, `mapLightLanduseColor`, `mapLightParkColor`, - /// `mapLightRoadColor`, `mapLightBuildingColor`, `mapLightBackgroundColor`, - /// `mapLightLabelColor`, `mapLightLabelHaloColor`. e.g. a theme constant + /// `mapLightRoadColor`, `mapLightMajorRoadColor`, `mapLightRoadCasingColor`, + /// `mapLightBuildingColor`, `mapLightBackgroundColor`, `mapLightLabelColor`, + /// `mapLightRoadLabelColor`, `mapLightLabelHaloColor`. e.g. a theme constant /// `mapLightWaterColor=#1e88e5`. public static MapStyle light() { - int water = themeColor("mapLightWaterColor", 0xffa0c8f0); - int road = themeColor("mapLightRoadColor", 0xffffffff); - int building = themeColor("mapLightBuildingColor", 0xffd9d0c9); - MapStyle s = new MapStyle("light", themeColor("mapLightBackgroundColor", 0xfff2efe9)); - addPolygonRule(s, "water", water); - addPolygonRule(s, "ocean", water); - addPolygonRule(s, "landcover", themeColor("mapLightLandcoverColor", 0xffd8e8c8)); - addPolygonRule(s, "landuse", themeColor("mapLightLanduseColor", 0xffe8f0d8)); - addPolygonRule(s, "park", themeColor("mapLightParkColor", 0xffc8e0b0)); - addLineRule(s, "waterway", water, WATERWAY_ZOOMS, WATERWAY_WIDTHS); - addLineRule(s, "road", road, ROAD_ZOOMS, ROAD_WIDTHS); - addLineRule(s, "transportation", road, ROAD_ZOOMS, ROAD_WIDTHS).excludeFilter("class", "ferry"); - addPolygonRule(s, "building", building); - addPolygonRule(s, "buildings", building); - int label = themeColor("mapLightLabelColor", 0xff333333); - int halo = themeColor("mapLightLabelHaloColor", 0xffffffff); - addBasemapLabels(s, label, halo); - return s; + Palette p = new Palette(); + p.background = themeColor("mapLightBackgroundColor", 0xfff5f3ee); + p.water = themeColor("mapLightWaterColor", 0xffabd3f0); + p.landcover = themeColor("mapLightLandcoverColor", 0xffe1edd6); + p.landuse = themeColor("mapLightLanduseColor", 0xfff1efe9); + p.park = themeColor("mapLightParkColor", 0xffd2e8c4); + p.road = themeColor("mapLightRoadColor", 0xffffffff); + p.majorRoad = themeColor("mapLightMajorRoadColor", 0xfffdf0c4); + p.casing = themeColor("mapLightRoadCasingColor", 0xffdcd8cf); + p.majorCasing = themeColor("mapLightRoadCasingColor", 0xffe3cc8c); + p.path = 0xffe6e1d6; + p.rail = 0xffd2cec6; + p.building = themeColor("mapLightBuildingColor", 0xffeae7e0); + p.label = themeColor("mapLightLabelColor", 0xff2f343b); + p.roadLabel = themeColor("mapLightRoadLabelColor", 0xff6a6f78); + p.halo = themeColor("mapLightLabelHaloColor", p.background); + return build("light", p); } - /// A dark basemap suited to night mode. Mirrors [#light()]: each colour is - /// overridable via a theme constant (`mapDarkWaterColor`, - /// `mapDarkLandcoverColor`, `mapDarkLanduseColor`, `mapDarkParkColor`, - /// `mapDarkRoadColor`, `mapDarkBuildingColor`, `mapDarkBackgroundColor`, - /// `mapDarkLabelColor`, `mapDarkLabelHaloColor`). + /// A dark basemap suited to night mode. Mirrors [#light()], with the same + /// order of importance in dark tones: each colour is overridable via a + /// theme constant (`mapDarkWaterColor`, `mapDarkLandcoverColor`, + /// `mapDarkLanduseColor`, `mapDarkParkColor`, `mapDarkRoadColor`, + /// `mapDarkMajorRoadColor`, `mapDarkRoadCasingColor`, + /// `mapDarkBuildingColor`, `mapDarkBackgroundColor`, `mapDarkLabelColor`, + /// `mapDarkRoadLabelColor`, `mapDarkLabelHaloColor`). public static MapStyle dark() { - int water = themeColor("mapDarkWaterColor", 0xff1b2733); - int road = themeColor("mapDarkRoadColor", 0xff3a4048); - int building = themeColor("mapDarkBuildingColor", 0xff20242a); - MapStyle s = new MapStyle("dark", themeColor("mapDarkBackgroundColor", 0xff121417)); - addPolygonRule(s, "water", water); - addPolygonRule(s, "ocean", water); - addPolygonRule(s, "landcover", themeColor("mapDarkLandcoverColor", 0xff1a1d20)); - addPolygonRule(s, "landuse", themeColor("mapDarkLanduseColor", 0xff1d2024)); - addPolygonRule(s, "park", themeColor("mapDarkParkColor", 0xff17251a)); - addLineRule(s, "waterway", water, WATERWAY_ZOOMS, WATERWAY_WIDTHS); - addLineRule(s, "road", road, ROAD_ZOOMS, ROAD_WIDTHS); - addLineRule(s, "transportation", road, ROAD_ZOOMS, ROAD_WIDTHS).excludeFilter("class", "ferry"); - addPolygonRule(s, "building", building); - addPolygonRule(s, "buildings", building); - int label = themeColor("mapDarkLabelColor", 0xffe8e8e8); - int halo = themeColor("mapDarkLabelHaloColor", 0xff000000); - addBasemapLabels(s, label, halo); + Palette p = new Palette(); + p.background = themeColor("mapDarkBackgroundColor", 0xff16191d); + p.water = themeColor("mapDarkWaterColor", 0xff0d1b29); + p.landcover = themeColor("mapDarkLandcoverColor", 0xff18211b); + p.landuse = themeColor("mapDarkLanduseColor", 0xff181b20); + p.park = themeColor("mapDarkParkColor", 0xff18281c); + p.road = themeColor("mapDarkRoadColor", 0xff323942); + p.majorRoad = themeColor("mapDarkMajorRoadColor", 0xff505966); + p.casing = themeColor("mapDarkRoadCasingColor", 0xff0e1013); + p.majorCasing = themeColor("mapDarkRoadCasingColor", 0xff0e1013); + p.path = 0xff22272e; + p.rail = 0xff2a2f36; + p.building = themeColor("mapDarkBuildingColor", 0xff1c2026); + p.label = themeColor("mapDarkLabelColor", 0xffe4e7eb); + p.roadLabel = themeColor("mapDarkRoadLabelColor", 0xff9aa2ad); + p.halo = themeColor("mapDarkLabelHaloColor", p.background); + return build("dark", p); + } + + // The colours of a built-in style. + private static final class Palette { + private int background; + private int water; + private int landcover; + private int landuse; + private int park; + private int road; + private int majorRoad; + private int casing; + private int majorCasing; + private int path; + private int rail; + private int building; + private int label; + private int roadLabel; + private int halo; + } + + // The classes of road, in the vocabularies of the two tile schemas in use + // (OpenMapTiles' `transportation`, and `road` of the Mapbox Streets one). + // A class that is in none of the lists is drawn as a side street: a road + // the style has never heard of is still a road. + private static final String[] MAJOR_ROADS = {"motorway", "trunk", "primary", "secondary", "tertiary", + "motorway_link", "trunk_link", "primary_link", "secondary_link", "tertiary_link", "main"}; + // The few that carry a city's traffic, and are tinted to say so. + private static final String[] HIGHWAYS = {"motorway", "trunk", "primary", + "motorway_link", "trunk_link", "primary_link"}; + // Alleys, driveways and the aisles of car parks: roads, but at the scale + // of a neighbourhood a rash of stubs off every street. + private static final String[] SERVICE_ROADS = {"service", "driveway", "parking_aisle"}; + private static final String[] PATHS = {"path", "track", "footway", "cycleway", "steps", "pedestrian"}; + private static final String[] RAILS = {"rail", "transit", "major_rail", "minor_rail"}; + // Neither a street nor drawn as one: the lists above, and what is no road + // at all. A ferry route is a line across open water, a pier is an area. + private static final String[] NOT_STREETS = {"motorway", "trunk", "primary", "secondary", "tertiary", + "motorway_link", "trunk_link", "primary_link", "secondary_link", "tertiary_link", "main", + "path", "track", "footway", "cycleway", "steps", "pedestrian", + "rail", "transit", "major_rail", "minor_rail", + "service", "driveway", "parking_aisle", + "ferry", "pier", "aerialway", "golf", "bridge"}; + private static final String[] ROAD_LAYERS = {"transportation", "road"}; + private static final String[] ROAD_NAME_LAYERS = {"transportation_name", "road", "road_label"}; + + private static MapStyle build(String name, Palette p) { + MapStyle s = new MapStyle(name, p.background); + addPolygonRule(s, "landcover", p.landcover); + addPolygonRule(s, "landuse", p.landuse); + addPolygonRule(s, "park", p.park); + addPolygonRule(s, "water", p.water); + addPolygonRule(s, "ocean", p.water); + addLineRule(s, "waterway", p.water, WATERWAY_ZOOMS, WATERWAY_WIDTHS); + // Under the roads, and not before the map is close enough to tell one + // from the next: at city scale they are a texture, and the strongest + // thing on the map if they are given any weight. + s.add(new StyleLayer(StyleLayer.TYPE_FILL).sourceLayer("building").fillColor(p.building) + .zoomRange(15, 24)); + s.add(new StyleLayer(StyleLayer.TYPE_FILL).sourceLayer("buildings").fillColor(p.building) + .zoomRange(15, 24)); + for (String layer : ROAD_LAYERS) { + addLineRule(s, layer, p.path, PATH_ZOOMS, PATH_WIDTHS).filterIn("class", PATHS) + .linesOnly().zoomRange(15, 24); + addLineRule(s, layer, p.rail, PATH_ZOOMS, PATH_WIDTHS).filterIn("class", RAILS) + .linesOnly().zoomRange(13, 24); + addLineRule(s, layer, p.road, SERVICE_ZOOMS, SERVICE_WIDTHS).filterIn("class", SERVICE_ROADS) + .linesOnly().zoomRange(16, 24); + } + // Every edge before any road, so that where two roads meet neither is + // edged across the other. + for (String layer : ROAD_LAYERS) { + addLineRule(s, layer, p.casing, STREET_ZOOMS, STREET_CASINGS).excludeIn("class", NOT_STREETS) + .linesOnly().zoomRange(15, 24); + addLineRule(s, layer, p.casing, MAJOR_ZOOMS, MAJOR_CASINGS).filterIn("class", MAJOR_ROADS) + .linesOnly().zoomRange(12, 24); + addLineRule(s, layer, p.majorCasing, MAJOR_ZOOMS, MAJOR_CASINGS).filterIn("class", HIGHWAYS) + .linesOnly().zoomRange(12, 24); + } + for (String layer : ROAD_LAYERS) { + addLineRule(s, layer, p.road, STREET_ZOOMS, STREET_WIDTHS).excludeIn("class", NOT_STREETS) + .linesOnly().zoomRange(12, 24); + } + for (String layer : ROAD_LAYERS) { + addLineRule(s, layer, p.road, MAJOR_ZOOMS, MAJOR_WIDTHS).filterIn("class", MAJOR_ROADS) + .linesOnly(); + } + for (String layer : ROAD_LAYERS) { + addLineRule(s, layer, p.majorRoad, MAJOR_ZOOMS, MAJOR_WIDTHS).filterIn("class", HIGHWAYS) + .linesOnly(); + } + addBasemapLabels(s, p.label, p.roadLabel, p.halo); return s; } @@ -149,9 +247,18 @@ private static void addPolygonRule(MapStyle s, String sourceLayer, int color) { // Line widths in logical pixels. Roads keep widening past zoom 18, where // the map is overzoomed and a street should read as a street, not a - // hairline; roughly doubling per level matches the ground they cover. - private static final double[] ROAD_ZOOMS = {6, 10, 14, 16, 18, 20, 22}; - private static final double[] ROAD_WIDTHS = {0.5, 1, 2.5, 5, 10, 22, 44}; + // hairline; roughly doubling per level matches the ground they cover. An + // edge is the same road drawn first, a little wider, in a darker colour. + private static final double[] STREET_ZOOMS = {12, 14, 16, 18, 20, 22}; + private static final double[] STREET_WIDTHS = {0.5, 1.2, 4, 9, 20, 40}; + private static final double[] STREET_CASINGS = {0.5, 1.2, 5.5, 10.5, 22, 42}; + private static final double[] MAJOR_ZOOMS = {6, 10, 12, 14, 16, 18, 20, 22}; + private static final double[] MAJOR_WIDTHS = {0.6, 1.2, 1.8, 3, 6.5, 13, 28, 52}; + private static final double[] MAJOR_CASINGS = {0.6, 1.2, 2.8, 4.4, 8, 14.5, 30, 54}; + private static final double[] SERVICE_ZOOMS = {16, 18, 20, 22}; + private static final double[] SERVICE_WIDTHS = {1.2, 4, 10, 20}; + private static final double[] PATH_ZOOMS = {13, 16, 20}; + private static final double[] PATH_WIDTHS = {0.6, 1.2, 4}; private static final double[] WATERWAY_ZOOMS = {6, 12, 16, 20}; private static final double[] WATERWAY_WIDTHS = {0.5, 1, 3, 10}; @@ -163,29 +270,37 @@ private static StyleLayer addLineRule(MapStyle s, String sourceLayer, int color, return sl; } - // Within each tile, consider settlement names before streets and landmarks. - // Detail labels only appear at neighbourhood/street zooms. Ferry routes share - // the street-name layer but their lines are not drawn (see the line rules), - // so their names are left out too: a name laid along an invisible route - // reads as text floating on open water. - private static void addBasemapLabels(MapStyle s, int label, int halo) { - addSymbolRule(s, "place", "name", label, halo); - addSymbolRule(s, "place_label", "name", label, halo); - addSymbolRule(s, "transportation_name", "name", label, halo).zoomRange(12, 24) - .excludeFilter("class", "ferry"); - addSymbolRule(s, "road", "name", label, halo).zoomRange(12, 24) - .excludeFilter("class", "ferry"); - addSymbolRule(s, "road_label", "name", label, halo).zoomRange(12, 24) - .excludeFilter("class", "ferry"); - addSymbolRule(s, "park", "name", label, halo).zoomRange(12, 24); - addSymbolRule(s, "poi", "name", label, halo).zoomRange(14, 24); + // Names are considered in the order they are listed, and one that is in + // the way of another already placed is left out, so the order is the + // order of importance: settlements, then main roads, then parks, and only + // close in the side streets and points of interest that would otherwise + // crowd everything else off the map. Ferry routes share the street-name + // layer but their lines are not drawn (see the line rules), so their names + // are left out too: a name laid along an invisible route reads as text + // floating on open water. + // + // Sizes are in logical pixels, about the size of the small print of an + // interface: a map is read around its names, not through them. + private static void addBasemapLabels(MapStyle s, int label, int roadLabel, int halo) { + addSymbolRule(s, "place", "name", label, halo, 13); + addSymbolRule(s, "place_label", "name", label, halo, 13); + for (String layer : ROAD_NAME_LAYERS) { + addSymbolRule(s, layer, "name", roadLabel, halo, 12).zoomRange(12, 24) + .filterIn("class", MAJOR_ROADS); + } + addSymbolRule(s, "park", "name", roadLabel, halo, 11).zoomRange(12, 24); + for (String layer : ROAD_NAME_LAYERS) { + addSymbolRule(s, layer, "name", roadLabel, halo, 11).zoomRange(14, 24) + .excludeIn("class", NOT_STREETS); + } + addSymbolRule(s, "poi", "name", roadLabel, halo, 11).zoomRange(16, 24); } private static StyleLayer addSymbolRule(MapStyle s, String sourceLayer, String field, - int textColor, int haloColor) { + int textColor, int haloColor, double size) { StyleLayer layer = new StyleLayer(StyleLayer.TYPE_SYMBOL).sourceLayer(sourceLayer).textField(field) .textColor(textColor).textHaloColor(haloColor) - .textSize(ZoomValue.constant(13)); + .textSize(ZoomValue.constant(size)); s.add(layer); return layer; } diff --git a/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java b/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java index 39100903016..e89bc2772cb 100644 --- a/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java +++ b/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java @@ -55,9 +55,10 @@ public final class StyleLayer { private String textField; private String filterKey; - private String filterValue; + private String[] filterValues; private String excludeKey; - private String excludeValue; + private String[] excludeValues; + private boolean linesOnly; StyleLayer(int type) { this.type = type; @@ -150,24 +151,47 @@ String getTextField() { } StyleLayer filter(String key, String value) { + return filterIn(key, new String[]{value}); + } + + /// Keeps only the features whose `key` attribute is one of `values`: the + /// classes of road that are drawn as main roads, say. + StyleLayer filterIn(String key, String[] values) { this.filterKey = key; - this.filterValue = value; + this.filterValues = values; + return this; + } + + /// Drops features whose `key` attribute is one of `values`. + StyleLayer excludeIn(String key, String[] values) { + this.excludeKey = key; + this.excludeValues = values; + return this; + } + + /// Makes a line layer stroke lines alone. A layer of roads also carries + /// areas -- piers, squares, the decks of bridges -- and stroking the edge + /// of an area as though it were a road draws a road around every pier. + StyleLayer linesOnly() { + this.linesOnly = true; return this; } + boolean isLinesOnly() { + return linesOnly; + } + /// Drops features whose `key` attribute equals `value` (e.g. excluding /// `class=ferry` so ferry routes are not drawn as roads across the water). StyleLayer excludeFilter(String key, String value) { - this.excludeKey = key; - this.excludeValue = value; - return this; + return excludeIn(key, new String[]{value}); } /// Whether `feature` passes this layer's optional equality / exclusion filters. boolean accepts(VectorFeature feature) { if (excludeKey != null) { Object ev = feature.getAttribute(excludeKey); - if (ev != null && excludeValue != null && excludeValue.equals(String.valueOf(ev))) { + if (ev != null && among(excludeValues, String.valueOf(ev))) { return false; } } @@ -175,6 +199,17 @@ boolean accepts(VectorFeature feature) { return true; } Object v = feature.getAttribute(filterKey); - return v != null && filterValue != null && filterValue.equals(String.valueOf(v)); + return v != null && among(filterValues, String.valueOf(v)); + } + + private static boolean among(String[] values, String value) { + if (values != null) { + for (String candidate : values) { + if (candidate != null && candidate.equals(value)) { + return true; + } + } + } + return false; } } diff --git a/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java b/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java index 762dd0b0e02..96ae14a9542 100644 --- a/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java +++ b/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java @@ -120,6 +120,9 @@ private static void renderLines(Graphics g, List features, StyleLayer sl, Viewpo if ((gt != VectorFeature.GEOM_LINESTRING && gt != VectorFeature.GEOM_POLYGON) || !sl.accepts(f)) { continue; } + if (gt == VectorFeature.GEOM_POLYGON && sl.isLinesOnly()) { + continue; + } List parts = f.getParts(); for (Object partObj : parts) { int[] line = (int[]) partObj; diff --git a/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java b/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java index 56bdc9d8a2d..cb1ac0e19a9 100644 --- a/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java +++ b/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java @@ -81,7 +81,12 @@ public final class VectorMapEngine { private double pixelRatio = 1.0; private final TileCache rendered; - private final Map labels = new HashMap(); + // The labels of each tile, bounded like the tiles themselves: a plain map + // kept the labels of every tile a long session had ever shown, long after + // the tile's picture was evicted. Twice the tiles' bound, so the labels of + // a tile still drawn are never the ones to go: every set of labels newer + // than a tile's came with at least one tile newer than it. + private final TileCache labels = new TileCache(512); private final Map pending = new HashMap(); private final Map failed = new HashMap(); private final LabelEngine labelEngine = new LabelEngine(); @@ -91,6 +96,32 @@ public final class VectorMapEngine { private final Map parentWaiters = new HashMap(); private int generation; + // The most tiles asked of the source at one time. A view that has just + // moved wants every tile it shows, and a drag wants a new row of them every + // few frames. Handed to the source all at once they are fetched in the + // order they were asked for, each waiting for all of those before it -- + // including the ones the drag left behind long before their turn came. + private static final int MAX_IN_FLIGHT = 6; + // How many levels up a stand-in for a missing tile is looked for. + private static final int STAND_IN_LEVELS = 5; + // The first wait before a tile that failed is asked for again, doubled for + // each failure after it up to the longest. + private static final int RETRY_FIRST_MILLIS = 2000; + private static final int RETRY_LONGEST_MILLIS = 60000; + private static final int RETRY_SLACK_MILLIS = 50; + + // The tiles wanted and not yet asked of the source: tile key to FetchJob. + // Every pass over the view says again which of them it still wants, and + // the ones it did not mention are dropped before any more are sent. + private final Map queued = new HashMap(); + private int inFlight; + private int pass; + private boolean dispatching; + // The middle of the view as a fraction of the world, for sending the + // tiles nearest to it first. + private double passCenterX; + private double passCenterY; + // What [#paintTiles] saw, for the [#paintLabels] call that follows it. private List frameLabels; private double frameScale; @@ -120,6 +151,12 @@ public void setRepaintCallback(Runnable r) { /// Replaces the tile source, clearing cached tiles. public void setSource(TileSource source) { this.source = source; + discardTiles(); + } + + // Forgets every tile and every request for one. What is on its way from + // the source still arrives, and is recognised by its generation. + private void discardTiles() { generation++; rendered.clear(); labels.clear(); @@ -127,6 +164,10 @@ public void setSource(TileSource source) { failed.clear(); decodedParents.clear(); parentWaiters.clear(); + queued.clear(); + inFlight = 0; + pieces.clear(); + waitingPieces.clear(); } /// The active tile source. @@ -137,13 +178,7 @@ public TileSource getSource() { /// Replaces the style, clearing rendered tiles so they redraw. public void setStyle(MapStyle style) { this.style = style; - generation++; - rendered.clear(); - labels.clear(); - pending.clear(); - failed.clear(); - decodedParents.clear(); - parentWaiters.clear(); + discardTiles(); } /// The active style. @@ -211,13 +246,7 @@ public void setViewport(int width, int height) { public void setPixelRatio(double ratio) { if (ratio > 0 && Math.abs(ratio - pixelRatio) > 1e-9) { pixelRatio = ratio; - generation++; - rendered.clear(); - labels.clear(); - pending.clear(); - failed.clear(); - decodedParents.clear(); - parentWaiters.clear(); + discardTiles(); } } @@ -411,6 +440,7 @@ public void paintTiles(Graphics g, int originX, int originY, int width, int heig Set labelKeys = new HashSet(); int sourceMax = source.getMaxZoom(); + beginPass(cwx, cwy); for (int tx = txMin; tx <= txMax; tx++) { for (int ty = tyMin; ty <= tyMax; ty++) { if (ty < 0 || ty >= tiles) { @@ -419,13 +449,14 @@ public void paintTiles(Graphics g, int originX, int originY, int width, int heig int wrappedTx = ((tx % tiles) + tiles) % tiles; String key = TileUtil.key(z, wrappedTx, ty); Image img = (Image) rendered.get(key); + int left = screenX(tx * (double) tileSize, s, cwx, originX, width); + int top = screenY(ty * (double) tileSize, s, cwy, originY, height); + int right = screenX((tx + 1) * (double) tileSize, s, cwx, originX, width); + int bottom = screenY((ty + 1) * (double) tileSize, s, cwy, originY, height); if (img == null) { requestTile(z, wrappedTx, ty); + paintStandIn(g, z, wrappedTx, ty, left, top, right - left, bottom - top); } else { - int left = screenX(tx * (double) tileSize, s, cwx, originX, width); - int top = screenY(ty * (double) tileSize, s, cwy, originY, height); - int right = screenX((tx + 1) * (double) tileSize, s, cwx, originX, width); - int bottom = screenY((ty + 1) * (double) tileSize, s, cwy, originY, height); g.drawImage(img, left, top, right - left, bottom - top); } // An overzoomed tile shows part of a deeper-level tile, whose @@ -443,6 +474,8 @@ public void paintTiles(Graphics g, int originX, int originY, int width, int heig } } + endPass(); + frameLabels = visibleLabels; frameScale = s; frameCenterX = cwx; @@ -489,6 +522,7 @@ private boolean checkVisibleTilesReady() { boolean ready = true; boolean sawTile = false; + beginPass(cwx, cwy); for (int tx = txMin; tx <= txMax; tx++) { for (int ty = tyMin; ty <= tyMax; ty++) { if (ty < 0 || ty >= tiles) { @@ -504,16 +538,70 @@ private boolean checkVisibleTilesReady() { } } } + endPass(); return sawTile && ready; } + // What a tile that has not arrived shows in the meantime: the part of a + // shallower tile that covers it, stretched, or failing that whichever of + // the four tiles one level down are at hand. Either is blurred or partial, + // and either is the map -- where a blank square is a hole in it, which is + // what every zoom across a whole level and every drag onto new ground + // showed until the tiles came. + private void paintStandIn(Graphics g, int z, int x, int y, int left, int top, int w, int h) { + if (w <= 0 || h <= 0) { + return; + } + Image ancestor = null; + int up = 0; + while (ancestor == null && up < STAND_IN_LEVELS && z - up > 0) { + up++; + ancestor = (Image) rendered.get(TileUtil.key(z - up, x >> up, y >> up)); + } + if (ancestor != null) { + int span = 1 << up; + int subX = x - ((x >> up) << up); + int subY = y - ((y >> up) << up); + int clipX = g.getClipX(); + int clipY = g.getClipY(); + int clipW = g.getClipWidth(); + int clipH = g.getClipHeight(); + g.clipRect(left, top, w, h); + g.drawImage(ancestor, left - subX * w, top - subY * h, w * span, h * span); + g.setClip(clipX, clipY, clipW, clipH); + return; + } + int midX = left + w / 2; + int midY = top + h / 2; + for (int i = 0; i < 2; i++) { + for (int j = 0; j < 2; j++) { + Image child = (Image) rendered.get(TileUtil.key(z + 1, x * 2 + i, y * 2 + j)); + if (child != null) { + int cx = i == 0 ? left : midX; + int cy = j == 0 ? top : midY; + g.drawImage(child, cx, cy, (i == 0 ? midX : left + w) - cx, (j == 0 ? midY : top + h) - cy); + } + } + } + } + private void drawLabels(Graphics g, List candidates, double s, double cwx, double cwy, int originX, int originY, int width, int height, int z) { - labelEngine.reset(); - for (Object cand : candidates) { - LabelCandidate c = (LabelCandidate) cand; + labelEngine.reset(originX, originY, originX + width, originY + height); + labelEngine.setHalo((int) Math.floor(1.2 * pixelRatio + 0.5)); + // The labels of one view nearly all come from tiles of one zoom, and + // a power is not cheap everywhere: worked out per label, it was a + // tenth of a frame in the browser. + int factorZoom = z; + double factor = 1; + int count = candidates.size(); + for (int i = 0; i < count; i++) { + LabelCandidate c = (LabelCandidate) candidates.get(i); // Candidate world coords are at its own tile zoom; rescale to this z. - double factor = MathUtil.pow(2, z - c.tileZoom); + if (c.tileZoom != factorZoom) { + factorZoom = c.tileZoom; + factor = MathUtil.pow(2, z - factorZoom); + } if (c.path != null) { drawLineLabel(g, c, factor * s, cwx, cwy, originX, originY, width, height); continue; @@ -527,7 +615,7 @@ private void drawLabels(Graphics g, List candidates, double s, double cwx, doubl continue; } int sizePx = (int) Math.round(c.sizePx * pixelRatio); - labelEngine.place(g, c.text, sizePx, c.textColor, c.haloColor, sx, sy); + labelEngine.place(g, c, sizePx, sx, sy); } } @@ -556,7 +644,7 @@ private void drawLineLabel(Graphics g, LabelCandidate c, double scale, double cw return; } int sizePx = (int) Math.round(c.sizePx * pixelRatio); - labelEngine.placeAlongLine(g, c.text, sizePx, c.textColor, c.haloColor, pts, + labelEngine.placeAlongLine(g, c, pts, sizePx, ROAD_LABEL_REPEAT * pixelRatio, originX, originY, originX + width, originY + height); } @@ -594,10 +682,28 @@ private static int floorDiv(int a, int b) { private void requestTile(final int z, final int x, final int y) { final String key = TileUtil.key(z, x, y); - if (pending.containsKey(key) || failed.containsKey(key)) { + boolean overzoomed = source.isVector() && z > source.getMaxZoom(); + if (pending.containsKey(key)) { + // Asked for already, and perhaps still waiting its turn: the view + // wants it yet. + if (overzoomed) { + int depth = z - source.getMaxZoom(); + String parentKey = TileUtil.key(source.getMaxZoom(), x >> depth, y >> depth); + stillWanted(parentKey); + Piece waiting = (Piece) waitingPieces.get(key); + if (waiting != null) { + waiting.pass = pass; + } + stillWaiting(parentKey, z, x, y); + } else { + stillWanted(key); + } + return; + } + if (failedForNow(key)) { return; } - if (source.isVector() && z > source.getMaxZoom()) { + if (overzoomed) { requestOverzoomTile(key, z, x, y); return; } @@ -606,7 +712,8 @@ private void requestTile(final int z, final int x, final int y) { // An overzoom fetch of this very tile is in flight (the user zoomed // back out): join it as its depth-0 piece instead of fetching again. pending.put(key, Integer.valueOf(generation)); - loading.add(new int[]{z, x, y}); + loading.add(new int[]{z, x, y, pass}); + stillWanted(key); return; } final int requestGeneration = generation; @@ -614,7 +721,7 @@ private void requestTile(final int z, final int x, final int y) { final MapStyle requestStyle = style; final int requestRasterSize = rasterTileSize(); pending.put(key, Integer.valueOf(requestGeneration)); - requestSource.fetchTile(z, x, y, new TileCallback() { + enqueue(key, z, x, y, new TileCallback() { @Override public void tileLoaded(int tz, int tx, int ty, byte[] data) { prepareTile(key, tz, tx, ty, data, requestSource, requestStyle, @@ -628,6 +735,222 @@ public void tileFailed(int tz, int tx, int ty) { }); } + // ---- The queue between the view and the source -------------------------- + + // Starts a pass over the tiles in view. Both passes there are -- a paint, + // and the readiness probe -- name every tile the view shows. + private void beginPass(double centerWorldX, double centerWorldY) { + pass++; + double world = tileSize * MathUtil.pow(2, zoom); + passCenterX = centerWorldX / world; + passCenterY = centerWorldY / world; + } + + // Ends it: what the pass did not ask for again is no longer in view, and is + // dropped unsent; then as many of the rest go to the source as it has room + // for. + private void endPass() { + if (!queued.isEmpty()) { + List gone = null; + for (Object o : queued.values()) { + FetchJob job = (FetchJob) o; + if (job.pass != pass) { + if (gone == null) { + gone = new ArrayList(); + } + gone.add(job); + } + } + if (gone != null) { + for (Object o : gone) { + FetchJob job = (FetchJob) o; + queued.remove(job.key); + forget(job.key); + } + } + } + // The pieces of an overzoomed tile whose parent was already decoded + // never were in the queue above: they wait here to be drawn. One the + // pass did not ask for has left the view, and left waiting it stayed + // wanted for good -- a fast drag across a deep zoom queued every tile + // it crossed and then drew them all, on the event thread, pushing the + // tiles now in view out of the cache. + if (!pieces.isEmpty()) { + for (int i = pieces.size() - 1; i >= 0; i--) { + Piece piece = (Piece) pieces.get(i); + if (piece.pass != pass) { + pieces.remove(i); + if (stopWaiting(piece)) { + Object wanted = pending.get(piece.key); + if (wanted instanceof Integer + && ((Integer) wanted).intValue() == piece.generation) { + pending.remove(piece.key); + } + } + } + } + } + dispatch(); + } + + private void enqueue(String key, int z, int x, int y, TileCallback callback) { + FetchJob job = new FetchJob(); + job.key = key; + job.z = z; + job.x = x; + job.y = y; + job.callback = callback; + job.pass = pass; + queued.put(key, job); + } + + private void stillWanted(String fetchKey) { + FetchJob job = (FetchJob) queued.get(fetchKey); + if (job != null) { + job.pass = pass; + } + } + + // A tile dropped from the queue was never asked of the source, so nothing + // will come to finish it: it, and the overzoomed pieces waiting on it, go + // back to being simply absent, to be asked for again if they come back + // into view. + private void forget(String key) { + pending.remove(key); + List waiting = (List) parentWaiters.remove(key); + if (waiting != null) { + for (Object w : waiting) { + int[] a = (int[]) w; + pending.remove(TileUtil.key(a[0], a[1], a[2])); + } + } + } + + private void dispatch() { + if (dispatching) { + // A source that answers before fetchTile returns ends up here from + // inside the loop below, which sends the next one itself. + return; + } + dispatching = true; + try { + while (inFlight < MAX_IN_FLIGHT && !queued.isEmpty()) { + FetchJob next = null; + double nearest = 0; + for (Object o : queued.values()) { + FetchJob job = (FetchJob) o; + double span = 1 << job.z; + double dx = (job.x + 0.5) / span - passCenterX; + double dy = (job.y + 0.5) / span - passCenterY; + double distance = dx * dx + dy * dy; + if (next == null || distance < nearest) { + next = job; + nearest = distance; + } + } + queued.remove(next.key); + inFlight++; + send(next); + } + } finally { + dispatching = false; + } + } + + private void send(final FetchJob job) { + final int sent = generation; + source.fetchTile(job.z, job.x, job.y, new TileCallback() { + @Override + public void tileLoaded(int tz, int tx, int ty, byte[] data) { + arrived(sent); + job.callback.tileLoaded(tz, tx, ty, data); + dispatch(); + } + + @Override + public void tileFailed(int tz, int tx, int ty) { + arrived(sent); + job.callback.tileFailed(tz, tx, ty); + dispatch(); + } + }); + } + + private void arrived(int sent) { + if (sent == generation && inFlight > 0) { + inFlight--; + } + } + + // Whether the tile failed and it is too soon to ask for it again. A + // failure used to be for good: one request lost to a tunnel left a hole in + // the map until the application was restarted. + private boolean failedForNow(String key) { + long[] failure = (long[]) failed.get(key); + return failure != null && System.currentTimeMillis() < failure[0]; + } + + private void markFailed(String key) { + long[] failure = (long[]) failed.get(key); + long wait = RETRY_FIRST_MILLIS; + if (failure == null) { + failure = new long[2]; + failed.put(key, failure); + } else { + wait = Math.min(RETRY_LONGEST_MILLIS, failure[1] * 2); + } + failure[1] = wait; + failure[0] = System.currentTimeMillis() + wait; + scheduleRetry(wait); + } + + // When the timer that looks at the failed tiles again is due, or 0 when + // none is waiting. + private long retryAt; + + // Which timer that is. One replaced by an earlier one still fires, and + // must then do nothing. + private int retryTimer; + + // A tile is only asked for by a paint, and a map nobody is touching does + // not paint: without this, the delay above ran out with nothing left to + // notice, and the hole stayed until the user happened to move the map. + // One timer counts at a time, set for the tile that may be retried + // soonest. A tile that has failed many times waits up to a minute, so a + // timer already waiting says nothing about a tile that failed just now: + // when the new moment is the earlier one, the timer is replaced. + private void scheduleRetry(long wait) { + long due = System.currentTimeMillis() + wait; + if (retryAt != 0L && retryAt <= due) { + return; + } + retryAt = due; + final int timer = ++retryTimer; + // A little past the moment, so the paint finds the tile retryable. + com.codename1.ui.CN.setTimeout((int) wait + RETRY_SLACK_MILLIS, new Runnable() { + @Override + public void run() { + if (timer != retryTimer) { + return; + } + retryAt = 0L; + long now = System.currentTimeMillis(); + long soonest = Long.MAX_VALUE; + for (Object value : failed.values()) { + long at = ((long[]) value)[0]; + if (at > now && at < soonest) { + soonest = at; + } + } + if (soonest != Long.MAX_VALUE) { + // Tiles that failed later are still waiting their turn. + scheduleRetry(soonest - now); + } + repaint(); + } + }); + } + private void prepareTile(final String key, final int z, final int x, final int y, final byte[] data, final TileSource requestSource, final MapStyle requestStyle, final int requestRasterSize, final int requestGeneration) { @@ -679,9 +1002,10 @@ private void applyTileResult(String key, TileResult result, int requestGeneratio image = Image.createImage(result.data, 0, result.data.length); } rendered.put(key, image); + failed.remove(key); drawn = true; } catch (Throwable t) { - failed.put(key, Boolean.TRUE); + markFailed(key); failWaiters(key, requestGeneration); } // Outside the try on purpose: the waiter list is read through @@ -697,7 +1021,7 @@ private void applyTileResult(String key, TileResult result, int requestGeneratio repaint(); } } else { - failed.put(key, Boolean.TRUE); + markFailed(key); failWaiters(key, requestGeneration); } } @@ -714,37 +1038,40 @@ private void requestOverzoomTile(String key, int z, int x, int y) { final String parentKey = TileUtil.key(parentZ, px, py); final int requestGeneration = generation; pending.put(key, Integer.valueOf(requestGeneration)); - final int[] address = new int[]{z, x, y}; + // The last element is the last pass that asked for the tile; see + // stillWaiting. + final int[] address = new int[]{z, x, y, pass}; final VectorTile parent = (VectorTile) decodedParents.get(parentKey); if (parent != null) { // Rasterizing is EDT work, but not work for the middle of a paint. - MapTileWorker.callSerially(new Runnable() { - @Override - public void run() { - if (requestGeneration != generation) { - return; - } - renderOverzoomed(parent, parentZ, address, requestGeneration); - repaint(); - } - }); + queuePiece(parent, parentZ, address, requestGeneration); return; } List waiters = (List) parentWaiters.get(parentKey); - boolean inFlight = waiters != null || pending.containsKey(parentKey); + boolean underway = waiters != null || pending.containsKey(parentKey); + if (!underway && failedForNow(parentKey)) { + // The ancestor failed and is waiting out its back-off. Each piece + // of it has a key of its own, so without this every sibling the + // view reaches would ask the source for the same tile again -- + // once per piece within a single paint when the source refuses at + // once -- and the back-off would hold for none of them. + finishFailed(key, requestGeneration); + return; + } if (waiters == null) { waiters = new ArrayList(); parentWaiters.put(parentKey, waiters); } waiters.add(address); - if (inFlight) { + if (underway) { // Either another piece already fetches the ancestor, or it is // loading as an ordinary deepest-level tile (the user zoomed in // while it loaded) and applyTileResult hands it to the waiters: // one download and one decode either way. + stillWanted(parentKey); return; } - source.fetchTile(parentZ, px, py, new TileCallback() { + enqueue(parentKey, parentZ, px, py, new TileCallback() { @Override public void tileLoaded(int tz, int tx, int ty, final byte[] data) { MapTileWorker.run(new Runnable() { @@ -785,14 +1112,139 @@ private void applyParent(String parentKey, int parentZ, VectorTile tile, int req return; } if (tile == null) { + // Under the ancestor's own key, which every later piece of it + // consults before asking again; see requestOverzoomTile. + markFailed(parentKey); failAll(waiters, requestGeneration); return; } + failed.remove(parentKey); decodedParents.put(parentKey, tile); for (Object w : waiters) { - renderOverzoomed(tile, parentZ, (int[]) w, requestGeneration); + queuePiece(tile, parentZ, (int[]) w, requestGeneration); + } + } + + // ---- Drawing the pieces of an overzoomed tile, a few at a time --------- + + // One tile of the source's deepest level is, three levels further in, + // sixty-four tiles of the map, and a view holds a dozen or more of them. + // They used to be drawn one after the other the moment their tile arrived, + // on the event thread, which did nothing else until the last was done: a + // third of a second of a map that would not move, each time a tile came + // in. Now they are drawn for a few milliseconds at a time, with a frame + // in between. + private static final int PIECE_BUDGET_MILLIS = 6; + private final List pieces = new ArrayList(); + // The same pieces by the key of the tile each will draw, so a pass can + // say which of them the view still shows. + private final Map waitingPieces = new HashMap(); + private boolean piecesScheduled; + private int pieceSerial; + + // Takes a piece off the map of those waiting, when it is the one waiting + // under its key: a tile dropped and asked for again has a newer piece + // there, which this one must not remove. False for a piece that had + // already been replaced. + private boolean stopWaiting(Piece piece) { + Piece waiting = (Piece) waitingPieces.get(piece.key); + if (waiting == null || waiting.serial != piece.serial) { + return false; } - repaint(); + waitingPieces.remove(piece.key); + return true; + } + + // A tile waiting on an ancestor whose fetch is already at the source is in + // neither the queue nor the pieces endPass looks at, so a pass that no + // longer shows it cannot drop it. Its address records the last pass that + // asked for it instead, and is refreshed here each time one does. + private void stillWaiting(String parentKey, int z, int x, int y) { + List waiters = (List) parentWaiters.get(parentKey); + if (waiters == null) { + return; + } + for (Object w : waiters) { + int[] a = (int[]) w; + if (a[0] == z && a[1] == x && a[2] == y) { + a[3] = pass; + } + } + } + + private void queuePiece(VectorTile parent, int parentZ, int[] address, int requestGeneration) { + if (address[3] != pass) { + // Its ancestor arrived after the view moved on: the last pass did + // not ask for it, and drawing it would spend the event thread on + // a tile nobody sees and push ones in view out of the cache. It + // goes back to being absent, as endPass leaves the tiles it drops. + removePendingIfMatches(TileUtil.key(address[0], address[1], address[2]), + requestGeneration); + return; + } + Piece piece = new Piece(); + piece.parent = parent; + piece.parentZ = parentZ; + piece.address = address; + piece.generation = requestGeneration; + piece.key = TileUtil.key(address[0], address[1], address[2]); + piece.pass = pass; + piece.serial = ++pieceSerial; + pieces.add(piece); + waitingPieces.put(piece.key, piece); + schedulePieces(); + } + + private void schedulePieces() { + if (piecesScheduled || pieces.isEmpty()) { + return; + } + piecesScheduled = true; + MapTileWorker.callSerially(new Runnable() { + @Override + public void run() { + piecesScheduled = false; + drawPieces(); + } + }); + } + + private void drawPieces() { + long started = System.currentTimeMillis(); + boolean drew = false; + while (!pieces.isEmpty()) { + Piece piece = (Piece) pieces.remove(0); + String key = piece.key; + stopWaiting(piece); + Object wanted = pending.get(key); + // Dropped since -- the view moved on before its turn -- or asked + // for by a map that has been reset. + if (piece.generation != generation || !(wanted instanceof Integer) + || ((Integer) wanted).intValue() != piece.generation) { + continue; + } + renderOverzoomed(piece.parent, piece.parentZ, piece.address, piece.generation); + drew = true; + if (System.currentTimeMillis() - started >= PIECE_BUDGET_MILLIS) { + break; + } + } + if (drew) { + repaint(); + } + schedulePieces(); + } + + private static final class Piece { + private VectorTile parent; + private int parentZ; + private int[] address; + private int generation; + // The tile this will draw, and the last pass that asked for it. + private String key; + private int pass; + // Which piece this is, counted as they are queued. + private int serial; } private void renderOverzoomed(VectorTile parent, int parentZ, int[] address, int requestGeneration) { @@ -806,6 +1258,7 @@ private void renderOverzoomed(VectorTile parent, int parentZ, int[] address, int Image buffer = Image.createImage(size, size, 0); TileRenderer.renderTile(buffer.getGraphics(), parent, style, address[0], size, subX, subY, depth); rendered.put(key, buffer); + failed.remove(key); // The parent's labels, styled for the zoom actually shown: a symbol // layer's minzoom/maxzoom and text size are judged at this zoom, // not at the source's deepest one, while the anchors stay in the @@ -814,12 +1267,12 @@ private void renderOverzoomed(VectorTile parent, int parentZ, int[] address, int // any ordinary tile. String labelKey = depth == 0 ? key : overzoomLabelKey(address[0], TileUtil.key(parentZ, address[1] >> depth, address[2] >> depth)); - if (!labels.containsKey(labelKey)) { + if (!labels.contains(labelKey)) { labels.put(labelKey, TileRenderer.extractLabels(parent, style, address[0], parentZ, address[1] >> depth, address[2] >> depth, tileSize)); } } catch (Throwable t) { - failed.put(key, Boolean.TRUE); + markFailed(key); } } @@ -835,7 +1288,7 @@ private void finishFailed(String key, int requestGeneration) { return; } removePendingIfMatches(key, requestGeneration); - failed.put(key, Boolean.TRUE); + markFailed(key); failWaiters(key, requestGeneration); } @@ -843,7 +1296,7 @@ private void drainWaiters(String parentKey, VectorTile tile, int parentZ, int re List waiting = (List) parentWaiters.remove(parentKey); if (waiting != null) { for (Object w : waiting) { - renderOverzoomed(tile, parentZ, (int[]) w, requestGeneration); + queuePiece(tile, parentZ, (int[]) w, requestGeneration); } } } @@ -892,13 +1345,18 @@ private void repaint() { /// Drops all cached tiles (e.g. on low memory). public void clearCache() { - generation++; - rendered.clear(); - labels.clear(); - pending.clear(); - failed.clear(); - decodedParents.clear(); - parentWaiters.clear(); + discardTiles(); + } + + // A tile to ask the source for when its turn comes. + private static final class FetchJob { + private String key; + private int z; + private int x; + private int y; + private TileCallback callback; + // The last pass over the view that wanted it. + private int pass; } private static final class TileResult { diff --git a/CodenameOne/src/com/codename1/orm/repository/Modifying.java b/CodenameOne/src/com/codename1/orm/repository/Modifying.java index 24aa9efa4aa..8ea28d8ef9d 100644 --- a/CodenameOne/src/com/codename1/orm/repository/Modifying.java +++ b/CodenameOne/src/com/codename1/orm/repository/Modifying.java @@ -27,10 +27,46 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// Marks a bulk update or delete. Always flushes before and clears after execution. +/// Marks a repository `@Query` method whose statement is a bulk UPDATE or DELETE. +/// +/// The statement runs in the active transaction and answers the number of rows it +/// changed. It goes straight to the database: per-entity callbacks and cascades do +/// not run, and entities this session already manages are not changed by it. An +/// entity read before the statement keeps the values it had then, while the row +/// may now hold others. It stays managed, so a change made to it afterwards is +/// still written when the transaction commits. Read the new values with +/// `Session.refresh(entity)`, or set `clearAutomatically` so the session is emptied +/// after the statement and the next read goes to the database. +/// +/// A row that a bulk DELETE removed can still be held by the session as an +/// entity. Changing that entity, or removing it, fails at flush with an +/// `OptimisticLockException`, because its row is gone. @com.codename1.impl.SharedWithBackend @Retention(RetentionPolicy.CLASS) @Target(ElementType.METHOD) public @interface Modifying { + /// Whether to flush the session's pending changes before the statement. + /// + /// Pending changes are flushed before every bulk statement whatever this says, + /// so the statement sees what the transaction already changed. That is what a + /// JPA provider's default `AUTO` flush mode does before a statement that reads + /// tables with pending writes; here every pending change is written, as the + /// session does not narrow a flush to the tables a statement reads. Skipping + /// the flush could only make the statement miss changes the transaction made, + /// so the attribute is accepted for source compatibility with Spring Data and + /// has no further effect. + /// + /// @return ignored; the session is always flushed first + boolean flushAutomatically() default false; + /// Whether to detach every entity this session manages once the statement has + /// run, so that what is read afterwards is read from the database. + /// + /// Off by default, as in Spring Data. Turning it on detaches entities the + /// caller may still be holding: a change made to one of them after this + /// method returns is not tracked and is never written. Make such changes + /// first, or read the entity again after the call. + /// + /// @return true to clear the session after the statement + boolean clearAutomatically() default false; } diff --git a/CodenameOne/src/com/codename1/orm/session/JpqlQuery.java b/CodenameOne/src/com/codename1/orm/session/JpqlQuery.java index b19003d8ec3..f680f3add96 100644 --- a/CodenameOne/src/com/codename1/orm/session/JpqlQuery.java +++ b/CodenameOne/src/com/codename1/orm/session/JpqlQuery.java @@ -73,8 +73,15 @@ public interface JpqlQuery { T first(); /// Executes a bulk update or delete in an active transaction. - /// Flushes first, bypasses entity cascades and lifecycle callbacks, then clears - /// the session so subsequent reads cannot reuse stale managed entities. + /// Flushes pending changes first, then runs the statement directly in the + /// database, bypassing entity cascades and lifecycle callbacks. + /// + /// The session is not cleared, as in JPA. Entities it already manages stay + /// managed and keep the values they had before the statement, so a change + /// made to one afterwards is still written at flush. To see what the statement + /// wrote, call `Session.refresh(entity)` or `Session.clear()` and read again. A + /// managed entity whose row a bulk DELETE removed fails with an + /// `OptimisticLockException` if it is changed or removed afterwards. /// @return number of affected rows /// @throws IllegalStateException if this is a select /// @throws IllegalArgumentException if pagination is set or a parameter is unbound diff --git a/CodenameOne/src/com/codename1/orm/session/Session.java b/CodenameOne/src/com/codename1/orm/session/Session.java index 8466048686f..7169c9ffde8 100644 --- a/CodenameOne/src/com/codename1/orm/session/Session.java +++ b/CodenameOne/src/com/codename1/orm/session/Session.java @@ -57,7 +57,7 @@ public interface Session { /// scalar column of String, Long, Integer, Double, Boolean, or byte[]. Entity /// hydration and tuples are not supported. SQL identifiers and functions are /// database-specific. Bulk UPDATE/DELETE requires a transaction, flushes first, - /// and clears the persistence context after success, just like JPQL. + /// and leaves managed entities as they are, just like JPQL. /// @param scalar result type /// @param statement one SELECT, UPDATE or DELETE statement /// @param resultType scalar class, or Object.class for mutations diff --git a/CodenameOne/src/com/codename1/ui/RunnableWrapper.java b/CodenameOne/src/com/codename1/ui/RunnableWrapper.java index 47aebfcb364..975a509d80b 100644 --- a/CodenameOne/src/com/codename1/ui/RunnableWrapper.java +++ b/CodenameOne/src/com/codename1/ui/RunnableWrapper.java @@ -34,6 +34,17 @@ class RunnableWrapper implements Runnable { private static int maxThreadCount = 5; private static int availableThreads = 0; + /// Counts the calls to `#retireThreadPool()`. A pool thread remembers the count it + /// started under and leaves once it is idle and the count has moved on. Read and written + /// under `THREADPOOL_LOCK` only. + private static int retirements = 0; + + /// For a pool thread, the `#retirements` count it was created under. Taken when the + /// thread is made, under the lock, and not when it first runs: a thread made just + /// before a retirement but scheduled after it would otherwise read the new count, take + /// itself for the next generation and wait for work forever. + private int generation; + private boolean done = false; private Runnable internal; private int type; @@ -54,17 +65,38 @@ public RunnableWrapper(Runnable internal, int type) { } static void pushToThreadPool(Runnable r) { - if (availableThreads == 0 && threadCount < maxThreadCount) { - threadCount++; - Thread poolThread = Display.getInstance().startThread(new RunnableWrapper(null, 4), "invokeAndBlock" + threadCount); - poolThread.start(); - } + // Under the lock since a retiring thread gives its place back under it: counted here + // without it, the two could lose each other's update and leave the pool one thread + // short for good. synchronized (THREADPOOL_LOCK) { + if (availableThreads == 0 && threadCount < maxThreadCount) { + threadCount++; + RunnableWrapper worker = new RunnableWrapper(null, 4); + worker.generation = retirements; + Thread poolThread = Display.getInstance().startThread(worker, "invokeAndBlock" + threadCount); + poolThread.start(); + } threadPool.add(r); THREADPOOL_LOCK.notifyAll(); } } + /// Lets the pool's threads end: each one leaves as soon as it has nothing to run, and + /// the next `invokeAndBlock` starts the threads it needs. + /// + /// A pool thread otherwise waits for work for as long as the process lives. That is what + /// an application wants, and it is why nothing in the framework calls this. A host that + /// runs several generations of Codename One in one process, each in a class loader of + /// its own -- the simulator's test runner gives every test one -- needs the opposite: a + /// thread still waiting is a live reference to every class and every static of a + /// generation that is over, so none of it can be collected. + static void retireThreadPool() { + synchronized (THREADPOOL_LOCK) { + retirements++; + THREADPOOL_LOCK.notifyAll(); + } + } + static void setMaxThreadCount(int maxThreadCount) { RunnableWrapper.maxThreadCount = maxThreadCount; } @@ -137,12 +169,20 @@ public void run() { Display.getInstance().mainEDTLoop(); break; case 4: + // Written before the thread was started, so it is seen here. + int startedUnder = generation; while (!Display.getInstance().codenameOneExited) { Runnable r = null; synchronized (THREADPOOL_LOCK) { if (!threadPool.isEmpty()) { r = threadPool.get(0); threadPool.remove(0); + } else if (startedUnder != retirements) { + // Retired, and idle. The place is given back under the lock + // the next request counts under, so that request starts a + // thread of its own and is not left waiting for this one. + threadCount--; + break; } else { try { availableThreads++; diff --git a/CodenameOne/src/com/codename1/ui/events/WheelEvent.java b/CodenameOne/src/com/codename1/ui/events/WheelEvent.java index f3f09ed7b95..6387a573b02 100644 --- a/CodenameOne/src/com/codename1/ui/events/WheelEvent.java +++ b/CodenameOne/src/com/codename1/ui/events/WheelEvent.java @@ -26,8 +26,10 @@ /// Event delivered to mouse wheel listeners when the wheel or a trackpad scroll gesture is moved. /// /// The deltas are reported in display pixels (already converted from the native notch count by the -/// port). A positive `deltaY` reveals content above (the user scrolled the wheel down), and a -/// positive `deltaX` reveals content to the left. Consuming this event with `#consume()` prevents +/// port). A positive `deltaY` reveals content above: the wheel was turned up, away from the user +/// (or, with natural scrolling, the fingers moved down). This is the opposite sign to a browser's +/// `WheelEvent.deltaY` and to AWT's wheel rotation, which the ports negate. A positive `deltaX` +/// reveals content to the left. Consuming this event with `#consume()` prevents /// the default scrolling behavior, which is useful for gestures such as control plus wheel to zoom. public class WheelEvent extends ActionEvent { diff --git a/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java b/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java index ee90cc09155..dd84bece23b 100644 --- a/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java +++ b/CodenameOne/src/com/codename1/ui/geom/GeneralPath.java @@ -591,13 +591,17 @@ private void checkBuf(int pointCount, boolean checkMove) { // awt.20A=First segment should be SEG_MOVETO type throw new IndexOutOfBoundsException("First segment must be a moveto"); //$NON-NLS-1$ } + // Both buffers grow by half their size, not by a fixed step. A fixed step + // copies the whole path again every ten segments, so building a path of n + // segments cost n * n / 20 element copies -- most of the time a vector map + // tile took to rasterize, whose fills are paths of thousands of points. if (typeSize == types.length) { - byte[] tmp = new byte[typeSize + BUFFER_CAPACITY]; + byte[] tmp = new byte[typeSize + Math.max(BUFFER_CAPACITY, typeSize >> 1)]; System.arraycopy(types, 0, tmp, 0, typeSize); types = tmp; } if (pointSize + pointCount > points.length) { - float[] tmp = new float[pointSize + Math.max(BUFFER_CAPACITY * 2, pointCount)]; + float[] tmp = new float[pointSize + Math.max(Math.max(BUFFER_CAPACITY * 2, pointCount), pointSize >> 1)]; System.arraycopy(points, 0, tmp, 0, pointSize); points = tmp; } diff --git a/CodenameOne/src/com/codename1/ui/layouts/GridLayout.java b/CodenameOne/src/com/codename1/ui/layouts/GridLayout.java index df83ef5a712..9908015141b 100644 --- a/CodenameOne/src/com/codename1/ui/layouts/GridLayout.java +++ b/CodenameOne/src/com/codename1/ui/layouts/GridLayout.java @@ -335,12 +335,15 @@ public Dimension getPreferredSize(Container parent) { width = width * columns; } - if (rows > 1) { - if (totalComponentCount > rows * columns) { //if there are more components than planned - height = height * (totalComponentCount / columns + (totalComponentCount % columns == 0 ? 0 : 1)); - } else { - height = height * rows; - } + // The rows the components really take, which layoutContainer() uses: + // more components than rows * columns run onto further rows. That + // has to be asked for a single row too -- GridLayout(int columns) is + // one row that "implicitly grows", and answering the height of one + // row for it squeezed every further row into that row's height. + if (totalComponentCount > rows * columns) { //if there are more components than planned + height = height * (totalComponentCount / columns + (totalComponentCount % columns == 0 ? 0 : 1)); + } else if (rows > 1) { + height = height * rows; } Style s = parent.getStyle(); diff --git a/CodenameOne/src/com/codename1/util/EasyThread.java b/CodenameOne/src/com/codename1/util/EasyThread.java index 21b4e3803d9..6c6d11456cc 100644 --- a/CodenameOne/src/com/codename1/util/EasyThread.java +++ b/CodenameOne/src/com/codename1/util/EasyThread.java @@ -33,6 +33,11 @@ /// create a thread and dispatch tasks to it. public final class EasyThread { private static final List globalErrorListenenrs = new ArrayList(); + + /// Every thread that has not left its loop yet, for `#killAll()`. A thread adds itself + /// as it starts and takes itself out as it ends, so this holds nothing that is not + /// alive anyway. It is its own lock. + private static final ArrayList LIVE = new ArrayList(); private final Object LOCK = new Object(); private final Thread t; private final ArrayList queue = new ArrayList(); @@ -48,6 +53,9 @@ public final class EasyThread { private boolean finished; private EasyThread(String name) { + synchronized (LIVE) { + LIVE.add(this); + } t = Display.getInstance().startThread(new Runnable() { @Override public void run() { @@ -99,11 +107,33 @@ public void run() { finished = true; LOCK.notifyAll(); } + synchronized (LIVE) { + LIVE.remove(EasyThread.this); + } } }, name); t.start(); } + /// Stops every thread started through this class, each as `#kill()` stops it. + /// + /// Not for an application, which is why it is not public: a thread kept in a static + /// field -- the image loader, the map's tile worker -- is expected to be there for as + /// long as the process is, and would refuse everything after this. It is for a host + /// that runs several generations of Codename One in one process, each in a class loader + /// of its own, as the simulator's test runner does with every test. There a thread + /// waiting for work it will never be given is a live reference to every class and every + /// static of a generation that is over, so none of it can be collected. + static void killAll() { + ArrayList all; + synchronized (LIVE) { + all = new ArrayList(LIVE); + } + for (EasyThread thread : all) { + thread.kill(); + } + } + /// Starts a new thread /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/xml/XMLParser.java b/CodenameOne/src/com/codename1/xml/XMLParser.java index a59668014c2..865a0988b39 100644 --- a/CodenameOne/src/com/codename1/xml/XMLParser.java +++ b/CodenameOne/src/com/codename1/xml/XMLParser.java @@ -58,9 +58,17 @@ public class XMLParser { /// A constant containing the CDATA tag identifier (minus the C which is read anyway first) /// CDATA nodes will be converted to text nodes private static final String CDATA_STR = "DATA["; - private static final char[] buffer = new char[8192]; - private static int buffOffset; - private static int buffSize = -1; + // The read-ahead buffer belongs to the parser, and both entry points empty + // it. It used to be static and only parse() emptied it, so an eventParser + // run that a callback ended early -- RSSService stops at its item limit by + // throwing -- left the rest of that document in the buffer, and the next + // eventParser call in the process read it before its own input: the next + // feed came back with the previous feed's leftover items. A static buffer + // was also shared by parsers running on different threads, and by a parse + // started from inside another parser's callback. + private char[] buffer; + private int buffOffset; + private int buffSize = -1; ParserCallback parserCallback; boolean includeWhitespacesBetweenTags; // For HTML white spaces between tags are significant to seperate words, in XML less so and it mostly creates garbage elements (Text with one space) private boolean eventParser; @@ -93,9 +101,13 @@ public class XMLParser { /// This hashtable contains user defined char entities private Hashtable userDefinedCharEntities; - private static int read(Reader is) throws IOException { + private int read(Reader is) throws IOException { int c = -1; if (buffSize < 0 || buffOffset >= buffSize) { + if (buffer == null) { + // A subclass may call parseTagContent without an entry point. + buffer = new char[8192]; + } buffSize = is.read(buffer, 0, buffer.length); if (buffSize < 0) { return -1; @@ -108,6 +120,11 @@ private static int read(Reader is) throws IOException { return c; } + private void resetBuffer() { + buffOffset = 0; + buffSize = -1; + } + /// Trims unneeded & and ; from the symbol if exist /// /// #### Parameters @@ -270,8 +287,7 @@ protected String convertCharEntity(String charEntity) { /// /// an Element object describing the parsed document (Basically its DOM) public Element parse(Reader is) { - buffOffset = 0; - buffSize = -1; + resetBuffer(); eventParser = false; Element rootElement = createNewElement("ROOT"); // ROOT is a "dummy" element that all other document elements are added to try { @@ -355,6 +371,7 @@ public void setIncludeWhitespacesBetweenTags(boolean include) { /// /// - `java.io.IOException`: if an exception is thrown by the reader public void eventParser(Reader r) throws IOException { + resetBuffer(); eventParser = true; parseTagContent(null, r); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 3f5bfb49eca..a1ee562fc85 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -3909,7 +3909,16 @@ int surfaceHeight() { public void hierarchyChanged(HierarchyEvent e) { long flags = e.getChangeFlags(); if ((flags & HierarchyEvent.DISPLAYABILITY_CHANGED) != 0 || (flags & HierarchyEvent.PARENT_CHANGED) != 0) { - installNativeMagnificationListeners(); + if (getParent() == null) { + // Taken out of its window, which is what deinitialize() does + // last. Installing here put the wheel listener straight back + // on the Toolkit, one statement after deinitialize() had taken + // it off: the Toolkit is the process's, so the listener, this + // canvas and the whole port behind it were never collected. + disposeGestureListeners(); + } else { + installNativeMagnificationListeners(); + } } } }); @@ -4106,7 +4115,18 @@ public void run() { // Routed by window id like every other input this canvas // produces: the canvas a trackpad magnify arrived on is the // window whose component tree has to see it. + // + // Each step is a whole gesture, begun and released. A + // component reads pinch(scale) as the scale since the + // gesture began, as a touch pinch reports it, and this + // listener reports no phases: undelimited, every 1.14 step + // read as the same one step from where the first began, + // and a component that waits for pinchReleased (MapView, + // ImageViewer) never left its pinch. + Display d = Display.getInstance(); + d.firePinchBeginGesture(); com.codename1.ui.Desktop.getInstance().windowMagnifyGesture(windowId, x, y, scale); + d.firePinchReleaseGesture(x, y); } }); } @@ -14255,17 +14275,32 @@ public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { private Rectangle2D getStringBoundsWithEmojis(java.awt.Font font, String str) { if (hasUnsupportedChars(font, str)) { - TextLayout textLayout = new TextLayout( - createAttributedString(font, str).getIterator(), - canvas.getFRC() - ); - - Rectangle2D.Float textBounds = ( Rectangle2D.Float ) textLayout.getBounds(); - return textBounds; + return mixedFontBounds(createAttributedString(font, str).getIterator(), canvas.getFRC()); } else { return font.getStringBounds(str, canvas.getFRC()); } } + + /** + * The box a string drawn in more than one font occupies, measured the way + * {@code Font.getStringBounds} measures a single font: by the advance, not + * by the ink. + * + * {@code TextLayout.getBounds()} is the tight box around the pixels. It + * leaves out the side bearings of the first and last glyph and every + * trailing space, and it starts at the first glyph's bearing rather than + * at the pen. drawString() draws from the pen, so a string measured by its + * ink ended that bearing past the width it reported: right aligned text in + * a component with no side padding lost the edge of its last glyph. Every + * string the bundled fonts cannot display takes this path -- Hebrew, + * Arabic, CJK, emoji. + */ + static Rectangle2D mixedFontBounds(java.text.AttributedCharacterIterator text, FontRenderContext frc) { + TextLayout layout = new TextLayout(text, frc); + Rectangle2D ink = layout.getBounds(); + double width = Math.max(layout.getAdvance(), ink.getX() + ink.getWidth()); + return new Rectangle2D.Double(0, ink.getY(), width, ink.getHeight()); + } private java.awt.Font emojiFont; private boolean attemptedToLoadEmojiFont; @@ -17074,6 +17109,17 @@ public OutputStream openOutputStream(Object connection, int offset) throws IOExc return o; } + /** + * The body of an error response. {@code getErrorStream()} answers null for + * one that has no body -- a bare 403 or 404 -- and wrapping that null made + * the first read fail with "Stream closed", so a response the server did + * send reached the application as an I/O failure with no status to act on. + */ + private static InputStream errorStream(HttpURLConnection connection) { + InputStream body = connection.getErrorStream(); + return body == null ? new ByteArrayInputStream(new byte[0]) : body; + } + /** * @inheritDoc */ @@ -17111,7 +17157,7 @@ public InputStream openInputStream(Object connection) throws IOException { if(con.getResponseCode() >= 200 && con.getResponseCode() < 300){ is = con.getInputStream(); }else{ - is = con.getErrorStream(); + is = errorStream(con); } boolean isText = false; String contentType = con.getContentType(); @@ -17160,7 +17206,7 @@ public void close() throws IOException { if(ht.getResponseCode() < 400) { return new BufferedInputStream(ht.getInputStream()); } - return new BufferedInputStream(ht.getErrorStream()); + return new BufferedInputStream(errorStream(ht)); } else { return new BufferedInputStream(((URLConnection) connection).getInputStream()); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/TestExecuter.java b/Ports/JavaSE/src/com/codename1/impl/javase/TestExecuter.java index 428fd868c66..3ad6bbe6570 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/TestExecuter.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/TestExecuter.java @@ -22,9 +22,12 @@ */ package com.codename1.impl.javase; +import com.codename1.io.NetworkManager; import com.codename1.testing.TestReporting; import com.codename1.testing.UnitTest; +import com.codename1.ui.Accessor; import com.codename1.ui.Display; +import com.codename1.util.EasyThreadAccessor; import java.util.Timer; import java.util.TimerTask; @@ -33,6 +36,37 @@ */ public class TestExecuter { private static boolean failed; + + /** + * Ends the threads of the Codename One this class was loaded with, once + * its test is over. + * + * Clean mode gives every test a class loader of its own, and with it a + * Codename One of its own: a Display, a port with its skin images and + * screen buffers, the application and every form it built. That is + * garbage when the test ends only if nothing still running was loaded by + * it. The network threads, the threads behind invokeAndBlock and every + * EasyThread (the map's tile worker, the image loader) wait for work for + * as long as the process lives, and a waiting thread keeps its class, the + * class keeps its loader and the loader keeps all of it. Every test's + * world stayed in memory this way until the last one finished, and a + * suite of a dozen screens grew past two gigabytes. + * + * Called by the runner, reflectively like {@link #runTest}, and only in + * clean mode: a runner that goes on to use this same Codename One for the + * next test still needs these threads. + */ + public static void retire() { + try { + NetworkManager.getInstance().shutdown(); + Accessor.retireThreadPool(); + EasyThreadAccessor.killAll(); + } catch (Throwable t) { + // The test has been judged already; failing to tidy up after it + // costs memory and is said, but it is not a failed test. + t.printStackTrace(); + } + } public static boolean runTest(final String mainClass, final String testClass, boolean quietMode) { try { if(quietMode) { @@ -98,6 +132,10 @@ public void run() { } finally { TestReporting.getInstance().finishedTestCase(test.getClass().getName(), !failed); timeoutTask.cancel(); + // The timer and not only its task: a cancelled task stays in + // the queue until the time it was set for, and until then the + // timer's thread holds this class and all that was loaded with it. + timeoutKiller.cancel(); } } } catch(Exception err) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/TestRunner.java b/Ports/JavaSE/src/com/codename1/impl/javase/TestRunner.java index 87ddf8cbc41..153ff713654 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/TestRunner.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/TestRunner.java @@ -199,6 +199,9 @@ private void init(String[] argv) { Class c = Class.forName("com.codename1.impl.javase.TestExecuter", true, ldr); Method m = c.getDeclaredMethod("runTest", String.class, String.class, Boolean.TYPE); Boolean passed = (Boolean)m.invoke(null, mainClass, currentTestClass, quietMode); + // This test's Codename One is not used again: without this + // its threads keep everything it loaded in memory. + c.getDeclaredMethod("retire").invoke(null); if(passed) { passedTests++; } else { diff --git a/Ports/JavaSE/src/com/codename1/ui/Accessor.java b/Ports/JavaSE/src/com/codename1/ui/Accessor.java index 2537a53bc70..1e43a0eb417 100644 --- a/Ports/JavaSE/src/com/codename1/ui/Accessor.java +++ b/Ports/JavaSE/src/com/codename1/ui/Accessor.java @@ -31,6 +31,15 @@ public static Object getNativeGraphics(Graphics g) { return g.getGraphics(); } + /** + * Lets the threads behind {@code invokeAndBlock} end once they are idle. + * For a host that is done with a whole generation of Codename One, such + * as the test runner after a test. + */ + public static void retireThreadPool() { + RunnableWrapper.retireThreadPool(); + } + public static boolean isScrollDecelerationMotionInProgress(Component cmp) { return cmp != null && cmp.isScrollDecelerationMotionInProgress(); } diff --git a/Ports/JavaSE/src/com/codename1/util/EasyThreadAccessor.java b/Ports/JavaSE/src/com/codename1/util/EasyThreadAccessor.java new file mode 100644 index 00000000000..f3023e7505c --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/util/EasyThreadAccessor.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.util; + +/** + * Reaches the part of {@link EasyThread} that is kept from applications, the way + * {@code com.codename1.ui.Accessor} does for the UI classes. + */ +public class EasyThreadAccessor { + private EasyThreadAccessor() { + } + + /** + * Stops every {@link EasyThread}. For a host that is done with a whole + * generation of Codename One, such as the test runner after a test. + */ + public static void killAll() { + EasyThread.killAll(); + } +} 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 abbf5bdddbb..20a4c2ac390 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 @@ -5088,6 +5088,92 @@ && isAnimationRunning(Display.getInstance().getCurrent())) { */ private boolean semanticRefreshPending; + /** + * How long the screen has to have stopped changing before the semantic tree is brought up + * to date, in milliseconds. + */ + private static final int SEMANTIC_QUIET_MILLIS = 100; + + /** + * The longest the semantic tree is left behind a screen that never stops changing, in + * milliseconds. + */ + private static final int SEMANTIC_STALE_LIMIT_MILLIS = 1000; + + /** + * True from the first change the tree has not caught up with until the refresh that + * was armed for it has run. + */ + private boolean semanticRefreshArmed; + + /** + * When the change that armed the refresh arrived, and when the latest one did. + */ + private long semanticFirstChangeMillis; + private long semanticLastChangeMillis; + + /** + * Never eagerly: a change arms one refresh, and that refresh describes everything that + * changed before it ran. + * + *

Answering true rebuilt the whole tree, and diffed it into the overlay, on every turn + * of the event thread that changed anything -- once per wheel event while a list scrolls, + * and several times over while a screen is filled in, each pass walking every component + * of the form. That was more than half of what scrolling cost and a third of a navigation. + * The change itself has already been recorded when this is asked: the tree is marked + * stale first, so the snapshot the refresh pulls is rebuilt then, once.

+ * + *

The refresh waits for the screen to be left alone for a moment, as it already waits + * for an animation: a tree that follows a scroll step by step is of no use to anybody + * reading it. A screen that keeps changing is described once per + * {@link #SEMANTIC_STALE_LIMIT_MILLIS} regardless.

+ */ + @Override + public boolean isAccessibilityTreeUpdateRequired() { + semanticLastChangeMillis = System.currentTimeMillis(); + if (!semanticRefreshArmed) { + semanticRefreshArmed = true; + semanticFirstChangeMillis = semanticLastChangeMillis; + armSemanticRefresh(SEMANTIC_QUIET_MILLIS); + } + return false; + } + + private void armSemanticRefresh(int millis) { + Window.setTimeout(semanticRefreshTimer, millis); + } + + // One handler for every refresh, not one per arming. The timer runs on the + // page, and a callback handed to the page keeps its entry in the runtime's + // table of worker callbacks for good: a new handler per batch of changes + // was one more kept for every pause in a scroll. At most one is armed at a + // time, and the next is armed only after this one has fired. + private final TimerHandler semanticRefreshTimer = new TimerHandler() { + @Override + public void onTimer() { + // On the event thread: the tree is only ever built there, and + // anywhere else is handed the one built last. + callSerially(semanticRefresh); + } + }; + + private final Runnable semanticRefresh = new Runnable() { + @Override + public void run() { + long now = System.currentTimeMillis(); + long quiet = now - semanticLastChangeMillis; + if (quiet >= 0 && quiet < SEMANTIC_QUIET_MILLIS + && now - semanticFirstChangeMillis < SEMANTIC_STALE_LIMIT_MILLIS) { + armSemanticRefresh((int) (SEMANTIC_QUIET_MILLIS - quiet)); + return; + } + // Cleared before the tree is read, so a change that + // arrives while it is being described arms the next one. + semanticRefreshArmed = false; + accessibilityTreeChanged(0); + } + }; + @Override public boolean isAccessibilityTreeSupported() { return true; @@ -13903,7 +13989,13 @@ public String getCSSFontFamily() { sb.append("sans-serif"); break; case Font.FACE_PROPORTIONAL: - sb.append("serif"); + // Proportional is the opposite of monospaced, not of + // sans-serif. Every other port answers it with its + // sans-serif face; this one answered Times, so anything + // drawn in it -- the labels of the vector map, for one -- + // was set in a different typeface in a browser than on + // every device. + sb.append("sans-serif"); break; case Font.FACE_MONOSPACE: sb.append("monospace"); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5LocationManager.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5LocationManager.java index e9209cf022a..38bde38821e 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5LocationManager.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5LocationManager.java @@ -23,117 +23,219 @@ package com.codename1.impl.html5; -import com.codename1.impl.html5.JSOImplementations.ErrorCallback; -import com.codename1.impl.html5.JSOImplementations.Geolocation; -import com.codename1.impl.html5.JSOImplementations.Geolocation.PositionOptions; -import com.codename1.impl.html5.JSOImplementations.PositionCallback; +import com.codename1.html5.js.browser.Window; +import com.codename1.html5.js.dom.MessageEvent; +import com.codename1.impl.html5.JSOImplementations.GeoCallback; import com.codename1.impl.html5.JSOImplementations.WindowExt; import com.codename1.location.Location; import com.codename1.location.LocationListener; import com.codename1.location.LocationManager; +import com.codename1.location.LocationRequest; import com.codename1.ui.Display; import java.io.IOException; -import com.codename1.html5.js.JSBody; -import com.codename1.html5.js.JSObject; -import com.codename1.html5.js.browser.Window; -import com.codename1.html5.js.core.JSString; - +/// The browser's Geolocation API as a [LocationManager]. +/// +/// The application runs in a worker and the API lives on the page, so every +/// request goes through `window.cn1GeoRequest` (js/localforage-shim.js), which +/// answers with the position or the error flattened into a string -- a +/// position object does not survive the trip. See there. +/// +/// Three things here are deliberate: +/// +/// - Nothing waits without a limit. The browser may leave a request +/// unanswered for as long as its permission prompt is open, which is as long +/// as the user likes. +/// - A listener is told about every failure, not only about a change of +/// status: the status starts out as "temporarily unavailable", so a first +/// request that failed that way used to tell nobody. +/// - The position is kept before anybody is told that the provider is +/// available. `getCurrentLocationSync` answers that news by asking for the +/// current location, on the event thread; it gets the fix that just came in +/// instead of a second round trip to the browser. public class HTML5LocationManager extends LocationManager { + /// How long [#getCurrentLocation] waits for the browser, and a little + /// longer for the answer to cross from the page. + private static final int REQUEST_TIMEOUT = 10000; + private static final int REQUEST_GRACE = 2000; + /// A fix this recent answers [#getCurrentLocation] without a new request. + private static final int FRESH_MILLIS = 5000; + Location lastKnownLocation; - int listenerId; - + private long lastFixTime; + private int watchId = -1; + private boolean watching; + + // Two callbacks for the life of the manager, not one per request. A + // callback handed to the page is given an entry in the runtime's table of + // worker callbacks that nothing ever removes, so a new one per request or + // per watch would be kept for good, with everything it holds. + // + // The one-shot answers are therefore shared: whoever is waiting takes + // the next answer to arrive, which is as current as the one asked for. + // Each answer bumps answerCount; a request waits for a count above the + // one it started at. + private final Object answerLock = new Object(); + private int answerCount; + private Answer latestAnswer; + private final GeoCallback oneShot = new GeoCallback() { + @Override + public void onAnswer(MessageEvent answer) { + Answer parsed = parse(answer == null ? null : answer.getDataAsString()); + synchronized (answerLock) { + latestAnswer = parsed; + answerCount++; + answerLock.notifyAll(); + } + } + }; + // An answer from a watch already cleared that arrives after another has + // started is still a position, and is delivered; one that arrives while + // nothing watches is dropped. + private final GeoCallback watcher = new GeoCallback() { + @Override + public void onAnswer(MessageEvent answer) { + final Answer parsed = parse(answer == null ? null : answer.getDataAsString()); + // On the event thread: the listener is the application's, and + // so is everything it touches. + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (watching) { + deliver(parsed); + } + } + }); + } + }; + public HTML5LocationManager(){ - + } - - @Override - public Location getCurrentLocation() throws IOException { - final Location[] result = new Location[1]; - final IOException[] error = new IOException[1]; - final boolean[] complete = new boolean[1]; - - nativeGetCurrentLocation(new LocationCallback() { - @Override - public void onLocation(Location loc) { - synchronized(complete) { - result[0] = loc; - complete[0] = true; - complete.notifyAll(); - } - } - - @Override - public void onError(IOException err) { - synchronized(complete) { - error[0] = err; - complete[0] = true; - complete.notifyAll(); + + /// A position or an error, as the page reported it. + static final class Answer { + /// Null for an error. + Location location; + /// As in GeolocationPositionError: 1 denied, 2 unavailable, 3 timed out. + int errorCode; + String errorMessage; + } + + /// Reads "P|lat|lng|accuracy|altitude|heading|speed|timestamp" or + /// "E|code|message". Anything else is an error of its own: an answer that + /// cannot be read must still end the wait for it. + static Answer parse(String text) { + Answer out = new Answer(); + if (text == null || text.length() < 2 || text.charAt(1) != '|') { + out.errorCode = 2; + out.errorMessage = "Unreadable answer from the location service"; + return out; + } + if (text.charAt(0) == 'P') { + String[] part = new String[7]; + int from = 2; + for (int iter = 0; iter < part.length; iter++) { + int to = text.indexOf('|', from); + if (to < 0) { + to = text.length(); } + part[iter] = from <= to ? text.substring(from, to) : ""; + from = Math.min(text.length(), to) + 1; } - }); - - // Wait for result - synchronized(complete) { - while (!complete[0]) { - try { - complete.wait(10000); - } catch (InterruptedException ie) { - throw new IOException("Interrupted while waiting for location", ie); - } + double latitude = number(part[0]); + double longitude = number(part[1]); + if (Double.isNaN(latitude) || Double.isNaN(longitude)) { + out.errorCode = 2; + out.errorMessage = "The location service answered without a position"; + return out; } + Location loc = new Location(); + loc.setLatitude(latitude); + loc.setLongitude(longitude); + loc.setAccuracy((float) orZero(number(part[2]))); + loc.setAltitude(orZero(number(part[3]))); + loc.setDirection((float) orZero(number(part[4]))); + loc.setVelocity((float) orZero(number(part[5]))); + double stamp = number(part[6]); + loc.setTimeStamp(Double.isNaN(stamp) ? System.currentTimeMillis() : (long) stamp); + out.location = loc; + return out; } - - if (error[0] != null) { - throw error[0]; + int bar = text.indexOf('|', 2); + String code = bar < 0 ? text.substring(2) : text.substring(2, bar); + double parsed = number(code); + out.errorCode = Double.isNaN(parsed) || parsed < 1 || parsed > 3 ? 2 : (int) parsed; + out.errorMessage = bar < 0 || bar + 1 >= text.length() ? "Location error " + out.errorCode + : text.substring(bar + 1); + return out; + } + + private static double number(String text) { + if (text == null || text.length() == 0) { + return Double.NaN; + } + try { + return Double.parseDouble(text); + } catch (NumberFormatException err) { + return Double.NaN; } - - lastKnownLocation = result[0]; - return lastKnownLocation; } - - @JSBody(params={"o"}, script="console.log(o)") - private static native void log(JSObject o); - - private static void log(String str) { - log(JSString.valueOf(str)); + + private static double orZero(double value) { + return Double.isNaN(value) ? 0 : value; } - - private interface LocationCallback { - void onLocation(Location loc); - void onError(IOException err); + + private static WindowExt window() { + return (WindowExt) Window.current(); } - - private static void nativeGetCurrentLocation(final LocationCallback callback){ - PositionOptions opts = (PositionOptions)((WindowExt)Window.current()).createEmptyObject(); - opts.setTimeout(5000); - Geolocation geo = ((WindowExt)Window.current()).getNavigator().getGeolocation(); - PositionCallback onPosition = new PositionCallback() { - - @Override - public void onLocation(final Geolocation.Position position) { - Location loc = new Location(); - loc.setLatitude(position.getCoords().getLatitude()); - loc.setLongitude(position.getCoords().getLongitude()); - loc.setAccuracy((float)position.getCoords().getAccuracy()); - loc.setAltitude(position.getCoords().getAltitude()); - loc.setDirection((float)position.getCoords().getHeading()); - loc.setTimeStamp(position.getTimestamp()); - loc.setVelocity((float)position.getCoords().getSpeed()); - callback.onLocation(loc); - } - }; - - ErrorCallback onError = new ErrorCallback() { - @Override - public void onError(JSOImplementations.JSError err) { - callback.onError(new IOException(err.getMessage())); + private void keep(Location loc) { + lastKnownLocation = loc; + lastFixTime = System.currentTimeMillis(); + } + + /// Blocks until the browser answers, for at most [#REQUEST_TIMEOUT] and a + /// moment more. Call it off the event thread, or use a listener. + @Override + public Location getCurrentLocation() throws IOException { + Location recent = lastKnownLocation; + if (recent != null && System.currentTimeMillis() - lastFixTime <= FRESH_MILLIS) { + return recent; + } + int asked; + synchronized (answerLock) { + asked = answerCount; + } + window().cn1GeoRequest(oneShot, false, REQUEST_TIMEOUT, 0, false); + + long deadline = System.currentTimeMillis() + REQUEST_TIMEOUT + REQUEST_GRACE; + Answer answer = null; + synchronized (answerLock) { + while (answerCount == asked) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + break; + } + try { + answerLock.wait(left); + } catch (InterruptedException ie) { + throw new IOException("Interrupted while waiting for location", ie); + } + } + if (answerCount != asked) { + answer = latestAnswer; } - - }; - - geo.getCurrentPosition(onPosition, onError, opts); + } + if (answer == null) { + // The permission prompt is still open, most likely. + throw new IOException("The browser did not answer the location request"); + } + if (answer.location == null) { + throw new IOException(answer.errorMessage); + } + keep(answer.location); + return answer.location; } @Override @@ -143,108 +245,49 @@ public Location getLastKnownLocation() { @Override protected void bindListener() { - if (listenerId==0){ - PositionOptions opts = (PositionOptions)((WindowExt)Window.current()).createEmptyObject(); - - Geolocation geo = ((WindowExt)Window.current()).getNavigator().getGeolocation(); - - PositionCallback onLocation = new PositionCallback(){ - - @Override - public void onLocation(final Geolocation.Position position) { - new Thread(){ - public void run(){ - final LocationListener l = HTML5LocationManager.this.getLocationListener(); - int oldStatus = HTML5LocationManager.this.getStatus(); - HTML5LocationManager.this.setStatus(LocationManager.AVAILABLE); - - if (oldStatus!=LocationManager.AVAILABLE && l!=null){ - Display.getInstance().callSerially(new Runnable() { - - @Override - public void run() { - l.providerStateChanged(LocationManager.AVAILABLE); - } - - }); - } - - Location loc = new Location(); - loc.setLatitude(position.getCoords().getLatitude()); - loc.setLongitude(position.getCoords().getLongitude()); - loc.setAccuracy((float)position.getCoords().getAccuracy()); - loc.setAltitude(position.getCoords().getAltitude()); - loc.setDirection((float)position.getCoords().getHeading()); - loc.setTimeStamp(position.getTimestamp()); - loc.setVelocity((float)position.getCoords().getSpeed()); - lastKnownLocation=loc; - - if (l != null){ - Display.getInstance().callSerially(new Runnable(){ - - @Override - public void run() { - l.locationUpdated(lastKnownLocation); - } - - }); - } - - - } - }.start(); - - } - - }; - - ErrorCallback onError = new ErrorCallback(){ + if (watching) { + return; + } + watching = true; + LocationRequest request = getRequest(); + boolean precise = request != null + && request.getPriority() == LocationRequest.PRIORITY_HIGH_ACCUARCY; + watchId = window().cn1GeoRequest(watcher, true, 0, 0, precise); + } - @Override - public void onError(JSOImplementations.JSError error) { - int oldStatus = HTML5LocationManager.this.getStatus(); - switch (error.getCode()){ - case 1: // Permission Denied - HTML5LocationManager.this.setStatus(LocationManager.OUT_OF_SERVICE); - break; - case 2: // Unavailable - HTML5LocationManager.this.setStatus(LocationManager.TEMPORARILY_UNAVAILABLE); - break; - case 3: // TIMEOUT - HTML5LocationManager.this.setStatus(LocationManager.TEMPORARILY_UNAVAILABLE); - break; - } - - if (oldStatus != HTML5LocationManager.this.getStatus()) { - final LocationListener l = HTML5LocationManager.this.getLocationListener(); - if (l!=null) { - new Thread(){ - public void run() { - Display.getInstance().callSerially(new Runnable() { - - @Override - public void run() { - l.providerStateChanged(HTML5LocationManager.this.getStatus()); - } - }); - } - }.start(); - } - } - } - - }; - - geo.getCurrentPosition(onLocation, onError, opts); + private void deliver(Answer answer) { + int oldStatus = getStatus(); + if (answer.location != null) { + keep(answer.location); + setStatus(LocationManager.AVAILABLE); + LocationListener l = getLocationListener(); + if (l != null && oldStatus != LocationManager.AVAILABLE) { + l.providerStateChanged(LocationManager.AVAILABLE); + } + // Read again: a listener may take itself off when it hears that. + l = getLocationListener(); + if (l != null) { + l.locationUpdated(answer.location); + } + return; + } + // Refused is for good, as far as this page can tell; no fix and no + // answer in time may both pass. + setStatus(answer.errorCode == 1 ? LocationManager.OUT_OF_SERVICE + : LocationManager.TEMPORARILY_UNAVAILABLE); + LocationListener l = getLocationListener(); + if (l != null) { + l.providerStateChanged(getStatus()); } } @Override protected void clearListener() { - if (listenerId!=0) { - Geolocation geo = ((WindowExt)Window.current()).getNavigator().getGeolocation(); - geo.clearWatch(listenerId); + if (watching) { + watching = false; + window().cn1GeoClear(watchId); + watchId = -1; } } - -} \ No newline at end of file + +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java index 07a66c65600..0147e4d636e 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java @@ -91,6 +91,17 @@ public interface WindowExt extends JSObject { @JSBody(params={}, script="return new Object()") JSObject createEmptyObject(); + + /// Asks the page for the device's position; js/localforage-shim.js has + /// it and says why the Geolocation API is not called directly. `watch` + /// keeps the answers coming until [#cn1GeoClear] is given the id this + /// returns, which is -1 when no watch was started (0 is a valid id). A + /// timeout or maximum age of 0 leaves the browser's default. + int cn1GeoRequest(GeoCallback callback, boolean watch, int timeoutMillis, + int maximumAgeMillis, boolean highAccuracy); + + /// Ends a watch started by [#cn1GeoRequest]. + void cn1GeoClear(int id); @JSProperty abstract public CN1Native getCn1(); @@ -623,6 +634,13 @@ public interface Coord extends JSObject { } } + /// What [WindowExt#cn1GeoRequest] answers with: an event whose data is + /// the position or the error as one string. + @JSFunctor + public interface GeoCallback extends JSObject { + public void onAnswer(com.codename1.html5.js.dom.MessageEvent answer); + } + @JSFunctor public interface PositionCallback extends JSObject { public void onLocation(Geolocation.Position position); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptTextLayer.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptTextLayer.java index 7183b4d0d76..c23347c2687 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptTextLayer.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptTextLayer.java @@ -359,6 +359,10 @@ public void endComponent(Component component) { frame.clipX, frame.clipY, frame.clipW, frame.clipH); } } + if (depth == 0 && component instanceof Form && frame.component == component + && !frame.clipEmpty) { + releaseUnpaintedWithin(frame.paintPass, frame.clipX, frame.clipY, frame.clipW, frame.clipH); + } frame.covering = false; frame.clipEmpty = false; frame.component = null; @@ -366,6 +370,34 @@ public void endComponent(Component component) { frame.sequence = 0; } + /** + * Releases the text of every component a paint of the whole form passed over. + * + *

A container does not visit the children its clip leaves out: a vertical box works out + * the first and last child the clip can reach and paints those. So a component that a layout + * moved far outside the viewport -- content arriving above it and pushing it a screen down -- + * is not handed an empty clip, it is not painted at all, and neither of its hooks runs. Its + * runs stayed where they were last drawn, above whatever the canvas now shows there: the + * rows at the bottom of a page were left written across the chart that had just loaded + * above them.

+ * + *

Only the form's own paint can decide this. It walks everything that intersects the + * region, so a run inside the region that nothing claimed belongs to a component that is + * no longer there. A repaint of one component says nothing about its neighbours, whose text + * stands above the canvas untouched.

+ * + * @param formPass the pass the form's paint was given; every component it painted has a later one + * @param x the repainted region's x, in Codename One pixels + * @param y the repainted region's y + * @param w the repainted region's width + * @param h the repainted region's height + */ + private void releaseUnpaintedWithin(int formPass, int x, int y, int w, int h) { + for (ComponentRuns runs : byComponent.values()) { + releaseStaleWithin(runs, formPass, x, y, w, h); + } + } + /** * Promotes a text run to the DOM. * diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java index d791e789ed0..8942102fba4 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java @@ -41,6 +41,8 @@ import com.codename1.html5.js.ajax.ReadyStateChangeHandler; import com.codename1.html5.js.ajax.XMLHttpRequest; import com.codename1.html5.js.core.JSRegExp; +import com.codename1.html5.js.dom.Event; +import com.codename1.html5.js.dom.EventListener; import com.codename1.html5.js.typedarrays.ArrayBuffer; import com.codename1.html5.js.typedarrays.Uint8Array; @@ -81,11 +83,26 @@ public NetworkConnection(String url, boolean read, boolean write, int timeout){ private static interface XHRBlob extends JSObject { void send(Blob blob); } + + /// The XMLHttpRequest as a target of events. Listeners are objects with a + /// handleEvent, the form that HTML5WebSocketImpl found is delivered for an + /// object that lives in the worker. + private static interface XHREvents extends JSObject { + void addEventListener(String type, EventListener listener); + } private void open(){ if (!isOpen){ isOpen = true; - req.open(httpMethod, url, false); + // Asynchronous, and waited for in openInputStream(). The whole + // application -- the event thread and every other thread -- runs in + // the one worker this code runs in, and a synchronous request stops + // that worker dead until the server has answered: nothing is laid + // out, painted or animated, and no pointer event is delivered. A + // map that fetched nineteen tiles while it was dragged stood still + // for two of the ten seconds the drag took. Only the thread that + // asked has any reason to wait. + req.open(httpMethod, url, true); // ``responseType = "arraybuffer"`` lets the browser hand back // a typed-array view of the bytes directly. The previous // ``overrideMimeType("text/plain; charset=x-user-defined")`` @@ -157,17 +174,68 @@ public InputStream openInputStream() throws IOException { req.setRequestHeader(h.name, h.value); } //req.setResponseType("arraybuffer"); - // Through send(), which applies the request's timeout and turns the - // TimeoutError a synchronous XHR throws into this method's IOException -- - // the error path a timed-out request takes on every other port. The - // timeouts were ignored here, so a request past its timeout simply waited - // for the answer; and NetworkManager's fallback timeout thread cannot run - // while a synchronous request holds the worker. + // send() applies the request's timeout, and a request that runs past it + // ends in this method's IOException -- the error path a timed-out + // request takes on every other port. + final String[] outcome = new String[1]; + EventListener ended = new EventListener() { + @Override + public void handleEvent(Event evt) { + String type = evt == null ? null : evt.getType(); + synchronized (outcome) { + if (outcome[0] == null) { + outcome[0] = type == null ? "load" : type; + } + outcome.notifyAll(); + } + } + }; + // One of these ends every request, whichever way it ends. Nothing + // here outlives the request: the XHR is made in the worker + // (XMLHttpRequest.create in port.js), so the listener becomes a plain + // closure on it through port.js's EventListener converter and is + // collected with it. Only a listener handed to an object on the main + // thread is given a lasting entry in the runtime's workerCallbacks. + XHREvents events = (XHREvents) req; + events.addEventListener("load", ended); + events.addEventListener("error", ended); + events.addEventListener("timeout", ended); + events.addEventListener("abort", ended); + int deadline = requestTimeout(); String failure = send(req, body == null ? null - : BlobUtil.createBlob(body.toByteArray(), "application/octet-stream"), requestTimeout()); + : BlobUtil.createBlob(body.toByteArray(), "application/octet-stream"), deadline); if (failure != null) { throw new IOException("Failed to load " + url + ": " + failure); } + // The browser enforces the deadline and reports it as "timeout". The + // limit here is for an event that never comes at all, so that a + // network thread cannot be lost to a request for good. + long giveUp = deadline > 0 ? System.currentTimeMillis() + deadline + 5000 : Long.MAX_VALUE; + synchronized (outcome) { + while (outcome[0] == null) { + long left = giveUp - System.currentTimeMillis(); + if (left <= 0) { + break; + } + try { + outcome.wait(Math.min(left, 1000)); + } catch (InterruptedException err) { + req.abort(); + throw new IOException("Interrupted while loading " + url); + } + } + } + if (outcome[0] == null) { + req.abort(); + throw new IOException("Failed to load " + url + ": TimeoutError: no answer"); + } + if (!"load".equals(outcome[0])) { + throw new IOException("Failed to load " + url + ": " + + ("timeout".equals(outcome[0]) ? "TimeoutError" : "NetworkError") + ": " + outcome[0]); + } + if (req.getStatus() == 0 && req.getResponse() == null) { + throw new IOException("Failed to load " + url + ": NetworkError: no response"); + } Uint8Array responseBytes = toResponseBytes(req); @@ -285,18 +353,15 @@ private String allResponseHeaders() { * whatever Access-Control-Expose-Headers allowed. A @JSBody runs its * script verbatim, so the method is really invoked. */ - /// The XHR timeout for this request, in milliseconds, or 0 for none. A - /// synchronous XHR has one deadline for the whole request, so it gets the - /// connect and the read timeout together; -1 for either means unset, which is - /// what a request that set neither passes. + /// The XHR timeout for this request, in milliseconds, or 0 for none. An + /// XHR has one deadline for the whole request, so it gets the connect and + /// the read timeout together; -1 for either means unset, which is what a + /// request that set neither passes. /// - /// Deliberately a deadline, not the idle limit the other ports enforce: a - /// synchronous XHR reports neither when it connected nor when bytes arrive, - /// so inactivity cannot be measured here. It is what NetworkManager's own - /// watchdog does with a request that shows no activity, except that the - /// watchdog cannot run while the request holds the worker. Leaving the - /// timeouts out instead let a request to an unreachable host wait forever. - /// The developer guide's JavaScript chapter documents the difference. + /// Deliberately a deadline, not the idle limit the other ports enforce. + /// Leaving the timeouts out instead let a request to an unreachable host + /// wait forever. The developer guide's JavaScript chapter documents the + /// difference. int requestTimeout() { if (timeout > 0 && readTimeout > 0) { return timeout + readTimeout; @@ -309,18 +374,14 @@ public void setReadTimeout(int readTimeout) { this.readTimeout = readTimeout; } - /// Sends `xhr` with `body` (null for none) under a `timeoutMillis` deadline - /// (0 for none), answering why it failed or null. Setting the timeout is - /// inside the try, because a synchronous XHR on a window rather than a - /// worker refuses one outright. A request that ended with no response at all - /// -- status 0 and nothing received, which is how a timed-out synchronous XHR - /// can come back without throwing -- is a failure too: reading on from there - /// reached `responseText`, which an arraybuffer request throws for, and that - /// JavaScript error ended the network thread with no callback of any kind. + /// Starts `xhr` with `body` (null for none) under a `timeoutMillis` deadline + /// (0 for none), answering why it could not be sent or null. How it ended + /// is reported by its events, which openInputStream() waits for. Setting + /// the timeout is inside a try of its own, because not every kind of + /// request accepts one. @JSBody(params={"xhr", "body", "timeoutMillis"}, script="try {" + " if (timeoutMillis > 0) { try { xhr.timeout = timeoutMillis; } catch (ignored) {} }" + " if (body) { xhr.send(body); } else { xhr.send(); }" - + " if (xhr.status === 0 && !xhr.response) { return 'NetworkError: no response'; }" + " return null;" + " } catch (e) { return (e && e.name ? e.name : 'Error') + (e && e.message ? ': ' + e.message : ''); }") static native String send(XMLHttpRequest xhr, Blob body, int timeoutMillis); diff --git a/Ports/JavaScriptPort/src/main/webapp/js/localforage-shim.js b/Ports/JavaScriptPort/src/main/webapp/js/localforage-shim.js index 9a3aee15e19..636d6ba03cb 100644 --- a/Ports/JavaScriptPort/src/main/webapp/js/localforage-shim.js +++ b/Ports/JavaScriptPort/src/main/webapp/js/localforage-shim.js @@ -45,6 +45,85 @@ if (typeof window.createConfigOptions !== "function") { window.createConfigOptions = function() { return {}; }; } + // ``WindowExt.createEmptyObject`` is the same kind of member: a TeaVM + // @JSBody ("return new Object()") the Java side calls on the window, which + // the JSO bridge looks up as a property of the host window and does not find. + // It is how HTML5LocationManager builds the options it passes to the + // Geolocation API, so without it the first location request -- the first + // screen of any app with a map -- ended in "Missing JS member + // createEmptyObject for host receiver" and the internal-error dialog. It + // lives here because this is the file that supplies such members, and it is + // loaded before the bridge. + if (typeof window.createEmptyObject !== "function") { + window.createEmptyObject = function() { return {}; }; + } + // Geolocation, for HTML5LocationManager. The application runs in a worker + // and the Geolocation API exists only here, on the page, so a position has + // to cross to the worker as a message. The bridge sends every callback + // argument as if it were a DOM event: it copies the event fields it knows + // and nothing else. A GeolocationPosition has none of them -- ``coords`` is + // an accessor on its prototype, not even an own property -- so what reached + // the worker was an object with no position in it, the Java callback threw + // on ``getCoords()`` before it woke the thread waiting for the answer, and + // an application that asked for the position on its event thread stopped + // for good the moment the user ALLOWED location. Refusing it worked, which + // is what made it look like a problem with the place. + // + // So the answer is flattened here into the one field the bridge carries + // verbatim, a string in ``data``: + // "P|latitude|longitude|accuracy|altitude|heading|speed|timestamp" + // "E|code|message" (code as in GeolocationPositionError: 1 denied, + // 2 unavailable, 3 timed out) + // A value the browser does not know (altitude on a desktop) is left empty. + if (typeof window.cn1GeoRequest !== "function") { + var cn1GeoNumber = function(v) { + return (v == null || typeof v !== "number" || !isFinite(v)) ? "" : String(v); + }; + // watch: keep reporting until cn1GeoClear(the id returned). timeoutMillis + // and maximumAgeMillis: 0 or less leaves the browser's default. Answers -1 + // when no watch was started: 0 is a watch id a browser may hand out. + window.cn1GeoRequest = function(callback, watch, timeoutMillis, maximumAgeMillis, highAccuracy) { + var answer = function(text) { + try { callback({ type: "cn1geolocation", data: text }); } catch (_e) { /* the worker is gone */ } + }; + var geo = window.navigator && window.navigator.geolocation; + if (!geo) { + window.setTimeout(function() { answer("E|2|This browser has no location service"); }, 0); + return -1; + } + var options = {}; + if (timeoutMillis > 0) { options.timeout = timeoutMillis; } + if (maximumAgeMillis > 0) { options.maximumAge = maximumAgeMillis; } + if (highAccuracy) { options.enableHighAccuracy = true; } + var onPosition = function(position) { + var c = (position && position.coords) || {}; + answer("P|" + cn1GeoNumber(c.latitude) + "|" + cn1GeoNumber(c.longitude) + "|" + + cn1GeoNumber(c.accuracy) + "|" + cn1GeoNumber(c.altitude) + "|" + + cn1GeoNumber(c.heading) + "|" + cn1GeoNumber(c.speed) + "|" + + cn1GeoNumber(position && position.timestamp)); + }; + var onError = function(error) { + answer("E|" + ((error && error.code) | 0) + "|" + ((error && error.message) || "")); + }; + try { + if (watch) { + return geo.watchPosition(onPosition, onError, options) | 0; + } + geo.getCurrentPosition(onPosition, onError, options); + } catch (err) { + // A page that is not a secure context, or a policy that forbids it. + window.setTimeout(function() { answer("E|2|" + (err && err.message ? err.message : err)); }, 0); + } + return -1; + }; + window.cn1GeoClear = function(id) { + var geo = window.navigator && window.navigator.geolocation; + // Zero is a real watch id; only the -1 cn1GeoRequest answers is none. + if (geo && typeof id === "number" && id >= 0) { + try { geo.clearWatch(id); } catch (_e) { /* nothing to clear */ } + } + }; + } // This synchronous-callback shim MUST own ``window.localforage`` on the // ParparVM port. The CN1 worker can't pump the async microtask/Promise loop // a real localForage relies on, so its callbacks must fire inline (see the @@ -171,10 +250,12 @@ } catch (_l) {} throw e; } + // The key and the length, never the value: what is stored here includes + // the app's sign-in tokens, and a prefix of the serialised form is a + // prefix of the token for anyone reading the console or a captured log. try { console.log("LF-SHIM:set-ok key=" + nk + " valType=" + valCtor - + " serLen=" + (serialised == null ? "null" : serialised.length) - + " serPrefix=" + (serialised == null ? "null" : serialised.substring(0, Math.min(60, serialised.length)))); + + " serLen=" + (serialised == null ? "null" : serialised.length)); } catch (_l) {} return value; } @@ -182,7 +263,7 @@ var nk = namespacedKey(key); var raw = window.localStorage.getItem(nk); try { - console.log("LF-SHIM:get key=" + nk + " raw=" + (raw == null ? "null" : ("len=" + raw.length + " prefix=" + raw.substring(0, Math.min(40, raw.length))))); + console.log("LF-SHIM:get key=" + nk + " raw=" + (raw == null ? "null" : ("len=" + raw.length))); } catch (_l) {} if (raw == null) { return null; diff --git a/Ports/JavaScriptPort/src/main/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js index af6718baa8d..c6f306d9fb8 100644 --- a/Ports/JavaScriptPort/src/main/webapp/port.js +++ b/Ports/JavaScriptPort/src/main/webapp/port.js @@ -1665,6 +1665,26 @@ bindNative(["cn1_com_codename1_html5_js_browser_Window_current_R_com_codename1_h // without a DOM. On the worker port they must run on the browser host: leaving // the stubs in place returns timer id 0 and silently drops every callback // (camera frame delivery was the first assertion test to expose this). +// A one-shot timer's callback is released once it has fired or been cleared. +// A function handed to the host gets an entry in jvm.workerCallbacks, and the +// host a proxy for it, that nothing else removes: one per setTimeout kept a +// Java handler and all it held for every timer the application ever set -- +// a pinch, a pause in a scroll, a text edit. So each timeout gets a function +// of its own, which drops its entry when it runs, and the host drops its +// proxy then too (browser_bridge.js). Timer id -> callback id, for a timeout +// cleared before it fired. +const cn1OneShotTimers = Object.create(null); + +function cn1ForgetOneShot(timerId) { + const callbackId = cn1OneShotTimers[timerId]; + if (callbackId != null) { + delete cn1OneShotTimers[timerId]; + if (jvm.workerCallbacks) { + delete jvm.workerCallbacks[callbackId]; + } + } +} + function* cn1SetHostTimer(kind, handler, delay) { if (typeof jvm.invokeHostNative !== "function") { return 0; @@ -1672,6 +1692,29 @@ function* cn1SetHostTimer(kind, handler, delay) { if (!handler || !handler.__class) { return 0; } + if (kind === "__cn1_timer_set_timeout__") { + let timerId = -1; + let fired = false; + const once = function() { + // By its own id as well: a timeout of 0 can fire before the call that + // set it has returned here and recorded it. + fired = true; + cn1ForgetOneShot(timerId); + if (jvm.workerCallbacks && typeof once.__cn1WorkerCallbackId === "number") { + delete jvm.workerCallbacks[once.__cn1WorkerCallbackId]; + } + try { + spawnVirtualCallback(handler, "cn1_s_onTimer", [], "__cn1TimerCallbackPending"); + } catch (err) { + jvm.fail(err); + } + }; + timerId = (yield jvm.invokeHostNative(kind, [once, Math.max(0, delay | 0)])) | 0; + if (!fired && typeof once.__cn1WorkerCallbackId === "number") { + cn1OneShotTimers[timerId] = once.__cn1WorkerCallbackId; + } + return timerId; + } if (!handler.__cn1NativeTimerCallback) { handler.__cn1NativeTimerCallback = function() { try { @@ -1698,6 +1741,7 @@ bindNative([ "cn1_com_codename1_html5_js_browser_Window_clearTimeout_int", "cn1_com_codename1_html5_js_browser_Window_clearTimeout___int" ], function*(id) { + cn1ForgetOneShot(id | 0); if (typeof jvm.invokeHostNative === "function") { yield jvm.invokeHostNative("__cn1_timer_clear_timeout__", [id | 0]); } diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/RefusalSnippets.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/RefusalSnippets.java new file mode 100644 index 00000000000..6b28eabbe77 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/RefusalSnippets.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend; + +import com.codename1.backend.ResponseStatusException; + +/// A method whose return type is the body of a success refuses by throwing. +public final class RefusalSnippets { + private RefusalSnippets() { + } + + static String register(String email) { + // tag::backend-web-refusal[] + if (taken(email)) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + // end::backend-web-refusal[] + return email; + } + + private static boolean taken(String email) { + return email.length() == 0; + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/RideNotifications.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/RideNotifications.java new file mode 100644 index 00000000000..0b6a55df2b9 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/RideNotifications.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.push.CloudPush; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushResult; +import com.codename1.backend.push.PushTarget; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Sends a notification and keeps a Live Activity current. Referenced from the + * backend chapter's push section. + */ +@Component +public class RideNotifications { + // tag::backend-push-send[] + private final CloudPush push; + + /** + * The application UUID and a push-scoped server API key, read from + * cn1.push.appId and cn1.push.serverKey -- or from the environment + * variables CN1_PUSH_APPID and CN1_PUSH_SERVERKEY, which override them. + */ + public RideNotifications(@Value("${cn1.push.appId}") String appId, + @Value("${cn1.push.serverKey}") String serverKey) { + this.push = new CloudPush(appId, serverKey); + } + + /** deviceKey is what the app registered, such as "cn1-fcm-..." */ + public boolean driverArrived(String deviceKey, String tripId) throws IOException { + PushNotification message = PushNotification.builder() + .title("Your driver is here") + .body("Look for the blue sedan") + .deepLink("ride://trip/" + tripId) + .collapseKey("trip-" + tripId) + .data("tripId", tripId) + .build(); + PushResult result = push.send(message, PushTarget.fromDeviceKey(deviceKey)); + if (result.getStatus() == 429) { + // Not admitted; try again after result.getRetryAfterSeconds() + return false; + } + return result.isAccepted(); + } + // end::backend-push-send[] + + // tag::backend-push-live-activity[] + public PushResult updateEta(PushTarget device, String tripId, int minutes) + throws IOException { + Map state = new LinkedHashMap(); + state.put("minutes", Integer.valueOf(minutes)); + state.put("status", minutes == 0 ? "arrived" : "on the way"); + PushNotification message = PushNotification.builder() + .silent(true) + .surface(PushNotification.liveUpdate("trip-" + tripId, state)) + .build(); + return push.send(message, device); + } + + public PushResult tripEnded(PushTarget device, String tripId) throws IOException { + PushNotification message = PushNotification.builder() + .silent(true) + .surface(PushNotification.liveEnd("trip-" + tripId, null, true)) + .build(); + return push.send(message, device); + } + // end::backend-push-live-activity[] +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/HostedAppTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/HostedAppTest.java new file mode 100644 index 00000000000..3638b59de95 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/HostedAppTest.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.testing; + +import com.codename1.backend.test.BackendTest; + +// tag::backend-web-hosted-test[] +@BackendTest(properties = "cn1.webapp.root=target/webapp") +class HostedAppTest { +} +// end::backend-web-hosted-test[] diff --git a/docs/demos/common/src/main/css/guide-snippets-theme.css b/docs/demos/common/src/main/css/guide-snippets-theme.css index 2123109f569..48ccbe0ea2b 100644 --- a/docs/demos/common/src/main/css/guide-snippets-theme.css +++ b/docs/demos/common/src/main/css/guide-snippets-theme.css @@ -850,3 +850,15 @@ Button.hover { } /* end::native-theme-os-settings[] */ + +/* tag::wayline-css-brand[] */ +#Constants { + --brand: #0f766e; + --brand-pressed: #115e59; + --brand-contrast: #ffffff; + --brand-dark: #5eead4; + --brand-pressed-dark: #2dd4bf; + --brand-contrast-dark: #042f2e; + --radius: 1.5mm; +} +/* end::wayline-css-brand[] */ diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/HostedAppSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/HostedAppSnippets.java new file mode 100644 index 00000000000..6a1d02547c7 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/HostedAppSnippets.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets; + +import com.codename1.ui.CN; + +/// Where an app hosted by its own server finds that server. +public final class HostedAppSnippets { + private HostedAppSnippets() { + } + + static String server() { + // tag::backend-web-hosted-origin[] + String origin = CN.getProperty("browser.window.location.origin", null); + String server = origin != null ? origin : "https://api.example.com"; + // end::backend-web-hosted-origin[] + return server; + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/WaylineMapSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/WaylineMapSnippets.java new file mode 100644 index 00000000000..d9956c89852 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/WaylineMapSnippets.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapSurface; +import com.codename1.maps.MapView; +import com.codename1.maps.Marker; +import com.codename1.maps.MarkerOptions; +import com.codename1.maps.NativeMap; +import com.codename1.maps.routing.OsrmRouteService; +import com.codename1.maps.routing.Routing; +import com.codename1.maps.vector.MapStyle; +import com.codename1.maps.vector.MvtTileSource; +import com.codename1.ui.EncodedImage; + +/// The changes the Wayline chapter describes to the sample's map classes. +public final class WaylineMapSnippets { + private static boolean dark; + + private WaylineMapSnippets() { + } + + // tag::wayline-map-own-services[] + /// Tiles from a server of your own, in place of the keyless default. + public static MapView create(LatLng center, int zoom) { + MapView map = new MapView( + new MvtTileSource("https://tiles.example.com/planet/{z}/{x}/{y}.pbf", 0, 14), + dark ? MapStyle.dark() : MapStyle.light()); + map.setName("map"); + map.moveCamera(center, zoom); + return map; + } + + /// Routes from your own OSRM instance. Call it once, as the app starts. + public static void useOwnRouting() { + Routing.setService(new OsrmRouteService("https://osrm.example.com")); + } + // end::wayline-map-own-services[] + + // tag::wayline-map-native[] + /// The platform's own map where the build selected a provider, and the + /// vector map everywhere else. + public static NativeMap createNative(LatLng center, int zoom) { + NativeMap map = new NativeMap(center, zoom, MvtTileSource.openFreeMap(), + dark ? MapStyle.dark() : MapStyle.light()); + map.setName("map"); + return map; + } + // end::wayline-map-native[] + + // tag::wayline-map-native-marker[] + /// Moves a marker on a map that may be native. A native provider is told + /// about a marker when it is added and not again, so a marker that moved + /// is taken off and put back. + static Marker move(MapSurface map, Marker marker, EncodedImage icon, LatLng position) { + if (!(map instanceof NativeMap) || !((NativeMap) map).isNativeMap()) { + marker.setPosition(position); + return marker; + } + map.removeMarker(marker); + return map.addMarker(new MarkerOptions(position).icon(icon).anchor(0.5f, 0.5f)); + } + // end::wayline-map-native-marker[] +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/backend-web.sh b/docs/demos/common/src/main/snippets/developer-guide/backend-web.sh new file mode 100644 index 00000000000..1c30456e877 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/backend-web.sh @@ -0,0 +1,11 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::backend-web-hosting[] +mvn -pl backend -Dcodename1.platform=backend cn1:backend-webapp <1> +mvn -pl backend -am -Dcodename1.platform=backend cn1:backend <2> +// end::backend-web-hosting[] + +// tag::backend-web-hosting-gradle[] +./gradlew :backend:backendWebApp <1> +./gradlew :backend:runBackend <2> +// end::backend-web-hosting-gradle[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/backend-web.txt b/docs/demos/common/src/main/snippets/developer-guide/backend-web.txt new file mode 100644 index 00000000000..6c3a280e910 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/backend-web.txt @@ -0,0 +1,10 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::backend-web-hosting-layout[] +/srv/myapp/ + myapp-server the binary + application.properties + webapp/ + index.html + ... +// end::backend-web-hosting-layout[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/wayline.properties b/docs/demos/common/src/main/snippets/developer-guide/wayline.properties new file mode 100644 index 00000000000..12a1944b6cb --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/wayline.properties @@ -0,0 +1,70 @@ +// Snippets of the Wayline chapters of the developer guide. Edit them here, not inline. + +// tag::wayline-properties-deployment[] +cn1.datasource.url=${DATABASE_URL} +cn1.security.authorizationserver.issuer=https://rides.example.com +wayline.admin.email=owner@rides.example.com +wayline.admin.password=${WAYLINE_ADMIN_PASSWORD} +wayline.sms.provider=twilio +wayline.sms.twilio.sid=${WAYLINE_SMS_TWILIO_SID} +wayline.sms.twilio.token=${WAYLINE_SMS_TWILIO_TOKEN} +wayline.sms.twilio.from=+15555550100 +wayline.payments.provider=stripe +wayline.payments.stripe.secret=${WAYLINE_PAYMENTS_STRIPE_SECRET} +// end::wayline-properties-deployment[] + +// tag::wayline-properties-fares[] +wayline.currency=EUR +wayline.fare.baseCents=350 +wayline.fare.perKmCents=180 +wayline.fare.perMinuteCents=40 +wayline.fare.minimumCents=700 +wayline.fare.serviceFeePercent=0 +wayline.fare.commissionPercent=0 +// end::wayline-properties-fares[] + +// tag::wayline-properties-matching[] +wayline.offer.seconds=30 +wayline.search.seconds=300 +// end::wayline-properties-matching[] + +// tag::wayline-properties-stripe[] +wayline.payments.provider=stripe +wayline.payments.stripe.secret=${WAYLINE_PAYMENTS_STRIPE_SECRET} +wayline.public.url=https://rides.example.com +// end::wayline-properties-stripe[] + +// tag::wayline-properties-sms[] +wayline.sms.provider=vonage +wayline.sms.vonage.key=${WAYLINE_SMS_VONAGE_KEY} +wayline.sms.vonage.secret=${WAYLINE_SMS_VONAGE_SECRET} +wayline.sms.vonage.from=Wayline +// end::wayline-properties-sms[] + +// tag::wayline-properties-geocoder[] +wayline.geocoder=photon +wayline.geocoder.url=https://photon.rides.example.com +// end::wayline-properties-geocoder[] + +// tag::wayline-properties-dev-database[] +cn1.datasource.url=./target/app.db +// end::wayline-properties-dev-database[] + +// tag::wayline-properties-native-maps[] +codename1.arg.ios.maps.provider=apple +codename1.arg.android.maps.provider=google +// end::wayline-properties-native-maps[] + +// tag::wayline-properties-push[] +wayline.push.app=3f0c2a4e-8b1d-4c7a-9e55-2d6b1f0a7c19 +wayline.push.key=${WAYLINE_PUSH_KEY} +// end::wayline-properties-push[] + +// tag::wayline-properties-build-hints[] +codename1.arg.ios.background_modes=location +codename1.arg.ios.locationUsageDescription=Your location is where a ride picks you up, and where riders find you while you drive. +codename1.arg.ios.NSLocationAlwaysAndWhenInUseUsageDescription=While you are online as a driver, Wayline keeps sharing your location with riders and dispatch, even with the app in the background. +codename1.arg.android.foregroundServiceType=location +codename1.arg.ios.includePush=true +codename1.arg.ios.surfaces.frequentUpdates=true +// end::wayline-properties-build-hints[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/wayline.sh b/docs/demos/common/src/main/snippets/developer-guide/wayline.sh new file mode 100644 index 00000000000..1ee3e09935c --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/wayline.sh @@ -0,0 +1,28 @@ +// Snippets of the Wayline chapters of the developer guide. Edit them here, not inline. + +// tag::wayline-sh-server[] +CN1_PROFILE=dev backend/server.sh run +// end::wayline-sh-server[] + +// tag::wayline-sh-simulator[] +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +// end::wayline-sh-simulator[] + +// tag::wayline-sh-web[] +backend/server.sh web +CN1_PROFILE=dev backend/server.sh run +// end::wayline-sh-web[] + +// tag::wayline-sh-tests[] +mvn -pl shared,backend -Dcodename1.platform=backend test # <1> +mvn -pl shared,backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true # <2> +// end::wayline-sh-tests[] + +// tag::wayline-sh-native[] +backend/server.sh build --native +backend/server.sh run --native --prebuilt --port 8080 +// end::wayline-sh-native[] + +// tag::wayline-sh-database[] +DATABASE_URL=postgres://wayline:secret@db.internal/wayline backend/server.sh run +// end::wayline-sh-database[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/wayline.sql b/docs/demos/common/src/main/snippets/developer-guide/wayline.sql new file mode 100644 index 00000000000..f4638479359 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/wayline.sql @@ -0,0 +1,19 @@ +// Snippets of the Wayline chapters of the developer guide. Edit them here, not inline. + +// tag::wayline-sql-column[] +-- V6__child_seat.sql +-- A rider may ask for a child seat. No ride has one until someone asks. +ALTER TABLE ride ADD COLUMN child_seat SMALLINT NOT NULL DEFAULT 0; +// end::wayline-sql-column[] + +// tag::wayline-sql-table[] +-- Companies that ride on account and are invoiced at the end of the month. +CREATE TABLE account_customer ( + id VARCHAR(40) NOT NULL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + billing_email VARCHAR(190) NOT NULL, + active SMALLINT NOT NULL, + created_at BIGINT NOT NULL +); +ALTER TABLE ride ADD COLUMN account_customer VARCHAR(40) NOT NULL DEFAULT ''; +// end::wayline-sql-table[] diff --git a/docs/demos/gradle/gradle-project-workflow.sh b/docs/demos/gradle/gradle-project-workflow.sh index b06e84ddd1d..a11817e2133 100644 --- a/docs/demos/gradle/gradle-project-workflow.sh +++ b/docs/demos/gradle/gradle-project-workflow.sh @@ -69,6 +69,11 @@ CN1_PROFILE=dev ./gradlew :backend:runBackend # run it on this JVM ./gradlew :backend:backendTest # and as a native binary # end::gradle-backend[] +# tag::gradle-backend-webapp[] +./gradlew :backend:backendWebApp # build the app for the browser and stage it +CN1_PROFILE=dev ./gradlew :backend:runBackend # the server now answers / with the app +# end::gradle-backend-webapp[] + # tag::gradle-backend-only[] CN1_PROFILE=dev ./gradlew runBackend ./gradlew backendPackage diff --git a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc index 096e5ac59de..d416627ddfc 100644 --- a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc +++ b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc @@ -25,6 +25,16 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g `Property` fields work the same way; the dao calls `Property#get` and `Property#set` for read and write. +Every field that isn't `static` or `transient` is stored, whatever its access: +a `private` field with a getter and a setter is mapped the same way a `public` +one is. The build weaves a static accessor into the class that declares each +non-public field and the generated code calls it, so no reflection is involved +on any platform. A field is left out of the table when it's `static`, marked +with the Java `transient` modifier, annotated `@DbTransient`, or is a non-public +`final` field without a mapping annotation, such as a `PropertyIndex`. A `final` +field that does carry a mapping annotation fails the build, since nothing could +write a loaded value into it. + ==== Field-level annotations [cols="1,3", options="header"] @@ -35,7 +45,9 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g `autoIncrement=true` (the default) emits `INTEGER PRIMARY KEY AUTOINCREMENT` and back-fills the field after `insert`. -| `@Column(name)` | Rename the column. Default: the field name. +| `@Column(name)` | Rename the column. Default: the field name in the app; + on the backend the field name in snake case + (`fareCents -> fare_cents`), as Spring Boot names it. | `@Column(type)` | Override the SQL type. Default: inferred from the Java type (`String -> TEXT`, `int/long -> INTEGER`, `float/double -> REAL`, `boolean -> INTEGER`, @@ -188,8 +200,12 @@ targets are rejected during query creation. Aggregate functions are allowed inside scalar nested queries, but not directly in assignments or bulk predicates. On MySQL and MariaDB, nested queries that read the bulk mutation target table are rejected during query creation. They flush pending -managed changes, bypass per-entity callbacks and cascades, and clear the context -so later reads can't reuse stale entities. Bulk assignments to required subtype +managed changes and bypass per-entity callbacks and cascades. As in JPA, they +don't clear the session: an entity it already manages stays managed and keeps +the values it had before the statement, and a change made to it afterward is +still written. Call `refresh(entity)` to read what the statement wrote, or +`clear()` and read again. An entity whose row a bulk DELETE removed fails with an +`OptimisticLockException` if it's changed or removed afterward. Bulk assignments to required subtype fields need a non-null literal or named parameter. Other assignment expressions are rejected for those fields. Literal and bound null assignments to primitive Java fields are rejected on both runtimes. Primitive assignments also reject @@ -254,8 +270,8 @@ correlated nested queries. `LENGTH` counts characters on every backend. ==== Values and callbacks -`@MappedSuperclass` contributes public inherited fields to a concrete entity. -`@Embedded` flattens a public `@Embeddable` value into columns prefixed with the +`@MappedSuperclass` contributes its inherited fields to a concrete entity. +`@Embedded` flattens an `@Embeddable` value into columns prefixed with the embedding field name. An all-null embedded value reads as null. Embedded fields can be used in query paths. Enums are stored by name. Mapped query parameters and literals accept the declared enum type, a valid constant name, or null. Invalid @@ -377,7 +393,7 @@ are rejected before pending changes are flushed. ==== Supported boundary This API provides managed persistence through CN1 annotations; it's not a JPA -provider or a Hibernate binary replacement. Entity access uses public fields. +provider or a Hibernate binary replacement. Entity access is by field, whether the field is public or private. Getter/setter access mappings, property-backed associations, joined/table-per-class inheritance, embeddable element collections, and independently stored entity-map keys aren't supported. Nested fetch graphs and mixed entity/scalar JPQL @@ -466,8 +482,11 @@ transaction. Derived methods support the following bounded grammar: * `findBy`, `findFirstBy`, `findTopBy`, `findFirstNBy`, and `findTopNBy` return the - entity or `List`. A single result means the first row, not a uniqueness - assertion. + entity, `Optional` or `List`. A single result means the first row, + not a uniqueness assertion; an entity return answers null when there is no row, + and an `Optional` answers `Optional.empty()`. A declared `@Query` select can + return `Optional` of its scalar the same way. `Optional` is part of the backend's + class library, not the client's, so it's for repositories the server uses. * `countBy` returns `long`; `existsBy` returns `boolean`; `deleteBy` returns `int` (affected rows) or `void`. * Direct scalar property names combine with `And` and `Or`; AND has higher precedence than OR. @@ -489,7 +508,16 @@ Derived methods support the following bounded grammar: `@Query` declares the existing JPQL subset. Every value argument requires a unique `@Param("name")` matching the query exactly. SQL values are bound, never interpolated. `@Modifying` is required for declared UPDATE/DELETE and forbidden -for SELECT. Such methods return `int` or `void`. Native SQL uses +for SELECT. Such methods return `int` or `void`. They behave like +`executeUpdate()`: pending changes are flushed first, and the session is left +unchanged, so an entity the caller read before the call is still managed and a +change made to it afterward is written. `@Modifying(clearAutomatically = true)` +empties the session after the statement instead, so what's read next comes from +the database; an entity read before the call is then detached, and nothing +written to it later reaches the database. `flushAutomatically` is accepted for +compatibility with Spring Data and changes nothing, because the flush always +happens. A derived `deleteBy` method is a bulk DELETE too and leaves the session +the same way. Native SQL uses `@Query(value="...", nativeSql=true)` with the same named parameters, including repeated occurrences. Native parameters accept primitive and boxed scalar types, String, Date, BigInteger, BigDecimal, and `byte[]` BLOBs. Date values bind as epoch diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index e93c56bac6e..09082b0dae7 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -151,6 +151,12 @@ and they're injected the same way: | The current transaction's persistence session, described under <>. +| A `@Repository` interface +| The generated repository, over that same session. One instance serves every + injection point, and like the session it works inside a `@Transactional` + method. A bean of the interface's type, when one is declared, is injected + instead. + | `HttpServer.Request` | The current request. Only a `@Scope("request")` bean can take it. diff --git a/docs/developer-guide/Backend-Data.asciidoc b/docs/developer-guide/Backend-Data.asciidoc index bde5b5deee7..f202bf9cdad 100644 --- a/docs/developer-guide/Backend-Data.asciidoc +++ b/docs/developer-guide/Backend-Data.asciidoc @@ -100,6 +100,15 @@ local SQLite database, and a module compiled against this runtime gets one whose statements are built for whichever engine the connection turns out to be. The class says what the data is; the module says where its rows live. +The names differ too. A table or column the entity doesn't name is called +after the class or field in the app's database, and after the same name in +snake case on the server, the way Spring Boot names it: `RideEvent` is stored in +`ride_event` and `fareCents` in `fare_cents`. An underscore goes before an upper +case letter that sits between two lower case letters or digits, so `userID` +becomes `userid` rather than `user_i_d`. A name given with `@Entity(table)` or +`@Column(name)` is used as written on both. Queries keep naming the Java fields; +only SQL written by hand sees the column names. + [source,java] ---- include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/OrmSnippets.java[tag=backend-orm-dao,indent=0] @@ -159,9 +168,10 @@ transaction instead of starting another. include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/OrmSnippets.java[tag=backend-orm-transaction,indent=0] ---- -Three things this doesn't do, on purpose. Relationships aren't supported -- -`@OneToMany` and friends don't exist, and a field referencing another entity -fails the build with a message saying to persist the foreign key as a scalar. +Three things this doesn't do, on purpose. A `Dao` maps one row to one object and +nothing more: an entity with a relationship, such as `@OneToMany`, is refused by +it, and belongs to a managed session instead -- see <> +and <>. `createTable` creates a table that isn't there and does nothing at all to one that is, so it's a convenience for development and for tests rather than a migration tool; see <> for that. And a boolean, a date and a char are stored as integers on every @@ -513,6 +523,19 @@ Spring; list it in `rollbackFor` to make it roll back, as the `Signups` example does. `noRollbackFor = DataAccessException.class` restores plain commit-on-checked. ==== +One kind of failure isn't a fault in the statement or the data. When two +transactions change the same rows, the database may end one of them: as the loser +of a deadlock, after a lock wait that timed out, or because a row changed after the +transaction first read it. PostgreSQL, MySQL and MariaDB each report that their own +way, and every one arrives as `ConcurrencyFailureException`, a +`DataAccessException`. The transaction rolls back like any other, and the code +that called the `@Transactional` method can run it again or answer that someone +else made the change first. A session reports a database failure as an unchecked +`PersistenceException`, so `ConcurrencyFailureException.isCauseOf(err)` looks for +it among the causes. Catch it outside the `@Transactional` method, not inside: the +database has usually ended the whole transaction by then, so nothing in it can +continue. + A method that joined a transaction can't roll back what the method that began it did before it, so when it fails with an exception that rolls back, it marks the whole transaction rollback-only instead. The method that began the transaction @@ -659,6 +682,7 @@ savepoint however it's made. The savepoints are named by the runtime and released when the method returns normally. All three engines support them. +[[backend-transaction-session]] ==== The transaction's session A bean can inject `com.codename1.orm.session.Session`, the persistence session of @@ -677,6 +701,12 @@ also clears the session, since the rows it had loaded since the savepoint no lon exist. Used outside a transaction, it throws with a message saying to annotate the method, or to open a session with `EntityManager.openSession()`. +A `@Repository` interface, described in the SQLite ORM chapter, is injected the +same way, as Spring Data injects one: the generated implementation is built once, +over this session, and handed to every bean that asks for the interface. Its +methods therefore run in the calling `@Transactional` method's transaction, and +refuse outside one exactly as the session does. + ==== Why a call through this works Spring applies `@Transactional` with a proxy: a wrapper object stands between a @@ -691,6 +721,13 @@ and committing, with the rollback decision written in. The transaction is therefore a property of the method, and every call gets it. The same rewriting gives `@Async`, `@Timed` and `@Counted` the same property. +A private method may take and return private nested classes of its bean; the +method needs no change for that. Two things must stay nameable from the bean's +package, and the build reports each with the class and method concerned: the class +that declares the annotated method can't itself be private, local or anonymous, +and an exception listed in `rollbackFor` or `noRollbackFor` can't be a private +class. + ==== Pitfalls * **Checked exceptions other than database failures commit.** A failed statement diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index a6bbd038b5d..31d547e79f2 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -4,8 +4,8 @@ A controller turns HTTP into method calls. This chapter covers how requests are mapped to methods and how their parts are bound to parameters, how a return value becomes a response, how the app and the server share one declaration of their -API, and how a server holds WebSocket connections open. <> -introduced the shape; this is the detail. +API, how a server holds WebSocket connections open, and how it sends push +notifications. <> introduced the shape; this is the detail. === Mapping requests @@ -125,6 +125,21 @@ Any other return type is a build error: a JDK class with no JSON form, such as with the build and the request both reporting success. `@ResponseStatus` takes a final status between 200 and 599. +A method that returns its own type can't return a refusal, because its return +type is the body of a success. It throws one: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/RefusalSnippets.java[tag=backend-web-refusal,indent=0] +---- + +The reason is sent as the body, in plain text, so keep out of it anything the +caller shouldn't learn. A status below 500 is an answer: it isn't logged and +isn't counted as a failed request. A status of 500 or above is reported the way +any other failure of the handler is, and answered with the status given. The +exception is unchecked, so inside a `@Transactional` method it rolls the +transaction back. + ==== Your own classes as JSON A method can return an entity or any class of your own, or take one as its body, @@ -162,6 +177,10 @@ app and the server reads the same on both sides: `getX` (or `isX`) and `setX` methods, and is left out when it has neither. * `@JsonProperty("due_at")` renames a field and `@JsonIgnore` leaves one out, both from `com.codename1.annotations`. +* A `record` is written from its accessors and read through its canonical + constructor, so it needs no constructor without arguments. A component the body + leaves out, or one marked `@JsonIgnore`, is passed its default: `null`, `0` or + `false`. * A `Date` is written as milliseconds since the epoch, which is Jackson's default and what the app's mapper reads, and is read from that or from an ISO-8601 date such as `2025-03-01T09:30:00Z`. A `byte[]` is base64, an enum is its name. @@ -186,8 +205,8 @@ it, would be written forever, so a response that nests more than 64 objects deep is answered 500 with a message naming the class; mark the field that points back `@JsonIgnore`. The build refuses what it can't write a codec for, and says why: a field typed by a type variable, such as `List` in a generic `Page`; a body -class with no constructor without arguments; an interface; and an array other -than `byte[]`. +class other than a record with no constructor without arguments; an interface; +and an array other than `byte[]`. A method that throws is answered 500 with the body `internal error`, and the exception is logged. The message isn't sent to the client, because an exception @@ -316,6 +335,188 @@ Conditional requests and ranges are honoured, and where the platform supports it the file is sent from the page cache to the socket without passing through the process. +[[backend-hosting-the-app]] +==== Hosting the app in the browser + +A project with an app and a server can have the server host the app's browser +build. One address is then both the API and the app: somebody opens it and is +using the app, with nothing to install. + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-web.sh[tag=backend-web-hosting,indent=0] +---- +<1> Builds the app for the browser and stages it in `backend/target/webapp`. +<2> Runs the server, which now answers `/` with the app. + +`cn1:backend-webapp` runs the project's own JavaScript build on this machine, +the one `mvn package -Dcodename1.platform=javascript +-Dcodename1.buildTarget=local-javascript` runs, then unpacks the bundle into the +server module's `target/webapp`. It's a goal of its own because that build takes +about a minute. Run it when the app changed, not every time the server restarts; +what it staged stays until `mvn clean`. + +The server looks for the app in a directory named `webapp` in its working +directory, beside `application.properties`, and serves it when the directory holds +an `index.html`. There's no setting to turn on. `cn1:backend` points the server at +`target/webapp` for you, and `cn1:backend-package` leaves the directory beside the +binary, so a deployment is the binary with `webapp` next to it: + +[source,text] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-web.txt[tag=backend-web-hosting-layout,indent=0] +---- + +The files aren't linked into the binary. They're sent from the page cache, which +needs them to be files. + +Routes still come first. A request reaches the app's files only when no route +answered it, so `/api/notes` is the API and `/` is the app, and a file can't +shadow a route by being named like one. + +The files of a build keep their names from one build to the next, so each is +served with `Cache-Control: no-cache` and an entity tag: a browser asks every +time and is told `304 Not Modified` for a file that didn't change. A deployment +that puts a version in the path can set a longer policy. The build also writes a +gzip copy beside each text file that shrinks, and a browser that accepts gzip is +sent that copy; a range request, and a browser that doesn't accept it, get the +file itself. + +[cols="1,2"] +|=== +| Setting | What it does + +| `cn1.webapp.root` +| The directory holding the app. `webapp` in the working directory by default. + A directory named here that holds no `index.html` stops the server from + starting, since a server told where its app is shouldn't answer 404 there. + +| `cn1.webapp.path` +| Where the app is served. `/` by default. + +| `cn1.webapp.cacheControl` +| The `Cache-Control` header of every file. `no-cache` by default. + +| `cn1.webapp.enabled` +| Set to `false` to serve no app even when the directory is there. +|=== + +The goal has a few parameters of its own: + +[cols="1,2"] +|=== +| Parameter | What it does + +| `cn1.backend.webapp.bundle` +| A bundle to stage instead of building one: the archive a JavaScript build + produced, or a directory unpacked from it. Use it for a bundle built by an + earlier job or by the build server. + +| `cn1.backend.webapp.output` +| Where to stage it. `target/webapp` in the server module by default. + +| `cn1.backend.webapp.appRoot` +| The app's root project. The directory above the server module by default. +|=== + +A <> has the same step as a task of its `backend` +project: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-web.sh[tag=backend-web-hosting-gradle,indent=0] +---- +<1> Builds the app for the browser and stages it in `backend/build/webapp`. +<2> Runs the server, which now answers `/` with the app. + +`backendWebApp` runs the root project's local JavaScript build, the one +`buildJavascriptLocal` runs, and unpacks the bundle into the backend's +`build/webapp`. `runBackend` points the server at that directory, and +`backendPackage` writes the binary into `build`, where `webapp` is beside it +already. What it staged stays until `./gradlew clean`. + +`-Pcn1.backend.webapp.bundle` and `-Pcn1.backend.webapp.output` do what the Maven +parameters of the same names do. There's no `appRoot`: the app is the root +project of the build. A backend-only project has no app to build, so its +`backendWebApp` stages a bundle named with `-Pcn1.backend.webapp.bundle`. + +A test that needs the app served names the directory, since a test runs in the +module directory and not in `target`: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/HostedAppTest.java[tag=backend-web-hosted-test,indent=0] +---- + +===== Talking to the server from the hosted app + +The hosted app and its API share an origin, so the app needs no server address +compiled in. In the browser the address of the page is the address of the server: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/HostedAppSnippets.java[tag=backend-web-hosted-origin,indent=0] +---- + +A same-origin request needs no CORS configuration and goes straight to the +server. The JavaScript build can route requests to other origins through a +proxy servlet; a backend isn't a servlet container, so the hosted build is made +without one unless the project sets `javascript.inject_proxy` or +`javascript.proxy.url` itself. A hosted app that calls a third-party service +directly needs that service to allow the app's origin. + +A WebSocket follows the same rule. Replace `http` with `ws` in the origin and +the socket goes to the server the page came from, over TLS when the page did. + +===== Signing in from the hosted app + +An installed app that signs in against the server's own authorization server +usually asks for the code to be sent to a loopback address and reads it off the +redirect. A page in a browser can't do that. The browser follows a redirect +itself, shows the page only what was at the end of it, and refuses to show it +anything from another origin. It also keeps the session cookie to itself: the +page can neither read `Set-Cookie` nor set `Cookie`. + +Three things make the same flow work there: + +* Register a redirect address on the server's own origin for the app's client, + such as `https://app.example.com/signin/code`, beside the loopback one. A + redirect address is matched exactly, scheme, host and port included, so it + has to be the address people open the app at. +* Answer that path with the code it was given, as JSON. The redirect then ends + on a same-origin response the app can read. The path needs no sign-in: it + repeats what was in its own address, and a code is worth nothing without the + PKCE verifier that only the app that started the sign-in holds. +* Answer the sign-in form with a status and not a redirect, using + `formLogin(form -> form.successHandler(...).failureHandler(...))`. The app + can't see which page a redirect led to, so `200` and `401` are how it learns + whether the password was accepted. + +The browser then carries the session cookie from the form to `/oauth2/authorize` +on its own, because both are the same origin, and the token request is the same +one the installed app makes. Tokens are kept by the app's token store, which in +a browser is the browser's storage for that origin. + +That cookie changes what the sign-in chain has to defend against. An installed +app holds its session itself, so no other site can ride on it. A browser sends +the cookie on its own. If the chain runs without CSRF tokens, because the app +posts the form itself, two things have to do their work: + +* The session cookie is `HttpOnly` and `SameSite=Lax` unless + `cn1.session.same-site` says otherwise, and `Secure` under TLS. Leave it that + way: `None` sends the session along with another site's requests. +* Refuse a request that changes something and names another site in `Origin`. + A browser sets that header on every such request and a page can't alter it. + The installed app sends none, so a request without one passes. A filter added + with `addFilterBefore(filter, LogoutFilter.class)` covers the form, the token + endpoint and sign-out. Compare against the configured issuer. + +With CSRF off, sign-out answers a `GET` as well as a `POST`. Narrow it with +`logout(logout -> logout.logoutRequestMatcher(...))`, and have the app send +`POST /logout` once it has its tokens. The session has done its work by then. +One that's never ended lasts until `cn1.session.timeout` seconds pass without a +request, which is 1800 unless set. + === Sharing the contract with the app This is where having the same language on both ends stops being a slogan. An @@ -350,6 +551,30 @@ The server half is off by default. Every existing project carries these interfaces for its client alone, and generating server classes into those builds would grow them for nothing. +==== A module both sides depend on + +A project with an app and a server keeps the contract in a module of its own, +which `common` and `backend` both depend on. Nothing needs switching on there: + +* The app's build finds the contract in the dependency and generates its client, + exactly as it does for an interface in `common` itself. +* The backend's `generate-annotation-stubs` goal writes, for each contract, + `NotesApiServer` and a `NotesApiController` into + `target/generated-sources/cn1-annotations`. The controller is an ordinary + `@RestController` that takes the `@Component` implementing `NotesApiServer` + and maps each operation to it, so routing, binding, security rules and + filters treat it like one written by hand. + +The module holds the data classes and the interfaces and nothing else. It +compiles against `codenameone-core` for the callback types the interfaces name, +with `provided` scope, so the server doesn't inherit the client library. A +contract method can't answer with a status of its own, so the implementation +throws `ResponseStatusException` to refuse a request. + +The Initializr's ride-hailing template is built this way and generates a +starting point under your own name and package. <> +describes it. + === Real-time with WebSockets The server speaks RFC 6455, so a Codename One app can hold a live connection to a @@ -501,3 +726,81 @@ suite can't shrink unnoticed to the ones that happen to pass. Sections 12 and 13 are excluded while permessage-deflate is unimplemented. They're excluded rather than tolerated: accepting their result as a pass would also accept it for a case that used to work. + +[[backend-push]] +=== Sending push notifications + +A WebSocket reaches an app that's running. A push notification reaches a phone +whose app isn't: a server tells a rider their driver has arrived, or moves the +countdown on the lock screen. The server hands the message to the Codename One +push service, which delivers it through Firebase, APNs, Huawei, WNS, or Web Push, +and the app receives it with `PushClient`. <> covers the +app's half and the Console. + +A server needs two values from the Console, both described in +<>: the application UUID of the push +application, and a server API key created with the `push` scope. The key is a +credential; keep it in configuration or the environment, never in source and never +in the app. + +`CloudPush` sends, `PushNotification` builds the message, and `PushTarget` names a +device: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/RideNotifications.java[tag=backend-push-send,indent=0] +---- + +A device is a provider and that provider's token. `PushTarget.of("fcm", token)` +takes the two values and accepts the older spellings the app can report (`gcm`, +`ios`, `hms`). `PushTarget.fromDeviceKey` reads the `cn1-fcm-...` form that +`Push.getPushKey()` returns. One `send` can address many devices at once. + +`PushNotification` writes the same envelope the app's `PushMessage` reads: title, +body, image, deep link, collapse key, time to live, silent delivery, application +data, and options for one provider. A message needs a body, data, or a surface +command. + +==== Live Activities and widgets + +A message can carry a command the app applies before its listener runs, which keeps +a Live Activity or a widget current while the app is in the background. Three +helpers build the command: + +* `PushNotification.liveUpdate(id, state)` gives a running activity new state. +* `PushNotification.liveEnd(id, finalState, dismissImmediately)` ends it, optionally + with a last state. +* `PushNotification.widget(kind, timelineJson)` publishes new widget content. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/RideNotifications.java[tag=backend-push-live-activity,indent=0] +---- + +The `id` is the one the app started the activity under, and the state map uses the +names the activity's layout binds. The helpers turn the map into the JSON string +the envelope carries, with its keys sorted the way the app writes state itself. +<> describes how each platform applies a command. + +==== What the answer means + +`send` returns a `PushResult` for every answer the service gives, a refusal +included. `isAccepted()` is true for a `202`, and that means _admitted_: the +service has queued the message durably, but no provider or device has seen it yet. +Whether it arrived is reported later, through delivery feedback. `getDeliveryIds()` +lists one id per admitted target, to match against that feedback. + +A refusal carries its status and the service's explanation in `getError()`: + +* *400*: the request is malformed, such as an unknown provider or an `appId` that + isn't the application UUID. +* *401*: the key is missing, wrong, or lacks the `push` scope. +* *402*: the account doesn't include what the message asked for. +* *429*: slow down. `getRetryAfterSeconds()` says how long to wait, or answers + `-1` when the service didn't say. + +Only a request that got no answer at all, because the service was unreachable or +the connection failed, throws an `IOException`. Each notification is one HTTPS request on the +calling thread, so a server that notifies as part of handling a request can hand it +to an `@Async` method rather than make its own caller wait. `withEndpoint` points a +sender at another URL, such as a stub server in a test. diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index 3dcb1b63948..9a85206173f 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -57,7 +57,8 @@ concurrency model and configuration. The chapters after it each take one part of a server and go into depth: * <>: mapping HTTP requests to methods, sharing a typed contract - with the app, and holding WebSocket connections open. + with the app, holding WebSocket connections open, and sending push + notifications. * <>: compiled HTML pages, typed templates, forms and htmx fragments. * <>: splitting a server into services the build wires together, with scopes, conditions and configuration binding. @@ -251,7 +252,8 @@ image::img/backend-request-lifecycle.svg["A request passing through the MCP endp route can't answer a health check. . Then the application's routes. . Static files from `cn1.static.root` are always last, so a file can't shadow a - route by being named like one. + route by being named like one. The app's browser build comes after those, + when the server hosts one; see <>. . A request nothing answered is a 404. Around the chain sits one wrapper, and it's where the per-request features @@ -348,6 +350,11 @@ there is nothing next to the binary. | `cn1.static.root` | A directory to serve files from, after every route. +| `cn1.webapp.root` +| The directory holding the app's browser build, served at `/` after every + route. `webapp` in the working directory by default; see + <>. + | `cn1.orm.createTables` | Whether the generated daos create their tables at start-up. True on a development profile, false everywhere else. @@ -380,6 +387,10 @@ there is nothing next to the binary. | Where static files are served (`/static`), the file a directory answers with (`index.html`), and their `Cache-Control` header (`public, max-age=3600`). +| `cn1.webapp.path`, `cn1.webapp.cacheControl`, `cn1.webapp.enabled` +| Where the app's browser build is served (`/`), its `Cache-Control` header + (`no-cache`), and whether it's served at all (`true`). + | `cn1.datasource.pool.borrowTimeoutMillis` | How long a request waits for a free connection before it fails, 10 seconds by default. diff --git a/docs/developer-guide/External-Surfaces.asciidoc b/docs/developer-guide/External-Surfaces.asciidoc index b717675c1fb..9c3d4177adb 100644 --- a/docs/developer-guide/External-Surfaces.asciidoc +++ b/docs/developer-guide/External-Surfaces.asciidoc @@ -1,3 +1,4 @@ +[[external-surfaces]] == External Surfaces: Widgets and Live Activities Home-screen widgets and live activities answer the same developer question: how to keep a live piece of information outside the app. Codename One models both as one concept -- an external surface -- under the `com.codename1.surfaces` package. A *widget* is persistent, user-placed and content-driven: the weather, the next meeting, the delivery status on the home screen. A *live activity* is transient, app-started and progress-driven: the delivery that's out right now, a running timer, a live score on the iOS lock screen and Dynamic Island, an ongoing Android notification, a floating desktop pill. Both share the same layout model, serialization, state mechanism, and action model. diff --git a/docs/developer-guide/Gradle-Project-Workflow.asciidoc b/docs/developer-guide/Gradle-Project-Workflow.asciidoc index 95e963e7c31..c113e6f9a6e 100644 --- a/docs/developer-guide/Gradle-Project-Workflow.asciidoc +++ b/docs/developer-guide/Gradle-Project-Workflow.asciidoc @@ -231,6 +231,10 @@ Gradle runs one Codename One build at a time, even when a command line names sev | `cn1:backend-package` | Builds a backend as a single native binary. +| `backendWebApp` +| `cn1:backend-webapp` +| Builds the app for the browser and stages it for the backend to serve at `/`. + | `backendTest` | `test -Dcn1.backend.compiledTests=true` | Runs a backend's tests as a native binary. The standard `test` task runs them on this JVM. @@ -354,6 +358,15 @@ Java and Kotlin classes in one project can call each other. Both are checked by include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-backend,indent=0] ---- +`backendWebApp` builds the app for the browser and stages it in `backend/build/webapp`. `runBackend` then serves the app at `/`, and `backendPackage` leaves it beside the binary, so one address is both the API and the app: + +[source,bash] +---- +include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-backend-webapp,indent=0] +---- + +The browser build takes about a minute, so neither task runs it for you: run `backendWebApp` when the app changed. <> covers how the server serves it. + A backend-only project has the same bootstrap with the server at the root: `application.properties`, `application-dev.properties` and `src/main/java`. Its tasks are `runBackend`, `backendPackage`, `backendTest`, `cn1Update` and the standard `test`, which runs the backend's tests on this JVM; <> covers writing them. `backendPackage` writes the binary to `build/`: [source,bash] @@ -388,4 +401,4 @@ These features remain Maven goals, with no Gradle task yet: * `cn1:create-game-scene`. * The on-device debugging helpers for Android and iOS. -The initializr's Tweet app template isn't available for Gradle projects yet. An application that needs one of these stays on Maven for now. +An application that needs one of these stays on Maven for now. diff --git a/docs/developer-guide/Push-Notifications.asciidoc b/docs/developer-guide/Push-Notifications.asciidoc index 3f730908c4c..a2bd007cc5c 100644 --- a/docs/developer-guide/Push-Notifications.asciidoc +++ b/docs/developer-guide/Push-Notifications.asciidoc @@ -139,6 +139,8 @@ A successful admission returns HTTP `202` with durable delivery IDs. Invalid tar The complete request, response, authentication, error, target, and message schemas are in the https://cloud.codenameone.com/openapi/push-v3.yaml[Push sending OpenAPI 3.0 specification]. Import that file into Swagger UI, an OpenAPI code generator, or an API client instead of reproducing DTOs from this chapter. +A <> doesn't need to build this request: `CloudPush` sends a typed message, Live Activity commands included, as <> describes. + Use the Console for operator-driven messages, stored audiences, campaigns, analytics, and automations. [[push-classic-server-migration]] diff --git a/docs/developer-guide/Wayline-Implementors-Guide.asciidoc b/docs/developer-guide/Wayline-Implementors-Guide.asciidoc new file mode 100644 index 00000000000..0cecc2adbf3 --- /dev/null +++ b/docs/developer-guide/Wayline-Implementors-Guide.asciidoc @@ -0,0 +1,2320 @@ +[[wayline-implementors-guide]] +== Wayline: Building on the sample + +<> shows Wayline from the outside. This chapter is for the +developer who wants to build on it. It covers how to run the project, how it's +put together, what to change for two common kinds of service, and the parts a +sample leaves for you to finish: taking real money, map data at production +volume, native maps, sign-in through another identity provider, real text +messages, push notifications, analytics, and the database it's deployed on. + +The Initializr's ride-hailing template generates the project under your own +name and package. Paths in this chapter are relative to the project's root. + +[[wayline-launch]] +=== Running it + +The project needs JDK 17 or newer and Maven, and nothing else installed. Start +the server on the development profile: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.sh[tag=wayline-sh-server,indent=0] +---- + +On that profile the database is in memory, verification codes are written to +the log and shown in the app in place of a text message, cards are simulated, +and the server creates the demo accounts listed in <>. It +listens on port 8080. + +Then run the app in the simulator, from another terminal in the same directory: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.sh[tag=wayline-sh-simulator,indent=0] +---- + +Sign in as `rider@wayline.example` with the password `wayline-demo`. Start a +second simulator and sign in as `driver@wayline.example` to follow a ride from +both ends. A phone on the same network reaches the server through **Server +address** on the welcome screen. + +To use the app in a browser, have the server host it. The first command builds +the browser version, which takes about a minute and is needed again only when +the app changes: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.sh[tag=wayline-sh-web,indent=0] +---- + +Then open `http://localhost:8080/`. <> describes what +the server does with the staged files and how to deploy them. + +`backend/server.sh` runs the server on the JVM for development. For a +deployment it builds a native binary with one command: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.sh[tag=wayline-sh-native,indent=0] +---- + +=== The modules + +[cols="1,4"] +|=== +| Module | What it holds + +| `shared` +| The contract: the records both sides exchange and the `@RestClient` + interfaces that name every call. It's plain Java 17 with no Codename One + build step of its own. + +| `common` +| The app: sign-in, the rider, driver and admin screens, the map, the wallet, + the settings and the live channel. The styling is in + `src/main/css/theme.css` and the translations in `src/main/l10n`. + +| `backend` +| The server: accounts and roles, phone verification, driving applications, + moderation, fares, matching, the ride state machine, payments, statistics, + push notifications and the WebSocket. The entity classes are in the `domain` package, the queries in + `repository`, the schema in `src/main/resources/db/migration`, and the + settings in + `application.properties` beside the module's `pom.xml`. + +|=== + +There are no platform modules: `common` builds the app for the simulator, the +desktop, Android, iOS and the browser. + +=== The contract both sides share + +Every call the app makes is a method of an interface in `shared`. This is part +of `RiderApi`: + +[source,java] +---- +include::../../scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RiderApi.java[tag=wayline-contract-api,indent=0] +---- + +The data that travels is a record marked `@Mapped`. This is `RideRequestDto`: + +[source,java] +---- +include::../../scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideRequestDto.java[tag=wayline-contract-dto,indent=0] +---- + +Two builds read these files. The app's build writes a client, `RiderApiImpl`, +which the app reaches through `Api.rider()` in `common`. The server's build +writes an interface, `RiderApiServer`, whose methods take the same parameters +without the callback and return the value, and a controller that routes each +path to it. The server then implements that interface. This is the start of +the class that does: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/RiderEndpoint.java[tag=wayline-contract-endpoint,indent=0] +---- + +A method added to the contract and not implemented on the server is a compile +error, and so is a component the app reads that the record no longer has. +<> covers the mechanism. + +The caller is never a parameter. `Caller.name()` reads the signed-in user from +the request's security context, so an endpoint acts on the rides of whoever +holds the token and can't be asked to act on someone else's. + +The interfaces are `AccountApi`, `RiderApi`, `DrivingApi` for the application +to drive, `DriverApi`, `PaymentApi`, `GeoApi` and `AdminApi`. Each has one +endpoint class on the server that does nothing but call a service: `Rides`, +`Payments`, `Applications`, `Accounts` and the rest. The services hold the rules. + +[[wayline-server-structure]] +=== How the server is put together + +Under the endpoints the server has three layers, and a change usually touches +one class in each. + +[cols="1,2,3"] +|=== +| Layer | Where | What it does + +| Entities +| The `domain` package +| One class for each table, with private fields behind getters and setters: + `Ride`, `RideEvent`, `RidePassed`, `Profile`, `Preference`, `PhoneCode`, + `DriverState`, `DriverApplication`, `DriverDocument`, `Payment`, + `PaymentMethod`, `Payout`, `Pricing` and the rest. They're never sent to the + app. The records in `shared` are what travels. + +| Repositories +| The `repository` package: `RideRepository`, `DriverStateRepository`, + `ProfileRepository`, `PaymentRepository`, `DriverApplicationRepository` and + one for each other entity +| The queries, and nothing else. Each is an interface, and the build writes + its implementation. + +| Services +| `Rides`, `Matcher`, `Fares`, `Payments`, `Earnings`, `Applications`, + `Accounts`, `Preferences`, `PhoneVerification`, `Statistics` +| The rules. Each is a `@Component` whose constructor takes the repositories + it uses, and whose methods are `@Transactional`. +|=== + +An entity gets no generated key: every id is text the server makes in `Ids`. +It names neither its table nor its columns. They're the names of the class and +its fields in snake case, so `Ride` is stored in `ride` and `pickupLat` in +`pickup_lat`, and a field whose type is an enum, such as `state`, holds the +constant's name. This is the start of `Ride`: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Ride.java[tag=wayline-entity,indent=0] +---- + +The annotations are the ones <> describes. The entities don't create +the tables. The migrations do, as <> explains, so a field and +its column are two edits that go together. + +A repository is an interface marked `@Repository` that extends +`CrudRepository`, which brings `findById`, `insert` and the other operations +on one entity: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideRepository.java[tag=wayline-repository,indent=0] +---- + +A query that its method's name can say is derived from the name, as these two +are. The rest carry a `@Query` over the entities, which names Java fields and +not columns, as `DriverStateRepository.freeInBox` does to find the free +drivers inside a bounding box. A query that changes rows is also marked +`@Modifying` and answers how many it changed. <> +covers both kinds. + +A service method that's marked `@Transactional` runs in one transaction, and +every repository it calls works inside it, as <> +describes. A change made to an entity the transaction read is written when the +method returns, with no call to save it: `PhoneVerification.store` sets the +fields of the `PhoneCode` it read and returns. Methods that only read are +marked `@Transactional(readOnly = true)`, which PostgreSQL and MySQL enforce: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Rides.java[tag=wayline-service-readonly,indent=0] +---- + +Two rules shape the services, and both are about what a transaction must not +contain: + +* A call that leaves the server isn't made inside a transaction. `Rides.complete` + is three steps: `finish` moves the ride to `COMPLETED` and commits, + `Payments.settle` charges the card with no transaction open, and `settled` + records the result in a second transaction. `PhoneVerification.start` stores + the code, commits, and only then sends the text message. A charge can take + seconds, and a transaction held across it would hold a database connection + for as long. +* The app is told of a change only once it's committed. The public methods of + `Rides` come in pairs for that reason: a private `@Transactional` method + makes the change and returns a `Change`, and the public method that called it + passes that to `tell`, which publishes it on the live channel. An app that's + told a ride changed asks for the ride at once, on another connection, and + must find it changed. + +A rider cancelling a ride is one such pair: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Rides.java[tag=wayline-service-transaction,indent=0] +---- + +A private method can carry `@Transactional` here and be called through `this`. +The build writes the transaction into the method itself, so there's no proxy +for the call to bypass. <> covers the rules. + +The public method runs the transaction through `attempt`. Two transactions +that change the same ride are meant to be settled by the update itself, as +<> describes: the one that comes second changes no rows. +MariaDB, as it's configured out of the box, refuses that second update +instead, because the ride it read has changed since, and ends its +transaction. `attempt` runs the transaction again in that case, up to three +times. The second run reads the ride as the first one left it, and its update +changes no rows, which is the answer the other databases give. + +[[wayline-access]] +=== Who may do what + +The server is its own OAuth 2.0 authorization server. `SecurityConfig` registers +one public client for the app, which signs in with the authorization-code flow +and PKCE and holds no secret. Accounts, the registered client, tokens and +signing keys are kept in the database by the tables the security layer creates. +<> documents the pieces. + +`SecurityConfig` declares two filter chains. The first covers the sign-in +paths: `/login`, `/oauth2/**`, `/.well-known/**`, `/userinfo` and `/logout`. It keeps a session for +the few requests a sign-in takes, answers the sign-in form with a status and +JSON, and limits attempts for each client address. + +The second chain covers `/api/**`, has no session, and requires a bearer token +on every request. After the rate limits it declares, its rules are the whole of +the access policy: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/SecurityConfig.java[tag=wayline-api-rules,indent=0] +---- + +Three things about that chain are worth knowing before changing it: + +* The roles are `RIDER`, `DRIVER` and `ADMIN`, in `Roles`. A token says who the + caller is and not what they may do. The chain reads the account's roles from + the database on every request, so a role an admin withdraws and a block an + admin places take effect on the next request, not when the token expires. + For that reason `Accounts.setRoles` answers 409 when asked to withdraw the + driver role from a driver who is on a ride: without the role they couldn't + finish or cancel it. The admin cancels the ride first, or waits. +* The application to drive is under `/api/driving`, not `/api/driver`, on + purpose: someone who isn't a driver yet has to reach it. +* The app hides the screens a role can't use. That's a courtesy. The chain is + what refuses the request. + +The same sign-in works from a browser with one difference. An installed app +receives the authorization code on a loopback address. A page can't, so the +server registers a second redirect address on its own origin, +`/signin/code`, which `SignInCodePage` answers with the code as JSON. Because a +browser sends a session cookie on its own, the sign-in chain refuses a request +that changes something and names another site in its `Origin` header. +<> explains the reasoning. + +=== A ride from request to receipt + +A ride is a `Ride` entity, a row in `ride`, with a state. The states are the +`RideState` enum in `shared`, so the app and the server name them the same +way, and `RideStateMachine` on the server lists the moves that are allowed: + +[cols="2,3"] +|=== +| State | How a ride gets there + +| `REQUESTED` +| A rider asked for it, or the driver it was offered to declined or didn't + answer. + +| `OFFERED` +| The server offered it to a driver. + +| `ACCEPTED`, `ARRIVED`, `IN_PROGRESS`, `COMPLETED` +| The driver accepted, reached the pickup, started the trip and finished it. + +| `CANCELLED_BY_RIDER` +| The rider cancelled before the trip started. + +| `CANCELLED_BY_DRIVER` +| The driver cancelled after accepting and before the trip started. + +| `CANCELLED_BY_ADMIN` +| An admin cancelled a ride that hadn't finished. + +| `NO_DRIVERS` +| Nobody took the ride within the time allowed. +|=== + +[[wayline-ride-atomic]] +==== Two answers at the same moment + +Two drivers can't both accept a ride, a rider can't cancel a ride in the instant +a driver accepts it, and an offer can't be accepted while the server is taking +it back. None of it takes a lock. Every change of state is one +conditional update in `RideRepository`, and the number of rows it changed is +the whole answer. + +`Rides.tryMove` asks `RideStateMachine.sources` which states the new one may be +reached from, and passes them to the statement for that kind of move: the +rider's, the driver's, a driver taking an offer, a driver giving one back, an +offer being made, or a move the server makes itself: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Rides.java[tag=wayline-ride-trymove,indent=0] +---- + +Each kind is a fixed statement of its own in the repository. This is the one +for a driver accepting an offer: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideRepository.java[tag=wayline-ride-move,indent=0] +---- + +It names the states the move may start from, the driver the ride was offered +to, and the time the offer lapses. It answers 1 for the request that made the +move and 0 for every other, because by the time the database runs the second +statement the row is no longer in a state the statement names. `tryMove` +records a `RideEvent` only for the 1, in the same transaction, and +`Rides.move` turns a 0 into a 409 that says where the ride is now. + +The service never reads the state and then writes it. Reading first and +writing second would let two transactions both read `OFFERED` and both write +`ACCEPTED`. One statement can't be interleaved that way on any of the three +engines, and nothing is held in the server's memory, so the rule holds with +several server processes on one database. + +`RideRaceTest` is the test that would notice the difference. It calls +`Rides.accept` for one offer from eight threads released together, and +requires that exactly one call returns the ride as `ACCEPTED`, that the other +seven are answered 409, and that the ride's history holds one `ACCEPTED` +event. Two more tests run the acceptances against `Rides.tick` while it takes +back an offer that has lapsed, and require that nobody gets the ride. + +Three other places use the same pattern. `RideRepository.rate` gives a ride +its stars only where it has none, so a ride rated twice is rated once. +`PhoneCodeRepository.takeGuess` has the database add one to the count of +wrong codes only while the count is under the limit, so of any number of +guesses sent together only as many as are left are looked at. +`RideRepository.claimTip` and `claimRefund` claim a tip and a refund the same +way before the payment provider is asked for anything, so of two +requests only one charges or refunds. Both claims update the ride's row, so a +refund that arrives while a tip is still being charged sees the tip and answers +409 until the charge has been recorded or given up. A server that stops after +claiming a refund leaves the ride marked refunded with no refund recorded. +The next request for that ride finds the payment row unchanged and asks the +provider again. That's safe because a refund carries a key made from the +charge it returns, so the provider answers a repeat with the refund it made. +The same rule lets a second refund through while the first is still at the +provider. Whichever of the two fails gives its claim back only while the +payment row is still unrefunded, and whichever succeeds writes the ride's +status along with the payment's. Both write the ride's row first, so they take +turns, and the ride ends up agreeing with its payment in either order. A +refunded fare can't come back as earnings for the driver to cash out. + +`Accounts.setRoles` and `Accounts.suspend` hold two rows: the profile of the +admin who asks and the profile of the account being changed, in the order of +their names. The admin's own role is then read again inside the transaction. +Without that, two admins could remove or block each other in the same moment +and leave the service with nobody to administer it. +Neither blocks an account in the middle of a ride, nor withdraws the driver +role from one: every request a blocked account sends is refused, so nobody +could end that ride. The admin cancels the ride first, or waits for it to end. +A driver taking an offer holds the driver's profile too and then checks that +the account is still allowed, so a block and an accepted offer can't both +land. +A live connection that was opening while the change committed reads the +account again once it's registered, so it can't keep what it read before. + +A conditional update settles a race over one row. Two races here are over a +row that doesn't exist yet: a rider asking for two rides at once, and two rides +choosing the same free driver. Each transaction reads, finds nothing in its +way, and inserts or updates a row of its own. `Rides.open` and `Rides.offer` +therefore start by writing the rider's profile row, or the driver's state row, +to the value it already has (`ProfileRepository.hold`, `DriverStateRepository.hold`). +The database keeps a written row for its writer until the transaction commits, +so the second transaction waits there and then reads what the first one left. +That write must come first in the transaction: a transaction that reads +first may go on seeing the database as it was at that read. That's why choosing +a driver and offering them the ride are two transactions. + +The same hold serializes everything else that reads an account and then +writes for it. Closing an account, removing a card and choosing the default +card hold the profile, so each takes turns with a ride being asked for. +Sending a phone code holds it too, so two requests can't both pass the wait +between codes. Changing a password holds it before the old password is +checked, so of two changes made together the second is checked against the +password the first one set. Approving a driving application holds the +applicant's profile, the row blocking the account holds as well: granting the +role rewrites the whole sign-in row, and an approval that read the account +before a block would otherwise write it back unblocked. +A driver's position report holds the driver's state row, so two +reports sent together don't count the same online minutes twice. Cashing out +holds the driver's payout account, so two requests sent together pay the +balance once and the second is told there's nothing left. Saving the payout +details holds the driver's profile, because the first save has no payout row +to hold yet. Saving a driving +application or uploading one of its documents holds the application, so +neither can land on one that was handed in a moment earlier. + +Changing a password ends the account's other sign-ins. A refresh token asks +for no password, so `account.Grants` removes every grant in the authorization +server's table except the one the request came with, and closing an account +removes them all. An access token already issued isn't recalled: it's checked +by its signature and runs out on its own. + +A ride's two parties see each other's display name, and each other's phone +number while they're meeting. The e-mail address an account signs in with is +its username, and `Rides.toDto` gives it only to its owner and to an admin. A +fare counts toward a driver's balance once the ride is paid for, in cash or by +a card whose charge came back, and a tip paid by card once its charge is +recorded. A charge that failed, was refunded or is still pending isn't money +the driver can cash out, and a refund takes the tip back with the fare. The +earnings screen's totals for the day, week and month count the same money as +the balance, so a refunded or unpaid ride counts as a trip and earns nothing. + +A statement that changes rows in bulk leaves the session unchanged, so an +entity read before it keeps the values it had. Every move of a ride is marked +`@Modifying(clearAutomatically = true)`, which empties the session after the +statement, so a `Ride` read before a move is read again after it. The services +do that wherever they go on to describe the ride. + +==== Fares, matching and the sweep + +`Rides.request` fixes the fare when the ride is created, from the server's own +prices in `Fares`. The distance the app sends is believed only while it's +plausible for the two points. The driver's share is fixed at the same moment. + +`Matcher` picks the driver. It selects drivers who are online, reported a +position in the last minute, are approved, aren't blocked, have no ride in hand, +drive the kind of car asked for and meet the ride's options. It takes the +nearest one who hasn't already passed on this ride. The search covers 3 km for +the first twenty seconds and 8 km after that. + +`Rides.tick` runs every two seconds as a scheduled job. It takes back offers +whose time ran out and searches again for every ride still waiting. Each ride +is dealt with in a transaction of its own, so one ride that fails doesn't cost +the others their turn. The job carries a lock name, so with several server +processes only one runs it at a time. <> covers scheduled +jobs. + +When a driver completes a ride, `Payments.settle` charges it. + +=== Live updates + +`LiveEndpoint` is a WebSocket at `/ws/live`. The server pushes a short JSON +frame when a ride changes state, when the driver of a ride moves, and, to +admins, when any car online moves. `LiveChannel` in the app receives them. + +A WebSocket request from a browser can't carry a bearer token in a header. The +app therefore first asks `POST /api/live/ticket`, which is a normal call that +carries the bearer token, for a ticket: a random value that works once, for thirty seconds. It then +opens the socket with the ticket in the address. The server stores only a hash +of each ticket. + +A phone whose app isn't open hears of the same changes through push +notifications, which <> covers. + +The screens don't depend on the socket. Each also asks the server for the +current state every few seconds, so a dropped connection delays an update and +doesn't lose it. + +NOTE: `LiveHub` keeps the open connections in the memory of one process. With +several server processes, a change made on one isn't pushed to the sockets held +by another, and those clients catch up on their next poll. Pushing across +processes needs a channel between them, such as PostgreSQL's `LISTEN` and +`NOTIFY` or a message broker. The sample has none. + +=== The app + +`Wayline` is the app's main class. It applies the look and the language, starts +the session and hands over to `Nav`. + +[cols="1,3"] +|=== +| Class | What it does + +| `Nav` +| Chooses the first screen from the roles `/api/me` returns: `AdminForm`, + `DriverForm`, `ApplyForm` for someone who has applied to drive, or + `RiderForm`. It remembers the mode a user with several roles was last in. + +| `net.Session` +| Signs in and out and keeps the tokens in secure storage. `net.Api` holds the + generated clients, and `net.Net` adapts their callbacks. + +| `AppConfig` +| The server's address. It's the page's own origin in a browser, the address + saved from the welcome screen otherwise, and `http://localhost:8080` when + neither is there. + +| `map.MapStage` +| A map filling the screen with the app's controls floating over it: a bar at + the top, a chip for one short fact, round buttons and a bottom sheet. The + rider's, the driver's and the live-map screens are all built on it. + +| `map.Maps` +| Creates the map and draws what goes on it: the pins, the car and the route. + +| `notify.Notifications` +| Registers for push notifications and tells the server which phone the + signed-in user is on. See <>. + +| `rider.RideCard` +| The ride on the lock screen while a driver is on the way. See + <>. + +| `driver.Shift` +| Keeps an online driver's position going to the server with the app in the + background. See <>. + +| `ui.Look`, `ui.Lang`, `ui.Layouts` +| The theme, the language and the layout for the width of the window. + +| `admin.Console` +| The admin screens side by side, for a wide window. +|=== + +The colors and the corner radius are CSS variables in the first block of +`theme.css`, each with a dark twin, and every style in the file is written in +terms of them. The style names all start with `Wl`. + +The text on every screen is English in the source, and the English text is the +key of the translation. `src/main/l10n` holds one `Bundle_.properties` +for each language. + +`Layouts.wide()` is true in a window at least 165 mm wide. `AdminForm` shows +the phone console below that width and `Console` above it, and switches when a +window is resized across it. Screens that have no wide layout are kept to a +column 150 mm wide. On the desktop the `nativeTheme=native` build hint gives +the app the operating system's own look. + +=== Tests + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.sh[tag=wayline-sh-tests,indent=0] +---- +<1> The server's tests on the JVM. They cover access by role, accounts, driving + applications, matching, the ride state machine, a whole ride, payments + against the simulated provider and against stand-in answers for Stripe, + pricing, statistics, moderation, the live socket, the push notifications + each step of a ride sends and the browser sign-in. +<2> The same tests again with the server compiled to a native binary, which is + how it ships. + +<> covers the server test support. + +=== Your project's names + +The Initializr generates the project under the name and package you choose: + +* The Java package becomes yours. +* `wayline` in artifact ids and configuration keys becomes your project's name + in lower case. `wayline.fare.baseCents` is `myapp.fare.baseCents` in a project + called `MyApp`. +* `WAYLINE` in environment variable names becomes the name in upper case. +* The main class takes the project's name. + +This chapter uses the name Wayline. In your project read `wayline` as your +own. + +=== Making it yours: A ride-sharing service + +Most of a ride-sharing product is already here. What changes is the identity, +the prices and the rules. + +==== Name, icon and colors + +The name and the package are chosen in the Initializr. After that they're in +`common/codenameone_settings.properties`, as `codename1.displayName` and +`codename1.packageName`, and the icon is `common/icon.png`. Text that names the +service on screen, such as the title of the welcome screen, is ordinary +translated text: search the bundles in `src/main/l10n` for it. + +The colors are the variables at the top of `theme.css`. Changing the brand +color and its dark twin recolors every button, link and selection: + +[source,css] +---- +include::../demos/common/src/main/css/guide-snippets-theme.css[tag=wayline-css-brand,indent=0] +---- + +The map's pins, car and route take their colors from the `WlMarkerPickup`, +`WlMarkerDropoff`, `WlMarkerCar` and `WlRoute` styles in the same file. + +==== Products and prices + +The prices start as settings and are the ones in force until an admin saves +the pricing screen, which writes them to the database. To start a deployment with your own, set them +in `backend/application.properties`. The amounts are in the currency's smallest +unit: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.properties[tag=wayline-properties-fares,indent=0] +---- + +The currency is a setting only. Rides already taken are recorded in it, and +changing it doesn't convert them. + +A fare is the base fare plus the distance and time charges, multiplied for the +kind of ride and for surge, and never less than the minimum. The service fee is +a percentage added on top for the rider. The commission is a percentage of the +fare kept from the driver. `Fares.price` is the arithmetic, and it's the place +for a different pricing model, such as fixed prices between zones or a price +for each stop. + +Surge is one multiplier an admin sets by hand. Nothing raises it when demand is +high. + +The three kinds of ride are named in several places, and adding or removing one +touches each: + +* `Fares.PRODUCTS`, and the multiplier for each in `Fares.price`. +* `Rides.PRODUCT_TEXT`, the name, description and seats the rider is shown. +* The `comfort` and `xl` fields of the `Pricing` entity and their columns in + `pricing`, `PricingDto` and `PricingForm`, if the new kind has a + multiplier an admin can set. +* The picker in `ApplicationForm`, where a driver says which kind their car is, + and `Ui.product`. +* The icon chosen in `RiderForm`. + +==== Matching rules + +How long a driver has to answer and how long the search goes on are settings: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.properties[tag=wayline-properties-matching,indent=0] +---- + +The search distances and the minute after which a silent driver counts as gone +are constants at the top of `Matcher`. The rule itself is in three places: the +query in `DriverStateRepository.freeInBox`, which finds the free, approved +drivers inside a box, `Matcher.nearby`, which keeps those within the radius +who fit the ride and orders them by distance, and `Matcher.pick`, which takes +the nearest who hasn't passed on the ride. A different rule, such as the +driver who has waited longest or the best rated within reach, replaces the +ordering there. Nothing else in the server knows how a driver was chosen. + +`DriverStateRepository.freeInBox` compares latitudes and longitudes in a bounding +box. That's right for one city on any of the three databases. A service with +tens of thousands of drivers online would move that query to a spatial index. + +==== What a driver has to provide + +The five documents are the `KINDS` array in `Applications` on the server and +the list of cards in `ApplicationForm` in the app. Add a kind to both, such as a +vehicle inspection or a background check, and the review screen shows it. The +fields of the form are fields of the `DriverApplication` entity, columns of +`driver_application` and components of `DriverApplicationDto`. +<> walks through adding one. + +The server checks that an uploaded file is a JPEG or a PNG and under 1.5 MB. +It doesn't read the documents. Whether a license is real is the admin's +judgment, or a verification service you call from `Applications.submit`. + +==== Text messages + +A development server prints verification codes on its console. Any other +server sends them through Twilio, Vonage or MessageBird, and needs an account +with one of them. The settings, the limits and how to add another provider +are in <>. + +==== Languages + +To add a language, add `Bundle_.properties` to `src/main/l10n`, add the +code to `Lang.CODES` in the app, and add it to the `LANGUAGES` array in +`Preferences` on the server, which refuses a language it doesn't know. A +language written right to left sets the `@rtl` key in its bundle, as the Hebrew +one does. + +==== Before it's a product + +These are things the sample doesn't have, and a ride-sharing product needs: + +* Real payments, payouts to drivers, and tax. See <>. +* An account with a text-message provider. See <>. +* A keyed digest for phone codes. `PhoneVerification` stores a plain SHA-256 + of the account and the code. Somebody who can read the `phone_code` table + can try all million codes within the five minutes a code lasts. Use an HMAC + under a server secret that isn't kept in the database. +* Sturdier push cleanup. A sign-out that can't reach the server leaves the + phone registered to that account until somebody signs in on it again. A + rider who signs out during a ride keeps its Live Activity until the system + removes it. One Live Activity is kept per ride, so a rider following a ride + on two devices sees the second one updated, and the first one not. +* Position reports while the driver's app is in the background. +* A cancellation fee, scheduled rides, shared rides and promotions. +* A check that the rider asking for a woman driver is one. `Matcher` filters + the drivers by the gender in their profile and doesn't look at the rider's. +* Statistics by local time. The dashboard's day and its busy hours are in UTC. +* TLS. The Android build allows clear-text traffic so that a phone can reach a + development server. Remove `android.xapplication_attr` from + `codenameone_settings.properties` once the server has a certificate. + +=== Making it yours: A dispatch service for a taxi station + +A station with twelve cars and a dispatcher is a smaller service with different +rules. The drivers are known, bookings arrive by phone as well as from the app, +a person decides who takes which ride, and most rides are paid in cash or put +on a company's account. The sample covers the riders' app, the drivers' app, the +fares, the receipts and the console. The rest is described here, with the class +each change belongs in. + +==== A fixed fleet + +Nobody applies to drive. An admin makes an account a driver directly: the +**Driver** switch on an account in **People** calls `Accounts.setRoles`, which +gives the role and records an approved application for it. A driver made this +way is of the standard kind and has no car details. To record each car, fill in +the application's vehicle fields from an admin screen of your own. + +To close the open door: + +* Remove **Drive with Wayline** from the rider's menu in `RiderForm`, the + **Drive** choice from `RegisterForm`, and `ApplyForm` from `Nav`. +* Decide whether the public may register. If only staff create accounts, take + **Create an account** off `WelcomeForm` and require the admin role for the + registration path, as <<_a_dispatcher,A dispatcher>> shows. + +==== A dispatcher + +A dispatcher needs more than a driver and less than an admin, which is a fourth +role. Add it to `Roles`, to the role switches in `Accounts.setRoles` and +`UsersForm`, and give its paths a rule of their own in the API chain that +<> shows. Above the rule for `/api/driver/**`, add one that +admits the dispatcher and the admin to the dispatcher's paths: +`.requestMatchers("/api/dispatch/**").hasAnyRole(Roles.DISPATCHER, Roles.ADMIN)`. +If only staff create accounts, the rule for registration changes too, from +`permitAll()` to `hasRole(Roles.ADMIN)`. + +Then declare the dispatcher's calls in a new `@RestClient` interface in +`shared`, such as `DispatchApi`, and implement the interface the server's build +generates from it. + +==== Assigning a ride by hand + +Automatic matching is two calls to `Rides.search`: one at the end of +`Rides.request` and one for each waiting ride in `Rides.tick`. Remove both and a +requested ride waits in `REQUESTED` until somebody assigns it. Keep the part of +`tick` that takes back unanswered offers. + +Assigning is the same move the matcher makes, with the driver chosen by a +person. This is the transaction in `Rides` that makes it for the matcher: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Rides.java[tag=wayline-ride-offer,indent=0] +---- + +An `assign` method for the dispatcher is a pair, the way the other moves are. +Its private `@Transactional` half does what `offer` does with the driver the +dispatcher picked, holding the driver first and calling `tryMove` with +`RideState.OFFERED`, the driver and the time the offer lapses. Where `offer` +answers null, `assign` answers 409, so a dispatcher who picked a busy driver, +or a ride that somebody else assigned first or that the rider cancelled, is +told. The public half runs it through `attempt` and passes the `Change` it +returns to `tell`, as `cancelByRider` does. `tryMove` records the event in the +ride's history, and `tell` has the driver's screen show the offer at once. The +driver's app needs no change: an offer is an offer whoever made it. + +Two settings matter once a person does the matching. `wayline.search.seconds` +is how long a ride may wait before it becomes `NO_DRIVERS`, and ninety seconds +is too short for a dispatcher on the phone. `wayline.offer.seconds` is how long +the driver has to answer. + +The dispatcher's screen is a list of waiting rides beside the live map. Both +exist: `AdminRidesForm` lists rides, `FleetForm` shows the cars, and `Console` +shows how a list and a detail sit side by side in a wide window. + +==== Bookings taken by phone + +`Rides.request` takes the rider's account name as its first parameter, and the +rider's endpoint passes the caller. A dispatcher's endpoint passes the customer +instead, so a booking entered by staff is the same ride with the same fare +rules. Two checks in that method are written for riders booking for themselves +and need a decision: + +* The rider's phone number must be verified. A customer the dispatcher is + speaking to has shown they hold the number, so staff-created accounts can be + marked verified. +* A rider may have one ride under way. That's right for a customer account and + wrong for one shared account that stands for everyone who phones in. Give + each caller an account keyed by their phone number, or lift the check for + bookings made by staff. + +The pickup and the destination come from the same place search the rider's app +uses, `GeoApi`, so the booking form can reuse `PlaceSearchForm`. + +==== Cash and accounts + +Cash is already a payment method. It's the default for an account with no saved +card, the driver's screen says how much to collect, and the ride is recorded as +paid. A station that takes no cards removes **Add a card** from `WalletForm` and +never configures a payment provider. + +Riding on account, where a company is invoiced at the end of the month, is a +method the sample doesn't have. Cash shows the pattern: `Payments.CASH` is a +method id that `Payments.settle` records as paid without asking the provider. +An account method does the same and records which customer to invoice, which +needs a table for the customers and a column on the ride. They go in a new +migration, numbered after the last one the project has, with an entity for the +table and a field on `Ride` for the column, as <> +describes: + +[source,sql] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.sql[tag=wayline-sql-table,indent=0] +---- + +Producing the invoice from those rows is yours to write. + +`Earnings` needs a decision too. It credits a driver with the fare less +commission for every completed ride, cash rides included, as if the service had +collected the money. For drivers who keep the cash they collect, subtract cash +fares from the balance, or the ledger says the station owes money it never +held. For drivers on a wage, remove `EarningsForm` from the driver's menu. + +==== What to switch off + +[cols="2,3"] +|=== +| Feature | Where + +| The service fee and commission +| Set both percentages to zero, in the settings or on the pricing screen. + +| The larger kinds of ride +| Leave one entry in `Fares.PRODUCTS` and `Rides.PRODUCT_TEXT`. + +| Surge +| Leave the multiplier at 1, and remove its field from `PricingForm`. + +| Tips through the app +| Remove the tip row from the end-of-ride sheet in `RiderForm` and from + `ReceiptForm`. + +| Driving applications +| Remove `ApplyForm`, `ApplicationForm` and the **Applications** section of the + console. `DrivingEndpoint` can stay unused or go with them. + +| Ratings +| Remove the stars from the end-of-ride sheet in `RiderForm`. +|=== + +[[wayline-billing]] +=== Billing + +==== What the sample does + +Every payment goes through one interface on the server, `PaymentProvider`: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PaymentProvider.java[tag=wayline-payment-provider,indent=0] +---- + +The shape is the one card processors share. A rider is a customer of the +processor. A card is saved to that customer once and comes back as a token. From +then on the server charges the token with nobody present, which is what lets a +ride be paid for as it ends. The server stores the token, the brand and the +last four digits. + +`Payments` is the service that uses the interface. It keeps the saved methods, +charges the fare and the service fee when a ride completes, charges a tip once, +refunds, and builds receipts. Every charge carries a key made from the ride's +id, so a request repeated after a timeout can't charge twice. + +A ride completes in one transaction and its payment is written in another, +with the charge between them. A server that stops between the two leaves a completed +ride whose payment is still pending. `Rides.settleLeftBehind` runs every minute, +finds such rides once they're two minutes old, and settles them. Because the +charge is keyed by the ride, a card that was already charged isn't charged +again. + +A charge can also fail without saying whether the card was charged: the +provider wasn't reached, or its answer was lost. Only a `PaymentDeclinedException` +is recorded as a failed payment. Any other failure leaves the ride pending, and +the same pass asks again every minute for 23 hours, which is inside the day a +provider keeps a charge's key. After that the ride stays pending for a person +to look up. A tip works the same way: a declined card takes the tip off the +ride, and any other failure leaves it there with no charge recorded, so the +rider can send the same tip again under the same key. The ride can't be +refunded until then. + +A refund can also be taken without being finished. Stripe answers some +refunds `pending` and only later says whether the bank returned the money, and +a pending refund can still end `failed` or `canceled`. `PaymentProvider.refund` +reports such a refund as pending, and the payment and its ride are then +`REFUND_PENDING`, not `REFUNDED`: the receipt says the refund is pending, and the +ride can't be refunded again. `Payments.settleRefunds` runs every minute and asks +the provider about each one through `PaymentProvider.refundStatus`. Once the +fare's refund and the tip's have both succeeded the ride is `REFUNDED`. If either +fails, the ride is `REFUND_FAILED` and isn't tried again: part of the money may +have gone back, so it takes a person with the provider's records to settle it. +A ride in either state earns its driver nothing. + +While a ride's charge is being asked about, the card it was sent to can't be +removed and the account can't be closed: `Payments.remove` and +`Payments.forget` answer 409. The retry looks the card up by its token, so +with the card gone it couldn't ask. + +Closing an account also deletes the rider's record at the provider, through +`PaymentProvider.forgetCustomer`, in the same transaction that deletes the +rest. A provider that can't be reached fails the closure and leaves the +account as it was. A record that's already gone counts as deleted, so closing +again finishes a closure that was interrupted. + +Removing a card works the same way for that one card. `Payments.remove` asks +`PaymentProvider.detachCard` to take the card off the rider's record before it +deletes the row, in the same transaction. A provider that can't be reached +leaves the card saved, and a card that's already detached counts as detached. + +Each card setup is a session made at the provider. An account can have five +open at once, and a sixth is refused with 429 until one is finished or an hour +has passed. `Payments.sweepSetups` runs every ten minutes and deletes the +finished setups and the ones abandoned more than two hours ago. + +The limit of eight cards is checked again when a setup finishes, because +several setups can start while there's room for one. By then the provider has +already attached the card, so when the limit refuses it, `Payments` detaches +it with `PaymentProvider.detachCard` after the refused transaction has rolled +back. If that detach fails, the server logs the card's token and still answers +409. + +Two implementations come with the sample, and the setting +`wayline.payments.provider` chooses between them: + +* `simulated` is `SimulatedPayments`. It accepts any card number that passes + the usual checksum, and treats the test numbers listed in + <> specially. The card is typed into the app. No money + moves. +* `stripe` is `StripePayments`, a starting point over Stripe's REST API that + hasn't been run against a Stripe account. <<_turning_on_stripe,Turning on + Stripe>> says what it does and what to check. + +Without the setting, a development profile (`dev`, `test` or `local`) gets +`simulated` and any other profile gets `stripe`. A provider that can't be set +up isn't replaced with anything. With `stripe` chosen and its key missing, or +with a name that's neither, the server starts, logs a configuration error that +names what to set, and logs it again on every payment call, each of which +fails. The rider is told payments are unavailable, and no ride is charged to a +card nobody can reach. A production server never falls back to simulated +cards. + +==== Turning on Stripe + +Choose Stripe, set the key in the server's environment, not in a file in the +repository, and tell the server the address it's reached at: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.properties[tag=wayline-properties-stripe,indent=0] +---- + +The first line is what a production profile chooses anyway, and a development +profile needs it to use Stripe at all. The second reads the key from the +environment variable `WAYLINE_PAYMENTS_STRIPE_SECRET`. The server reads that +variable for this setting without the line as well, since every setting is +read from the environment under its name in upper case with an underscore for +each dot. The third line is the address Stripe sends the browser back to. It's +needed only when that address differs from the issuer, which the server uses +otherwise. With the key or both addresses missing, every payment call fails +with the configuration error described above. + +`StripePayments` calls Stripe's REST API directly and uses no SDK: + +* Adding a card creates a Checkout Session in setup mode. The app opens the + session's page in the device's browser, so the card number is typed into + Stripe's page and never reaches the app or your server. +* Stripe sends the browser back to `/pay/return` on your server, a plain page + from `PayReturnPage` that tells the user to return to the app. When the app + comes back to the foreground, it asks the server to finish the setup, and the + server reads the saved card from the session. +* A ride is charged with a PaymentIntent that's confirmed at once and marked + off-session, with the ride's key as the idempotency key. +* A refund is a Stripe refund of that PaymentIntent. One that Stripe answers + `pending` or `requires_action` is read again with `GET /v1/refunds/{id}` + until it succeeds, fails or is canceled. +* Removing a card detaches its PaymentMethod with + `POST /v1/payment_methods/{id}/detach`. If Stripe refuses, the server reads + the PaymentMethod, and one that's no longer attached to the rider counts as + detached. + +WARNING: The tests cover this path with stand-in answers in place of Stripe's. +Nothing in the project has run against a Stripe account. Use a test-mode key +and Stripe's test cards, and follow one card from adding it to a charge, a tip +and a refund, before a live key goes near it. + +==== What's left to do + +The sample stops well short of a billing system. In rough order of need: + +Webhooks:: The server learns the result of a charge from the response to its +own request and listens for nothing. A dispute, a refund made in Stripe's +dashboard, or a payment that settles later is never seen. Add a controller for +Stripe's events, verify each event's signature, and update `payment` from +it. + +Cards that need the owner's approval:: A bank may require authentication for an +off-session charge. `StripePayments` treats that answer as a decline. A product +brings the rider back into the app to approve the payment, and tries again. + +Failed payments:: When the charge at the end of a ride fails, the ride is +marked as failed to pay and the failure is logged. Nothing retries it, asks for +another card, or stops that rider from asking for the next ride. Decide the +policy and put it in `Payments.settle` and `Rides.request`. + +Holding the fare first:: The sample charges once, at the end. Placing a hold on +the card when the ride is requested, and capturing it at the end, catches a +card with no funds before the driver has driven. + +Paying drivers:: Earnings and payouts are a ledger. **Cash out** writes a row +that says the balance was paid, and no money leaves. Paying drivers needs a +product built for it, such as Stripe Connect, where each driver is a connected +account that passes the processor's identity checks and receives transfers. The +alternative is a payment file for your bank produced from `payout`. The bank +details the sample stores are a holder's name, a bank's name and four digits, +which is enough to show on a screen and not enough to pay anyone. + +Tax and invoices:: A receipt lists the fare's parts and the total. There's no +tax line, no invoice number and no document to download. What a receipt must +show depends on where you operate. + +==== Another payment provider + +A second processor is a second class that implements `PaymentProvider`. +`StripePayments` shows what a provider with a page of its own does where the +simulated one takes a typed card. Its setup creates a session on the +provider's side and hands back the address of the provider's page: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/StripePayments.java[tag=wayline-payment-hosted,indent=0] +---- + +The app opens the provider's page for any setup that says it's hosted, so it +needs no change. `Services` is where the server picks an implementation from +`wayline.payments.provider`, and a new provider is one more name there: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Services.java[tag=wayline-payment-choice,indent=0] +---- + +The name a provider returns from `name()` is what the app is told, and is how +it knows whether to show a card form or open a page. + +A provider that takes the card inside the app through its own mobile SDK is a +bigger change. The setup then happens in native code in the app, and the server +is sent a token, not a typed card. + +[[wayline-sms]] +=== Text messages + +A phone number is verified with a six-digit code sent by text message. +`PhoneVerification` makes the code and checks it, and hands the sending to an +`SmsSender`, an interface of two methods: `send(phone, text)` and `delivers()`. + +The setting `wayline.sms.provider` chooses the implementation, and +`Services.smsSender` builds it: + +[cols="1,2,3"] +|=== +| Value | Class | Settings it needs + +| `console` +| `ConsoleSmsSender` +| None. It writes `[sms] to`, the number and the text to the server's output, + and answers `false` from `delivers()`. Only a development profile (`dev`, + `test` or `local`) may use it. + +| `twilio` +| `TwilioSmsSender` +| `wayline.sms.twilio.sid`, `.token` and `.from`: the account SID, the auth + token and the number the messages come from. + +| `vonage` +| `VonageSmsSender` +| `wayline.sms.vonage.key`, `.secret` and `.from`: the API key, the API secret + and the sender name or number. + +| `messagebird` +| `MessageBirdSmsSender` +| `wayline.sms.messagebird.key` and `.originator`: the access key and the + sender name or number. +|=== + +Without the setting, a development profile gets `console` and any other +profile gets `twilio`. The development settings say so outright: + +[source,properties] +---- +include::../../scripts/wayline/backend/application-dev.properties[tag=wayline-properties-dev-providers] +---- + +==== Turning on a provider + +Name the provider and give it its settings, with the secrets read from the +environment: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.properties[tag=wayline-properties-sms] +---- + +The server reads every setting from the environment as well, under its name in +upper case with an underscore for each dot, so `wayline.sms.vonage.secret` is +also `WAYLINE_SMS_VONAGE_SECRET`. Keep keys, tokens and secrets there and not +in a committed file. Nothing in the app changes when the provider does. + +A provider that can't be set up isn't replaced with the console. With a +setting missing, with `console` asked for on a production profile, or with a +name that's none of the four, the server uses `MisconfiguredSmsSender`. It +logs a `[config]` line naming what to set when the server starts and again on +every message, and every message fails, which the verification answers with +503. A server whose provider was forgotten verifies nobody, rather than +everybody. + +On a development profile with the console sender, the server also returns the +code to the app, which shows it on the verification screen so a demo needs no +phone. With any other sender, or on any other profile, the code is never +returned. + +==== What limits a code + +These are in `PhoneVerification` and `SecurityConfig`: + +* A code is good for five minutes, `CODE_SECONDS`. +* Five wrong entries, `MAX_ATTEMPTS`, spend the code, and the user asks for + another. The count is one statement in the database, + `PhoneCodeRepository.takeGuess`, which also applies the limit, so guesses + sent together can't get past it. +* Another code for the same account within 30 seconds, `RESEND_SECONDS`, is + answered 429. +* `SecurityConfig` allows `/api/account/phone/**` ten requests in ten minutes + for each signed-in user. +* Only a hash of the code is stored, in `phone_code`. A new code doesn't + void the one before it, which stays good until it would have expired: + people ask again when a message is slow, and text messages aren't delivered + in the order they were sent, so the first code can arrive last. At most two + codes are good at once, and entering either one uses up both. The earlier + code keeps the guesses it had left rather than getting five more. +* The code is stored and committed before the message is sent. When the + provider refuses the message, the code is discarded and the request is + answered 503. The code sent before it, while still good, remains the one + to enter. + +The ten-in-ten-minutes limit uses an `InMemoryRateLimiter`, so it's counted in +each server process. The other limits are in the database and hold across +instances. + +==== Another provider + +The interface is all a provider has to implement: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/SmsSender.java[tag=wayline-sms-sender,indent=0] +---- + +Another provider is another class, with `delivers()` answering `true`. The +three that come with the sample have one shape: a form posted to the +provider's API. `VonageSmsSender` is the shortest: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/VonageSmsSender.java[tag=wayline-sms-provider,indent=0] +---- + +`send` throws an `IOException` when the provider refuses the message, and +`PhoneVerification` turns that into the 503. `Services.smsSender` then needs +one more name for `wayline.sms.provider`, built the way the Vonage one is: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Services.java[tag=wayline-sms-choice,indent=0] +---- + +A message costs money and a number can be anyone's, so before going live +decide which countries you send to and add that check to `Accounts.phone`, +which accepts any number of seven to fifteen digits. + +[[wayline-push]] +=== Push notifications + +The live channel tells an app that's open. A push notification is for the phone +in a pocket: the rider whose driver has arrived, the driver who has been +offered a ride. The server sends them through the Codename One push service, +which delivers to Firebase, APNs and Huawei, and the app receives them with +`PushClient`. <> covers the push service and the +Console, and <> the server's side of the API. + +==== The app registers + +Each phone registers with its platform's push service when the app comes to the +foreground, and hands the token it gets to the server for whoever is signed in. +Signing out takes it back first, so the next person to use the phone isn't told +about the last one's rides. That's `Notifications` in the `notify` package: + +[source,java] +---- +include::../../scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/notify/Notifications.java[tag=wayline-push-register,indent=0] +---- + +The two calls are part of `AccountApi`: + +[source,java] +---- +include::../../scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AccountApi.java[tag=wayline-push-contract,indent=0] +---- + +A token is the provider's own, so the server stores it with the provider's name. +`PushDevices` keeps one row per phone: a phone that changes hands moves to the +account that signed in on it last, and an account keeps its eight most recent +phones. Closing an account removes them all. + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/PushDevices.java[tag=wayline-push-devices,indent=0] +---- + +A tapped notification opens the app on the screen that shows the ride, which +then reads the ride from the server as it does when it's opened any other way: + +[source,java] +---- +include::../../scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/notify/Notifications.java[tag=wayline-push-listener,indent=0] +---- + +The app registers only on Android and iOS devices, and only with its push key +set. The simulator has no push service to register with, and a page in a +browser hears everything over the live channel while it's open. + +[source,java] +---- +include::../../scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/AppConfig.java[tag=wayline-push-key,indent=0] +---- + +==== What the server sends + +`Rides` tells `RidePushes` of every change to a ride once the change is +committed, at the same moment it tells the live channel: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/RidePushes.java[tag=wayline-push-ride-changed,indent=0] +---- + +A notification is a courtesy on top of what the app reads from the server, so +nothing that goes wrong sending one fails the change it's about. `RidePushes` +decides who hears what: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/RidePushes.java[tag=wayline-push-messages,indent=0] +---- + +Every message carries the ride's id, its state and whether it's for the rider or +the driver, so the app knows which screen to open. Each ride and role has its +own collapse key, so a phone that was out of reach shows the latest step and not +every one. An offer to a driver lives only as long as the offer, fifteen seconds +by default: one delivered later would offer a ride that's gone. + +The switches under **Notifications** in the app's settings decide what's said. A +switch that's off silences the notification and nothing else, so the lock screen +still follows the ride: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/RidePushes.java[tag=wayline-push-preferences,indent=0] +---- + +==== Turning on the push service + +`wayline.push.provider` chooses how notifications leave the server, and +`Services.pushSender` builds the sender: + +[cols="1,2,3"] +|=== +| Value | Class | What it does + +| `cloud` +| `CloudPushSender` +| Sends through the Codename One push service. It needs `wayline.push.app`, the + application UUID of the push application, and `wayline.push.key`, a server + API key with the `push` scope. Both come from the Push section of the Console. + +| `console` +| `ConsolePushSender` +| Writes `[push]`, the number of devices and the message to the server's + output. Only a development profile may use it. + +| `recording` +| `RecordingPushSender` +| Keeps every message for the tests to read. Only a development profile may use + it, and the test profile does. +|=== + +Without the setting, the test profile gets `recording`, the other development +profiles get `console` and any other profile gets `cloud`: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Services.java[tag=wayline-push-choice,indent=0] +---- + +To turn it on, give the server the two values, with the key read from the +environment: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.properties[tag=wayline-properties-push] +---- + +Then put the push key the Console shows for the app in `AppConfig.PUSH_APP_KEY`, +and configure each platform's credentials in the Console as +<> describes. + +IMPORTANT: Push on Android starts switched off, so the generated project +builds for Android before you have a Firebase project. An app that uses +`PushClient` needs `google-services.json` from Firebase, +`agconnect-services.json` from Huawei, or both, in the Android native files +as <> describes, and the build stops with an +error without them. Until then `codenameone_settings.properties` carries +`codename1.arg.android.messagingService=none`, which builds the app without +push. Add the file and delete that line, and Android gets notifications too. + +A server that's missing a setting +still starts and serves rides; the sender it gets instead, `MisconfiguredPushSender`, +logs a `[config]` line naming the setting when the server starts and again for +every notification it can't send. `CloudPushSender` sends on a thread of its own, +so a ride's request never waits on the push service, and it logs a refusal +rather than throwing it: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/CloudPushSender.java[tag=wayline-push-cloud,indent=0] +---- + +To send through another service, implement `PushSender`, one method, and return +it from `Services.pushSender`: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/PushSender.java[tag=wayline-push-sender,indent=0] +---- + +[[wayline-live-activity]] +=== The ride on the lock screen + +While a driver is on the way, the rider's ride shows outside the app: a Live +Activity on the lock screen and on the Dynamic Island of an iPhone, an ongoing +notification on Android, and a small window on the desktop and in the +simulator. A browser shows none. <> covers the API. + +`RideCard` in the `rider` package starts it when a driver accepts, moves it on +with every change the rider screen sees and ends it with the ride. A finished +ride stays on the lock screen for a while with its last state; a canceled one +goes at once. Starting one is a round trip to the system, so it runs off the +event thread, and the activity's id goes to the server: + +[source,java] +---- +include::../../scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/RideCard.java[tag=wayline-ride-card-follow,indent=0] +---- + +The layout is data. The app sends it once when the activity starts, with +`${key}` placeholders the platform fills from the state, so an update is a few +short strings and a number. The keys are constants in `RideActivityKeys` in +`shared`, because the server writes the same state: + +[source,java] +---- +include::../../scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/RideCard.java[tag=wayline-ride-card-layout,indent=0] +---- + +Once the app is in the background the server keeps the activity current. It +records the activity's id against the ride, but only the rider's own ride and +only while a driver is bringing or carrying them: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/RidePushes.java[tag=wayline-push-activity,indent=0] +---- + +Each change of state then carries the activity's new state in the same +notification that announces it, and the end of the ride ends the activity. In +between, the driver's position reports move the minutes on in a silent message, +no more often than `wayline.push.activity.seconds`, 45 by default. A conditional +update of the ride's row decides whether one is due, so two servers receiving +reports at once send one update between them. + +The app's push client applies such a message when the Codename One runtime +receives it, before the listener runs. Where the platform doesn't start the app +for a push in the background, the message waits and is applied when the app +next starts or resumes. Apple's own route for a server to update a Live +Activity, the ActivityKit push token, isn't used, so on an iPhone the activity of +an app iOS has suspended is only as current as the last message the app +received. While the app is open, the rider screen keeps it exact itself. + +The app declares the Live Activity in `surfaces.json`, in `src/main/resources` +of `common`: + +[source,json] +---- +include::../../scripts/wayline/common/src/main/resources/surfaces.json[] +---- + +`ios.surfaces.frequentUpdates` among the build hints below asks iOS for the +frequent updates a moving car needs. + +[[wayline-background-location]] +=== Driving in the background + +A driver who switches to a navigation app or locks the phone is still driving, +and the server stops offering rides to a driver it hasn't heard from for a +minute. While a driver is online, `Shift` in the `driver` package keeps the +position going to the server with the app in the background: + +[source,java] +---- +include::../../scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/Shift.java[tag=wayline-shift,indent=0] +---- + +`DriverForm` begins the shift when the server says the driver is online and ends +it when they go offline; signing out and switching to the rider's side end it +too. Reports go through the same call the driver screen makes in the foreground, +so the server needs nothing new. They're spaced by distance and time, 25 meters +or twenty seconds, which is what keeps the battery cost down: the car's position +changes far more often than the server needs to hear of it. + +`Locator` asks the platform for the precise, frequent fixes a driver needs only +while a shift is on, and lets go of location altogether when the app is in the +background without one: + +[source,java] +---- +include::../../scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/Locator.java[tag=wayline-locator-track,indent=0] +---- + +[cols="1,3"] +|=== +| Platform | How it keeps running + +| iOS +| The `location` background mode. iOS asks the driver for permission to use the + location while the app isn't in use, with the text of the + `NSLocationAlwaysAndWhenInUseUsageDescription` build hint. + +| Android +| A foreground service of the `location` type, with the **You're online** + notification Android requires while it runs. + +| The simulator, the desktop and a browser +| None. Reports stop while the app isn't in front. +|=== + +These are the build hints the three features need. The project already has +them in `common/codenameone_settings.properties`: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.properties[tag=wayline-properties-build-hints] +---- + +Both app stores review background location. Apple asks what the location is +used for while the app is in the background, and Google Play asks for a +declaration and a video of the feature in use. A driver app has a good answer, +and the usage description should give it in the driver's terms. + +[[wayline-external-signin]] +=== Signing in through another identity provider + +Out of the box the server is its own authorization server: accounts and +passwords are in its database, and the tokens the API accepts are the ones it +signs. A product often wants people to sign in with an account they already +have, at Google, Microsoft Entra ID, Auth0 or a Keycloak of its own. There +are two ways to do it, and they differ in who issues the token the API sees. + +[cols="1,2,2"] +|=== +| | Beside the built-in server | Instead of it + +| Who signs the user in +| The provider, or Wayline's own password form +| The provider only + +| Whose token the API accepts +| Wayline's, as now +| The provider's + +| Where the roles are +| Wayline's database, as now +| Wayline's database, or a claim in the token + +| What changes +| The sign-in chain, and how the app starts a sign-in +| The API chain, the app's sign-in, registration and the password screens +|=== + +The first is the smaller change and keeps password accounts working. Choose +the second when the organization already manages its people, and their roles, +in the provider. + +==== Beside the built-in server + +The server keeps issuing its own tokens and leaves it to the provider to +identify the user. On the server that's `oauth2Login` on the sign-in chain, with the +provider's client id and secret in the configuration: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-security.properties[tag=backend-security-properties-oauth2,indent=0] +---- + +A registration called `google` or `microsoft` needs only the id and the +secret. Auth0 and Keycloak are described by a provider block with their +`issuer-uri`, as the `acme` registration is. The secret belongs in the +environment. + +Wayline's accounts are named by e-mail address, which is what +`LinkingOAuth2UserService` matches on: a person whose provider vouches for +the address of an existing account signs in as that account, with its roles. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/SocialLoginConfig.java[tag=backend-security-oauth2-linking,indent=0] +---- + +In Wayline the `oauth2Login` call goes on the `signIn` chain in +`SecurityConfig`, beside `formLogin`. <> covers +the registration keys and the linking rules. Two things are particular to +Wayline: + +* An account has a row in `profile` as well as a sign-in, and + `Accounts.register` writes both. With `setCreateUsers(true)` the linking + service creates the sign-in alone. Either leave it `false` and send new + people through the app's registration first, or create the profile for a + user who arrives without one. +* The redirect address to register with the provider is the server's: + `/login/oauth2/code/` and the registration's name, on the issuer's `https` + address. It's one address for every platform, because the provider only + ever redirects to the server. + +In the app, `Session.authenticate` posts the e-mail address and password to +`/login` and then asks for the code itself, with no browser. A provider's +sign-in page has to be shown, so that path changes to the browser flow of +`OidcClient`. `Session` already builds the client with `OidcClient.create`. +The sign-in becomes a call to `authorize()`: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/BackendSignInSnippets.java[tag=backend-signin-authorize,indent=0] +---- + +The redirect address of that flow is the app's, and it differs by platform: + +[cols="1,3"] +|=== +| Where the app runs | Redirect address + +| iOS and Android +| An address in a scheme of the app's own, such as + `com.example.rides:/oauth2redirect`. Declare the scheme with the + `ios.urlScheme` and `android.xintent_filter` build hints, as + <> shows. + +| The hosted browser app +| `/signin/code` on the server's own origin, `AppConfig.WEB_REDIRECT_PATH`, + which the server already registers. + +| The simulator and the desktop app +| An `https` address. The browser window closes when it reaches it. +|=== + +Each address the app uses has to be on the registered client. Add the scheme +address with another `redirectUri` call where `SecurityConfig.clients` builds +the `RegisteredClient`, and return it from `AppConfig.redirectUri()` on a +device. The client is saved only when the database has none with that id, so a +database that already holds the registration keeps the old list until the +stored client is updated. + +==== Instead of the built-in server + +With this approach the provider issues the tokens and the server only +validates them. + +*The app.* Build the client from the provider's metadata instead of from +Wayline's endpoints, with the client id the provider issued: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AuthenticationAndIdentityJava001Snippet.java[tag=authentication-and-identity-java-001,indent=0] +---- + +`OidcClient.discover` works with any provider that publishes OpenID Connect +metadata. Keep the `OidcRequestAuthorizer` that `Session` installs on the +API's address. It attaches the token and refreshes it whatever the issuer is. +The redirect addresses are the ones in the table above, registered with the +provider this time: the scheme address for the installed apps and the `https` +address of the hosted app. Register the app as a public client, with no +secret. + +*The server.* The API chain stops building a decoder from its own keys and +validates the provider's tokens: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-security.properties[tag=backend-security-properties-jwt,indent=0] +---- + +Set `audiences` to the identifier the provider puts in the access token's +`aud` for your API. In `SecurityConfig.api`, drop the `DefaultJwtDecoder` +built from the signing keys and the `decoder` call, and the chain uses the +one these properties describe. The `signIn` chain, the `clients` bean and +`SignInCodePage` are no longer used. <> +covers the keys, what's verified and a server that trusts several issuers. + +The API needs an access token that's a JWT the server can verify with the +provider's published keys. Check what your provider issues for an API of your +own before choosing this approach. A provider whose access tokens are opaque, +or meant only for its own APIs, fits the first approach. + +*What goes with it.* Registration, the password change and the password +check before deleting an account all work on Wayline's own user store. +With a provider in charge of the accounts, a person's first request has to +create their profile, and the password screens go. + +==== From claims to roles + +The API rules ask for `RIDER`, `DRIVER` and `ADMIN`. Where those come from is +the `JwtAuthenticationConverter` on the API chain, and there are two choices. + +*Keep the roles in Wayline's database.* The converter in `SecurityConfig` +ignores the token's claims and loads the account named by the token's subject +on every request. A provider's subject is an opaque id and not an e-mail +address, so tell the converter which claim names the account with +`setPrincipalClaimName`, and use a claim the provider has verified. Driver +approval, an admin's grant and a block all keep working as they do now, and +they take effect on the next request. + +*Read the roles from the token.* Have the provider put the roles in a claim +and map the claim: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/ResourceServerConfig.java[tag=backend-security-jwt-converter,indent=0] +---- + +With the `ROLE_` prefix, a claim holding `RIDER`, `DRIVER` or `ADMIN` matches +the rules unchanged. `JwtGrantedAuthoritiesConverter` reads a claim holding a +flat list of names. Each provider has its own way to add such a claim to a +token and its own name for it, so configure the provider to send one and +check a token from your own tenant. + +The cost of this choice is timing. A role withdrawn at the provider is still +in every token already issued and holds until the token expires, and the same +is true of a blocked account. Wayline's flows also grant roles themselves: +approving a driving application makes a driver, and the admin screens grant +and withdraw admin. With roles in the provider, those two steps have to change +the roles there, or the driver role has to stay in the database. A reasonable +split is `ADMIN` from the provider and `RIDER` and `DRIVER` from the database, +which a converter of your own can combine. + +[[wayline-analytics]] +=== Analytics + +The app reports how it's used through the framework's analytics API, which +<> documents. Everything it does is in one class, `Telemetry`, so +that's the file to read and the file to change. + +==== Consent + +Nothing is reported until the user agrees. After the first sign-in, +`ConsentForm` asks once, with **Share usage statistics** and **Not now**. +The answer is kept on the device, not with the account, and the **Usage +statistics** switch under **Privacy and safety** in the settings changes it +at any time. `Telemetry` passes the answer to `Analytics.setConsent` with +analytics and crash reporting set to the answer and personalization and +advertising storage always off. <> describes what each of +those gates. + +Deleting the account calls `Telemetry.forget`, which resets the anonymous id +the reports carry. + +==== What's reported + +Every screen reports a screen view. Beyond that the app reports these events: + +[cols="1,2,2"] +|=== +| Area | Event | Parameters + +| Account +| `login`, `sign_up`, `logout`, `phone_verified` +| The method, whether the person meant to ride or drive, and why a session + ended: the user, expiry or deletion + +| Rides +| `ride_quoted`, `ride_requested`, `ride_cancelled`, `ride_completed`, + `rating_given` +| The product, the payment kind, a distance bracket, a fare bracket, who + cancelled and in which state, the number of stars + +| Payments +| `tip_added`, `card_added`, `card_removed` +| Where the tip was added and its share of the fare + +| Driving +| `driver_application_started`, `driver_application_submitted`, + `driver_online`, `driver_offline`, `offer_accepted`, `offer_declined`, + `payout_requested` +| Whether an application is a resubmission, and an amount bracket + +| Administration +| `admin_application_approved`, `admin_application_rejected`, + `admin_user_flagged`, `admin_user_unflagged`, `admin_user_blocked`, + `admin_user_unblocked`, `admin_ride_refunded`, `admin_pricing_changed` +| Which screen a refund was made from + +| Settings +| `language_changed`, `theme_changed` +| The language and the theme +|=== + +Two user properties go with them, the role and the language. An uncaught +error is reported with `Analytics.crash` and carries the exception's class +name alone. + +No event carries a name, an e-mail address, a phone number, a street address, +a position, a card number or a ride's id. Money and distance are reported as +brackets and never as amounts. Keep to that when you add an event: add a +method to `Telemetry` and call it from the screen, so the list of what leaves +the device stays in one file. + +==== Where the reports go + +`Telemetry.start` registers `LoggingAnalyticsProvider` in the simulator and +`CodenameOneAnalyticsProvider` everywhere else. A run in the simulator +writes its events to the console and sends nothing. The Codename One provider +identifies the app from properties the build puts into it, which a local run +doesn't have. A built app reports to Codename One, and the reports are shown +in the developer console. + +To send the events somewhere else, register another provider in +`Telemetry.start`. <> lists the ones the framework has and how to +write one. + +[[wayline-maps-data]] +=== Maps, search and routes + +==== What the sample uses + +[cols="1,2,2"] +|=== +| What | Where it comes from | Called by + +| The map +| Vector tiles of OpenStreetMap data from OpenFreeMap, drawn by `MapView`. No + key and no account. +| The app, directly. + +| Routes +| The OSRM project's public demo server, through the `Routing` class. +| The app, directly. + +| Place search +| A public Photon server, which searches OpenStreetMap data. +| The server, at `/api/geo/search` and `/api/geo/reverse`. +|=== + +Place search goes through your server so that the app never sends what a user +types to a third party directly, and so that you can change the service without +shipping a new app. + +When a route can't be fetched, the app draws a straight line between the two +points and estimates the time from the distance. The server prices from its own +estimate whenever the app's figures aren't plausible, so a failed route changes +what the rider sees and not what they pay. + +==== What these services are good for + +All three are run by other people at no charge. They're the right choice for +development, a demo and a pilot, and they need a decision before a launch: + +* The OSRM demo server has no service guarantee and limits how often it can be + asked. It exists for trying OSRM out. +* The public Photon server is shared by everyone who uses it and is meant for + light use. +* OpenFreeMap asks for no key. Read its terms of use, and plan for what your + app does when a service you don't control is slow or gone. + +None of them knows about traffic. The time the app shows and the time part of +the fare come from distances and typical speeds. + +==== Pointing at your own + +Tiles and routes are chosen in the app. `Maps.create` builds the map, and +`Routing.setService` replaces the route service for the whole app: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/WaylineMapSnippets.java[tag=wayline-map-own-services,indent=0] +---- + +A keyed tile provider takes its key with `setApiKey`, and a provider that isn't +OSRM-compatible is an implementation of `RouteService`. +<<_tile_sources_and_styles_mapview,The Maps chapter>> covers both. + +Place search is chosen on the server: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.properties[tag=wayline-properties-geocoder,indent=0] +---- + +That points `PhotonGeocoder` at a Photon server you run. A different search +service is another implementation of the `Geocoder` interface, chosen in +`Services`. + +==== Where the app starts + +A device that reports its position opens the map there. Until it does, and in a +browser that won't say, the map opens on `AppConfig.DEFAULT_LAT` and +`DEFAULT_LNG`, which is San Francisco's northern waterfront. Change them to +your city. + +=== Demo data + +Everything a development server shows on first start is made by two classes. + +`DemoAccounts` creates the accounts when the server starts. On a development +profile, which is `dev`, `test` or `local`, it creates the riders, the drivers, +the applicant and the admin, gives the drivers approved applications with cars +and placeholder documents, and saves a simulated card for some riders. On any +other profile it creates one account and no more: the first admin, from +`wayline.admin.email` and `wayline.admin.password`. + +`DemoRides` stores two weeks of rides as entities, already finished: three to six a +day between ten places in San Francisco, about three quarters of them +completed, with their events, payments and ratings, and a payout of the first +week's earnings. The +sequence is fixed, so every start produces the same fortnight ending today. +That's what the dashboard's charts draw on a fresh server. + +The cars a rider sees are real drivers who are online. Nothing on the server +invents traffic. To see a car move, sign in as a driver in a second simulator +and go online. In the simulator the app has no position, so it stands at the +default one, and the driver's screen drives the car along the route itself at +25 meters a second. + +To use your own data: + +* For your own city, change the ten places in `DemoRides` and in + `StubGeocoder`, and the default position in `AppConfig`. +* For a demo with your own cast, edit the list in `DemoAccounts`. +* For a production database, do nothing. Neither class writes demo data + outside a development profile. +* To load real history, such as rides from a system you're replacing, write a + one-off job that stores `Ride`, `RideEvent` and `Payment` entities through + their repositories the way `DemoRides` does, or a migration that inserts into + `ride`, `ride_event` and `payment`. The statistics read those tables + and don't care who wrote the rows. + +The app's tests use 24 vector tiles of the same waterfront, stored in +`common/src/test/resources`. They aren't part of the app. `Maps.useTiles` is the +hook that swaps them in. + +=== Native maps + +The sample draws its map with `MapView`, the vector map that Codename One +renders itself. It looks the same on every platform, the app's bottom sheet and +buttons float over it, and its light and dark styles follow the app. + +`NativeMap` shows the platform's own map instead: Apple MapKit on iOS, and +Google Maps or Huawei Map Kit on Android. It has the same `MapSurface` +operations as `MapView`, and it falls back to a `MapView` where no provider is +available, which includes the simulator, the desktop and the browser. +<<_native_maps_and_providers,Native maps and providers>> documents it. + +Three changes move Wayline to it. + +First, select a provider for each platform with build hints in +`common/codenameone_settings.properties`: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.properties[tag=wayline-properties-native-maps,indent=0] +---- + +Apple MapKit needs no key. Google Maps needs an API key for each platform, +supplied through the build hints the Maps chapter lists. + +Second, create the map as a `NativeMap` in `Maps.create`. The tile source and +style passed to it are what the fallback uses: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/WaylineMapSnippets.java[tag=wayline-map-native,indent=0] +---- + +`MapStage.map`, the `CarMarker` constructor and the helper methods of `Maps` +are declared with the type `MapView`. Change them to `MapSurface`, which has +the camera, marker and line methods they call. The few calls that treat the map +as a component, such as `repaint` and `getHeight`, go through `asComponent()`. + +Third, change how the car moves. `CarMarker` glides a marker by setting its +position twenty times a second. A native provider is told about a marker when +it's added and isn't told when its position changes, so on a native map the +marker has to be taken off and put back: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/WaylineMapSnippets.java[tag=wayline-map-native-marker,indent=0] +---- + +Do that a few times a second, not twenty: every move is a call into the +platform's map. + +The sample hasn't been built with a native provider, so treat this as the +route to take and test it on devices. What you gain and give up: + +[cols="1,2,2"] +|=== +| | `MapView`, as shipped | `NativeMap` with a provider + +| Keys and accounts +| None. +| None for Apple MapKit. A key and a billing account for Google Maps, and + Huawei's onboarding for Map Kit. + +| Look +| One style on every platform, in the app's light and dark colors, with the + pins and car drawn from `theme.css`. +| The platform's map, which users know. Its colors are the provider's, and a + dark app doesn't make the map dark. + +| Data +| OpenStreetMap, as good as its coverage of your city. +| The provider's data, which often includes more businesses and addresses. + +| Layering +| An ordinary component. The bottom sheet, the chip and the buttons are drawn + over it. +| A native view embedded in the screen. Test every screen that floats controls + over the map, on both platforms. + +| Offline and tests +| Tiles can be bundled with the app, which is how the tests run. +| The provider decides what's cached. In the simulator and in tests you see + the fallback, not the native map. +|=== + +[[wayline-database]] +=== The database + +==== Engines and where the address comes from + +The server runs on SQLite, PostgreSQL, MySQL and MariaDB with the same code. It +reads one setting, `cn1.datasource.url`: + +[cols="1,3"] +|=== +| Value | What it opens + +| `:memory:` +| A SQLite database that lives as long as the process. It's what + `backend/application-dev.properties` and `application-test.properties` set. + +| A file path, such as `./target/app.db` +| A SQLite database in that file. + +| `postgres://user:password@host/database` +| PostgreSQL. + +| `mysql://user:password@host/database` +| MySQL, or MariaDB. Which of the two answered is read from the server and not + from the URL. +|=== + +The setting is also read from the environment, as `CN1_DATASOURCE_URL` and as +`DATABASE_URL`, which is the name a hosting platform usually sets for a +database it attaches. `backend/application.properties` sets the key to +`${DATABASE_URL}`, so a deployment supplies the address in its environment and +no address is committed. + +To keep data between restarts while developing, give the `dev` profile a file: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.properties[tag=wayline-properties-dev-database,indent=0] +---- + +To run against PostgreSQL or MySQL, give the server a URL. Nothing else +changes, and there's no driver to add: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.sh[tag=wayline-sh-database,indent=0] +---- + +The server holds a pool of connections. `cn1.datasource.pool.size` sets how +many: eight for PostgreSQL and MySQL unless set, four for a SQLite file and one +for an in-memory database. A request that finds no free connection waits +`cn1.datasource.pool.borrowTimeoutMillis`, ten seconds unless set, and then +fails. Every server process has a pool of its own, so the processes times the +pool size has to stay under the number of connections the database allows. +<> covers the pool, and the table of settings in +<<_configuration_and_profiles,Configuration and profiles>> lists these keys with the rest. + +The sample's own tests run on the `test` profile, which is an in-memory SQLite +database. Run the server tests against the engine you deploy on before you +rely on it there. + +[[wayline-schema]] +==== The schema + +The tables are created by the migration scripts in +`backend/src/main/resources/db/migration`, which run in order when the server +starts. <> describes the rules. + +[cols="1,3"] +|=== +| Script | What it creates + +| `V1__accounts.sql` +| The profile, the preferences, the pending phone codes, the live-channel + tickets, the moderation history and the hashed addresses of closed accounts. + +| `V2__drivers.sql` +| A driver's state and position, the driving applications and the hours + driven. + +| `V3__driver_documents.sql` +| The photographs behind a driving application. + +| `V4__rides.sql` +| The rides, their events, and the drivers who passed on each. + +| `V5__payments.sql` +| Payments, the provider's customers, saved methods and setups, payout + accounts, payouts and the prices. + +| `V6__pricing_row.sql` +| The one row of prices, marked as not yet set, so that saving prices always + updates it and two admins saving at once can't both try to create it. +|=== + +The sign-in tables are separate. The security layer creates them because +`cn1.security.schema.enabled` is set, as <> describes. + +Five of the six scripts are written once, in SQL that all three engines +accept. They use no generated keys and no boolean or timestamp types: an id is +text the server makes, a yes or no is a `SMALLINT` of 0 or 1, and a time is +milliseconds in a `BIGINT`. That's also how the ORM stores a `boolean` and a +`long` field, so the entities and the columns agree on every engine. + +`V3__driver_documents.sql` is the exception, and it shows what the per-engine +directories are for. A document is stored as text, and it outgrows the 64 KB of +MySQL's `TEXT`. MySQL needs `LONGTEXT`, a type the other two don't have. For that reason +the script isn't in `db/migration` itself. It exists three times under the same +name, in `db/migration/sqlite`, `db/migration/postgresql` and +`db/migration/mysql`, and the server runs the one for the engine it's connected +to. MariaDB reads the `mysql` directory. A version is either one common script +or one script for each engine, never both. + +[[wayline-schema-change]] +==== Changing the schema later + +A change to the schema is a new script with the next version number. The next +one is `V6`, and the two underscores before the description are part of the +name. A child seat a rider can ask for is `V6__child_seat.sql`: + +[source,sql] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.sql[tag=wayline-sql-column,indent=0] +---- + +A column added to a table that already has rows needs a default, as +`child_seat` has here. `ADD COLUMN` with a `NOT NULL` default is accepted by +all three engines, so a script that sticks to it stays a common script. When the SQL has to differ, put the script in the +three engine directories under one name, as `V3` does, and leave it out of +`db/migration` itself. + +Add the field to the entity in the same change. The migrations create the +columns and the entities read them, and neither is derived from the other. The +option to bring a pet is the column of `ride` the child seat would sit beside, +and in `Ride` it's one private field: + +[source,java] +---- +include::../../scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Ride.java[tag=wayline-column-entity,indent=0] +---- + +The child seat is a `childSeat` field written the same way, with an +`isChildSeat` and a `setChildSeat` beside the getter and setter `pets` has. +The field's name in snake case is the column's, so `childSeat` reads +`child_seat` with nothing more said. + +A new table gets an entity class of its own in `domain` as well, as the +customers of <<_cash_and_accounts,Cash and accounts>> do. The server tests start from +an empty database and run every migration, so `RideFlowTest` and the others +fail on an entity and a script that disagree. + +What happens when the new build starts: + +* Before it accepts a request, the server compares the scripts in the build + with the history the database keeps and applies the ones the database hasn't + had, in order. +* On SQLite and PostgreSQL each script runs in a transaction with the row that + records it, so a script that fails leaves nothing behind and the server + doesn't start. MySQL and MariaDB commit each schema change as it runs. A + script that fails there is recorded as failed, and the server refuses to + migrate until what it left has been removed and `repair` has been called. +* Several server processes starting together take a lock in the database, so + the script runs once. +* A database that's already at the newest version isn't touched. + +Never edit a script that has been applied. The server checks the applied +scripts against the ones in the build before it migrates, and refuses to start +on a difference. A mistake in `V6` that has reached a database is corrected in +`V7`. Before a script has left your own machine, delete the development +database and start again. An in-memory database starts from nothing every +time. + +A script that removes a column should ship one release after the build that +stopped reading it. While a deployment replaces its instances one at a time, +the old build runs against the new schema. + +==== A column, end to end + +The script and the entity field above are the first two of the places a piece +of data touches. The option to bring a pet is already in the sample and shows +every one, so follow it for the child seat, a flight number or a cost center. + +*The schema.* `pets` is a `SMALLINT` column of `ride`, in `V4__rides.sql`, +and of `driver_application`, in `V2__drivers.sql`. For new data it's a new +script, as `V6__child_seat.sql` is. + +*The entities.* `Ride.pets` and `DriverApplication.pets` are private +`boolean` fields with a `@Column`, as `childSeat` is. + +*The services.* `Rides.open`, the transaction of `Rides.request`, copies +`petFriendly` from the request to `Ride.pets`, and `Rides.toDto` copies it into +the ride the app is sent. Because the pet option also decides who is offered +the ride, `Matcher.pick` passes `Ride.isPets` to `Matcher.nearby`, which drops +every driver whose application doesn't take pets. + +*The contract.* The value is a component of the record in `shared` that +carries it, `petFriendly` in `RideRequestDto`, and of `RideDto` so the driver +sees it. A child seat is a `boolean childSeat` component beside it in each, +with a line for it in the record's comment. Every place that builds one of +those records then passes the new value, and the compiler lists each place +that doesn't. + +A component needs nothing else. Both builds regenerate the JSON code for the +record. + +*The endpoint.* A new field on an existing call changes no endpoint. A new call +is a method on the `@RestClient` interface and its implementation on the +server, and the server's build fails until the two match. + +*The app.* `SettingsForm` has the switch, `Prefs` keeps its value, `RiderForm` +copies it into the request, and `DriverForm` shows it on the offer. The label is +a key in each of the five bundles in `src/main/l10n`. + +*The tests.* `MatchingTest` on the server checks that a ride with a pet is +offered only to a driver who takes pets. + +More data about a person follows the same path through `Profile`, `profile` +and `ProfileDto`, about a driver's car through `DriverApplication`, +`driver_application` and `DriverApplicationDto`, and about the service's +prices through `Pricing`, `pricing` and `PricingDto`. A new kind of record +gets a table, an entity, a repository, a class in `shared`, and a service. + +[[wayline-hosting]] +==== Hosting it: A managed database and one binary + +A deployment that suits a new service is a managed PostgreSQL and the server's +native binary in a small container or virtual machine, behind the platform's +HTTPS proxy. The steps are the same on any platform that runs a container or a +process and attaches a database to it. + +*Build.* `backend/server.sh build --native --web` runs `cn1:backend-package`, +which writes the binary to `backend/target/wayline-server`, and +`cn1:backend-webapp`, which stages the browser app in `backend/target/webapp`. +The binary is built for the machine the build runs on, so build on the +architecture you deploy to, or use the cross-compiled targets +<> describes. + +*Lay it out.* The server reads its settings and looks for the browser app in +its working directory. Put the binary, `application.properties` and the +`webapp` directory side by side, as <> shows, and +start the binary from that directory. Leave `webapp` out and the server is the +API alone. + +*Create the database.* Create an empty PostgreSQL database and a user that +owns it. The server creates every table at its first start, which takes a user +that may create tables. Don't create anything by hand: a database that has +tables and no migration history is refused. + +*Set the environment.* Nothing secret belongs in `application.properties`, +which is committed. A setting is read from the environment under its name in +upper case with an underscore for each dot. + +[cols="2,3"] +|=== +| Variable | What it holds + +| `DATABASE_URL` +| The `postgres://` address of the database. Many platforms set it themselves + when a database is attached. + +| `PORT` +| The port to listen on. A platform that sets it wins over `cn1.server.port`. + +| `CN1_SECURITY_AUTHORIZATIONSERVER_ISSUER` +| The public `https` address of the server, without a trailing slash. It goes + into every token, and it's the only origin the browser app may sign in from. + +| `CN1_SECURITY_AUTHORIZATIONSERVER_JWK_KEYS` +| The files holding the key that signs the tokens. Mount them as secret files. + They're required outside a development profile. + <> covers the keys and their rotation. + +| `WAYLINE_ADMIN_EMAIL`, `WAYLINE_ADMIN_PASSWORD` +| The first administrator, created at the first start. That account grants + the admin role to the rest. + +| `WAYLINE_SMS_PROVIDER`, then `WAYLINE_SMS_TWILIO_SID`, `WAYLINE_SMS_TWILIO_TOKEN`, `WAYLINE_SMS_TWILIO_FROM` or the settings of another provider +| The text-message provider. `twilio` when unset. See <>. + +| `WAYLINE_PAYMENTS_PROVIDER`, `WAYLINE_PAYMENTS_STRIPE_SECRET` +| The payment provider. `stripe` when unset. See <>. +|=== + +The same settings as properties, for a platform that mounts a file instead: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/wayline.properties[tag=wayline-properties-deployment,indent=0] +---- + +*Leave TLS to the proxy.* The platform's proxy holds the certificate and +forwards plain HTTP to the port. Two things follow. The issuer is the address +users reach, which is the proxy's `https` address and not the container's. And +the server sees every connection coming from the proxy, so the limit on +sign-in attempts for each client address would count all users as one. Set +`cn1.server.forwardHeaders` to `true` and list the proxy's addresses in +`cn1.server.trustedProxies`, as <> describes. A +server with no proxy in front terminates TLS itself with +`cn1.server.tls.certificate` and `cn1.server.tls.key`. + +*Give the platform a health check.* `GET /manage/health` answers without a +token, with 503 while the server is starting or draining. The management +endpoints are linked into a packaged server only when `cn1.management.enabled` +is `true` in `backend/application.properties` at build time, and outside a +development profile they also need `cn1.management.token` set. +<> covers both. + +*Start it.* The first start applies the five migrations, creates the sign-in +tables, registers the app as a client and creates the first administrator. No +demo accounts and no demo rides are written outside a development profile. + +*Point the apps at it.* The hosted browser app uses the page's own origin and +needs nothing. An installed app takes the address from **Server address** on +the welcome screen. To build an app that needs no such step, change the +address `AppConfig` falls back to, and remove the Android clear-text +attribute listed in <<_before_its_a_product,Before it's a product>>. + +*What's rate limited.* `SecurityConfig` limits registration, the phone codes, +the password change, the document uploads, the card setups and the address +search under `/api/geo`. Each search is a request the server makes of the geocoder you +configured, so the limit protects that service's allowance and not only this +server. + +*Before a second instance.* A change of ride state, the matching job and the +migrations are each safe with several processes on one database. +Two things are held in one process's memory: the open sockets in `LiveHub`, +and the counters of the `InMemoryRateLimiter` that `SecurityConfig` gives each +rate limit, so every instance allows the full number of attempts. + diff --git a/docs/developer-guide/Wayline-Users-Guide.asciidoc b/docs/developer-guide/Wayline-Users-Guide.asciidoc new file mode 100644 index 00000000000..2a4acacd1b2 --- /dev/null +++ b/docs/developer-guide/Wayline-Users-Guide.asciidoc @@ -0,0 +1,604 @@ +[[wayline-users-guide]] +== Wayline: A ride-hailing app from three sides + +Wayline is a sample app with a server behind it. A rider asks for a ride, the +nearest suitable driver is offered it, both follow the trip on a map, the ride is +paid for when it ends, and an administrator runs the service. The app and the +server are one Maven project, written in Java on both sides, and the +Initializr's ride-hailing template generates it under your own name. + +This chapter is the user's guide: what each of the three kinds of user sees and +can do. <> is the other half. It explains how the +project is built and what to change to make it yours. + +One app serves all three. The server gives every account a set of roles, and the +app opens on the screen that fits them: a rider on the map, a driver on the +driving screen, an administrator on the operations console. Someone who holds +more than one role switches between them from the menu. + +[cols="1,3"] +|=== +| Role | What it adds + +| Rider +| Every account is a rider. Search for a destination, compare three kinds of + car, choose how to pay, follow the driver, rate the ride, tip and keep the + receipt. + +| Driver +| Granted when an administrator approves a driving application. Go online, + accept or decline the rides offered, drive them, and see and cash out what + they earned. + +| Admin +| Granted by another administrator. See the service in numbers and charts, + review driving applications, flag or block accounts, cancel and refund rides, + read the payments and set the prices. +|=== + +=== Trying it + +Start the server on its development profile and it creates a set of accounts, +all with the password `wayline-demo`, and two weeks of rides so that the charts +have something to draw. <> has the commands. + +[cols="2,3"] +|=== +| Account | Role + +| `rider@wayline.example`, `rider2@wayline.example` +| Riders, each with a saved test card. + +| `rider3@wayline.example` +| A rider an administrator has flagged. + +| `driver@wayline.example`, `driver2@wayline.example` +| Approved drivers of the standard kind of car. + +| `driver3@wayline.example`, `driver4@wayline.example` +| Approved drivers of the two larger kinds. The last one takes a wheelchair. + +| `applicant@wayline.example` +| Has applied to drive and is waiting for a decision. + +| `admin@wayline.example` +| Runs the service. +|=== + +Sign in as the rider in one simulator and as a driver in another to follow a +ride from both ends. + +No money moves on a development server, which simulates cards unless it's told +to use a payment provider. Four numbers behave in four ways: + +[cols="2,3"] +|=== +| Card number | What happens + +| `4242 4242 4242 4242` +| Accepted, and every charge succeeds. + +| `4000 0000 0000 0002` +| Declined when you try to add it. + +| `4000 0000 0000 0341` +| Accepted when added, then declined when a ride is charged. + +| `4000 0000 0000 0119` +| Accepted when added, then every charge fails without an answer, so the ride's +payment stays pending. + +| `4000 0000 0000 7726` +| Accepted, and every charge succeeds. A refund is pending at first, and shows +as refunded about a minute later. +|=== + +Any expiry date in the future and any security code work with them. + +=== Getting in + +The welcome screen offers to sign in or to create an account. **Server address** +is there for a phone on the same network as a development server: `localhost` +on a phone is the phone, so the app has to be told where the server is. The +app in a browser has no such entry: it always talks to the server that hosts it. + +.The welcome screen +image::img/wayline-welcome.png["The Wayline welcome screen with buttons to sign in, to create an account and to set the server address",scaledwidth=40%] + +Creating an account takes a name, an e-mail address, a mobile number and a +password. The e-mail address is what you sign in with. +// vale-skip: Microsoft.FirstPerson: "I want to" is the label on the screen. +Under **I want to**, you say +whether you plan to ride or to drive. Choosing **Drive** doesn't make you a +driver; it takes you to the driving application once the account exists. + +.Creating an account +image::img/wayline-register.png["The form for a new account with fields for name, e-mail, mobile number and password, and a choice between riding and driving",scaledwidth=40%] + +The app then asks for the six-digit code sent to the mobile number. A code is +good for five minutes and five attempts, and a new one can be requested after +half a minute. A development server prints the text on its console instead +of sending it, so it shows the code on the screen as well. You +can skip this step with **Not now**, but the server refuses a ride request from +an account whose number isn't verified. + +.Verifying the mobile number on a development server +image::img/wayline-verify-phone.png[The verification screen showing the code on screen because a development server sends no text message,scaledwidth=40%] + +Signing in is the app's own form. Behind it, the app runs an OAuth 2.0 +authorization-code flow against the server without opening a browser, and keeps +the tokens it gets in the device's secure storage. Signing out revokes the +refresh token at the server. + +After the first sign-in the app asks once whether it may share usage +statistics: which screens are opened and which features are used, and never a +name, an e-mail address, a phone number, a place or a card. **Share usage +statistics** agrees and **Not now** declines, and the app works the same +either way. The answer can be changed at any time in the settings. + +.The question about usage statistics +image::img/wayline-analytics-consent.png["The screen that asks whether to share usage statistics, with a button to share them and a button to decline",scaledwidth=40%] + +=== The rider + +A rider can: + +* Search for a destination by name or address, or pick the saved **Home** or + **Work**. +* See the route, its length and how long it takes before asking. +* Compare three kinds of ride with a price each: the standard car, a more + comfortable one, and a larger one for up to six people. +* See how far the nearest car of each kind is, and the free cars on the map. +* Pay with a saved card or with cash. +* Ask for a quiet ride, an accessible vehicle, a car that takes pets, or a + woman driver. +* Follow the driver to the pickup and along the trip. +* Call or message the driver, share the trip, or cancel before the trip starts. +* Rate the ride from one to five stars and add a tip. +* Read the list of past rides and the receipt of each. + +The home screen is a map with a search card over it and a sheet at the bottom. +The round button at the top opens the menu, and the one at the side puts the +map back on where you are. + +.The rider's home screen +image::img/wayline-rider-home.png[The rider home screen: a map of the city with a search card at the top and shortcuts for home and work at the bottom,scaledwidth=40%] + +.The rider's home screen in the dark theme +image::img/wayline-rider-home-dark.png[The rider home screen in the dark theme with the map drawn in dark colors,scaledwidth=40%] + +The map starts on where you are. While the device is still finding that, a +notice at the top of the map says **Looking for your location**. If it has no +answer after eight seconds, the notice becomes **Still looking for your +location. Try again**. When the device can't work out its position, the notice is +**Can't find your location. Try again**, and when the app has no permission to +ask, it's **Location is turned off. Try again**. Tapping the notice asks again. +Until a position arrives the map shows the service's default city, and you can +still search for a pickup and a destination. + +Pick a destination and the app draws the route and asks the server for a quote. +The sheet lists the three kinds of ride with what each costs, how many it seats +and how many minutes away the nearest one is. Under them is the way to pay, +which you can change by tapping it. The price shown is the fare with the +service fee included. + +.Choosing a ride +image::img/wayline-ride-quote.png[The quote sheet listing three kinds of ride with their prices and the card that will pay,scaledwidth=40%] + +The quote is for information. When you ask for the ride, the server works the +fare out again from its own prices and fixes it for that ride. A price change an +administrator makes a minute later applies to the next ride, not to this one. + +The server offers the ride to the nearest free driver who fits what you asked +for. That driver has fifteen seconds to accept. If they decline or let the time +pass, the next nearest is offered it, and after a short while the search widens +from three kilometers to eight. If nobody takes the ride within a minute and a +half the app says so, and nothing is charged. + +Once a driver accepts, the sheet shows their name, rating, car and license +plate, and the car moves on the map as the driver reports in. + +.A driver on the way to the pickup +image::img/wayline-ride-driver-on-the-way.png["The rider screen while a driver approaches: the car on the route, the time to pickup, and the driver's name, car and plate",scaledwidth=40%] + +On a phone the app doesn't have to stay open for any of this. A notification +says when a driver accepts, when they arrive, when the trip starts and when it +ends, and tapping one opens the ride. While a driver is on the way, the ride +also shows outside the app: on the lock screen and on the Dynamic Island of an +iPhone, and as an ongoing notification on Android. It names the driver, the car +and its plate, counts down the minutes to the pickup and fills a bar as the +ride goes from on the way to arrived, the trip and its end. It moves on with +every step of the ride and, every minute or so, with the minutes left. It goes +when the ride is canceled, and stays a short while to say you've arrived when +the trip is complete. + +Notifications reach a phone only from a server connected to the push service, +which <> sets up. A server started for development prints each +notification in its output instead. + +.The ride on the lock screen +[cols="1,3"] +|=== +| Where | What shows + +| iPhone +| A Live Activity on the lock screen and on the Dynamic Island. + +| Android +| An ongoing notification. The first ride asks for permission to show + notifications. + +| Desktop and the simulator +| A small window beside the app, while the app runs. + +| A browser +| Nothing. The page shows the ride while it's open. +|=== + +**Call** and **Message** open the phone's dialer and its messaging app, so +they're offered only on a device that can dial. **Share** opens the device's +share sheet with a sentence naming the destination, the driver and the car. +**Cancel** ends the ride at any point before the trip starts. The sample charges +nothing for a cancelled ride. + +When the driver completes the trip the server charges the card, and the sheet +asks how the ride was. + +.The end of a ride +image::img/wayline-ride-receipt.png["The sheet shown when a ride ends, with the fare, five stars to rate the driver and a row of tip percentages",scaledwidth=40%] + +The tip is a percentage of the fare or, from the receipt, any amount. It's a +second charge to the same card, made once, and the whole of it goes to the driver. On +a ride paid in cash the tip is recorded on the receipt and in the driver's +earnings, and no card is charged for it. + +==== Wallet and receipts + +**Wallet** in the menu lists the saved cards and cash, which is always there. +Tapping a card makes it the one that pays unless you choose otherwise when you +ask for a ride. An account holds up to eight cards. + +.The wallet +image::img/wayline-wallet.png["The wallet with one saved card marked as the default, cash, and a button to add a card",scaledwidth=40%] + +How a card is added depends on the server. With simulated payments the card is +typed into the app. With a real provider the app opens the provider's own page +in the browser, so the card number never reaches the app or the server, and the +app picks the result up when you come back to it. + +Past rides are listed under **Your rides**. Pull the list down to load it +again. Each ride opens a receipt with the trip, the driver and the fare broken +into its parts. + +.A receipt +image::img/wayline-receipt.png["A receipt showing the total paid, the pickup and drop-off, the driver, and the fare split into base fare, distance, time, service fee and tip",scaledwidth=40%] + +=== The driver + +A driver can: + +* Apply to drive, and correct and resend an application that was sent back. +* Go online and offline. +* See each ride offered with its pickup, destination, distance and fare, and + accept or decline it within the time allowed. +* See what the rider asked for: a quiet ride, an accessible vehicle, a pet. +* Mark the arrival at the pickup, the start of the trip and its end. +* Call or message the rider, or cancel before the trip starts. +* See today's and this week's earnings, the last two weeks day by day, and the + list of past trips. +* Register a bank account and cash out the balance. + +==== Applying to drive + +Anyone with an account can apply, from **Drive with Wayline** in the rider's +menu. The application has five steps, and each is saved as you leave it, so an +application can be finished later: + +. Your legal name, date of birth and gender. +. The driving license: its number and when it expires. +. The car: make, model, year, color, plate, how many it seats, the kind of ride + it suits, and whether it takes a wheelchair or pets. +. Five photographs: an identity document, the license, a picture of you, the + vehicle registration and the insurance. +. A review of the whole application before it's sent. + +.The documents step of a driving application +image::img/wayline-onboarding-documents.png["Step four of five of a driving application, with a card for each document and buttons to retake a photo or pick one from the gallery",scaledwidth=40%] + +Each photograph comes from the camera or the gallery. The app shrinks it before +sending, and the server accepts JPEG and PNG files of up to 1.5 MB. + +Once sent, an application waits for an administrator. Until one decides, the applicant +sees where it stands and goes on riding. + +.An application waiting for a decision +image::img/wayline-onboarding-under-review.png["The screen an applicant sees while an application is under review, with the steps that follow",scaledwidth=40%] + +Approval gives the account the driver role, and the app opens on the driving +screen from then on. A rejection comes with the administrator's reason. The +applicant can change what was wrong and send the application again. + +==== Driving + +The driving screen is a map with the day's earnings at the top and a badge +that reads **Offline** or **Online**. A driver starts offline. The sheet says +**You are offline** and, under it, **Go online to start taking rides**. + +.A driver who is offline +image::img/wayline-driver-offline.png["The driving screen while offline: the day's earnings, a badge reading Offline and the round GO button",scaledwidth=40%] + +**GO** puts the driver online. The badge changes to **Online**, the sheet says +**Looking for rides**, and the app reports the car's position to the server +every few seconds. **Go offline** stops the offers. A driver who is online and +stops reporting for a minute is no longer offered rides either. + +A driver doesn't have to keep the app on the screen. While they're online the +phone goes on reporting where the car is with the app in the background, when +the driver switches to a navigation app or locks the phone. It reports when the +car has moved about 25 meters, and every twenty seconds when it hasn't, which +spares the battery and keeps the driver well inside the minute. On Android a +notification that reads **You're online** stays while it does. An iPhone asks +the driver whether Wayline may use the location while the app isn't in use, +and the reports from the background need that answer to be yes. Going +offline, signing out or switching to the rider's side of the app stops the +reports. The simulator and a browser don't report from the background. + +A new ride request arrives as a notification too, and tapping it opens the +driving screen with the offer. The offer is open for fifteen seconds, so the +notification is too: one that can't be delivered within that time may never +arrive. A rider who cancels is also announced in a notification. + +An offer shows the fare, how far the pickup is and how long the trip is, with a +ring counting down the time left to answer. + +.A ride offered to a driver +image::img/wayline-driver-offer.png["The driver screen with a ride request: the fare, a countdown ring, the pickup and destination, and buttons to accept or decline",scaledwidth=40%] + +After accepting, the driver follows the route to the pickup, taps **I have +arrived**, then **Start trip** when the rider is in the car and **Complete +trip** at the destination. For a ride paid in cash the sheet says how much to +collect. + +.A trip under way, paid in cash +image::img/wayline-driver-on-trip.png["The driver screen during a trip, with the route to the destination, the amount to collect in cash and a button to complete the trip",scaledwidth=40%] + +A driver can cancel after accepting and before the trip starts. A trip that has +started can only be completed, or cancelled by an administrator. + +==== Earnings + +**Earnings** in the driver's menu shows the balance ready to cash out, the +totals for today, this week and this month, a chart of the last two weeks, the +number of trips, the tips, the rating and the share of offers accepted. Under +them are the payouts already made. + +.A driver's earnings +image::img/wayline-driver-earnings.png["The earnings screen with the balance ready to cash out, the totals for today, this week and this month, and a chart of the last two weeks",scaledwidth=40%] + +A driver earns the fare less the service's commission, and every tip in full. +**Cash out** asks for a bank account the first time: the holder's name, the bank +and the last four digits of the account number. + +NOTE: Cashing out records a payout and sets the balance to zero. The sample +moves no money to a bank. <> says what that takes. + +=== The administrator + +An administrator can: + +* See what's happening now: rides today, rides under way, drivers online and + fares collected. +* See trends over 7, 14 or 30 days: rides and fares by day, the share + completed, the average fare and wait, requests by hour of the day, rides by + outcome and by kind, and the drivers with the most rides. +* Review driving applications with their documents, and approve them or send + them back with a reason. +* Search the accounts, see each one's rides, spending, earnings and rating, and + give or take away the driver and admin roles. +* Flag an account, which marks it for the other administrators, or block it, + which signs it out and refuses it until the block is lifted. Both take a + reason, and both are kept in the account's history with who did it and when. +* See every ride, cancel one under way, and refund one that was paid by + card. +* See every payment. +* Set the prices. +* Watch the cars that are online move on a map. + +The same screens are laid out in two ways. On a phone the console is one screen +at a time with a menu. In a window at least 165 mm wide, which is a tablet, a +desktop or a browser, it's a console with its sections down the side and a list +beside the item picked from it. + +==== On a phone + +The dashboard opens with what needs attention, as buttons that lead to it, then +the four numbers and the charts. + +.The dashboard on a phone +image::img/wayline-admin-dashboard.png[The admin dashboard on a phone: an alert for one application to review and one flagged account above four numbers describing the service right now,scaledwidth=40%] + +.Trends, further down the dashboard +image::img/wayline-admin-trends.png["Two bar charts on the admin dashboard, one of rides per day and one of fares per day",scaledwidth=40%] + +The lists of applications and of rides are loaded again by pulling them down. +A desktop window has no such gesture, and its lists load when the section +opens. + +An application opens with everything the applicant entered and the five +documents, each of which opens full size. **Approve** and **Send back** are at +the end of it. + +.A driving application under review +image::img/wayline-admin-application.png["A driving application as an administrator reads it, with the applicant's details and the vehicle",scaledwidth=40%] + +An account opens with its standing, its numbers, its contact details, the two +role switches, the moderation buttons and its history. + +.A blocked account +image::img/wayline-admin-user.png["An account an administrator has blocked, showing the reason, the number of rides taken and cancelled, and the amounts spent and earned",scaledwidth=40%] + +==== On a wide screen + +The desktop build takes the look of the operating system it runs on, in its +colors, fonts, and controls. + +.The dashboard in a desktop window +image::img/wayline-admin-desktop-dashboard.png[The admin console in a desktop window with its sections down the left side and the dashboard's numbers and charts laid out in three columns,scaledwidth=95%] + +**Applications** lists them by status, and the one picked opens beside the +list. + +.Applications, with one open beside the list +image::img/wayline-admin-desktop-applications.png[The applications section of the console: a list of waiting applications on the left and the selected application with its documents on the right,scaledwidth=95%] + +**People** has a search field and filters for riders, drivers, admins, flagged +and blocked accounts. + +.People, with an account open +image::img/wayline-admin-desktop-user.png["The people section of the console: a searchable list of accounts with their roles, and the selected account's numbers, roles, moderation buttons and history",scaledwidth=95%] + +**Rides** lists the most recent rides with their state: every ride, or only +those under way, completed, or cancelled. A ride opens with the trip, the fare +and how it was paid. From there an administrator can cancel a ride that hasn't +finished and refund one that was paid by card. A refund returns +the fare, the service fee and the tip. A ride paid in cash has nothing to +refund. A refund the payment provider hasn't finished shows as **Refund +pending** until it does. One the provider then fails shows as **Refund failed** +and needs looking up with the provider. + +.Rides +image::img/wayline-admin-desktop-rides.png["The rides section of the console: filters for all, under way, completed and cancelled rides above a list of rides with their fares and states",scaledwidth=95%] + +**Payments** lists what was charged for each ride, by which method and with +what result. + +.Payments +image::img/wayline-admin-desktop-payments.png["The payments section of the console listing each charge with its destination, the rider, the card or cash, the amount and whether it was paid",scaledwidth=95%] + +**Pricing** holds the numbers every fare is worked out from: a base fare, a +price for each kilometer and each minute, a minimum, the service fee added on +top for the rider, the commission kept from the driver, and a multiplier for +each of the two larger kinds of ride and for times of high demand. The currency +is a setting of the server and isn't changed here. + +.Pricing +image::img/wayline-admin-desktop-pricing.png["The pricing form with the base fare, the price by kilometer and minute, the minimum fare, the service fee and commission, and the multipliers",scaledwidth=95%] + +**Live map** fills the window with a map of the cars that are online, which +move as their drivers report in. + +.The live map +image::img/wayline-admin-desktop-live-map.png[The live map section of the console filling the window with a map of the city and a note that no cars are online,scaledwidth=95%] + +The screens the console doesn't own, such as the settings, keep their phone +layout in a column in the middle of the window. An administrator is a rider too, +and **Riding** under **Switch to** opens the rider's map. + +.The rider's home screen in a desktop window +image::img/wayline-rider-desktop.png[The rider home screen filling a desktop window with a wide map and the search card across the top,scaledwidth=95%] + +=== Settings + +The **Settings** screen is in every role's menu and is the same in each. What's +chosen there is stored on the server with the account, so it follows the user +to another device. + +.Settings +image::img/wayline-settings.png["The settings screen with the account's profile, verified phone number and password, followed by the ride preferences",scaledwidth=40%] + +.Settings in the dark theme +image::img/wayline-settings-dark.png[The settings screen in the dark theme,scaledwidth=40%] + +[cols="1,3"] +|=== +| Section | What it holds + +| Account +| The profile, which is the name, gender, an emergency contact and the saved + home and work addresses. The mobile number and whether it's verified. + Changing the password, which asks for the current one. Every other device + signed in to the account has to sign in again with the new password; the + device you changed it on stays signed in. + +| Ride preferences +| Any driver or women only, a quiet ride, an accessible vehicle, and a + pet-friendly car. They're sent with every ride request. + +| Payment +| The wallet, the method that pays by default, and the tip offered first. + +| Notifications +| Switches for ride updates, receipts and offers. With **Ride updates** off + the phone gets no notification of a ride's steps, and with **Receipts** off + none when a trip ends. The ride on the lock screen goes on either way. + +| Appearance +| The language, the theme, and whether distances are in kilometers or miles. + +| Privacy and safety +| Whether to share trips, the emergency contact, and **Usage statistics**. + +| About +| The terms of service, the privacy policy, the server address, and deleting + the account. +|=== + +The app is translated into English, Spanish, French, German, and Hebrew. Hebrew +is laid out right to left. The theme follows the device, or is fixed to light or +dark, and the map changes with it. + +.Settings in Hebrew +image::img/wayline-settings-hebrew.png["The settings screen in Hebrew, laid out from right to left",scaledwidth=40%] + +.Settings in a desktop window +image::img/wayline-settings-desktop.png["The settings screen in a desktop window, kept to a column in the middle and drawn with the operating system's own controls",scaledwidth=95%] + +**Usage statistics** is the answer to the question asked after the first +sign-in. Unlike the rest of the screen it's kept on the device and not with +the account, so each device has its own answer. While it's off the app reports +nothing. Deleting the account also resets the anonymous id the statistics +carry. + +.The usage statistics switch +image::img/wayline-settings-analytics.png["The privacy and safety section of the settings, with the switch for usage statistics",scaledwidth=40%] + +Three of these settings are stored and do little or nothing more in the +sample: + +* The **Offers** switch is saved with the account, but the server sends no + offers or promotions to switch off. +// vale-skip: Microsoft.FirstPerson: "Share my trips" is the label on the screen. +* **Share my trips** and the emergency contact are saved with the account, and + the screen says that no message is sent. Sharing a trip is the **Share** + button during a ride. +* **Women only** limits the offer to drivers whose profile says they're women. + The server doesn't check the gender of the rider who asks. + +Deleting the account removes the sign-in, the profile, the saved cards and any +driving application, and the record the payment provider keeps of the rider. +The app asks for the password again first, and the server refuses while a ride +is under way or a card payment for one is still pending. The e-mail address of a deleted account +can't be registered again, because the rides that account took stay in the +service's records under it. + +The recent destinations are kept on the device and belong to the account that +chose them. When a different account signs in on the same device, the list +starts empty, and so do home, work, the emergency contact and the ride +options until that account's own arrive from the server. The language, the +theme and the units stay as the device had them. + +A past ride shows who drove it and in which car. It doesn't show where that +driver is now. + +=== In a browser + +The server can host the app itself, so that opening the server's address in a +browser is using the app with nothing installed. It's the same app compiled to +JavaScript, with the same screens. + +A browser gives a page less than a phone gives an app, and the app says so where +it matters. Calling and messaging are offered only where the device can dial. +The map opens on a default position when the browser won't say where it is. + +<> has the commands, and +<> explains how a server hosts an app. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index fd86060c6aa..daccf66c721 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -145,6 +145,10 @@ include::Backend-MCP.asciidoc[] include::Backend-Operations.asciidoc[] +include::Wayline-Users-Guide.asciidoc[] + +include::Wayline-Implementors-Guide.asciidoc[] + = Device and platform services include::Push-Notifications.asciidoc[] diff --git a/docs/developer-guide/img/wayline-admin-application.png b/docs/developer-guide/img/wayline-admin-application.png new file mode 100644 index 00000000000..edabb5f8405 Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-application.png differ diff --git a/docs/developer-guide/img/wayline-admin-dashboard.png b/docs/developer-guide/img/wayline-admin-dashboard.png new file mode 100644 index 00000000000..e31bd102ef6 Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-dashboard.png differ diff --git a/docs/developer-guide/img/wayline-admin-desktop-applications.png b/docs/developer-guide/img/wayline-admin-desktop-applications.png new file mode 100644 index 00000000000..a8151bca845 Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-desktop-applications.png differ diff --git a/docs/developer-guide/img/wayline-admin-desktop-dashboard.png b/docs/developer-guide/img/wayline-admin-desktop-dashboard.png new file mode 100644 index 00000000000..d804ab958ca Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-desktop-dashboard.png differ diff --git a/docs/developer-guide/img/wayline-admin-desktop-live-map.png b/docs/developer-guide/img/wayline-admin-desktop-live-map.png new file mode 100644 index 00000000000..44e08ec8412 Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-desktop-live-map.png differ diff --git a/docs/developer-guide/img/wayline-admin-desktop-payments.png b/docs/developer-guide/img/wayline-admin-desktop-payments.png new file mode 100644 index 00000000000..8bfd2387fe7 Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-desktop-payments.png differ diff --git a/docs/developer-guide/img/wayline-admin-desktop-pricing.png b/docs/developer-guide/img/wayline-admin-desktop-pricing.png new file mode 100644 index 00000000000..1015fdb1474 Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-desktop-pricing.png differ diff --git a/docs/developer-guide/img/wayline-admin-desktop-rides.png b/docs/developer-guide/img/wayline-admin-desktop-rides.png new file mode 100644 index 00000000000..d9012939b0b Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-desktop-rides.png differ diff --git a/docs/developer-guide/img/wayline-admin-desktop-user.png b/docs/developer-guide/img/wayline-admin-desktop-user.png new file mode 100644 index 00000000000..214ff032e00 Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-desktop-user.png differ diff --git a/docs/developer-guide/img/wayline-admin-trends.png b/docs/developer-guide/img/wayline-admin-trends.png new file mode 100644 index 00000000000..5a705917310 Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-trends.png differ diff --git a/docs/developer-guide/img/wayline-admin-user.png b/docs/developer-guide/img/wayline-admin-user.png new file mode 100644 index 00000000000..8e9cbb43b20 Binary files /dev/null and b/docs/developer-guide/img/wayline-admin-user.png differ diff --git a/docs/developer-guide/img/wayline-analytics-consent.png b/docs/developer-guide/img/wayline-analytics-consent.png new file mode 100644 index 00000000000..2bd59b7824f Binary files /dev/null and b/docs/developer-guide/img/wayline-analytics-consent.png differ diff --git a/docs/developer-guide/img/wayline-driver-earnings.png b/docs/developer-guide/img/wayline-driver-earnings.png new file mode 100644 index 00000000000..a7827379c65 Binary files /dev/null and b/docs/developer-guide/img/wayline-driver-earnings.png differ diff --git a/docs/developer-guide/img/wayline-driver-offer.png b/docs/developer-guide/img/wayline-driver-offer.png new file mode 100644 index 00000000000..e93958ea7d1 Binary files /dev/null and b/docs/developer-guide/img/wayline-driver-offer.png differ diff --git a/docs/developer-guide/img/wayline-driver-offline.png b/docs/developer-guide/img/wayline-driver-offline.png new file mode 100644 index 00000000000..3046dd619fe Binary files /dev/null and b/docs/developer-guide/img/wayline-driver-offline.png differ diff --git a/docs/developer-guide/img/wayline-driver-on-trip.png b/docs/developer-guide/img/wayline-driver-on-trip.png new file mode 100644 index 00000000000..225c96881cb Binary files /dev/null and b/docs/developer-guide/img/wayline-driver-on-trip.png differ diff --git a/docs/developer-guide/img/wayline-onboarding-documents.png b/docs/developer-guide/img/wayline-onboarding-documents.png new file mode 100644 index 00000000000..ba403988e83 Binary files /dev/null and b/docs/developer-guide/img/wayline-onboarding-documents.png differ diff --git a/docs/developer-guide/img/wayline-onboarding-under-review.png b/docs/developer-guide/img/wayline-onboarding-under-review.png new file mode 100644 index 00000000000..d17284db7da Binary files /dev/null and b/docs/developer-guide/img/wayline-onboarding-under-review.png differ diff --git a/docs/developer-guide/img/wayline-receipt.png b/docs/developer-guide/img/wayline-receipt.png new file mode 100644 index 00000000000..cc8e8d65538 Binary files /dev/null and b/docs/developer-guide/img/wayline-receipt.png differ diff --git a/docs/developer-guide/img/wayline-register.png b/docs/developer-guide/img/wayline-register.png new file mode 100644 index 00000000000..f5e67411f9a Binary files /dev/null and b/docs/developer-guide/img/wayline-register.png differ diff --git a/docs/developer-guide/img/wayline-ride-driver-on-the-way.png b/docs/developer-guide/img/wayline-ride-driver-on-the-way.png new file mode 100644 index 00000000000..127cecbb8c6 Binary files /dev/null and b/docs/developer-guide/img/wayline-ride-driver-on-the-way.png differ diff --git a/docs/developer-guide/img/wayline-ride-quote.png b/docs/developer-guide/img/wayline-ride-quote.png new file mode 100644 index 00000000000..47adb2ab98a Binary files /dev/null and b/docs/developer-guide/img/wayline-ride-quote.png differ diff --git a/docs/developer-guide/img/wayline-ride-receipt.png b/docs/developer-guide/img/wayline-ride-receipt.png new file mode 100644 index 00000000000..5025f17528f Binary files /dev/null and b/docs/developer-guide/img/wayline-ride-receipt.png differ diff --git a/docs/developer-guide/img/wayline-rider-desktop.png b/docs/developer-guide/img/wayline-rider-desktop.png new file mode 100644 index 00000000000..f7118d0a5c5 Binary files /dev/null and b/docs/developer-guide/img/wayline-rider-desktop.png differ diff --git a/docs/developer-guide/img/wayline-rider-home-dark.png b/docs/developer-guide/img/wayline-rider-home-dark.png new file mode 100644 index 00000000000..b037b661719 Binary files /dev/null and b/docs/developer-guide/img/wayline-rider-home-dark.png differ diff --git a/docs/developer-guide/img/wayline-rider-home.png b/docs/developer-guide/img/wayline-rider-home.png new file mode 100644 index 00000000000..8595bd00e72 Binary files /dev/null and b/docs/developer-guide/img/wayline-rider-home.png differ diff --git a/docs/developer-guide/img/wayline-settings-analytics.png b/docs/developer-guide/img/wayline-settings-analytics.png new file mode 100644 index 00000000000..8116b624566 Binary files /dev/null and b/docs/developer-guide/img/wayline-settings-analytics.png differ diff --git a/docs/developer-guide/img/wayline-settings-dark.png b/docs/developer-guide/img/wayline-settings-dark.png new file mode 100644 index 00000000000..bd95d7763ab Binary files /dev/null and b/docs/developer-guide/img/wayline-settings-dark.png differ diff --git a/docs/developer-guide/img/wayline-settings-desktop.png b/docs/developer-guide/img/wayline-settings-desktop.png new file mode 100644 index 00000000000..aba337dad7b Binary files /dev/null and b/docs/developer-guide/img/wayline-settings-desktop.png differ diff --git a/docs/developer-guide/img/wayline-settings-hebrew.png b/docs/developer-guide/img/wayline-settings-hebrew.png new file mode 100644 index 00000000000..9981f6148d6 Binary files /dev/null and b/docs/developer-guide/img/wayline-settings-hebrew.png differ diff --git a/docs/developer-guide/img/wayline-settings.png b/docs/developer-guide/img/wayline-settings.png new file mode 100644 index 00000000000..4bafa14665a Binary files /dev/null and b/docs/developer-guide/img/wayline-settings.png differ diff --git a/docs/developer-guide/img/wayline-verify-phone.png b/docs/developer-guide/img/wayline-verify-phone.png new file mode 100644 index 00000000000..080a5e4431a Binary files /dev/null and b/docs/developer-guide/img/wayline-verify-phone.png differ diff --git a/docs/developer-guide/img/wayline-wallet.png b/docs/developer-guide/img/wayline-wallet.png new file mode 100644 index 00000000000..c281b516872 Binary files /dev/null and b/docs/developer-guide/img/wayline-wallet.png differ diff --git a/docs/developer-guide/img/wayline-welcome.png b/docs/developer-guide/img/wayline-welcome.png new file mode 100644 index 00000000000..35688aa3ca3 Binary files /dev/null and b/docs/developer-guide/img/wayline-welcome.png differ diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index c71b484ddbf..4fa92f50950 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -803,3 +803,6 @@ Toolchain # (performance.asciidoc). inlining inlined +# ----------------------------------------------------------------------------- +# The sample application the Wayline chapters walk through. +Wayline diff --git a/maven/backend/src/test/java/com/codename1/backend/ResponseStatusExceptionTest.java b/maven/backend/src/test/java/com/codename1/backend/ResponseStatusExceptionTest.java new file mode 100644 index 00000000000..037e7a6a0bb --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/ResponseStatusExceptionTest.java @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A handler that throws [ResponseStatusException] is answered with the status +/// and the reason it chose, on the wire and through the in-process dispatch a +/// test uses, and anything else it throws is still a 500 that says nothing. +class ResponseStatusExceptionTest { + + @Test + void aClientErrorIsAnsweredWithItsStatusAndReason() throws Exception { + Backend backend = serve(); + try { + int port = backend.getServer().getPort(); + assertAnswer(port, "/taken", 409, "That e-mail already has an account"); + // The server is still serving after it: the throw was an answer, + // not a failure of the connection. + assertAnswer(port, "/ok", 200, "ok"); + } finally { + backend.stop(); + } + } + + @Test + void aServerErrorIsAnsweredWithItsStatusAndReason() throws Exception { + Backend backend = serve(); + try { + int port = backend.getServer().getPort(); + assertAnswer(port, "/down", 503, "The index is rebuilding"); + } finally { + backend.stop(); + } + } + + /// The reason of an ordinary exception is the application's, and may hold + /// anything; only the one thrown to be an answer is sent. + @Test + void anyOtherThrowIsStillABareInternalError() throws Exception { + Backend backend = serve(); + try { + int port = backend.getServer().getPort(); + assertAnswer(port, "/broken", 500, "internal error"); + } finally { + backend.stop(); + } + } + + @Test + void anAbsentReasonIsAnEmptyBody() throws Exception { + Backend backend = serve(); + try { + int port = backend.getServer().getPort(); + assertAnswer(port, "/bare", 404, ""); + } finally { + backend.stop(); + } + } + + /// What MockMvc calls. Without the same translation a test of a handler + /// that refuses a request saw the exception, where a client sees a 409. + @Test + void theInProcessDispatchGivesTheSameAnswer() throws Exception { + Backend backend = serve(); + try { + HttpServer.Response taken = backend.dispatch(request("/taken")); + assertEquals(409, taken.getStatus()); + assertEquals("That e-mail already has an account", + new String(com.codename1.impl.backend.BackendAccess.get().body(taken), "UTF-8")); + assertEquals(503, backend.dispatch(request("/down")).getStatus()); + assertEquals(200, backend.dispatch(request("/ok")).getStatus()); + } finally { + backend.stop(); + } + } + + /// A chosen status dispatched in process carries the headers the listener + /// would put on it -- CORS among them -- so a test sees what a browser sees. + @Test + void aDispatchedStatusIsDecoratedAsOnTheWire() throws Exception { + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, "0"); + settings.setProperty(Cors.ALLOWED_ORIGINS, "https://app.example"); + Backend backend = Backend.builder(Config.of(settings, "test")) + .quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + throw new ResponseStatusException(409, "taken"); + } + }) + .start(); + try { + HashMap headers = new HashMap(); + headers.put("origin", "https://app.example"); + HttpServer.Response answer = backend.dispatch( + new HttpServer.Request("GET", "/taken", "HTTP/1.1", headers, null)); + assertEquals(409, answer.getStatus()); + StringBuilder sent = new StringBuilder(); + for (Object pair : com.codename1.impl.backend.BackendAccess.get().headers(answer)) { + sent.append(java.util.Arrays.toString((String[]) pair)).append(' '); + } + assertTrue(sent.indexOf("Access-Control-Allow-Origin, https://app.example") >= 0, + sent.toString()); + } finally { + backend.stop(); + } + } + + /// The request log records what the client was sent. A chosen 409 used to + /// be logged as a 500 carrying the exception, so every refused sign-up read + /// as a failure of the server; a chosen 503 is one, and says why. + @Test + void theRequestLogRecordsTheChosenStatus() throws Exception { + Backend backend = serve(); + try { + backend.getRequestLog().enable(8); + int port = backend.getServer().getPort(); + assertAnswer(port, "/taken", 409, "That e-mail already has an account"); + assertAnswer(port, "/down", 503, "The index is rebuilding"); + assertAnswer(port, "/broken", 500, "internal error"); + + java.util.List all = backend.getRequestLog().recent(8, false); + assertEquals(3, all.size(), String.valueOf(all)); + java.util.Map broken = (java.util.Map) all.get(0); + java.util.Map down = (java.util.Map) all.get(1); + java.util.Map taken = (java.util.Map) all.get(2); + assertEquals(Integer.valueOf(409), taken.get("status")); + assertNull(taken.get("error"), "an answer is not an error"); + assertEquals(Integer.valueOf(503), down.get("status")); + assertEquals(Integer.valueOf(500), broken.get("status")); + assertEquals(2, backend.getRequestLog().recent(8, true).size(), + "only the two the server failed"); + String why = String.valueOf(down.get("error")) + down.get("causes"); + assertEquals(true, why.contains("The index is rebuilding") && why.contains("disk"), why); + } finally { + backend.stop(); + } + } + + @Test + void onlyAnErrorStatusIsAccepted() { + assertThrows(IllegalArgumentException.class, () -> new ResponseStatusException(200, "fine")); + assertThrows(IllegalArgumentException.class, () -> new ResponseStatusException(399, "moved")); + assertThrows(IllegalArgumentException.class, () -> new ResponseStatusException(600, "none")); + assertEquals(400, new ResponseStatusException(400, "bad").getStatus()); + assertEquals(599, new ResponseStatusException(599, "worse").getStatus()); + } + + @Test + void itCarriesItsCauseAndNeverANullReason() { + IllegalStateException cause = new IllegalStateException("why"); + ResponseStatusException thrown = new ResponseStatusException(502, "upstream", cause); + assertSame(cause, thrown.getCause()); + assertEquals("upstream", thrown.getReason()); + assertEquals("502 upstream", thrown.getMessage()); + ResponseStatusException bare = new ResponseStatusException(404, null); + assertEquals("", bare.getReason()); + assertNull(bare.getCause()); + } + + private static Backend serve() throws Exception { + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, "0"); + return Backend.builder(Config.of(settings, "test")) + .quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + String path = request.getTarget(); + if ("/taken".equals(path)) { + throw new ResponseStatusException(409, + "That e-mail already has an account"); + } + if ("/down".equals(path)) { + throw new ResponseStatusException(503, "The index is rebuilding", + new IOException("disk")); + } + if ("/bare".equals(path)) { + throw new ResponseStatusException(404, null); + } + if ("/broken".equals(path)) { + throw new IllegalStateException("a secret the caller must not read"); + } + return HttpServer.Response.text(200, "ok"); + } + }) + .start(); + } + + private static HttpServer.Request request(String target) { + return new HttpServer.Request("GET", target, "HTTP/1.1", new HashMap(), null); + } + + private static void assertAnswer(int port, String path, int status, String body) + throws Exception { + HttpURLConnection connection = (HttpURLConnection) + new URL("http://127.0.0.1:" + port + path).openConnection(); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + try { + assertEquals(status, connection.getResponseCode(), path); + InputStream in = status < 400 ? connection.getInputStream() : connection.getErrorStream(); + assertEquals(body, in == null ? "" : read(in), path); + } finally { + connection.disconnect(); + } + } + + private static String read(InputStream in) throws IOException { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[1024]; + int n = in.read(chunk); + while (n > 0) { + out.write(chunk, 0, n); + n = in.read(chunk); + } + return new String(out.toByteArray(), "UTF-8"); + } finally { + in.close(); + } + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/WebAppHostingTest.java b/maven/backend/src/test/java/com/codename1/backend/WebAppHostingTest.java new file mode 100644 index 00000000000..3bcdd87a498 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/WebAppHostingTest.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Properties; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The server hosting the browser build of its own application. + * + *

Real servers on real ports again: what is being promised is what a browser + * receives -- which file, under which headers, in which encoding -- and none of + * that exists short of a response on a socket. + */ +class WebAppHostingTest { + + /** The port of the server the running test started. */ + private int port; + + @Test + @DisplayName("the app is served at the root, index first, revalidated every time") + void servesTheAppAtTheRoot(@TempDir File dir) throws Exception { + write(new File(dir, "index.html"), "the app"); + write(new File(dir, "translated_app.js"), "run();"); + new File(dir, "js").mkdir(); + write(new File(dir, "js/fontmetrics.js"), "metrics();"); + Backend backend = start(dir, null, null); + try { + Reply index = get(port, "/", null, null); + assertEquals(200, index.status); + assertEquals("the app", index.text()); + assertTrue(index.type.startsWith("text/html"), index.type); + // The files of a build keep their names, so a browser has to ask. + assertEquals("no-cache", index.cacheControl); + assertEquals("run();", get(port, "/translated_app.js", null, null).text()); + assertEquals("metrics();", get(port, "/js/fontmetrics.js", null, null).text()); + assertEquals(404, get(port, "/nothing-here.js", null, null).status); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("an unchanged file answers 304 to the validator it was served with") + void revalidatesWithAnEntityTag(@TempDir File dir) throws Exception { + write(new File(dir, "index.html"), "the app"); + Backend backend = start(dir, null, null); + try { + Reply first = get(port, "/index.html", null, null); + assertTrue(first.etag != null && first.etag.length() > 0, "no ETag on a revalidated file"); + Reply again = get(port, "/index.html", null, first.etag); + assertEquals(304, again.status); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a route answers before the app does, so the API keeps its paths") + void routesWinOverTheApp(@TempDir File dir) throws Exception { + write(new File(dir, "index.html"), "the app"); + new File(dir, "api").mkdir(); + write(new File(dir, "api/rides"), "from the file"); + Properties settings = settings(dir, null, null); + Backend backend = Backend.builder(Config.of(settings, "test")) + .quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + if ("/api/rides".equals(request.getTarget())) { + return new HttpServer.Response(200, "text/plain", + "from the handler".getBytes("UTF-8")); + } + return null; + } + }) + .start(); + try { + assertEquals("from the handler", get(port, "/api/rides", null, null).text()); + assertEquals("the app", get(port, "/", null, null).text()); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a compressed copy beside a file is what a browser that accepts gzip gets") + void servesThePrecompressedCopy(@TempDir File dir) throws Exception { + StringBuilder script = new StringBuilder(); + for (int i = 0; i < 2000; i++) { + script.append("function f").append(i).append("(){return ").append(i).append(";}\n"); + } + write(new File(dir, "index.html"), "the app"); + write(new File(dir, "translated_app.js"), script.toString()); + writeGzip(new File(dir, "translated_app.js.gz"), script.toString()); + long packed = new File(dir, "translated_app.js.gz").length(); + Backend backend = start(dir, null, null); + try { + Reply gzip = get(port, "/translated_app.js", "gzip, deflate, br", null); + assertEquals(200, gzip.status); + assertEquals("gzip", gzip.encoding); + assertEquals(packed, gzip.body.length); + assertEquals(script.toString(), gunzip(gzip.body)); + // Named for what it IS, not for the bytes on the wire. + assertTrue(gzip.type.indexOf("javascript") >= 0, gzip.type); + assertTrue(gzip.vary != null && gzip.vary.indexOf("Accept-Encoding") >= 0, "Vary: " + gzip.vary); + + Reply plain = get(port, "/translated_app.js", "identity", null); + assertNull(plain.encoding); + assertEquals(script.toString(), plain.text()); + assertTrue(plain.vary != null && plain.vary.indexOf("Accept-Encoding") >= 0, "Vary: " + plain.vary); + // Two representations of one URL: a validator for one must not + // answer 304 for the other. + assertNotEquals(gzip.etag, plain.etag); + + // No copy beside it: the file itself, whatever was accepted. + Reply index = get(port, "/", "gzip", null); + assertNull(index.encoding); + assertEquals("the app", index.text()); + + // The copy is not a page of its own under a guessed type. + Reply refused = get(port, "/translated_app.js", "gzip;q=0", null); + assertNull(refused.encoding); + assertEquals(script.toString(), refused.text()); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a byte range is of the file, never of its compressed copy") + void aRangeIsOfTheFileItself(@TempDir File dir) throws Exception { + write(new File(dir, "index.html"), "the app"); + write(new File(dir, "data.json"), "0123456789"); + writeGzip(new File(dir, "data.json.gz"), "0123456789"); + Backend backend = start(dir, null, null); + try { + HttpURLConnection connection = open(port, "/data.json"); + connection.setRequestProperty("Accept-Encoding", "gzip"); + connection.setRequestProperty("Range", "bytes=2-5"); + try { + assertEquals(206, connection.getResponseCode()); + assertNull(connection.getHeaderField("Content-Encoding")); + assertEquals("2345", new String(readAll(connection.getInputStream()), "UTF-8")); + } finally { + connection.disconnect(); + } + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a path cannot climb out of the app's directory") + void cannotEscapeTheDirectory(@TempDir File dir) throws Exception { + File root = new File(dir, "webapp"); + root.mkdir(); + write(new File(root, "index.html"), "the app"); + write(new File(dir, "secret.txt"), "not for the browser"); + writeGzip(new File(dir, "secret.txt.gz"), "not for the browser"); + Backend backend = start(root, null, null); + try { + String[] attempts = { + "/../secret.txt", "/%2e%2e/secret.txt", "/..%2fsecret.txt", + "/js/../../secret.txt", "/%2e%2e%2fsecret.txt" + }; + for (String attempt : attempts) { + Reply reply = raw(port, attempt, "gzip"); + assertTrue(reply.status == 400 || reply.status == 403 || reply.status == 404, + attempt + " answered " + reply.status); + assertTrue(reply.text().indexOf("not for the browser") < 0, attempt); + } + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("it can be mounted beneath a path and given a cache policy") + void mountsBeneathAPath(@TempDir File dir) throws Exception { + write(new File(dir, "index.html"), "the app"); + write(new File(dir, "port.js"), "port();"); + Backend backend = start(dir, "/app", "public, max-age=60"); + try { + Reply script = get(port, "/app/port.js", null, null); + assertEquals("port();", script.text()); + assertEquals("public, max-age=60", script.cacheControl); + assertEquals("the app", get(port, "/app/", null, null).text()); + assertEquals(404, get(port, "/port.js", null, null).status); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a server with no web build serves none, and says nothing") + void absentByDefault() throws Exception { + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + assertNull(Backend.webApp(Config.of(settings, "test"))); + } + + @Test + @DisplayName("naming a directory that holds no app stops the server from starting") + void aNamedDirectoryMustHoldTheApp(@TempDir File dir) throws Exception { + final Properties settings = settings(dir, null, null); + IOException refused = assertThrows(IOException.class, () -> + Backend.builder(Config.of(settings, "test")).quiet().handler(nothing()).start()); + assertTrue(refused.getMessage().indexOf(Config.WEBAPP_ROOT) >= 0, refused.getMessage()); + assertTrue(refused.getMessage().indexOf("index.html") >= 0, refused.getMessage()); + } + + @Test + @DisplayName("it can be switched off without moving the directory") + void canBeSwitchedOff(@TempDir File dir) throws Exception { + write(new File(dir, "index.html"), "the app"); + Properties settings = settings(dir, null, null); + settings.setProperty(Config.WEBAPP_ENABLED, "false"); + assertNull(Backend.webApp(Config.of(settings, "test"))); + } + + private Backend start(File root, String path, String cacheControl) throws Exception { + return Backend.builder(Config.of(settings(root, path, cacheControl), "test")) + .quiet().handler(nothing()).start(); + } + + private Properties settings(File root, String path, String cacheControl) throws IOException { + Properties settings = new Properties(); + port = freePort(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + settings.setProperty(Config.WEBAPP_ROOT, root.getAbsolutePath()); + if (path != null) { + settings.setProperty(Config.WEBAPP_PATH, path); + } + if (cacheControl != null) { + settings.setProperty(Config.WEBAPP_CACHE_CONTROL, cacheControl); + } + return settings; + } + + private static HttpServer.Handler nothing() { + return new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + return null; + } + }; + } + + /** What came back, with the headers these tests are about. */ + private static final class Reply { + int status; + String type; + String encoding; + String cacheControl; + String etag; + String vary; + byte[] body = new byte[0]; + + String text() throws IOException { + return new String(body, "UTF-8"); + } + } + + private static HttpURLConnection open(int port, String path) throws IOException { + HttpURLConnection connection = (HttpURLConnection) + new URL("http://127.0.0.1:" + port + path).openConnection(); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + return connection; + } + + private static Reply get(int port, String path, String acceptEncoding, String ifNoneMatch) + throws IOException { + HttpURLConnection connection = open(port, path); + if (acceptEncoding != null) { + connection.setRequestProperty("Accept-Encoding", acceptEncoding); + } + if (ifNoneMatch != null) { + connection.setRequestProperty("If-None-Match", ifNoneMatch); + } + try { + Reply reply = new Reply(); + reply.status = connection.getResponseCode(); + reply.type = connection.getHeaderField("Content-Type"); + reply.encoding = connection.getHeaderField("Content-Encoding"); + reply.cacheControl = connection.getHeaderField("Cache-Control"); + reply.etag = connection.getHeaderField("ETag"); + reply.vary = connection.getHeaderField("Vary"); + InputStream in = reply.status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + if (in != null) { + reply.body = readAll(in); + } + return reply; + } finally { + connection.disconnect(); + } + } + + /** + * A request whose target reaches the server exactly as written: a URL class + * would tidy the dot segments away before the server ever saw them. + */ + private static Reply raw(int port, String target, String acceptEncoding) throws IOException { + java.net.Socket socket = new java.net.Socket("127.0.0.1", port); + try { + socket.setSoTimeout(5000); + OutputStream out = socket.getOutputStream(); + out.write(("GET " + target + " HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept-Encoding: " + + acceptEncoding + "\r\nConnection: close\r\n\r\n").getBytes("ISO-8859-1")); + out.flush(); + byte[] all = readAll(socket.getInputStream()); + String head = new String(all, "ISO-8859-1"); + Reply reply = new Reply(); + reply.status = Integer.parseInt(head.substring(9, 12)); + reply.body = all; + return reply; + } finally { + socket.close(); + } + } + + private static byte[] readAll(InputStream in) throws IOException { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int read = in.read(chunk); + while (read >= 0) { + out.write(chunk, 0, read); + read = in.read(chunk); + } + return out.toByteArray(); + } finally { + in.close(); + } + } + + private static String gunzip(byte[] packed) throws IOException { + return new String(readAll(new GZIPInputStream(new ByteArrayInputStream(packed))), "UTF-8"); + } + + private static int freePort() throws IOException { + java.net.ServerSocket probe = new java.net.ServerSocket(0); + try { + return probe.getLocalPort(); + } finally { + probe.close(); + } + } + + private static void write(File file, String content) throws IOException { + OutputStream out = new FileOutputStream(file); + try { + out.write(content.getBytes("UTF-8")); + } finally { + out.close(); + } + } + + private static void writeGzip(File file, String content) throws IOException { + OutputStream out = new GZIPOutputStream(new FileOutputStream(file)); + try { + out.write(content.getBytes("UTF-8")); + } finally { + out.close(); + } + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionDatabaseTest.java b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionDatabaseTest.java index 99d7da37355..6de0138f1e0 100644 --- a/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionDatabaseTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionDatabaseTest.java @@ -120,7 +120,7 @@ public Attribute[] attributes() { assertThrows(IllegalArgumentException.class,()->session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",1.5).executeUpdate()); session.rollbackTransaction(); for(String mutation:new String[]{"update ManagedSessionTest$Record r set r.counter=sum(r.counter)","update ManagedSessionTest$Record r set r.counter=1 where count(r.id)>0","delete from ManagedSessionTest$Record r where count(r.id)>0"}) assertThrows(IllegalArgumentException.class,()->session.createQuery(mutation)); - if(!mysql) { session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=(select max(i.counter) from ManagedSessionTest$Record i)").executeUpdate());assertEquals(0,session.createQuery("delete from ManagedSessionTest$Record r where r.id in (select i.id from ManagedSessionTest$Record i where i.id<0)").executeUpdate());session.commitTransaction(); } + if(!mysql) { session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=(select max(i.counter) from ManagedSessionTest$Record i)").executeUpdate());assertEquals(0,session.createQuery("delete from ManagedSessionTest$Record r where r.id in (select i.id from ManagedSessionTest$Record i where i.id<0)").executeUpdate());session.commitTransaction();session.clear(); } assertThrows(IllegalArgumentException.class,()->session.find(ManagedSessionTest.Record.class,"abc")); assertThrows(IllegalArgumentException.class,()->session.find(ManagedSessionTest.Record.class,1.5)); assertEquals(Long.valueOf(0),session.createQuery("select count(NULL) from ManagedSessionTest$Record r",Long.class).first()); @@ -208,9 +208,9 @@ public Attribute[] attributes() { Future first=workers.submit(increment),second=workers.submit(increment);first.get(10,TimeUnit.SECONDS);second.get(10,TimeUnit.SECONDS); session.clear();assertEquals(11,session.find(ManagedSessionTest.Record.class,entity.id).counter); assertEquals(11,session.find(ManagedSessionTest.Record.class,entity.id).version); - session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.name=:name where r.id=:id").setParameter("name","updated").setParameter("id",entity.id).executeUpdate());session.commitTransaction(); + session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.name=:name where r.id=:id").setParameter("name","updated").setParameter("id",entity.id).executeUpdate());session.commitTransaction();session.clear(); assertEquals("updated",session.find(ManagedSessionTest.Record.class,entity.id).name); - session.beginTransaction();assertEquals(1,session.createQuery("delete from ManagedSessionTest$Record r where r.id=:id").setParameter("id",entity.id).executeUpdate());session.commitTransaction(); + session.beginTransaction();assertEquals(1,session.createQuery("delete from ManagedSessionTest$Record r where r.id=:id").setParameter("id",entity.id).executeUpdate());session.commitTransaction();session.clear(); assertNull(session.find(ManagedSessionTest.Record.class,entity.id)); java.util.Map> cycleModels=new java.util.LinkedHashMap>();cycleModels.put(Cycle.class.getName(),new CycleModel()); com.codename1.impl.orm.BackendSqlAccess cycleAccess=new com.codename1.impl.orm.BackendSqlAccess(null,db,db.dialect()); diff --git a/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java index 93c35d20898..09168bc6451 100644 --- a/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java @@ -67,7 +67,9 @@ static void assertCaseAndNativeQueries(Session s) { assertThrows(PersistenceException.class,()->s.createNativeQuery("UPDATE managed_record SET counter=:n",Object.class).setParameter("n",9L).executeUpdate()); s.beginTransaction();one.counter=8; assertEquals(1,s.createNativeQuery("UPDATE managed_record SET counter=:n WHERE id=:id AND counter=:old",Object.class).setParameter("n",9L).setParameter("id",one.id).setParameter("old",8L).executeUpdate()); - assertFalse(s.contains(one));assertEquals(9,s.find(Record.class,one.id).counter); + // As in JPA the session is not cleared: the entity stays managed with the + // value it had, until it is refreshed. + assertTrue(s.contains(one));assertSame(one,s.find(Record.class,one.id));assertEquals(8,one.counter);s.refresh(one);assertEquals(9,one.counter); s.rollbackTransaction();assertEquals(4,s.find(Record.class,one.id).counter); s.beginTransaction();s.createQuery("delete from "+Record.class.getName()+"").executeUpdate();s.commitTransaction(); } @@ -213,7 +215,7 @@ private Record seed(Session session) { assertThrows(IllegalArgumentException.class,()->s.createQuery("select unsupported(r.name) from ManagedSessionTest$Record r",String.class)); s.beginTransaction();r.name="flushed"; assertEquals(1,s.createQuery("update ManagedSessionTest$Record r set r.counter = r.counter + :delta where r.name = :name").setParameter("delta",4L).setParameter("name","flushed").executeUpdate()); - assertFalse(s.contains(r));s.commitTransaction();assertEquals(4,s.find(Record.class,r.id).counter); + assertTrue(s.contains(r));s.commitTransaction();s.clear();assertEquals(4,s.find(Record.class,r.id).counter); s.beginTransaction();assertEquals(1,s.createQuery("delete from ManagedSessionTest$Record r where r.counter between 3 and 5").executeUpdate());s.commitTransaction(); assertEquals(0,s.query(Record.class).count());s.close(); } finally { em.close(); } @@ -366,13 +368,13 @@ public Attribute[] attributes() { session.beginTransaction(); com.codename1.orm.session.JpqlQuery query=session.createQuery("update ManagedSessionTest$Record r set r.name="+expression); if(expression.contains(":value")) query.setParameter("value",oversize); - if(explicit) { assertEquals(1,query.executeUpdate());session.commitTransaction(); } + if(explicit) { assertEquals(1,query.executeUpdate());session.commitTransaction();session.clear(); } else { assertThrows(PersistenceException.class,query::executeUpdate);session.rollbackTransaction();assertEquals("original",session.find(Record.class,row.id).name); } } String unicode=new String(new char[255]).replace("\0","\ud83d\ude00"); session.beginTransaction();session.find(Record.class,row.id).name=unicode;session.commitTransaction();session.clear();assertEquals(unicode,session.find(Record.class,row.id).name); for(String value:new String[]{boundary,unicode,null}) { - session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.name=:value").setParameter("value",value).executeUpdate());session.commitTransaction();assertEquals(value,session.find(Record.class,row.id).name); + session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.name=:value").setParameter("value",value).executeUpdate());session.commitTransaction();session.clear();assertEquals(value,session.find(Record.class,row.id).name); } } finally { session.close(); } } @@ -532,10 +534,10 @@ public com.codename1.impl.orm.Relationship[] relationships() { try { Session s=em.openSession();Record first=seed(s);s.beginTransaction();Record kept=new Record();kept.name="keep";s.persist(kept);s.commitTransaction(); s.beginTransaction(); - assertEquals(1,s.createQuery("update ManagedSessionTest$Record r set r.counter = r.counter + :delta where exists (select x.id from ManagedSessionTest$Record x where x.id = r.id and x.name = :name)").setParameter("delta",2L).setParameter("name","first").executeUpdate());s.commitTransaction();assertEquals(2,s.find(Record.class,first.id).counter);assertEquals(0,s.find(Record.class,kept.id).counter); + assertEquals(1,s.createQuery("update ManagedSessionTest$Record r set r.counter = r.counter + :delta where exists (select x.id from ManagedSessionTest$Record x where x.id = r.id and x.name = :name)").setParameter("delta",2L).setParameter("name","first").executeUpdate());s.commitTransaction();s.clear();assertEquals(2,s.find(Record.class,first.id).counter);assertEquals(0,s.find(Record.class,kept.id).counter); s.beginTransaction(); assertEquals(1,s.createQuery("delete from ManagedSessionTest$Record r where exists (select x.id from ManagedSessionTest$Record x where x.id = r.id and x.name = :name)").setParameter("name","first").executeUpdate()); - s.commitTransaction();assertNull(s.find(Record.class,first.id));assertNotNull(s.find(Record.class,kept.id));assertEquals(1,s.query(Record.class).count());s.close(); + s.commitTransaction();s.clear();assertNull(s.find(Record.class,first.id));assertNotNull(s.find(Record.class,kept.id));assertEquals(1,s.query(Record.class).count());s.close(); } finally { em.close(); } } @@ -617,7 +619,7 @@ public com.codename1.impl.orm.Relationship[] relationships() { for(String query:new String[]{"update ManagedSessionTest$Record r set r.counter=sum(r.counter)","update ManagedSessionTest$Record r set r.counter=coalesce(max(r.counter),0)","update ManagedSessionTest$Record r set r.counter=1 where count(r.id)>0","delete from ManagedSessionTest$Record r where count(r.id)>0","delete from ManagedSessionTest$Record r where coalesce(sum(r.counter),0)>=0"}) { assertThrows(IllegalArgumentException.class,()->session.createQuery(query),query); } - session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=(select max(i.counter)+1 from ManagedSessionTest$Record i)").executeUpdate());session.commitTransaction();assertEquals(1,session.find(Record.class,record.id).counter); + session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=(select max(i.counter)+1 from ManagedSessionTest$Record i)").executeUpdate());session.commitTransaction();session.clear();assertEquals(1,session.find(Record.class,record.id).counter); session.beginTransaction();assertEquals(1,session.createQuery("delete from ManagedSessionTest$Record r where r.id in (select i.id from ManagedSessionTest$Record i where i.counter=1)").executeUpdate());session.commitTransaction();assertEquals(0,session.query(Record.class).count());session.close(); } finally { em.close(); } } @@ -655,10 +657,10 @@ static void assertIntegerRanges(Session session) { assertThrows(PersistenceException.class,query::executeUpdate,expression);session.rollbackTransaction();assertEquals(0L,session.find(Record.class,id).counter); } for(long boundary:new long[]{Integer.MIN_VALUE,Integer.MAX_VALUE,0}) { - session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",boundary).executeUpdate());session.commitTransaction();assertEquals(boundary,session.find(Record.class,id).counter); + session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",boundary).executeUpdate());session.commitTransaction();session.clear();assertEquals(boundary,session.find(Record.class,id).counter); } for(long[] change:new long[][]{{Integer.MIN_VALUE,2147483648L,0},{Integer.MIN_VALUE,4294967295L,Integer.MAX_VALUE},{Integer.MAX_VALUE,-4294967295L,Integer.MIN_VALUE},{Integer.MAX_VALUE,-2147483649L,-2}}) { - session.beginTransaction();session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",change[0]).executeUpdate();session.commitTransaction(); + session.beginTransaction();session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",change[0]).executeUpdate();session.commitTransaction();session.clear(); Record current=session.find(Record.class,id);long version=current.version; session.beginTransaction();assertTrue(session.increment(Record.class,id,"counter",change[1]));assertEquals(change[2],current.counter);assertEquals(version+1,current.version);session.commitTransaction(); session.clear();assertEquals(change[2],session.find(Record.class,id).counter); @@ -692,11 +694,11 @@ static void assertRealAssignments(Session session) { assertThrows(PersistenceException.class,query::executeUpdate,field+"="+expression);session.rollbackTransaction();assertEquals(1f,session.find(RealRecord.class,id).narrow);assertEquals(Float.valueOf(1f),session.find(RealRecord.class,id).boxed); } for(double boundary:new double[]{0,-0.0,Float.MIN_VALUE,-Float.MIN_VALUE,Float.MAX_VALUE,-Float.MAX_VALUE,1e-45,-1e-45}) { - session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$RealRecord r set r."+field+"=:value").setParameter("value",boundary).executeUpdate());session.commitTransaction();session.clear();RealRecord loaded=session.find(RealRecord.class,id);assertEquals((float)boundary,field.equals("narrow")?loaded.narrow:loaded.boxed.floatValue(),0.0f); + session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$RealRecord r set r."+field+"=:value").setParameter("value",boundary).executeUpdate());session.commitTransaction();session.clear();session.clear();RealRecord loaded=session.find(RealRecord.class,id);assertEquals((float)boundary,field.equals("narrow")?loaded.narrow:loaded.boxed.floatValue(),0.0f); } session.beginTransaction();session.createQuery("update ManagedSessionTest$RealRecord r set r."+field+"=1").executeUpdate();session.commitTransaction(); } - session.beginTransaction();session.createQuery("update ManagedSessionTest$RealRecord r set r.boxed=NULL,r.wide=1e-100").executeUpdate();session.commitTransaction();assertNull(session.find(RealRecord.class,id).boxed);assertEquals(1e-100,session.find(RealRecord.class,id).wide); + session.beginTransaction();session.createQuery("update ManagedSessionTest$RealRecord r set r.boxed=NULL,r.wide=1e-100").executeUpdate();session.commitTransaction();session.clear();assertNull(session.find(RealRecord.class,id).boxed);assertEquals(1e-100,session.find(RealRecord.class,id).wide); session.beginTransaction();assertThrows(PersistenceException.class,()->session.createQuery("update ManagedSessionTest$RealRecord r set r.narrow=r.wide/2").executeUpdate());session.rollbackTransaction(); for(String zero:new String[]{"0e-400","-0e-400","0.000e-400"}) assertEquals(0.0,session.createQuery("select "+zero+" from ManagedSessionTest$RealRecord r",Double.class).first().doubleValue(),0.0); } @@ -706,7 +708,7 @@ static void assertRealAssignments(Session session) { static void assertMinimumLongLiteral(Session session) { session.createTables();session.beginTransaction();Record record=new Record();record.name="minimum";session.persist(record);session.commitTransaction(); - session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=-9223372036854775808").executeUpdate());session.commitTransaction(); + session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=-9223372036854775808").executeUpdate());session.commitTransaction();session.clear(); assertEquals(Long.MIN_VALUE,session.find(Record.class,record.id).counter); assertEquals(1,session.createQuery("select r from ManagedSessionTest$Record r where r.counter=(-9223372036854775808)").list().size()); assertEquals(Long.valueOf(Long.MIN_VALUE),session.createQuery("select -9223372036854775808 from ManagedSessionTest$Record r",Long.class).first()); @@ -758,7 +760,7 @@ static void assertMinimumLongLiteral(Session session) { assertEquals(0L,session.find(Record.class,record.id).counter); assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",7L).executeUpdate()); assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=r.counter+2").executeUpdate()); - session.commitTransaction();assertEquals(9L,session.find(Record.class,record.id).counter);session.close(); + session.commitTransaction();session.clear();assertEquals(9L,session.find(Record.class,record.id).counter);session.close(); } finally { em.close(); } } @@ -873,7 +875,7 @@ static void assertMinimumLongLiteral(Session session) { assertThrows(IllegalArgumentException.class,()->session.createQuery("update ManagedSessionTest$Record r set r.name=:value").setParameter("value",7L).executeUpdate()); assertThrows(IllegalArgumentException.class,()->session.createQuery("update ManagedSessionTest$Record r set r.bytes=:value").setParameter("value","oops").executeUpdate()); assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=:value,r.name=:name,r.bytes=:bytes").setParameter("value",7L).setParameter("name",null).setParameter("bytes",new byte[]{1,2}).executeUpdate()); - session.commitTransaction();Record loaded=session.find(Record.class,record.id); + session.commitTransaction();session.clear();Record loaded=session.find(Record.class,record.id); assertEquals(7,loaded.counter);assertNull(loaded.name);assertArrayEquals(new byte[]{1,2},loaded.bytes); session.close(); } finally { em.close(); } @@ -946,7 +948,7 @@ static void assertMinimumLongLiteral(Session session) { assertEquals(Long.valueOf(-1),session.createQuery("select -r.counter / 2 from ManagedSessionTest$Record r",Long.class).first()); assertEquals(Double.valueOf(1.5),session.createQuery("select r.counter / 2.0 from ManagedSessionTest$Record r",Double.class).first()); assertNull(session.createQuery("select r.counter / 0 from ManagedSessionTest$Record r",Long.class).first()); - session.beginTransaction();session.createQuery("update ManagedSessionTest$Record r set r.counter = r.counter / 2").executeUpdate();session.commitTransaction(); + session.beginTransaction();session.createQuery("update ManagedSessionTest$Record r set r.counter = r.counter / 2").executeUpdate();session.commitTransaction();session.clear(); assertEquals(1,session.find(Record.class,r.id).counter);session.close(); } finally { em.close(); } } diff --git a/maven/backend/src/test/java/com/codename1/backend/push/CloudPushTest.java b/maven/backend/src/test/java/com/codename1/backend/push/CloudPushTest.java new file mode 100644 index 00000000000..96aec0d7899 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/push/CloudPushTest.java @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.push; + +import com.sun.net.httpserver.HttpExchange; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * CloudPush against a stub of the push service on a loopback port: what goes on + * the wire, and how each kind of answer comes back. + */ +class CloudPushTest { + private static final String APP_ID = "4f9c2d36-8a1e-4b7d-9c55-0e3b7a1f2c64"; + private static final String KEY = "srv_test_key"; + + private com.sun.net.httpserver.HttpServer stub; + private final AtomicInteger requests = new AtomicInteger(); + private final AtomicReference method = new AtomicReference(); + private final AtomicReference path = new AtomicReference(); + private final AtomicReference authorization = new AtomicReference(); + private final AtomicReference contentType = new AtomicReference(); + private final AtomicReference body = new AtomicReference(); + + private volatile int answerStatus = 202; + private volatile String answerBody = "{\"deliveryIds\":[\"d-1\",\"d-2\"]," + + "\"quota\":{\"used\":2,\"limit\":1000,\"seats\":1,\"period\":\"2026-10\"}}"; + private volatile String answerRetryAfter; + + @BeforeEach + void startStub() throws IOException { + stub = com.sun.net.httpserver.HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + stub.createContext("/", (HttpExchange exchange) -> { + requests.incrementAndGet(); + method.set(exchange.getRequestMethod()); + path.set(exchange.getRequestURI().getPath()); + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + contentType.set(exchange.getRequestHeaders().getFirst("Content-Type")); + body.set(new String(readAll(exchange.getRequestBody()), StandardCharsets.UTF_8)); + byte[] answer = answerBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + if (answerRetryAfter != null) { + exchange.getResponseHeaders().set("Retry-After", answerRetryAfter); + } + exchange.sendResponseHeaders(answerStatus, answer.length); + OutputStream out = exchange.getResponseBody(); + out.write(answer); + out.close(); + }); + stub.start(); + } + + @AfterEach + void stopStub() { + stub.stop(0); + } + + private CloudPush push() { + return new CloudPush(APP_ID, KEY).withEndpoint("http://127.0.0.1:" + + stub.getAddress().getPort() + "/api/v3/push/messages"); + } + + private static PushNotification hello() { + return PushNotification.builder().title("Hi").body("There").build(); + } + + @Test + @DisplayName("the request is the documented V3 call, byte for byte") + void requestOnTheWire() throws Exception { + Map state = new LinkedHashMap(); + state.put("status", "arriving"); + state.put("eta", Integer.valueOf(5)); + PushNotification message = PushNotification.builder() + .title("Hi") + .body("There") + .surface(PushNotification.liveUpdate("trip-42", state)) + .data("tripId", "42") + .build(); + + PushResult result = push().send(message, + PushTarget.of("gcm", "tok-1"), PushTarget.fromDeviceKey("cn1-ios-tok-2")); + + assertEquals("POST", method.get()); + assertEquals("/api/v3/push/messages", path.get()); + assertEquals("Bearer " + KEY, authorization.get()); + assertEquals("application/json", contentType.get()); + assertEquals("{\"appId\":\"" + APP_ID + "\"," + + "\"targets\":[{\"provider\":\"fcm\",\"token\":\"tok-1\"}," + + "{\"provider\":\"apns\",\"token\":\"tok-2\"}]," + + "\"message\":{\"schema\":3,\"title\":\"Hi\",\"body\":\"There\"," + + "\"surface\":{\"operation\":\"live-update\",\"id\":\"trip-42\"," + + "\"state\":\"{\\\"eta\\\":5,\\\"status\\\":\\\"arriving\\\"}\"}," + + "\"data\":{\"tripId\":\"42\"}}}", body.get()); + + assertEquals(202, result.getStatus()); + assertTrue(result.isAccepted()); + assertEquals(Arrays.asList("d-1", "d-2"), result.getDeliveryIds()); + assertNull(result.getError()); + assertEquals(-1, result.getRetryAfterSeconds()); + } + + @Test + @DisplayName("a 400 is a result carrying the service's error and message") + void badRequest() throws Exception { + answerStatus = 400; + answerBody = "{\"error\":\"invalid_target\",\"message\":\"Unknown provider\"}"; + PushResult result = push().send(hello(), PushTarget.of("fcm", "t")); + assertEquals(400, result.getStatus()); + assertFalse(result.isAccepted()); + assertEquals("invalid_target: Unknown provider", result.getError()); + assertTrue(result.getDeliveryIds().isEmpty()); + } + + @Test + @DisplayName("a 401 is a refused result, and an error code alone is reported as the code") + void unauthorized() throws Exception { + answerStatus = 401; + answerBody = "{\"error\":\"unauthorized\"}"; + PushResult result = push().send(hello(), PushTarget.of("fcm", "t")); + assertEquals(401, result.getStatus()); + assertFalse(result.isAccepted()); + // Not the body's text on this arm: HttpURLConnection, in the fixed-length + // streaming mode Web's Java SE twin posts with, disconnects on a 401 and + // hands back no error stream at all (measured: the same POST without + // streaming mode reads the body). So the result falls back to the status. + // The packaged arm reads the body; the parsing is checked directly below. + assertTrue(result.getError() != null && result.getError().length() > 0); + PushResult parsed = CloudPush.toResult(401, answerBody, null); + assertEquals("unauthorized", parsed.getError()); + assertEquals(-1, parsed.getRetryAfterSeconds()); + } + + @Test + @DisplayName("a 429 reports Retry-After in seconds") + void rateLimited() throws Exception { + answerStatus = 429; + answerRetryAfter = "30"; + answerBody = "{\"error\":\"rate_limited\",\"message\":\"Slow down\"}"; + PushResult result = push().send(hello(), PushTarget.of("fcm", "t")); + assertEquals(429, result.getStatus()); + assertEquals(30, result.getRetryAfterSeconds()); + assertEquals("rate_limited: Slow down", result.getError()); + } + + @Test + @DisplayName("an answer that isn't JSON still reports its status") + void notJson() throws Exception { + answerStatus = 502; + answerBody = "bad gateway"; + PushResult result = push().send(hello(), PushTarget.of("fcm", "t")); + assertEquals(502, result.getStatus()); + assertEquals("HTTP 502", result.getError()); + } + + @Test + @DisplayName("no answer at all is an IOException") + void transferFailure() throws Exception { + int port; + try (ServerSocket closed = new ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress())) { + port = closed.getLocalPort(); + } + CloudPush unreachable = new CloudPush(APP_ID, KEY) + .withEndpoint("http://127.0.0.1:" + port + "/api/v3/push/messages"); + IOException err = assertThrows(IOException.class, + () -> unreachable.send(hello(), PushTarget.of("fcm", "secret-token"))); + assertFalse(String.valueOf(err.getMessage()).contains(KEY)); + assertFalse(String.valueOf(err.getMessage()).contains("secret-token")); + } + + @Test + @DisplayName("arguments are checked before any request is made") + void validation() { + assertThrows(IllegalArgumentException.class, () -> new CloudPush(null, KEY)); + assertThrows(IllegalArgumentException.class, () -> new CloudPush(" ", KEY)); + assertThrows(IllegalArgumentException.class, () -> new CloudPush(APP_ID, "")); + assertThrows(IllegalArgumentException.class, () -> new CloudPush(APP_ID, KEY).withEndpoint("")); + CloudPush push = push(); + PushTarget target = PushTarget.of("fcm", "t"); + assertThrows(IllegalArgumentException.class, () -> push.send(null, target)); + assertThrows(IllegalArgumentException.class, + () -> push.send(PushNotification.builder().title("only a title").build(), target)); + assertThrows(IllegalArgumentException.class, () -> push.send(hello())); + assertThrows(IllegalArgumentException.class, + () -> push.send(hello(), (PushTarget[]) null)); + assertThrows(IllegalArgumentException.class, + () -> push.send(hello(), new ArrayList())); + List raw = new ArrayList(); + raw.add("cn1-fcm-token"); + assertThrows(IllegalArgumentException.class, () -> push.send(hello(), raw)); + assertEquals(0, requests.get()); + } + + @Test + @DisplayName("neither the key nor a token appears in toString") + void nothingSecretInToString() { + assertFalse(push().toString().contains(KEY)); + assertFalse(PushTarget.of("fcm", "secret-token").toString().contains("secret-token")); + assertEquals(CloudPush.DEFAULT_ENDPOINT, new CloudPush(APP_ID, KEY).getEndpoint()); + } + + @Test + @DisplayName("Retry-After takes delta seconds and nothing else") + void retryAfterParsing() { + assertEquals(120, CloudPush.parseRetryAfter(" 120 ")); + assertEquals(-1, CloudPush.parseRetryAfter(null)); + assertEquals(-1, CloudPush.parseRetryAfter("")); + assertEquals(-1, CloudPush.parseRetryAfter("Wed, 21 Oct 2026 07:28:00 GMT")); + assertEquals(-1, CloudPush.parseRetryAfter("-5")); + assertEquals(-1, CloudPush.parseRetryAfter("99999999999")); + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int n; + while ((n = in.read(chunk)) > 0) { + out.write(chunk, 0, n); + } + return out.toByteArray(); + } + + @Test + @DisplayName("a Turkish default locale doesn't change provider matching") + void turkishLocale() { + Locale saved = Locale.getDefault(); + try { + Locale.setDefault(new Locale("tr", "TR")); + assertEquals("apns", PushTarget.of("IOS", "t").getProvider()); + assertEquals("huawei", PushTarget.of("HMS", "t").getProvider()); + } finally { + Locale.setDefault(saved); + } + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/push/PushNotificationTest.java b/maven/backend/src/test/java/com/codename1/backend/push/PushNotificationTest.java new file mode 100644 index 00000000000..98236620a65 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/push/PushNotificationTest.java @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.push; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The envelope, the surface commands and the device targets, without a network. + */ +class PushNotificationTest { + + @Test + @DisplayName("every builder field lands in the schema-3 envelope") + void envelope() { + Map fcm = new LinkedHashMap(); + fcm.put("priority", "high"); + PushNotification message = PushNotification.builder() + .id("m-1") + .title("T") + .body("B") + .imageUrl("https://example.com/i.png") + .deepLink("app://trip/1") + .collapseKey("trip-1") + .ttlSeconds(600) + .silent(false) + .data("n", Integer.valueOf(3)) + .data("gone", "x") + .data("gone", null) + .platform("GCM", fcm) + .build(); + assertEquals("{\"schema\":3,\"id\":\"m-1\",\"title\":\"T\",\"body\":\"B\"," + + "\"image\":\"https://example.com/i.png\",\"deepLink\":\"app://trip/1\"," + + "\"collapseKey\":\"trip-1\",\"ttl\":600,\"silent\":false," + + "\"data\":{\"n\":3},\"platform\":{\"fcm\":{\"priority\":\"high\"}}}", + message.toJson()); + } + + @Test + @DisplayName("a built notification can't be changed through toMap or the builder") + void immutable() { + PushNotification.Builder builder = PushNotification.builder().body("first"); + PushNotification message = builder.build(); + builder.body("second"); + Map map = message.toMap(); + map.put("body", "third"); + assertEquals("first", message.toMap().get("body")); + } + + @Test + @DisplayName("ttl must be positive, keys must be clean, providers must exist") + void validation() { + PushNotification.Builder builder = PushNotification.builder(); + assertThrows(IllegalArgumentException.class, () -> builder.ttlSeconds(0)); + assertThrows(IllegalArgumentException.class, () -> builder.ttlSeconds(-1)); + assertThrows(IllegalArgumentException.class, () -> builder.data("", "v")); + assertThrows(IllegalArgumentException.class, () -> builder.data(" k", "v")); + assertThrows(IllegalArgumentException.class, + () -> builder.platform("telegraph", new LinkedHashMap())); + Map bogus = new LinkedHashMap(); + bogus.put("operation", "explode"); + assertThrows(IllegalArgumentException.class, () -> builder.surface(bogus)); + assertThrows(IllegalArgumentException.class, + () -> PushNotification.liveUpdate("", null)); + assertThrows(IllegalArgumentException.class, + () -> PushNotification.widget("kind", " ")); + } + + @Test + @DisplayName("liveUpdate serializes its state to a string with sorted keys") + void liveUpdate() { + Map state = new LinkedHashMap(); + state.put("zeta", Boolean.TRUE); + state.put("alpha", "a\"b"); + state.put("mid", Integer.valueOf(7)); + Map command = PushNotification.liveUpdate("ride-9", state); + assertEquals("live-update", command.get("operation")); + assertEquals("ride-9", command.get("id")); + assertEquals("{\"alpha\":\"a\\\"b\",\"mid\":7,\"zeta\":true}", command.get("state")); + assertEquals(3, command.size()); + assertEquals("{}", PushNotification.liveUpdate("ride-9", null).get("state")); + } + + @Test + @DisplayName("liveEnd carries the final state only when there is one") + void liveEnd() { + Map state = new LinkedHashMap(); + state.put("status", "done"); + Map withState = PushNotification.liveEnd("ride-9", state, false); + assertEquals("live-end", withState.get("operation")); + assertEquals("{\"status\":\"done\"}", withState.get("state")); + assertEquals(Boolean.FALSE, withState.get("dismissImmediately")); + + Map bare = PushNotification.liveEnd("ride-9", null, true); + assertFalse(bare.containsKey("state")); + assertEquals(Boolean.TRUE, bare.get("dismissImmediately")); + } + + @Test + @DisplayName("widget passes the timeline through as a string") + void widget() { + Map command = PushNotification.widget("eta", "{\"entries\":[]}"); + assertEquals("widget", command.get("operation")); + assertEquals("eta", command.get("kind")); + assertEquals("{\"entries\":[]}", command.get("timeline")); + PushNotification message = PushNotification.builder().surface(command).build(); + assertEquals("{\"schema\":3,\"surface\":{\"operation\":\"widget\",\"kind\":\"eta\"," + + "\"timeline\":\"{\\\"entries\\\":[]}\"}}", message.toJson()); + } + + @Test + @DisplayName("provider aliases normalize to the service's names") + void targets() { + assertEquals("fcm", PushTarget.of("fcm", "t").getProvider()); + assertEquals("fcm", PushTarget.of("GCM", "t").getProvider()); + assertEquals("apns", PushTarget.of("ios", "t").getProvider()); + assertEquals("apns", PushTarget.of("APNS", "t").getProvider()); + assertEquals("huawei", PushTarget.of("hms", "t").getProvider()); + assertEquals("huawei", PushTarget.of("Huawei", "t").getProvider()); + assertEquals("wns", PushTarget.of("wns", "t").getProvider()); + assertEquals("web", PushTarget.of("WEB", "t").getProvider()); + assertEquals("t", PushTarget.of("fcm", "t").getToken()); + assertThrows(IllegalArgumentException.class, () -> PushTarget.of("sms", "t")); + assertThrows(IllegalArgumentException.class, () -> PushTarget.of(null, "t")); + assertThrows(IllegalArgumentException.class, () -> PushTarget.of("fcm", "")); + assertThrows(IllegalArgumentException.class, () -> PushTarget.of("fcm", " ")); + assertThrows(IllegalArgumentException.class, () -> PushTarget.of("fcm", null)); + } + + @Test + @DisplayName("classic device keys lose their prefix and keep every dash of the token") + void deviceKeys() { + PushTarget fcm = PushTarget.fromDeviceKey("cn1-fcm-abc:def-ghi"); + assertEquals("fcm", fcm.getProvider()); + assertEquals("abc:def-ghi", fcm.getToken()); + assertEquals("fcm", PushTarget.fromDeviceKey("cn1-gcm-x").getProvider()); + PushTarget ios = PushTarget.fromDeviceKey("cn1-ios-0a1b2c"); + assertEquals("apns", ios.getProvider()); + assertEquals("0a1b2c", ios.getToken()); + assertEquals("huawei", PushTarget.fromDeviceKey("cn1-hms-y").getProvider()); + assertThrows(IllegalArgumentException.class, () -> PushTarget.fromDeviceKey(null)); + assertThrows(IllegalArgumentException.class, () -> PushTarget.fromDeviceKey("abc")); + assertThrows(IllegalArgumentException.class, () -> PushTarget.fromDeviceKey("cn1-fcm")); + assertThrows(IllegalArgumentException.class, () -> PushTarget.fromDeviceKey("cn1--tok")); + assertThrows(IllegalArgumentException.class, () -> PushTarget.fromDeviceKey("cn1-fcm-")); + assertThrows(IllegalArgumentException.class, () -> PushTarget.fromDeviceKey("cn1-pager-t")); + } + + @Test + @DisplayName("a result copies its ids and never answers null") + void result() { + PushResult none = new PushResult(202, null, null, -1); + assertEquals(0, none.getDeliveryIds().size()); + List ids = new java.util.ArrayList(); + ids.add("a"); + PushResult one = new PushResult(202, ids, null, -1); + ids.add("b"); + assertEquals(1, one.getDeliveryIds().size()); + assertThrows(UnsupportedOperationException.class, () -> one.getDeliveryIds().add("c")); + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/sql/ConcurrencyFailureTest.java b/maven/backend/src/test/java/com/codename1/backend/sql/ConcurrencyFailureTest.java new file mode 100644 index 00000000000..bea15cbf418 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/sql/ConcurrencyFailureTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.sql; + +import com.codename1.backend.ConcurrencyFailureException; +import com.codename1.backend.DataAccessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Which engine errors mean "another transaction got there first". + * + *

The difference matters to the caller and to nobody else: such a failure is + * answered by running the transaction again or by saying that someone else made + * the change, and any other failure is a fault. An error wrongly recognized is + * retried for ever, and one wrongly missed is a 500 for the loser of an ordinary + * race. + */ +class ConcurrencyFailureTest { + + @Test + @DisplayName("MySQL and MariaDB: a deadlock, a lock wait, NOWAIT and a changed record") + void recognizesMySqlFamilyConflicts() { + assertTrue(MySql.isConcurrencyFailure(1213, "40001")); + assertTrue(MySql.isConcurrencyFailure(1205, "HY000")); + assertTrue(MySql.isConcurrencyFailure(3572, "HY000")); + // MariaDB, holding a REPEATABLE READ transaction to its snapshot. + assertTrue(MySql.isConcurrencyFailure(1020, "HY000")); + // A number this client does not know, in the standard's rollback class. + assertTrue(MySql.isConcurrencyFailure(1637, "40000")); + // An error packet from before SQLSTATEs carries none. + assertTrue(MySql.isConcurrencyFailure(1213, "")); + } + + @Test + @DisplayName("MySQL and MariaDB: a duplicate key or a syntax error is not a conflict") + void leavesOtherMySqlErrorsAlone() { + assertFalse(MySql.isConcurrencyFailure(1062, "23000")); + assertFalse(MySql.isConcurrencyFailure(1064, "42000")); + assertFalse(MySql.isConcurrencyFailure(1146, "42S02")); + assertFalse(MySql.isConcurrencyFailure(1045, "")); + assertFalse(MySql.isConcurrencyFailure(2000, null)); + } + + @Test + @DisplayName("PostgreSQL: a serialization failure, a deadlock and an unavailable lock") + void recognizesPostgresConflicts() { + assertTrue(Postgres.isConcurrencyFailure("40001")); + assertTrue(Postgres.isConcurrencyFailure("40P01")); + assertTrue(Postgres.isConcurrencyFailure("55P03")); + } + + @Test + @DisplayName("PostgreSQL: a unique violation, an aborted transaction or no code is not a conflict") + void leavesOtherPostgresErrorsAlone() { + assertFalse(Postgres.isConcurrencyFailure("23505")); + assertFalse(Postgres.isConcurrencyFailure("25P02")); + assertFalse(Postgres.isConcurrencyFailure("42601")); + // Rolled back for a constraint checked at commit: running it again + // fails again. + assertFalse(Postgres.isConcurrencyFailure("40002")); + assertFalse(Postgres.isConcurrencyFailure(null)); + } + + @Test + @DisplayName("a conflict is found under whatever wrapped it") + void findsTheConflictInAChainOfCauses() { + ConcurrencyFailureException conflict = new ConcurrencyFailureException("deadlock"); + assertTrue(ConcurrencyFailureException.isCauseOf(conflict)); + // What an ORM session throws: unchecked, with the failure as its cause. + assertTrue(ConcurrencyFailureException.isCauseOf( + new RuntimeException("flush failed", new IOException("wrapped", conflict)))); + // It is a data access failure, so a transaction rolls back for it. + assertTrue(DataAccessException.class.isInstance(conflict)); + } + + @Test + @DisplayName("any other failure is not a conflict, and neither is none") + void answersFalseForEverythingElse() { + assertFalse(ConcurrencyFailureException.isCauseOf(null)); + assertFalse(ConcurrencyFailureException.isCauseOf(new DataAccessException("syntax"))); + assertFalse(ConcurrencyFailureException.isCauseOf( + new RuntimeException("flush failed", new IOException("the connection dropped")))); + } + + @Test + @DisplayName("a chain of causes that loops ends the search") + void survivesACycleOfCauses() { + RuntimeException first = new RuntimeException("first"); + RuntimeException second = new RuntimeException("second", first); + first.initCause(second); + assertFalse(ConcurrencyFailureException.isCauseOf(first)); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java b/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java index 2b538a188c5..426e36a5a28 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java @@ -88,6 +88,53 @@ public AnnotationProcessing(Log log, File outputDirectory, File stubSourceDirect this.compileClasspath = compileClasspath == null ? Collections.emptyList() : compileClasspath; } + /// Class directories on the compile classpath that hold this module's OWN + /// classes, compiled by another pass of the same build. + private List ownClassDirectories = Collections.emptyList(); + + /// Names the classpath entries that are the module's own classes and not a + /// dependency's, so no dependency class is offered out of them. + /// + /// A project in two languages is compiled in two passes, and each pass has + /// the other's classes on its classpath: Kotlin's pass sees the Java it has + /// not compiled yet, javac's sees Kotlin's output. Those are this module's + /// classes and the other pass processes them. Read as a dependency's, a + /// `@Mapped` Java class was mapped by Kotlin's pass and then again by its + /// own, and the build stopped on two copies of one generated class. + /// + /// The entries stay on the classpath -- types are still resolved from them. + public AnnotationProcessing withOwnClassDirectories(List directories) { + this.ownClassDirectories = directories == null + ? Collections.emptyList() : new ArrayList(directories); + return this; + } + + /// The compile classpath without [#ownClassDirectories]. + private List dependencyClasspath() { + if (ownClassDirectories.isEmpty()) { + return compileClasspath; + } + Set own = new java.util.HashSet(); + for (File directory : ownClassDirectories) { + own.add(canonical(directory)); + } + List out = new ArrayList(); + for (String element : compileClasspath) { + if (!own.contains(canonical(new File(element)))) { + out.add(element); + } + } + return out; + } + + private static File canonical(File file) { + try { + return file.getCanonicalFile(); + } catch (java.io.IOException e) { + return file.getAbsoluteFile(); + } + } + /// Runs every processor. public void run() throws BuildExecutionException { List classpath = new java.util.ArrayList(); @@ -174,6 +221,8 @@ private void runProcessors() throws BuildExecutionException { } } + offerDependencyClasses(processors, ctx); + // finish() for (Iterator it = processors.iterator(); it.hasNext(); ) { AnnotationProcessor p = it.next(); @@ -274,6 +323,65 @@ private void runProcessors() throws BuildExecutionException { } } + /// Offers the annotated classes of the module's dependencies to the + /// processors that asked for them. + /// + /// This is what lets a library be shared between an application and its + /// server: the REST contract and the objects it transfers are compiled once, + /// in a module both depend on, and each side generates its own half from + /// them here. Without it a class outside the module's own output directory + /// was invisible to every processor. + /// + /// A class is not offered when the module itself defines one of the same + /// name -- the module's own definition shadows it on the classpath, and has + /// already been processed above. + private void offerDependencyClasses(List processors, ProcessorContext ctx) + throws BuildExecutionException { + List interested = new ArrayList(); + Set descriptors = new java.util.LinkedHashSet(); + for (AnnotationProcessor p : processors) { + if (p instanceof com.codename1.maven.annotations.ProcessesDependencyClasses + && ((com.codename1.maven.annotations.ProcessesDependencyClasses) p) + .acceptsDependencyClasses(ctx)) { + interested.add(p); + descriptors.addAll(p.getAnnotationDescriptors()); + } + } + if (interested.isEmpty()) { + return; + } + Map dependencies; + try { + dependencies = com.codename1.maven.annotations.DependencyClasses.scan( + dependencyClasspath(), outputDirectory, descriptors); + } catch (ProcessingException e) { + throw new BuildExecutionException("Failed to scan the classes of " + + outputDirectory + "'s dependencies: " + e.getMessage(), e); + } + if (dependencies.isEmpty()) { + return; + } + ctx.setDependencyIndex(dependencies); + for (AnnotatedClass cls : dependencies.values()) { + if (ctx.lookup(cls.getInternalName()) != null) { + continue; + } + Set present = cls.getAllAnnotationDescriptors(); + for (AnnotationProcessor p : interested) { + if (intersects(p.getAnnotationDescriptors(), present)) { + try { + p.processClass(cls, ctx); + } catch (ProcessingException e) { + throw new BuildFailureException( + "Annotation processor " + p.getClass().getName() + + " failed on dependency class " + cls.getBinaryName() + + ": " + e.getMessage(), e); + } + } + } + } + } + private static boolean intersects(Set a, Set b) { if (a == null || b == null || a.isEmpty() || b.isEmpty()) return false; if (a.size() > b.size()) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java b/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java index 66c42efdc5a..accb54499e2 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java @@ -1117,7 +1117,14 @@ protected int requiredJavaFeature() throws BuildFailureException { /// Use the explicit JDK or the build JVM; retain the legacy override for Java 8 projects. File resolveJdk() throws BuildFailureException { // The legacy environment override only applies to Java 8 projects. - String[] configured = {jdkHome, requiredJavaFeature() <= 8 ? jdk8Home : null}; + // ...and only while every dependency is Java 8 bytecode too. A Java 8 + // application may depend on a contract module compiled at release 17 + // (records as transfer objects are the reason to), and JDK 8's javac + // cannot read a version 61 class file: the compile fails with "class + // file has wrong version 61.0, should be 52.0" on a project the JVM + // build and tests accept. + String[] configured = {jdkHome, + requiredJavaFeature() <= 8 && !dependencyNeedsNewerThanJava8() ? jdk8Home : null}; for (int i = 0; i < configured.length; i++) { if (configured[i] != null && configured[i].length() > 0) { File home = new File(configured[i]); @@ -1142,6 +1149,106 @@ File resolveJdk() throws BuildFailureException { + "-Dcn1.backend.jdk at one."); } + /** + * True when a class file on the compile classpath is newer than Java 8. + * + * `module-info.class` and everything under `META-INF/` are skipped: many Java 8 + * libraries ship a version 53 module descriptor or multi-release classes + * that a Java 8 compile never reads. A classpath that cannot be resolved here + * answers false, which keeps the configured JDK 8 -- the compile that follows + * reports the real problem. + */ + boolean dependencyNeedsNewerThanJava8() { + List classpath; + try { + classpath = compileClasspathWithoutRuntime(); + } catch (Exception unresolved) { + return false; + } + for (String element : classpath) { + File file = new File(element); + try { + if (file.isDirectory() ? newerThanJava8(file, file) : file.isFile() + && newerThanJava8(file)) { + return true; + } + } catch (IOException unreadable) { + // Not a class container we can read; javac will say so if it matters. + } + } + return false; + } + + private static boolean newerThanJava8(File root, File dir) throws IOException { + File[] children = dir.listFiles(); + if (children == null) { + return false; + } + for (File child : children) { + if (child.isDirectory()) { + if (!(dir.equals(root) && "META-INF".equals(child.getName())) + && newerThanJava8(root, child)) { + return true; + } + } else if (child.getName().endsWith(".class") + && !"module-info.class".equals(child.getName())) { + java.io.InputStream in = new java.io.FileInputStream(child); + try { + if (classMajor(in) > 52) { + return true; + } + } finally { + in.close(); + } + } + } + return false; + } + + private static boolean newerThanJava8(File jar) throws IOException { + java.util.zip.ZipFile zip = new java.util.zip.ZipFile(jar); + try { + java.util.Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + java.util.zip.ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if (entry.isDirectory() || !name.endsWith(".class") || name.startsWith("META-INF/") + || name.endsWith("module-info.class")) { + continue; + } + java.io.InputStream in = zip.getInputStream(entry); + try { + if (classMajor(in) > 52) { + return true; + } + } finally { + in.close(); + } + } + } finally { + zip.close(); + } + return false; + } + + /// The class file major version, or -1 for anything that is not a class file. + static int classMajor(java.io.InputStream in) throws IOException { + byte[] head = new byte[8]; + int read = 0; + while (read < head.length) { + int n = in.read(head, read, head.length - read); + if (n < 0) { + return -1; + } + read += n; + } + if ((head[0] & 0xff) != 0xCA || (head[1] & 0xff) != 0xFE + || (head[2] & 0xff) != 0xBA || (head[3] & 0xff) != 0xBE) { + return -1; + } + return ((head[6] & 0xff) << 8) | (head[7] & 0xff); + } + /** * Refuses a JDK older than 8, whose javac cannot emit the format the * translator reads. diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BackendWebApp.java b/maven/build-engine/src/main/java/com/codename1/maven/BackendWebApp.java new file mode 100644 index 00000000000..e0f3500a9e6 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendWebApp.java @@ -0,0 +1,592 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/// Puts the browser build of an application where its server serves it from. +/// +/// A project with a backend module usually wants one address that is both the +/// API and the application: a person opens it and is using the app, with +/// nothing installed. The JavaScript build already produces everything a +/// browser needs as one bundle, and the server already knows how to serve a +/// directory; this is the step between them, shared by the Maven +/// `cn1:backend-webapp` goal and anything else that stages a bundle. +/// +/// Three things happen here, and each is here because doing it by hand goes +/// wrong quietly: +/// +/// - **The bundle is found.** It is whichever module builds for the browser -- +/// a `javascript` module, or `common` standing in for one -- and its name +/// carries the version, so a script that hard-codes it breaks at the next +/// release. +/// - **It is unpacked with its names checked.** An archive entry is a path +/// somebody else wrote. One that climbs out of the directory is refused +/// rather than followed. +/// - **Text is compressed once, here.** The translated application is several +/// megabytes of JavaScript that shrinks to about a fifth. The server sends a +/// file straight from the page cache without reading it, which is what makes +/// it fast and also what stops it compressing on the way out -- so the +/// compressed copy is written beside the file now, and the server picks it +/// for a browser that accepts it. +public final class BackendWebApp { + + /// The file whose presence makes a directory an application. + public static final String INDEX = "index.html"; + + /// The setting the server reads its application's directory from. + public static final String ROOT_PROPERTY = "cn1.webapp.root"; + /// The setting that names the profile a server runs on. + static final String PROFILE_PROPERTY = "cn1.profile"; + + /// Extensions worth a compressed copy: text, and the formats that are text + /// in all but name. Images, fonts in a compressed container and archives + /// are already as small as gzip will make them. + private static final Set COMPRESSIBLE = new HashSet(Arrays.asList( + "html", "htm", "css", "js", "mjs", "json", "svg", "txt", "xml", "map", + "wasm", "ttf", "otf", "res", "webmanifest")); + + /// Below this a compressed copy saves less than the header that announces it. + private static final long SMALLEST_WORTH_COMPRESSING = 1024; + + private final Log log; + + public BackendWebApp(Log log) { + this.log = log; + } + + /// The browser bundle the application at `appRoot` last built, or null when + /// it has built none. + /// + /// Looked for in the `javascript` module and then in `common`, which builds + /// for the browser itself in a project that has no such module. Among + /// several archives the newest that holds an `index.html` wins: `target` + /// also collects the source archives sent to a build server, and those are + /// not applications. + public static File locateBundle(File appRoot) { + for (String module : new String[] {"javascript", "common"}) { + File best = locateBundleIn(new File(appRoot, module + "/target")); + if (best != null) { + return best; + } + } + return null; + } + + /// The browser bundle in `buildDirectory`, the directory a build writes its + /// results to, or null when it holds none: the newest archive there that + /// holds an `index.html`. + /// + /// This is the whole answer for a project whose one build directory is + /// every platform's, as a Gradle project's is. + public static File locateBundleIn(File buildDirectory) { + File[] found = buildDirectory == null ? null : buildDirectory.listFiles(); + if (found == null) { + return null; + } + File best = null; + for (File candidate : found) { + if (!candidate.isFile() || !endsWithIgnoreCase(candidate.getName(), ".zip")) { + continue; + } + if (!holdsIndex(candidate)) { + continue; + } + if (best == null || candidate.lastModified() > best.lastModified()) { + best = candidate; + } + } + return best; + } + + /// Whether the application's settings already say how the browser build + /// reaches other origins. + /// + /// The JavaScript build routes cross-origin requests through a proxy + /// servlet it expects to find beside the page. A backend is not a servlet + /// container and has no such path, so a hosted build is made without it -- + /// unless the project has chosen for itself, in which case its choice + /// stands. + public static boolean choosesProxy(File settings) throws BuildExecutionException { + if (settings == null || !settings.isFile()) { + return false; + } + Properties declared = new Properties(); + try { + InputStream in = new FileInputStream(settings); + try { + declared.load(in); + } finally { + in.close(); + } + } catch (IOException err) { + throw new BuildExecutionException("Could not read " + settings, err); + } + return declared.getProperty("codename1.arg.javascript.inject_proxy") != null + || declared.getProperty("codename1.arg.javascript.proxy.url") != null; + } + + /// Whether the server module already says where its application is, so the + /// build must not answer for it. + /// + /// A value passed as a system property outranks the module's own files, so + /// supplying one unasked would silently override a project that had + /// configured this itself. The three places a project can say so are the + /// JVM arguments, the environment and the settings files in the module. + public static boolean rootIsConfigured(File moduleDir, Map environment, String jvmArgs) + throws BuildExecutionException { + if (jvmArgs != null && jvmArgs.indexOf("-D" + ROOT_PROPERTY + "=") >= 0) { + return true; + } + if (environment != null && environment.get("CN1_WEBAPP_ROOT") != null) { + return true; + } + // Only the files this run will read: application.properties and the + // active profile's. A root declared in application-dev.properties says + // nothing about a server started on the default profile, which never + // loads that file -- treating it as configured withheld the staged + // application and left the server answering 404 for its own pages. + Properties base = declaredIn(new File(moduleDir, "application.properties")); + if (base.getProperty(ROOT_PROPERTY) != null) { + return true; + } + String profile = activeProfile(base, environment, jvmArgs); + if (profile == null) { + // Named in a way this cannot follow. Fall back to the careful + // answer: a root declared in any profile file counts. + File[] files = moduleDir.listFiles(); + if (files == null) { + return false; + } + for (File file : files) { + String name = file.getName(); + if (file.isFile() && name.startsWith("application-") && name.endsWith(".properties") + && declaredIn(file).getProperty(ROOT_PROPERTY) != null) { + return true; + } + } + return false; + } + return declaredIn(new File(moduleDir, "application-" + profile + ".properties")) + .getProperty(ROOT_PROPERTY) != null; + } + + /// The profile the server will run on, settled the way the server settles + /// it: a `-Dcn1.profile` argument, then `CN1_PROFILE`, then `cn1.profile` + /// in `application.properties`, then `default`. Null when the file names + /// it through a placeholder this cannot resolve. + static String activeProfile(Properties base, Map environment, String jvmArgs) { + String flag = "-D" + PROFILE_PROPERTY + "="; + int at = jvmArgs == null ? -1 : jvmArgs.lastIndexOf(flag); + if (at >= 0) { + int end = at + flag.length(); + while (end < jvmArgs.length() && !Character.isWhitespace(jvmArgs.charAt(end))) { + end++; + } + String named = jvmArgs.substring(at + flag.length(), end); + if (named.length() > 0) { + return named; + } + } + String fromEnvironment = environment == null ? null : environment.get("CN1_PROFILE"); + if (fromEnvironment != null && fromEnvironment.length() > 0) { + return fromEnvironment; + } + String named = base.getProperty(PROFILE_PROPERTY); + if (named == null) { + return "default"; + } + named = named.trim(); + if (named.startsWith("${") && named.endsWith("}") && named.indexOf("${", 2) < 0) { + // ${NAME:fallback}, the one form a profile is usually written in. + String inner = named.substring(2, named.length() - 1); + int colon = inner.indexOf(':'); + String variable = colon < 0 ? inner : inner.substring(0, colon); + String value = environment == null ? null : environment.get(variable); + if (value == null || value.length() == 0) { + value = colon < 0 ? "" : inner.substring(colon + 1); + } + named = value; + } + if (named.indexOf("${") >= 0) { + return null; + } + return named.length() == 0 ? "default" : named; + } + + private static Properties declaredIn(File file) throws BuildExecutionException { + Properties declared = new Properties(); + if (!file.isFile()) { + return declared; + } + try { + InputStream in = new FileInputStream(file); + try { + declared.load(in); + } finally { + in.close(); + } + } catch (IOException err) { + throw new BuildExecutionException("Could not read " + file, err); + } + return declared; + } + + /// Replaces `output` with the application in `bundle`, which is the archive + /// a browser build produces or a directory already unpacked from one. + /// + /// @return the number of files staged, compressed copies not counted + public int stage(File bundle, File output) throws BuildFailureException, BuildExecutionException { + if (bundle == null || !bundle.exists()) { + throw new BuildFailureException("There is no browser build at " + bundle); + } + try { + File canonicalOutput = output.getCanonicalFile(); + if (bundle.isDirectory() && isInside(canonicalOutput, bundle.getCanonicalFile())) { + throw new BuildFailureException("The browser build " + bundle + " contains the " + + "directory it would be staged into, " + output); + } + // The other way round: the output is emptied first, and a bundle (a + // directory or an archive) inside it would be deleted before it is + // read. + if (isInside(bundle.getCanonicalFile(), canonicalOutput)) { + throw new BuildFailureException("The browser build " + bundle + " is inside the " + + "directory it would be staged into, " + output); + } + delete(canonicalOutput); + if (!canonicalOutput.mkdirs() && !canonicalOutput.isDirectory()) { + throw new BuildExecutionException("Could not create " + canonicalOutput); + } + int count = bundle.isDirectory() + ? copyTree(bundle, canonicalOutput) + : unpack(bundle, canonicalOutput); + if (!new File(canonicalOutput, INDEX).isFile()) { + delete(canonicalOutput); + throw new BuildFailureException(bundle + " holds no " + INDEX + + ", so it is not a browser build of an application"); + } + long[] saved = new long[2]; + compress(canonicalOutput, saved); + log.info("Staged " + count + " files of the web app in " + canonicalOutput + + (saved[0] > 0 ? " (" + saved[0] / 1024 + " KB of text, " + + saved[1] / 1024 + " KB compressed)" : "")); + return count; + } catch (IOException err) { + throw new BuildExecutionException("Could not stage " + bundle + " into " + output, err); + } + } + + /// Copies a staged application to `destination`, for a server packaged + /// somewhere other than beside it. + public int copy(File staged, File destination) throws BuildExecutionException { + try { + File target = destination.getCanonicalFile(); + File source = staged.getCanonicalFile(); + if (target.equals(source)) { + return 0; + } + // A binary written under the staged app (target/webapp/bin/server) + // puts the copy inside its own source, and the tree walk would copy + // the copy into itself until the path is too long. The other way + // round, delete(target) would remove the source before reading it. + if (isInside(target, source) || isInside(source, target)) { + throw new BuildExecutionException("The web app staged in " + source + + " cannot be copied to " + target + ", which " + + (isInside(target, source) ? "is inside it" : "contains it") + + ". Write the server binary somewhere else"); + } + delete(target); + if (!target.mkdirs() && !target.isDirectory()) { + throw new BuildExecutionException("Could not create " + target); + } + return copyTree(staged, target); + } catch (IOException err) { + throw new BuildExecutionException("Could not copy " + staged + " to " + destination, err); + } + } + + private int unpack(File archive, File output) throws IOException, BuildFailureException { + ZipFile zip = new ZipFile(archive); + try { + String strip = wrapperDirectory(zip); + String root = output.getPath() + File.separator; + int count = 0; + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName().replace('\\', '/'); + if (strip != null) { + if (!name.startsWith(strip)) { + continue; + } + name = name.substring(strip.length()); + } + if (name.length() == 0) { + continue; + } + File target = new File(output, name).getCanonicalFile(); + // The entry's name was written by whoever made the archive. One + // that resolves outside the directory is the whole of the + // "zip slip" attack, so it stops the build rather than being + // skipped: an archive that tries it is not one to serve. + if (!target.getPath().startsWith(root)) { + throw new BuildFailureException(archive + " holds the entry " + entry.getName() + + ", which would be written outside " + output); + } + if (entry.isDirectory()) { + if (!target.mkdirs() && !target.isDirectory()) { + throw new IOException("Could not create " + target); + } + continue; + } + File parent = target.getParentFile(); + if (!parent.mkdirs() && !parent.isDirectory()) { + throw new IOException("Could not create " + parent); + } + InputStream in = zip.getInputStream(entry); + try { + write(in, target); + } finally { + in.close(); + } + count++; + } + return count; + } finally { + zip.close(); + } + } + + /// The single directory everything in `zip` sits under, with its trailing + /// slash, or null when the application is at the top of the archive. + private static String wrapperDirectory(ZipFile zip) { + if (zip.getEntry(INDEX) != null) { + return null; + } + String wrapper = null; + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + String name = entries.nextElement().getName().replace('\\', '/'); + int slash = name.indexOf('/'); + if (slash <= 0) { + return null; + } + String top = name.substring(0, slash + 1); + if (wrapper == null) { + wrapper = top; + } else if (!wrapper.equals(top)) { + return null; + } + } + return wrapper; + } + + private static boolean holdsIndex(File archive) { + try { + ZipFile zip = new ZipFile(archive); + try { + if (zip.getEntry(INDEX) != null) { + return true; + } + String wrapper = wrapperDirectory(zip); + return wrapper != null && zip.getEntry(wrapper + INDEX) != null; + } finally { + zip.close(); + } + } catch (IOException notAnArchive) { + return false; + } + } + + private int copyTree(File from, File to) throws IOException { + File[] children = from.listFiles(); + if (children == null) { + return 0; + } + int count = 0; + for (File child : children) { + File target = new File(to, child.getName()); + // Follows symbolic links on purpose. The source is the project's + // own web build output, and a link its author put there (a shared + // asset folder, say) is content they mean to publish, as the + // Maven resources plugin treats it. A link back to the bundle + // itself or one of its ancestors is refused before it is followed, + // rather than copied into itself until the path gives out. The + // OUTPUT side is different: delete() never descends into a link + // there, because emptying it would delete the target's files. + if (child.isDirectory()) { + if (Files.isSymbolicLink(child.toPath())) { + String linked = child.getCanonicalPath() + File.separator; + if ((from.getCanonicalPath() + File.separator).startsWith(linked)) { + throw new IOException(child + " links back to " + linked + + ", which contains it; the browser build cannot be copied"); + } + } + if (!target.mkdirs() && !target.isDirectory()) { + throw new IOException("Could not create " + target); + } + count += copyTree(child, target); + } else { + InputStream in = new FileInputStream(child); + try { + write(in, target); + } finally { + in.close(); + } + // Kept, so the server's validators do not change for a file + // that did not. + if (!target.setLastModified(child.lastModified())) { + log.debug("Could not keep the modification time of " + target); + } + if (!child.getName().endsWith(".gz")) { + count++; + } + } + } + return count; + } + + /// Writes a `.gz` beside every file under `dir` that is text and shrinks. + private void compress(File dir, long[] saved) throws IOException { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + List files = new ArrayList(Arrays.asList(children)); + for (File child : files) { + if (child.isDirectory()) { + compress(child, saved); + continue; + } + String name = child.getName(); + int dot = name.lastIndexOf('.'); + if (dot < 0 || child.length() < SMALLEST_WORTH_COMPRESSING + || !COMPRESSIBLE.contains(asciiLower(name.substring(dot + 1)))) { + continue; + } + File packed = new File(dir, name + ".gz"); + InputStream in = new FileInputStream(child); + try { + OutputStream out = new GZIPOutputStream(new FileOutputStream(packed)); + try { + pump(in, out); + } finally { + out.close(); + } + } finally { + in.close(); + } + // A copy that saves under a tenth is not worth a second file and a + // decompression in every browser. + if (packed.length() * 10 > child.length() * 9) { + if (!packed.delete()) { + throw new IOException("Could not remove " + packed); + } + continue; + } + saved[0] += child.length(); + saved[1] += packed.length(); + } + } + + private static void write(InputStream in, File target) throws IOException { + OutputStream out = new FileOutputStream(target); + try { + pump(in, out); + } finally { + out.close(); + } + } + + private static void pump(InputStream in, OutputStream out) throws IOException { + byte[] chunk = new byte[65536]; + int read = in.read(chunk); + while (read >= 0) { + out.write(chunk, 0, read); + read = in.read(chunk); + } + } + + private static void delete(File file) throws IOException { + // A symbolic link is removed as a link and never descended into. + // listFiles() follows it, so a link in the old output (or in a copy + // destination) to a directory outside the tree would otherwise have + // that directory's contents deleted before the link itself. A dangling + // link is caught here too: File.exists() follows the link, answers + // false for one, and the link would have been left behind. + if (Files.isSymbolicLink(file.toPath())) { + Files.delete(file.toPath()); + return; + } + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + delete(child); + } + } + if (file.exists() && !file.delete()) { + throw new IOException("Could not remove " + file); + } + } + + private static boolean isInside(File file, File dir) { + return (file.getPath() + File.separator).startsWith(dir.getPath() + File.separator); + } + + private static boolean endsWithIgnoreCase(String value, String suffix) { + return value.length() >= suffix.length() + && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, suffix.length()); + } + + /// File extensions are ASCII; folding them with the default locale would + /// turn `JS` into something else on a machine set to Turkish. + private static String asciiLower(String value) { + StringBuilder out = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + out.append(c >= 'A' && c <= 'Z' ? (char) (c + 32) : c); + } + return out.toString(); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BytecodeCompliance.java b/maven/build-engine/src/main/java/com/codename1/maven/BytecodeCompliance.java index 393a7480fa4..2f5992c24e7 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/BytecodeCompliance.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/BytecodeCompliance.java @@ -94,6 +94,7 @@ public class BytecodeCompliance { protected final ProjectHost host; private List siblingClassRoots = Collections.emptyList(); + private List checkedDependencyRoots = Collections.emptyList(); private boolean backendCheck; @@ -115,6 +116,21 @@ public BytecodeCompliance siblingClassRoots(List roots) { return this; } + /// Class directories of plain modules this project depends on from the same + /// build, which are checked along with the project's own classes. + /// + /// A module that holds the classes an application shares with its server -- + /// the DTOs and the `@RestClient` contracts -- is an ordinary jar module, so + /// nothing checks it when it is built: it compiles against the whole JDK. + /// Its classes end up in the application all the same, and one that names a + /// class the device runtime lacks would pass every step here and fail in the + /// device build, far from the line that caused it. Checking them where they + /// are consumed reports it at once, against the module's own class file. + public BytecodeCompliance checkedDependencyRoots(List roots) { + this.checkedDependencyRoots = roots == null ? Collections.emptyList() : new ArrayList(roots); + return this; + } + /// Internal names (`a/b/C`) of this project's classes that are not compiled /// yet, so a reference to one -- or to a class nested in one -- is the /// project's own and allowed. Kotlin compiles before javac, so a Kotlin class @@ -323,6 +339,21 @@ public void execute() throws BuildExecutionException { projectAndDependencyIndex.putAll(buildClassIndex(siblingClassRoots)); List violations = scanProjectClasses(outputDir, allowedIndex, projectAndDependencyIndex); + // A shared module's classes are checked as they are, never through + // applyInvocationRewrites, so a call the rewrites would redirect in an + // application class (String.replaceAll, the wrappers' static hashCode) + // is reported here instead. That is deliberate. The same classes are + // translated for the server too, against vm/JavaAPI, which has none of + // those methods and gets no rewrites (checkBackend applies none, and + // the server runtime has no JdkApiRewriteHelper). A rewritten client + // overlay would only move the failure into the native backend build, + // far from the line that caused it; a shared module keeps to what both + // runtimes define. + for (File root : checkedDependencyRoots) { + if (root != null && root.isDirectory()) { + violations.addAll(scanProjectClasses(root, allowedIndex, projectAndDependencyIndex)); + } + } if (!violations.isEmpty()) { writeComplianceReport(violations, outputDir, dependencyJars, rewrittenClassCount); logViolationSummary(violations); @@ -463,7 +494,7 @@ private boolean lastCheckFailed() throws IOException { private static final FilenameFilter CLASS_FILES_FILTER = (dir, name) -> name.endsWith(".class"); - private long getCompiledClassesModificationTime() { + long getCompiledClassesModificationTime() { long mTime = lastModifiedRecursive(new File(host.outputDirectory().getPath()), CLASS_FILES_FILTER); // With kotlin.compiler.incremental the Kotlin compiler writes to its // own output tree which executeImpl copies into the output directory, @@ -472,6 +503,13 @@ private long getCompiledClassesModificationTime() { if (kotlinIncrementalOutputDir.exists()) { mTime = Math.max(mTime, lastModifiedRecursive(kotlinIncrementalOutputDir, CLASS_FILES_FILTER)); } + // The sibling modules checked with these classes (a `shared` module of + // DTOs and contracts). They are compiled by their own module, so a + // change to one alone leaves this module's sources and classes as they + // were, and the check would be skipped without reading the new ones. + for (File root : checkedDependencyRoots) { + mTime = Math.max(mTime, lastModifiedRecursive(root, CLASS_FILES_FILTER)); + } return mTime; } @@ -1196,7 +1234,12 @@ private List getDependencyJarsForScanning() { || "runtime".equals(artifact.getScope()) || "test".equals(artifact.getScope())) { File jar = host.getJar(artifact); - if (isClassArchive(jar)) { + // A module of the same build resolves to its classes DIRECTORY + // whenever the build stops short of `package` -- `mvn compile`, + // an IDE's incremental build. Left out, every class of that + // module was reported as a forbidden API, hundreds of + // violations naming the application's own DTOs. + if (isClassArchive(jar) || (jar != null && jar.isDirectory())) { jars.add(jar); } } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/Cn1TestRunner.java b/maven/build-engine/src/main/java/com/codename1/maven/Cn1TestRunner.java index c21f7808451..48dcb54a55a 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/Cn1TestRunner.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/Cn1TestRunner.java @@ -111,6 +111,38 @@ public int compare(Class t, Class t1) { } } + /// The runner's exit status when a test failed. Any other status that is + /// not 0 means the runner ended without reporting on its tests. + static final int EXIT_TESTS_FAILED = 100; + + /// What a failed run is told to the developer as. + /// + /// A failed test has already been reported by the runner, and is "Tests + /// failed". The runner's process ending any other way has reported nothing, + /// and "Tests failed" under the output of a test that was passing sends the + /// reader looking for a failure in that test. The usual case is the process + /// being killed: a status over 128 is 128 plus the signal, and signal 9 on + /// Linux is most often the kernel's out-of-memory killer, which leaves no + /// stack trace, no message and no crash file behind. + /// + /// @param status the runner's exit status, which is not 0 + public static String describeFailure(int status) { + if (status == EXIT_TESTS_FAILED) { + return "Tests failed"; + } + String text = "The test runner's process ended with exit status " + status + + " without reporting a result; no test failed"; + if (status > 128 && status < 160) { + int signal = status - 128; + text += ". It was killed by signal " + signal; + if (signal == 9) { + text += ", which is what the operating system's out-of-memory killer sends:" + + " check the memory available to this machine or container"; + } + } + return text; + } + /// Forks the test runner. /// /// @param java the task to fork with, already set up for logging diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java index ecd435bd820..ced17c086b3 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java @@ -146,6 +146,17 @@ public String getSourceName() { /// enclosing it are public. public boolean isAccessibleFromAnywhere() { return accessible; } + private boolean nameableInPackage = true; + void setNameableInPackage(boolean nameableInPackage) { + this.nameableInPackage = nameableInPackage; + } + /// Whether another top-level class of this class's own package can write its + /// name: neither it nor a class enclosing it is private, local or anonymous. + /// A private member class is package-private in its class file, so calling + /// through it is legal bytecode -- it is only Java SOURCE that cannot say + /// its name, which is what generated source has to know. + public boolean isNameableInPackage() { return nameableInPackage; } + private String sourceName; public String getInternalName() { return internalName; } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/ClassScanner.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ClassScanner.java index 348c26f24ed..a38844a06ca 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/annotations/ClassScanner.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ClassScanner.java @@ -162,6 +162,7 @@ AnnotatedClass build() { out.setSignature(signature); out.setSourceName(sourceNameOf(internalName)); out.setAccessible(accessibleFromAnywhere(internalName)); + out.setNameableInPackage(nameableInPackage(internalName)); return out; } @@ -198,9 +199,38 @@ public void visitInnerClass(String name, String outerName, String innerName, if (name != null && outerName != null && innerName != null) { nesting.put(name, new String[] { outerName, innerName }); this.innerAccess.put(name, Integer.valueOf(innerAccess)); + } else if (name != null) { + unnamed.add(name); } } + /** The local and anonymous classes of the chain, which no source can name. */ + private final java.util.Set unnamed = new java.util.HashSet(); + + /** + * Whether another class of the SAME package can name this class: nothing + * in its chain is private, local or anonymous. The declared flags again, + * because the class file of a private member class says package-private. + */ + private boolean nameableInPackage(String internal) { + String current = internal; + while (current != null) { + if (unnamed.contains(current)) { + return false; + } + Integer declared = innerAccess.get(current); + String[] entry = nesting.get(current); + if (declared == null || entry == null) { + return true; + } + if ((declared.intValue() & Opcodes.ACC_PRIVATE) != 0) { + return false; + } + current = entry[0]; + } + return true; + } + /** * Whether code in ANOTHER package can name this class: it is public, and so * is every class it is nested in. The class file's own flags cannot say diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/DependencyClasses.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/DependencyClasses.java new file mode 100644 index 00000000000..b084b31a9a7 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/DependencyClasses.java @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.annotations; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/// Reads the classes a module's dependencies contribute, for the processors +/// that generate code from a library the application shares between its halves. +/// +/// Three kinds of entry are left out: +/// +/// - The module's own output. It is first on Maven's compile classpath and has +/// already been scanned as the module itself. +/// - The Codename One runtimes. They are thousands of classes, none of them +/// the application's, and reading them would cost every build for nothing. +/// - `java/`, `javax/` and `kotlin/`, for the same reason. +/// +/// A class file is parsed only when its bytes hold one of the wanted annotation +/// descriptors. A descriptor an annotation is applied with is a constant pool +/// entry, so it appears in the file verbatim; a class that does not contain the +/// text cannot carry the annotation, and most classes are dismissed on that test +/// without being parsed at all. +public final class DependencyClasses { + + /// Marks an entry as the client runtime. + private static final String CORE_MARKER = "com/codename1/ui/Display.class"; + + /// Marks an entry as the server runtime. + private static final String BACKEND_MARKER = "com/codename1/backend/Backend.class"; + + private DependencyClasses() { + } + + /// Every dependency class carrying one of `descriptors`, keyed by internal + /// name. The first definition of a name wins, as it does on a classpath. + /// + /// @param compileClasspath the module's compile classpath + /// @param ownOutput the module's output directory, or null + /// @param descriptors annotation descriptors, in `Lcom/example/Name;` form + public static Map scan(List compileClasspath, + File ownOutput, Iterable descriptors) throws ProcessingException { + Map out = new LinkedHashMap(); + if (compileClasspath == null) { + return out; + } + java.util.List needles = new java.util.ArrayList(); + for (String descriptor : descriptors) { + needles.add(ascii(descriptor)); + } + if (needles.isEmpty()) { + return out; + } + File own = canonical(ownOutput); + // Every class name met so far, wanted or not. A definition the JVM + // would load first shadows a later one even when it is the later one + // that carries the annotation -- two versions of a dependency on one + // classpath -- so a name is claimed before the annotation test, and + // code is never generated from an annotation the loaded class lacks. + Set defined = new HashSet(); + for (String element : compileClasspath) { + File file = new File(element); + if (file.isDirectory()) { + if (own != null && own.equals(canonical(file))) { + continue; + } + if (new File(file, CORE_MARKER).isFile() || new File(file, BACKEND_MARKER).isFile()) { + continue; + } + scanDirectory(file, file, needles, defined, out); + } else if (file.isFile()) { + scanArchive(file, needles, defined, out); + } + } + return out; + } + + /// Whether `entryName` is on the classpath, as a file in a directory entry + /// or an entry of an archive. Nothing is loaded. + public static boolean onClasspath(List compileClasspath, String entryName) { + return onClasspath(compileClasspath, null, entryName); + } + + /// Whether a DEPENDENCY supplies `entryName`: the same question with the + /// module's own output left out of it. + /// + /// The output directory is on the module's compile classpath, and it holds + /// what the previous build generated. Asked without this exclusion, "has a + /// dependency already generated this class?" is answered yes by the + /// module's own last build, so the second build of a module generated + /// nothing, left the stale class in place and dropped it from the + /// bootstrap that registers it. + /// + /// @param ownOutput the module's output directory, or null to consider every entry + public static boolean onClasspath(List compileClasspath, File ownOutput, + String entryName) { + if (compileClasspath == null) { + return false; + } + File own = canonical(ownOutput); + for (String element : compileClasspath) { + File file = new File(element); + if (file.isDirectory()) { + if (own != null && own.equals(canonical(file))) { + continue; + } + if (new File(file, entryName.replace('/', File.separatorChar)).isFile()) { + return true; + } + continue; + } + if (!file.isFile()) { + continue; + } + try { + ZipFile zip = new ZipFile(file); + try { + if (zip.getEntry(entryName) != null) { + return true; + } + } finally { + zip.close(); + } + } catch (IOException unreadable) { + // Not an archive, which most of what Maven lists legitimately is + // not. It simply does not answer the question. + continue; + } + } + return false; + } + + /// Reads one class off the classpath by internal name, or null when no + /// entry defines it. For the types an annotated dependency class refers to + /// -- an enum a transfer object holds, say -- which carry no annotation of + /// their own and so are not part of a [#scan]. + public static AnnotatedClass read(List compileClasspath, String internalName) + throws ProcessingException { + if (compileClasspath == null || internalName == null || skipped(internalName + ".class")) { + return null; + } + String entryName = internalName + ".class"; + for (String element : compileClasspath) { + File file = new File(element); + if (file.isDirectory()) { + File candidate = new File(file, entryName.replace('/', File.separatorChar)); + if (candidate.isFile()) { + return ClassScanner.readClass(candidate); + } + continue; + } + if (!file.isFile()) { + continue; + } + ZipFile zip; + try { + zip = new ZipFile(file); + } catch (IOException notAnArchive) { + continue; + } + try { + ZipEntry entry = zip.getEntry(entryName); + if (entry == null) { + continue; + } + InputStream in = zip.getInputStream(entry); + try { + return ClassScanner.readClass(new ByteArrayInputStream(readAll(in)), file); + } finally { + in.close(); + } + } catch (IOException unreadable) { + throw new ProcessingException("Could not read " + entryName + " from " + file + + ": " + unreadable.getMessage(), unreadable); + } finally { + try { + zip.close(); + } catch (IOException ignored) { + // Read-only; nothing a failed close can lose. + } + } + } + return null; + } + + private static void scanDirectory(File root, File dir, List needles, + Set defined, Map out) throws ProcessingException { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + java.util.Arrays.sort(children); + for (File child : children) { + if (child.isDirectory()) { + scanDirectory(root, child, needles, defined, out); + continue; + } + String entryName = root.toPath().relativize(child.toPath()).toString() + .replace(File.separatorChar, '/'); + if (!entryName.endsWith(".class") || skipped(entryName) + || !defined.add(entryName)) { + continue; + } + byte[] bytes; + try { + InputStream in = new FileInputStream(child); + try { + bytes = readAll(in); + } finally { + in.close(); + } + } catch (IOException unreadable) { + throw new ProcessingException("Could not read " + child + ": " + + unreadable.getMessage(), unreadable); + } + consider(bytes, child, needles, out); + } + } + + private static void scanArchive(File archive, List needles, + Set defined, Map out) throws ProcessingException { + ZipFile zip; + try { + zip = new ZipFile(archive); + } catch (IOException notAnArchive) { + // A pom-typed or otherwise non-archive dependency holds no classes. + return; + } + try { + // The runtime's own classes are never the application's, and the + // client and server runtimes are thousands of them: an archive + // carrying either is read for everything but those. Not skipped + // whole, because a shaded or fat jar can carry the application's + // contract beside the runtime it was bundled with. + boolean runtime = zip.getEntry(CORE_MARKER) != null || zip.getEntry(BACKEND_MARKER) != null; + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if (entry.isDirectory() || !name.endsWith(".class") || skipped(name) + || (runtime && name.startsWith("com/codename1/")) + || !defined.add(name)) { + continue; + } + byte[] bytes; + InputStream in = zip.getInputStream(entry); + try { + bytes = readAll(in); + } finally { + in.close(); + } + consider(bytes, archive, needles, out); + } + } catch (IOException unreadable) { + throw new ProcessingException("Could not read " + archive + ": " + + unreadable.getMessage(), unreadable); + } finally { + try { + zip.close(); + } catch (IOException ignored) { + // Read-only; nothing a failed close can lose. + } + } + } + + private static void consider(byte[] bytes, File source, List needles, + Map out) throws ProcessingException { + boolean wanted = false; + for (byte[] needle : needles) { + if (indexOf(bytes, needle) >= 0) { + wanted = true; + break; + } + } + if (!wanted) { + return; + } + AnnotatedClass cls = ClassScanner.readClass(new ByteArrayInputStream(bytes), source); + if (cls != null && !out.containsKey(cls.getInternalName())) { + out.put(cls.getInternalName(), cls); + } + } + + private static boolean skipped(String entryName) { + return entryName.startsWith("java/") || entryName.startsWith("javax/") + || entryName.startsWith("kotlin/") || entryName.startsWith("META-INF/") + || entryName.endsWith("module-info.class"); + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(4096); + byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) > 0) { + buffer.write(chunk, 0, n); + } + return buffer.toByteArray(); + } + + private static byte[] ascii(String text) { + byte[] out = new byte[text.length()]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) text.charAt(i); + } + return out; + } + + private static int indexOf(byte[] haystack, byte[] needle) { + int last = haystack.length - needle.length; + outer: + for (int i = 0; i <= last; i++) { + for (int j = 0; j < needle.length; j++) { + if (haystack[i + j] != needle[j]) { + continue outer; + } + } + return i; + } + return -1; + } + + private static File canonical(File file) { + if (file == null) { + return null; + } + try { + return file.getCanonicalFile(); + } catch (IOException unreadable) { + return file.getAbsoluteFile(); + } + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/FieldInfo.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/FieldInfo.java index 3a382643fc9..6b14019493c 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/annotations/FieldInfo.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/FieldInfo.java @@ -66,6 +66,10 @@ public final class FieldInfo { public boolean isStatic() { return (access & Opcodes.ACC_STATIC) != 0; } public boolean isFinal() { return (access & Opcodes.ACC_FINAL) != 0; } public boolean isPrivate() { return (access & Opcodes.ACC_PRIVATE) != 0; } + /// The Java `transient` modifier. + public boolean isTransient() { return (access & Opcodes.ACC_TRANSIENT) != 0; } + /// A field the compiler made up, such as an inner class's `this$0`. + public boolean isSynthetic() { return (access & Opcodes.ACC_SYNTHETIC) != 0; } public Map getAnnotations() { return annotations; } public AnnotationValues getAnnotation(String descriptor) { return annotations.get(descriptor); } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java index f4b9e4a6fc3..18afdde01d7 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java @@ -75,6 +75,27 @@ public static final class CompilerScope implements AutoCloseable { /// one call every processor makes, rather than in each processor. private static final ThreadLocal> PROJECT_CLASSPATH = new ThreadLocal>(); + /// Directories searched BEFORE everything else, set by [#compileAgainst]. + private static final ThreadLocal> PRIORITY_CLASSPATH = new ThreadLocal>(); + + /// As [#compile(Map, File, List)], with `priority` ahead of every other + /// classpath entry, the output directory included. + /// + /// For the ORM's generated model: it reads and writes non-public entity + /// fields, which javac only accepts against copies of the entity classes + /// whose fields were made public. Those copies have to win over the real + /// classes, which sit in the output directory and on the project classpath. + public static void compileAgainst(List priority, Map sources, + File outputClassDir, List extraClasspath) throws IOException { + List previous = PRIORITY_CLASSPATH.get(); + PRIORITY_CLASSPATH.set(priority); + try { + compile(sources, outputClassDir, extraClasspath); + } finally { + if (previous == null) PRIORITY_CLASSPATH.remove(); else PRIORITY_CLASSPATH.set(previous); + } + } + /// Makes `classpath` part of every compile on this thread until /// [#clearProjectClasspath()]. public static void setProjectClasspath(List classpath) { @@ -140,6 +161,7 @@ public static void compile(Map sources, File outputClassDir, ? surefireCp : System.getProperty("java.class.path", ""); List cp = new ArrayList(); + if (PRIORITY_CLASSPATH.get() != null) cp.addAll(PRIORITY_CLASSPATH.get()); if (existing.length() > 0) { for (String s : existing.split(File.pathSeparator)) { cp.add(new File(s)); @@ -204,6 +226,7 @@ private static void compileForked(File jdk, Map sources, File ou "-encoding", "UTF-8", "-d", output.getAbsolutePath())); if (level != null) command.addAll(Arrays.asList("-source", level, "-target", level)); List cp = new ArrayList(); + if (PRIORITY_CLASSPATH.get() != null) cp.addAll(PRIORITY_CLASSPATH.get()); cp.add(output); if (extraClasspath != null) cp.addAll(extraClasspath); if (PROJECT_CLASSPATH.get() != null) cp.addAll(PROJECT_CLASSPATH.get()); diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessesDependencyClasses.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessesDependencyClasses.java new file mode 100644 index 00000000000..4aa68d0bf89 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessesDependencyClasses.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.annotations; + +/// A processor that also wants the annotated classes of the module's +/// DEPENDENCIES, not only the module's own. +/// +/// A module is normally processed alone: the build scans its output directory +/// and nothing else. That leaves no place for a class two modules share. A REST +/// contract and its transfer objects belong in a library both the application +/// and its server depend on, and scanned that way neither side ever sees them -- +/// the application gets no client and the server no routes, with nothing in +/// either build to say so. +/// +/// A processor implementing this is offered the classes [DependencyClasses] +/// finds as well, through the same `processClass`. Whatever it generates still +/// goes into the module being built; a dependency is somebody else's output and +/// is never written to. +public interface ProcessesDependencyClasses { + + /// Whether this build is one where dependency classes should be offered. + /// + /// Asked once per run, after `start`. A processor answers from the compile + /// classpath: the client half of a contract belongs in a module that has the + /// Codename One core, and offering it to a server module would generate code + /// that cannot link there. + boolean acceptsDependencyClasses(ProcessorContext ctx); +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessorContext.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessorContext.java index f350934201a..86395b77387 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessorContext.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ProcessorContext.java @@ -161,6 +161,60 @@ public ProcessorContext(File outputClassDir, File stubSourceDir, /// dependency class). public AnnotatedClass lookup(String internalName) { return classIndex.get(internalName); } + /// The annotated classes this module's dependencies contribute, as + /// [DependencyClasses] found them. Empty unless a processor asked for them. + private Map dependencyIndex = Collections.emptyMap(); + + /// Installed by the driver before any dependency class is offered. + public void setDependencyIndex(Map index) { + this.dependencyIndex = index == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap(index)); + } + + public Map getDependencyIndex() { return dependencyIndex; } + + /// Looks a class up among the module's own classes, then among the + /// dependency classes. + /// + /// Separate from [#lookup] on purpose. Several processors read "is in the + /// index" as "belongs to this module" -- it is how they decide a class has a + /// source here, or is theirs to rewrite -- and answering that from a + /// dependency would hand them a class they must not touch. + public AnnotatedClass lookupWithDependencies(String internalName) { + AnnotatedClass own = classIndex.get(internalName); + if (own != null) { + return own; + } + AnnotatedClass scanned = dependencyIndex.get(internalName); + if (scanned != null || dependencyIndex.isEmpty()) { + // An empty dependency index means no processor asked for dependency + // classes in this build, and the classpath is then not read at all. + return scanned; + } + if (referenced.containsKey(internalName)) { + return referenced.get(internalName); + } + AnnotatedClass read = null; + try { + read = DependencyClasses.read(compileClasspath, internalName); + } catch (ProcessingException unreadable) { + log.debug("cn1: could not read " + internalName + " off the classpath: " + + unreadable.getMessage()); + } + referenced.put(internalName, read); + return read; + } + + /// Classes read on demand by [#lookupWithDependencies], misses included, so + /// a type many fields share is read once. + private final Map referenced = new java.util.HashMap(); + + /// Whether `cls` came from a dependency instead of this module's output. + public boolean isDependencyClass(AnnotatedClass cls) { + return cls != null && !classIndex.containsKey(cls.getInternalName()); + } + public Log getLog() { return log; } /// Reports a validation error attributed to `source`. Continues processing. diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java index ebf003f4e34..6ecb06cfdd9 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -391,6 +391,10 @@ static final class Aspect { String security; /// Bean name -> Java type, for the beans [#security] calls. Map securityBeans = Collections.emptyMap(); + /// The method's descriptor with every type the helper class cannot name + /// replaced by the nearest superclass it can, or null when it can name + /// them all; see [BackendBeans#erasedDescriptor]. + String erased; Aspect(MethodInfo method) { this.method = method; @@ -452,6 +456,9 @@ static final class Aspect { boolean needsDatabase; boolean needsEntities; boolean needsSession; + /// The `@Repository` interfaces injected anywhere, by internal name, each + /// to the field of the entry point that holds its one instance. + final Map repositories = new LinkedHashMap(); int requestSlots; int sessionSlots; int lazySlots; @@ -2330,6 +2337,22 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { } matches = named; } + if (matches.isEmpty() && p.qualifier == null && isRepository(type)) { + // A repository interface nothing else provides is built from the + // factory the ORM's bootstrap registered, over the injected Session, + // as Spring Data makes one a bean. One instance per interface: it + // holds nothing but that session, which forwards every call to the + // calling thread's transaction, so sharing it is as safe as sharing + // the session. Like the session it needs a @Transactional caller. + p.builtin = "repository"; + needsDatabase = true; + needsEntities = true; + needsSession = true; + if (!repositories.containsKey(type)) { + repositories.put(type, "repository" + repositories.size()); + } + return; + } if (matches.isEmpty()) { if (p.required) { ctx.error(where, p.where + " needs a " + p.type.getClassName() + ", and no " @@ -2398,6 +2421,14 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { p.candidates.addAll(matches); } + /// Whether `type` is an interface annotated `@Repository`, in this module + /// or a dependency. + private boolean isRepository(String type) { + AnnotatedClass cls = ctx.lookupWithDependencies(type); + return cls != null && cls.isInterface() + && cls.getClassAnnotation(RepositoryGenerator.REPOSITORY) != null; + } + /// Orders `list` by @Order, lowest first. Stable, so beans with the same /// value -- every bean that has none, usually -- keep the order the build /// found them in, which is the order such a list always had. @@ -2840,6 +2871,7 @@ private void collectAspects() { } boolean usesSecurity = classSecurity != null; Aspects found = null; + Set helperSignatures = new HashSet(); for (MethodInfo m : cls.getMethods()) { if (m.isConstructor() || m.isSynthetic() || "".equals(m.getName()) || BackendWeaver.isBody(m.getName()) @@ -2912,6 +2944,10 @@ private void collectAspects() { continue; } } + String erased = erasedDescriptor(cls, m.getDescriptor()); + if (!helperCanReach(cls, m, tx, where, erased, helperSignatures)) { + continue; + } if (found == null) { String pkg = RestClientAnnotationProcessor.packageOf(cls.getBinaryName()); found = new Aspects(cls, qualify(pkg, baseName(cls.getInternalName()) @@ -2919,6 +2955,7 @@ private void collectAspects() { aspects.put(cls.getInternalName(), found); } Aspect a = new Aspect(m); + a.erased = erased.equals(m.getDescriptor()) ? null : erased; if (security != null) { compileSecurity(cls, m, security, a, where); } @@ -2944,6 +2981,151 @@ private void collectAspects() { } } + // An aspect lives in a class GENERATED AS SOURCE beside the one it wraps, so + // every type in the wrapped method's signature has to be one that class can + // write. A private member class is not, although nothing is wrong with it at + // run time: its class file is package-private, and only javac refuses the + // name. So the helper is written against the nearest superclass it CAN name, + // and the weaver puts the two casts where the real type is in scope -- in the + // class itself. Both casts are of a value that already is of the type, so + // neither can fail; that matters, because on the translated server a failed + // cast does not throw. + + /// Whether source in package `pkg` can write the name of the class. + private boolean nameableFrom(String internal, String pkg) { + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(ctx, internal); + if (c == null) { + // Not a class of this build or its dependencies: the JDK's, which the + // method's own source could only name if it were accessible. + return true; + } + if (!c.isNameableInPackage()) { + return false; + } + // A protected member of a superclass in another package is in scope in + // the subclass and nowhere else in the subclass's package. + return c.isAccessibleFromAnywhere() + || RestClientAnnotationProcessor.packageOf(c.getBinaryName()).equals(pkg); + } + + private Type erase(Type t, String pkg) { + if (t.getSort() == Type.ARRAY) { + Type element = erase(t.getElementType(), pkg); + if (element.equals(t.getElementType())) { + return t; + } + // Arrays are covariant, so an Answer[] is a Base[] of the same depth. + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < t.getDimensions(); i++) { + sb.append('['); + } + return Type.getType(sb.append(element.getDescriptor()).toString()); + } + if (t.getSort() != Type.OBJECT) { + return t; + } + String current = t.getInternalName(); + for (int depth = 0; depth < 64 && !nameableFrom(current, pkg); depth++) { + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(ctx, current); + current = c == null || c.getSuperInternalName() == null || c.isInterface() + ? "java/lang/Object" : c.getSuperInternalName(); + } + return Type.getObjectType(current); + } + + /// A method descriptor as the class's aspect helper can write it: each + /// parameter and return type the helper cannot name -- a private member + /// class, most often -- replaced by the nearest superclass it can. The same + /// descriptor when there is nothing to replace. + /// + /// Decided by the source file alone, as the weaver needs: a type only that + /// file can name is declared in it, superclass clause included, so a build + /// that changes the answer has recompiled the class and weaves it afresh. + String erasedDescriptor(AnnotatedClass cls, String descriptor) { + String pkg = RestClientAnnotationProcessor.packageOf(cls.getBinaryName()); + Type[] args = Type.getArgumentTypes(descriptor); + for (int i = 0; i < args.length; i++) { + args[i] = erase(args[i], pkg); + } + return Type.getMethodDescriptor(erase(Type.getReturnType(descriptor), pkg), args); + } + + /// Reports what no rewrite of the signature can get the helper past, and + /// answers whether the method can have its aspects. + private boolean helperCanReach(AnnotatedClass cls, MethodInfo m, AnnotationValues tx, + String where, String erased, Set signatures) { + String pkg = RestClientAnnotationProcessor.packageOf(cls.getBinaryName()); + if (!nameableFrom(cls.getInternalName(), pkg)) { + ctx.error(cls, where + " has an aspect annotation (@Transactional, @Async, @Timed, " + + "@Counted or an authorization check), and " + cls.getSourceName() + + " is a private, local or anonymous class. The aspect is compiled into " + + "a class beside it, which has to name " + cls.getSourceName() + + " to call the method: make it a member class that is not private, or " + + "move the method to one."); + return false; + } + boolean ok = true; + if (tx != null) { + for (String attribute : new String[] {"rollbackFor", "noRollbackFor"}) { + Object listed = tx.get(attribute); + if (!(listed instanceof List)) { + continue; + } + for (Object o : (List) listed) { + if (o instanceof Type && ((Type) o).getSort() == Type.OBJECT + && !nameableFrom(((Type) o).getInternalName(), pkg)) { + ctx.error(cls, "@Transactional on " + where + " lists " + + sourceNameOf(((Type) o).getInternalName()) + " in " + + attribute + ", which is a private class. The rollback rule " + + "is compiled into a class beside " + cls.getSourceName() + + " and tests the exception against that type by name, so " + + "the type has to be one it can name: remove `private` from " + + "its declaration."); + ok = false; + } + } + } + } + // Overloads that differ only in a type the helper cannot name become one + // signature there. Never the case unless a type was replaced. + String key = m.getName() + erased.substring(0, erased.indexOf(')') + 1); + if (!signatures.add(key) && ok) { + ctx.error(cls, cls.getSourceName() + " has two methods named " + m.getName() + + " with aspect annotations whose parameters differ only in a private " + + "class (" + inaccessibleTypes(cls, m.getName()) + "). The aspects " + + "are compiled into a class beside " + cls.getSourceName() + ", which " + + "sees a private class as its superclass and so cannot tell the two " + + "apart: rename one of them, or remove `private` from the class."); + ok = false; + } + return ok; + } + + private String sourceNameOf(String internal) { + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(ctx, internal); + return c != null ? c.getSourceName() : internal.replace('/', '.'); + } + + /// The parameter types of the class's methods of one name that its helper + /// cannot name, for a message. + private String inaccessibleTypes(AnnotatedClass cls, String method) { + String pkg = RestClientAnnotationProcessor.packageOf(cls.getBinaryName()); + Set names = new LinkedHashSet(); + for (MethodInfo m : cls.getMethods()) { + if (!m.getName().equals(method)) { + continue; + } + for (Type t : Type.getArgumentTypes(m.getDescriptor())) { + Type element = t.getSort() == Type.ARRAY ? t.getElementType() : t; + if (element.getSort() == Type.OBJECT + && !nameableFrom(element.getInternalName(), pkg)) { + names.add(sourceNameOf(element.getInternalName())); + } + } + } + return names.toString().replace("[", "").replace("]", ""); + } + /// The simple name of a Spring Security annotation the build has nothing /// for, or null. private static String unsupportedSecurity(String descriptor) { @@ -3114,7 +3296,11 @@ private void plan() { if (a != null) { plan = plan(plan, cls, helper); for (Aspect aspect : a.methods) { - plan.aspects.add(aspect.method.getName() + aspect.method.getDescriptor()); + String key = aspect.method.getName() + aspect.method.getDescriptor(); + plan.aspects.add(key); + if (aspect.erased != null) { + plan.erasedAspects.put(key, aspect.erased); + } } } if (plan != null) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java index e48456d1908..61cd67332dc 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java @@ -99,6 +99,8 @@ static final class Prop { String field; /// The setter the reader calls instead, or null. String setter; + /// A record component: the reader passes it to the canonical constructor. + boolean component; } /// A class that gets a codec. @@ -277,7 +279,7 @@ private String checkDto(String binary, boolean read, Set visiting) { return null; } for (Prop p : dto.props) { - if (read ? p.field == null && p.setter == null : p.get == null) { + if (read ? p.field == null && p.setter == null && !p.component : p.get == null) { continue; } String why = check(p.type, read, visiting); @@ -328,7 +330,7 @@ private Dto dto(String binary) { if (cls.isAbstract()) { dto.readProblem = "the abstract class " + binary + ", which a body cannot " + "create; accept a concrete subclass"; - } else if (!hasNoArgConstructor(cls)) { + } else if (!cls.isRecord() && !hasNoArgConstructor(cls)) { dto.readProblem = binary + ", which has no constructor without arguments for a " + "body to create it with; add one"; } @@ -348,12 +350,19 @@ private void collectProps(Dto dto, AnnotatedClass cls) { } String pkg = packageOf(dto.binary); Set names = new HashSet(); + // A record's state is its components: private final fields, in + // declaration order, read through the public accessor of the same name + // and set all at once through the canonical constructor. Java records + // carry no JavaBeans getters or setters, so the rules below would find + // nothing and the record would go over the wire as {}. + boolean record = cls.isRecord(); for (AnnotatedClass c : chain) { for (FieldInfo f : c.getFields()) { int access = f.getAccess(); + boolean ignored = f.getAnnotation(JSON_IGNORE) != null; if ((access & (Opcodes.ACC_STATIC | Opcodes.ACC_TRANSIENT | Opcodes.ACC_SYNTHETIC)) != 0 || f.getName().startsWith("this$") - || f.getAnnotation(JSON_IGNORE) != null) { + || ignored && !record) { continue; } Prop p = new Prop(); @@ -375,6 +384,25 @@ private void collectProps(Dto dto, AnnotatedClass cls) { && packageOf(c.getBinaryName()).equals(pkg)); String cap = Character.toUpperCase(f.getName().charAt(0)) + f.getName().substring(1); + if (record) { + // An ignored component is still an argument of the canonical + // constructor: it is neither written nor read, and arrives as + // its type's default. + p.get = ignored ? null : "v." + f.getName() + "()"; + p.component = !ignored; + if (ignored) { + p.json = null; + dto.props.add(p); + continue; + } + if (!names.add(p.json)) { + dto.problem = "a record with two components named \"" + p.json + + "\" in JSON (" + dto.binary + "); rename one with @JsonProperty"; + return; + } + dto.props.add(p); + continue; + } if (reachable) { p.get = "v." + f.getName(); } else { @@ -1155,10 +1183,26 @@ private String codecSource(Dto dto) { sb.append(" java.util.Map m = (java.util.Map) json;\n"); sb.append(" ").append(CODEC).append(".Path here = ").append(CODEC) .append(".enter(at, name, index);\n"); - sb.append(" ").append(type).append(" v = new ").append(type).append("();\n"); + boolean record = dto.cls.isRecord(); + // A record is built last, from one local per component; an absent + // member leaves its local at the type's default, as it leaves a + // field of a class. + List args = new ArrayList(); + if (!record) { + sb.append(" ").append(type).append(" v = new ").append(type).append("();\n"); + } for (Prop p : dto.props) { boolean direct = p.field != null; - if (!direct && p.setter == null) { + String local = null; + if (record) { + local = fresh("c"); + args.add(local); + sb.append(" ").append(source(strip(p.type))).append(' ').append(local) + .append(" = ").append(defaultOf(strip(p.type))).append(";\n"); + if (!p.component) { + continue; // @JsonIgnore: the default, never the body's value + } + } else if (!direct && p.setter == null) { continue; } String t = strip(p.type); @@ -1172,7 +1216,8 @@ private String codecSource(Dto dto) { // Jackson's answer for a primitive too. String value = scalar(t, j, "here, " + quote(p.json) + ", -1"); sb.append(" if (").append(j).append(" != null) {\n"); - sb.append(" ").append(direct ? "v." + p.field + " = " + value + ";" + sb.append(" ").append(local != null ? local + " = " + value + ";" + : direct ? "v." + p.field + " = " + value + ";" : "v." + p.setter + "(" + value + ");").append("\n"); sb.append(" }\n"); continue; @@ -1182,10 +1227,19 @@ private String codecSource(Dto dto) { sb.append(" ").append(source(t)).append(' ').append(tmp).append(" = ") .append(defaultOf(t)).append(";\n"); read(sb, t, j, "here", quote(p.json), "-1", "depth", tmp, " "); - sb.append(" ").append(direct ? "v." + p.field + " = " + tmp + ";" + sb.append(" ").append(local != null ? local + " = " + tmp + ";" + : direct ? "v." + p.field + " = " + tmp + ";" : "v." + p.setter + "(" + tmp + ");").append("\n"); sb.append(" }\n"); } + if (record) { + sb.append(" return new ").append(type).append('('); + for (int i = 0; i < args.size(); i++) { + sb.append(i == 0 ? "" : ", ").append(args.get(i)); + } + sb.append(");\n"); + return sb.append(" }\n}\n").toString(); + } sb.append(" return v;\n"); sb.append(" }\n"); } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java index 81c22f5fc9e..96f70773498 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java @@ -67,14 +67,20 @@ void aspects(BackendBeans.Aspects a, Map out) { BackendBeans.Aspect aspect = a.methods.get(index); MethodInfo m = aspect.method; boolean isStatic = m.isStatic(); - Type[] args = Type.getArgumentTypes(m.getDescriptor()); - Type ret = Type.getReturnType(m.getDescriptor()); + // A signature naming a type this class cannot write -- a private + // member class of the component -- is written with the nearest + // superclass it can, and calls the entry the weaver added for it. + String descriptor = aspect.erased != null ? aspect.erased : m.getDescriptor(); + Type[] args = Type.getArgumentTypes(descriptor); + Type ret = Type.getReturnType(descriptor); boolean isVoid = ret.getSort() == Type.VOID; String retType = isVoid ? "void" : typeName(ret); String params = parameters(owner, isStatic, args); String callArgs = arguments(isStatic, args.length); String inner = (isStatic ? owner : "self") + "." - + BackendWeaver.bodyName(cls.getInternalName(), m.getName()) + + (aspect.erased != null + ? BackendWeaver.erasedBodyName(cls.getInternalName(), m.getName()) + : BackendWeaver.bodyName(cls.getInternalName(), m.getName())) + "(" + plainArguments(args.length) + ")"; List layers = new ArrayList(); if (aspect.transactional != null) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWeaver.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWeaver.java index 32cf5799a14..57485b235b1 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWeaver.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWeaver.java @@ -86,6 +86,14 @@ static String bodyName(String ownerInternalName, String method) { + BackendBeans.baseName(ownerInternalName); } + /// The name of the method the helper calls INSTEAD of the body when the + /// body's signature names a type the helper cannot write: the same call with + /// that type's nearest nameable superclass in its place. It contains the + /// body's suffix, so everything that skips a body skips this too. + static String erasedBodyName(String ownerInternalName, String method) { + return bodyName(ownerInternalName, method) + "$erased"; + } + /// Whether `name` is a moved body's. static boolean isBody(String name) { return name.indexOf(BODY_SUFFIX) >= 0; @@ -110,6 +118,10 @@ static final class Plan { final Set constructors = new LinkedHashSet(); /// name + descriptor of methods to give aspects final Set aspects = new LinkedHashSet(); + /// For the members of [#aspects] whose signature names a type the helper + /// class cannot write: name + descriptor -> the descriptor the helper is + /// written with instead. + final Map erasedAspects = new LinkedHashMap(); Plan(String internalName, String helperInternalName) { this.internalName = internalName; @@ -179,6 +191,7 @@ public FieldVisitor visitField(int access, String name, String descriptor, } final ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); final String owner = plan.internalName; + final java.util.List erasedBodies = new java.util.ArrayList(); reader.accept(new ClassVisitor(Opcodes.ASM9, writer) { @Override public MethodVisitor visitMethod(int access, String name, String descriptor, @@ -202,8 +215,13 @@ public MethodVisitor visitMethod(int access, String name, String descriptor, descriptor, signature, exceptions); MethodVisitor stub = writer.visitMethod(access & ~Opcodes.ACC_SYNCHRONIZED, name, descriptor, signature, exceptions); + String erased = plan.erasedAspects.get(name + descriptor); + if (erased != null) { + erasedBodies.add(new Object[] {name, descriptor, erased, + Boolean.valueOf((access & Opcodes.ACC_STATIC) != 0)}); + } return new AspectSplitter(body, stub, owner, plan.helperInternalName, access, - name, descriptor); + name, descriptor, erased != null ? erased : descriptor); } @Override @@ -223,6 +241,10 @@ public void visitEnd() { for (String descriptor : plan.constructors) { emitConstructorBridge(writer, owner, descriptor); } + for (Object[] erased : erasedBodies) { + emitErasedBody(writer, owner, (String) erased[0], (String) erased[1], + (String) erased[2], ((Boolean) erased[3]).booleanValue()); + } super.visitEnd(); } }, 0); @@ -267,6 +289,42 @@ private static void emitBridge(ClassWriter writer, String owner, String name, m.visitEnd(); } + /// The body's entry for a helper that cannot name every type of the body's + /// signature: it takes the helper's types and narrows each to the body's. + /// + /// The narrowing is here because this is the class that can name the type. + /// It never fails -- the only caller is the helper, which passes on exactly + /// what the stub was given, and the stub's own descriptor says each value is + /// of the type narrowed to. That is what makes it right for the translated + /// server too, where a cast is not checked at all. + private static void emitErasedBody(ClassWriter writer, String owner, String name, + String descriptor, String erased, boolean isStatic) { + // Not synthetic, or javac would not let the helper call it; final and + // named for its class, so no subclass can stand in for it. + MethodVisitor m = writer.visitMethod(isStatic ? Opcodes.ACC_STATIC : Opcodes.ACC_FINAL, + erasedBodyName(owner, name), erased, null, null); + m.visitCode(); + int slot = 0; + if (!isStatic) { + m.visitVarInsn(Opcodes.ALOAD, 0); + slot = 1; + } + Type[] real = Type.getArgumentTypes(descriptor); + Type[] passed = Type.getArgumentTypes(erased); + for (int i = 0; i < real.length; i++) { + m.visitVarInsn(real[i].getOpcode(Opcodes.ILOAD), slot); + slot += real[i].getSize(); + if (!real[i].equals(passed[i])) { + m.visitTypeInsn(Opcodes.CHECKCAST, real[i].getInternalName()); + } + } + m.visitMethodInsn(isStatic ? Opcodes.INVOKESTATIC : Opcodes.INVOKEVIRTUAL, owner, + bodyName(owner, name), descriptor, false); + m.visitInsn(Type.getReturnType(descriptor).getOpcode(Opcodes.IRETURN)); + m.visitMaxs(0, 0); + m.visitEnd(); + } + private static void emitConstructorBridge(ClassWriter writer, String owner, String descriptor) { Type[] args = Type.getArgumentTypes(descriptor); @@ -296,9 +354,12 @@ private static final class AspectSplitter extends MethodVisitor { private final int access; private final String name; private final String descriptor; + /// The descriptor the helper's method was written with: [#descriptor], + /// unless that names a type the helper cannot. + private final String helperSignature; AspectSplitter(MethodVisitor body, MethodVisitor stub, String owner, String helper, - int access, String name, String descriptor) { + int access, String name, String descriptor, String helperSignature) { super(Opcodes.ASM9, body); this.stub = stub; this.owner = owner; @@ -306,6 +367,7 @@ private static final class AspectSplitter extends MethodVisitor { this.access = access; this.name = name; this.descriptor = descriptor; + this.helperSignature = helperSignature; } @Override @@ -353,15 +415,26 @@ public void visitEnd() { helperDescriptor.append('L').append(owner).append(';'); slot = 1; } + // Loaded by the method's own types and passed as the helper's, which + // are those or superclasses of them -- no cast on the way in. for (Type arg : Type.getArgumentTypes(descriptor)) { stub.visitVarInsn(arg.getOpcode(Opcodes.ILOAD), slot); slot += arg.getSize(); + } + for (Type arg : Type.getArgumentTypes(helperSignature)) { helperDescriptor.append(arg.getDescriptor()); } Type ret = Type.getReturnType(descriptor); - helperDescriptor.append(')').append(ret.getDescriptor()); + Type helperRet = Type.getReturnType(helperSignature); + helperDescriptor.append(')').append(helperRet.getDescriptor()); stub.visitMethodInsn(Opcodes.INVOKESTATIC, helper, name, helperDescriptor.toString(), false); + if (!helperRet.equals(ret)) { + // The helper returns what the body returned, under the name of a + // superclass; this gives the verifier the type back. It cannot + // fail, so nothing is lost where casts are unchecked. + stub.visitTypeInsn(Opcodes.CHECKCAST, ret.getInternalName()); + } stub.visitInsn(ret.getOpcode(Opcodes.IRETURN)); stub.visitMaxs(0, 0); stub.visitEnd(); diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java index 77ae5d36477..3d0b3a23e80 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java @@ -141,6 +141,10 @@ private void fields(StringBuilder sb) { sb.append(" private com.codename1.backend.orm.EntityManager entities;\n"); sb.append(" private com.codename1.impl.backend.TransactionSession transactionSession;\n"); sb.append(" private com.codename1.impl.backend.Scheduler scheduler;\n"); + for (Map.Entry r : model.repositories.entrySet()) { + sb.append(" private ").append(r.getKey().replace('/', '.').replace('$', '.')) + .append(' ').append(r.getValue()).append(";\n"); + } // The controllers and endpoints this start registered, by binary name, // for describeRoutes(): one whose condition is off serves nothing, and // listing its routes anyway sent an agent to endpoints that answer 404. @@ -187,6 +191,9 @@ private void create(StringBuilder sb, List routers) { // instance and hand it to routes, injections, tools and jobs. sb.append(" synchronized (this) {\n"); sb.append(" scheduler = null;\n transactionSession = null;\n"); + for (String var : model.repositories.values()) { + sb.append(" ").append(var).append(" = null;\n"); + } sb.append(" active.clear();\n"); for (BackendBeans.Bean b : model.beans) { if (BackendBeans.PROTOTYPE.equals(b.scope)) { @@ -206,6 +213,12 @@ private void create(StringBuilder sb, List routers) { + "TransactionSession(\n com.codename1.backend.Backend." + "requireEntities(entities, \"the injected Session\"));\n"); } + for (Map.Entry r : model.repositories.entrySet()) { + sb.append(" ").append(r.getValue()) + .append(" = com.codename1.orm.repository.Repositories.create(") + .append(r.getKey().replace('/', '.').replace('$', '.')) + .append(".class, transactionSession);\n"); + } // Stand-ins first: they hold nothing but their scope, and anything built // below may be given one. for (BackendBeans.Bean b : model.beans) { @@ -561,6 +574,9 @@ String point(BackendBeans.Point p) { if ("session".equals(p.builtin)) { return "transactionSession"; } + if ("repository".equals(p.builtin)) { + return model.repositories.get(p.type.getInternalName()); + } if ("httpSession".equals(p.builtin)) { return "request.getSession(true)"; } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java index 2ec47d3d888..0c689156a66 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java @@ -59,7 +59,18 @@ /// `JavaSEPort#postInit` loads it via `Class.forName`. Direct symbol /// references survive ParparVM rename and R8 obfuscation; the JavaSE /// classloading path is the legitimate exception (unobfuscated run). -public final class MappingAnnotationProcessor extends AbstractAnnotationProcessor { +public final class MappingAnnotationProcessor extends AbstractAnnotationProcessor + implements com.codename1.maven.annotations.ProcessesDependencyClasses { + + /// A `@Mapped` type in a library the application shares with its server is + /// mapped here, in the application, where the mapper runtime is. Not in a + /// module without that runtime: the generated mapper could not link there. + @Override + public boolean acceptsDependencyClasses(ProcessorContext ctx) { + return com.codename1.maven.annotations.DependencyClasses.onClasspath( + ctx.getCompileClasspath(), "com/codename1/mapping/Mappers.class"); + } + public static final String MAPPED_DESC = "Lcom/codename1/annotations/Mapped;"; public static final String JSON_PROPERTY_DESC = "Lcom/codename1/annotations/JsonProperty;"; @@ -98,6 +109,16 @@ public void start(ProcessorContext ctx) throws ProcessingException { public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { if (cls.isSynthetic()) return; if (cls.getClassAnnotation(MAPPED_DESC) == null) return; + if (ctx.isDependencyClass(cls) + && com.codename1.maven.annotations.DependencyClasses.onClasspath( + ctx.getCompileClasspath(), ctx.getOutputClassDir(), + cls.getInternalName() + "Cn1Mapper.class")) { + // Already mapped by the module that owns it, or by a module between + // that one and this: a platform module sees the application's types + // as dependencies too, and generating a second mapper there would + // put two definitions of one class into the build. + return; + } boolean isRecord = cls.isRecord(); if (!isRecord && (cls.isAbstract() || cls.isInterface())) { ctx.error(cls, "@Mapped requires a concrete class; " @@ -858,11 +879,11 @@ private static void emitFieldFromMap(StringBuilder sb, MappedField f, boolean is } else if (isScalarBinary(f.kind.elementBinaryName)) { if (f.kind.kind == PropertyTypeKind.Kind.LIST) { sb.append(" java.util.ArrayList<").append(f.kind.elementBinaryName).append("> _l = new java.util.ArrayList<").append(f.kind.elementBinaryName).append(">();\n"); - sb.append(" for (Object _e : (java.util.List) _v) { _l.add((").append(f.kind.elementBinaryName).append(") _e); }\n"); + sb.append(" for (Object _e : (java.util.List) _v) { _l.add(").append(scalarElement(f.kind.elementBinaryName, "_e")).append("); }\n"); sb.append(" ").append(writeStmt(f, isRecord, "_l")).append("\n"); } else { sb.append(" ").append(readExpr(f, isRecord)).append(".clear();\n"); - sb.append(" for (Object _e : (java.util.List) _v) { ").append(readExpr(f, isRecord)).append(".add((").append(f.kind.elementBinaryName).append(") _e); }\n"); + sb.append(" for (Object _e : (java.util.List) _v) { ").append(readExpr(f, isRecord)).append(".add(").append(scalarElement(f.kind.elementBinaryName, "_e")).append("); }\n"); } } else if ("java.util.Date".equals(f.kind.elementBinaryName)) { if (f.kind.kind == PropertyTypeKind.Kind.LIST) { @@ -1192,6 +1213,42 @@ private static void emitScalarFromString(StringBuilder sb, MappedField f, String // Misc // --------------------------------------------------------------- + /// The expression that turns `var`, one element of a parsed JSON array, into + /// the boxed scalar `binary` names. + /// + /// Never a bare cast. A JSON parser hands every number over as a `Double` + /// (or a `Long`), whatever the field is declared as, so casting an element + /// of a `List` throws ClassCastException on a JVM -- and on + /// ParparVM, whose casts are unchecked, puts a `Double` in the list for the + /// first `intValue()` to read as something it is not. An element of the + /// wrong kind altogether becomes null. + static String scalarElement(String binary, String var) { + String number = "((Number) " + var + ")"; + String wrapped; + if ("java.lang.Integer".equals(binary)) { + wrapped = "Integer.valueOf(" + number + ".intValue())"; + } else if ("java.lang.Long".equals(binary)) { + wrapped = "Long.valueOf(" + number + ".longValue())"; + } else if ("java.lang.Short".equals(binary)) { + wrapped = "Short.valueOf(" + number + ".shortValue())"; + } else if ("java.lang.Byte".equals(binary)) { + wrapped = "Byte.valueOf(" + number + ".byteValue())"; + } else if ("java.lang.Double".equals(binary)) { + wrapped = "Double.valueOf(" + number + ".doubleValue())"; + } else if ("java.lang.Float".equals(binary)) { + wrapped = "Float.valueOf(" + number + ".floatValue())"; + } else if ("java.lang.Boolean".equals(binary)) { + return "(" + var + " instanceof Boolean ? (Boolean) " + var + " : null)"; + } else if ("java.lang.Character".equals(binary)) { + return "(" + var + " instanceof Character ? (Character) " + var + + " : " + var + " instanceof String && ((String) " + var + ").length() > 0" + + " ? Character.valueOf(((String) " + var + ").charAt(0)) : null)"; + } else { + return "(" + var + " instanceof String ? (String) " + var + " : null)"; + } + return "(" + var + " instanceof Number ? " + wrapped + " : null)"; + } + private static boolean isScalarBinary(String binary) { return "java.lang.String".equals(binary) || "java.lang.Integer".equals(binary) @@ -1210,7 +1267,7 @@ private static boolean isScalarBinary(String binary) { private static boolean isEnumType(String binaryName, ProcessorContext ctx) { if (binaryName == null || binaryName.length() == 0) return false; com.codename1.maven.annotations.AnnotatedClass c = - ctx.lookup(binaryName.replace('.', '/')); + ctx.lookupWithDependencies(binaryName.replace('.', '/')); return c != null && c.isEnum(); } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java index 8cc0e1a0b73..1afcd0e616a 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java @@ -409,7 +409,7 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from Object indexes=entityAnn.get("indexes"); if(indexes instanceof List) for(Object index:(List)indexes) ec.indexes.add((AnnotationValues)index); String table = entityAnn.getString("table"); - ec.tableName = (table == null || table.length() == 0) ? ec.simpleName : table; + ec.tableName = (table == null || table.length() == 0) ? physical(ec.simpleName) : table; if (backend && tooLongForAnEngine(ec.tableName)) { ctx.error(cls, "@Entity " + ec.binaryName + " is stored in a table named '" + ec.tableName + "', which is " + utf8Length(ec.tableName) + " bytes. " @@ -433,16 +433,13 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from if (f.isStatic()) continue; if (f.getName().startsWith("this$")) continue; if (f.getAnnotation(DB_TRANSIENT_DESC) != null) continue; - if (!f.isPublic()) { - for(String annotation:f.getAnnotations().keySet()) { - if(annotation.equals(ID_DESC) || annotation.equals(COLUMN_DESC) || annotation.equals("Lcom/codename1/annotations/db/Version;") - || annotation.equals("Lcom/codename1/annotations/db/OneToOne;") || annotation.equals("Lcom/codename1/annotations/db/OneToMany;") - || annotation.equals("Lcom/codename1/annotations/db/ManyToOne;") || annotation.equals("Lcom/codename1/annotations/db/ManyToMany;") - || annotation.matches("Lcom/codename1/annotations/db/(ElementCollection|Convert|GeneratedValue|JoinColumn|JoinTable|MapKey|MapKeyColumn|OrderBy|OrderColumn);")) - ctx.error(cls,"Persistent annotated fields currently require public access: "+f.getName()); - } - continue; - } + // Private, package-private and protected fields are persistent like + // public ones. The generated model cannot name them in source, so it + // is compiled against public copies of the declaring classes and + // OrmEnhancer then routes every access through a static accessor + // woven into the declaring class -- straight-line code, so it works + // on ParparVM, which has no reflection to fall back on. + hide(ec,fieldPath.owner.replace('.','/'),f); RelationField relationship = relation(f, ec, ctx); if (relationship != null) { if(fieldPath.prefix.length()>0) { ctx.error(cls,"Relationships inside embeddables are not supported: "+fieldPath.path);continue; } @@ -501,7 +498,8 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from colType = col.getString("type"); nullable = col.getBoolOrDefault("nullable", true); } - pf.columnName = (colName == null || colName.length() == 0) ? pf.fieldName.replace('.', '_') : fieldPath.prefix.replace('.', '_')+colName; + pf.columnName = (colName == null || colName.length() == 0) ? physical(pf.fieldName) + : physical(fieldPath.prefix)+colName; pf.sqlType = (colType == null || colType.length() == 0) ? defaultSqlType(pf.kind) : colType; // The EXPLICIT type, separately: the client flavour needs a type for // every column and defaults it to SQLite's, while the backend one @@ -842,7 +840,22 @@ public void finish(ProcessorContext ctx) throws ProcessingException { // regardless, which is what makes its provenance readable. sources.put(backend ? BACKEND_BOOTSTRAP_BINARY : BOOTSTRAP_BINARY, generateBootstrapSource(accepted.values(), repositories, backend, !migrations.isEmpty())); - compileGenerated(ctx, sources); + java.io.File stubs = null; + try { + stubs = OrmEnhancer.publicStubs(accepted, ctx); + compileGenerated(ctx, sources, stubs); + } catch (IOException ioe) { + throw new ProcessingException("Could not prepare the entity classes for the generated " + + "dao sources: " + ioe.getMessage(), ioe); + } finally { + if (stubs != null) { + try { + org.apache.commons.io.FileUtils.deleteDirectory(stubs); + } catch (IOException ignored) { + // A temporary directory left behind is not worth failing the build over. + } + } + } try { OrmEnhancer.enhance(accepted, ctx); } catch (IOException ioe) { @@ -859,6 +872,13 @@ public void finish(ProcessorContext ctx) throws ProcessingException { /// Compiles generated sources into the module's output directory. private static void compileGenerated(ProcessorContext ctx, Map sources) throws ProcessingException { + compileGenerated(ctx, sources, null); + } + + /// @param stubs a directory of entity classes with their persistent fields + /// made public, searched before everything else; null when none needed one. + private static void compileGenerated(ProcessorContext ctx, Map sources, + java.io.File stubs) throws ProcessingException { try { java.util.List cp = new java.util.ArrayList(); cp.add(ctx.getOutputClassDir()); @@ -870,7 +890,12 @@ private static void compileGenerated(ProcessorContext ctx, Map s for (String element : ctx.getCompileClasspath()) { cp.add(new java.io.File(element)); } - JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); + if (stubs != null) { + JavaSourceCompiler.compileAgainst(java.util.Collections.singletonList(stubs), + sources, ctx.getOutputClassDir(), cp); + } else { + JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); + } } catch (IOException ioe) { throw new ProcessingException("Could not compile generated dao sources: " + ioe.getMessage(), ioe); @@ -985,29 +1010,116 @@ private List persistentFields(AnnotatedClass cls,EntityClass entity,P for(FieldInfo field:cls.getFields()) { String path=prefix+field.getName(); for(FieldPath inherited:result) { - if(inherited.field.isPublic() && !inherited.declaringType.equals(cls.getBinaryName()) + // Every inherited field in the list is persistent now that access + // does not decide it, so hiding any of them is the same conflict: + // two fields claiming one attribute name and one column. + if(!inherited.declaringType.equals(cls.getBinaryName()) && (inherited.path.equals(path) || inherited.path.startsWith(path+"."))) { ctx.error("Hidden inherited persistent field: "+cls.getBinaryName()+"."+path); } } - if(field.isStatic() || field.getAnnotation(DB_TRANSIENT_DESC)!=null) continue; + if(!isPersistentCandidate(field)) continue; if(field.getAnnotation("Lcom/codename1/annotations/db/Embedded;")!=null || field.getAnnotation("Lcom/codename1/annotations/db/EmbeddedId;")!=null) { if(field.getAnnotation("Lcom/codename1/annotations/db/EmbeddedId;")!=null) { if(entity.embeddedId!=null) ctx.error("Multiple embedded identifiers"); entity.embeddedId=path; } - if(!field.isPublic() || field.isFinal()) { ctx.error("Embedded field must be public and writable: "+path);continue; } + if(field.isFinal()) { ctx.error("Embedded field must be writable: "+path);continue; } String type=org.objectweb.asm.Type.getType(field.getDescriptor()).getClassName(); AnnotatedClass embedded=findType(type,ctx); if(embedded==null || embedded.getClassAnnotation("Lcom/codename1/annotations/db/Embeddable;")==null || !hasPublicNoArgConstructor(embedded)) { ctx.error("Embedded value requires @Embeddable and a public no-arg constructor: "+path);continue; } entity.embedded.put(path,type); - for(FieldPath nested:persistentFields(embedded,entity,ctx,path+".",visiting)) result.add(new FieldPath(nested.field,nested.path,nested.prefix,cls.getBinaryName())); - } else result.add(new FieldPath(field,path,prefix,cls.getBinaryName())); + hide(entity,cls,field); + for(FieldPath nested:persistentFields(embedded,entity,ctx,path+".",visiting)) result.add(new FieldPath(nested.field,nested.path,nested.prefix,cls.getBinaryName(),nested.owner)); + } else result.add(new FieldPath(field,path,prefix,cls.getBinaryName(),cls.getBinaryName())); } visiting.remove(cls.getBinaryName());return result; } + /// The table or column name a Java name gets when the developer names none. + /// + /// On the BACKEND it is Spring Boot's physical naming + /// (CamelCaseToUnderscoresNamingStrategy): dots become underscores, an + /// underscore goes before an upper case letter that sits between a lower case + /// letter or digit and another lower case letter or digit, and the whole is + /// lower cased. So `fareCents` is `fare_cents`, `RideEvent` is `ride_event`, + /// an embedded `home.zipCode` is `home_zip_code`, and an acronym such as + /// `userID` stays one word, `userid`. A server developer expects the schema + /// Spring would have made, and an explicit `@Column(name)` or + /// `@Entity(table)` still wins. + /// + /// The CLIENT keeps the Java name, dots made underscores. Applications already + /// ship SQLite files whose columns are named after their fields, and + /// `CREATE TABLE IF NOT EXISTS` would leave those tables alone while the + /// generated SQL went looking for columns they do not have. + /// + /// There is no switch back to the Java names on the backend. The backend is + /// a technology preview that follows Spring Boot, and a mode that kept the + /// old names would keep a default that was wrong; a schema made with them + /// keeps working by naming its tables and columns with `@Entity(table)` and + /// `@Column(name)`. + String physical(String name) { + String flat=name.replace('.','_'); + return backend ? snakeCase(flat) : flat; + } + + /// Spring's algorithm character for character, Character classes included, + /// so that a name gets the column it would get under Spring. + static String snakeCase(String name) { + StringBuilder out=new StringBuilder(name.length()+8); + for(int i=0;i0 && i+10 && (join!=null || table!=null)) ctx.error("Inverse associations must not declare join columns or tables: "+relation.field); @@ -2854,7 +2968,11 @@ private static String escape(String s) { static final class FieldPath { final FieldInfo field;final String path,prefix,declaringType; - FieldPath(FieldInfo field,String path,String prefix,String declaringType) { this.field=field;this.path=path;this.prefix=prefix;this.declaringType=declaringType; } + /// The class that really declares the field. Differs from declaringType + /// inside an embeddable, where declaringType is the class holding the + /// embedded value and this is the embeddable itself. + final String owner; + FieldPath(FieldInfo field,String path,String prefix,String declaringType,String owner) { this.field=field;this.path=path;this.prefix=prefix;this.declaringType=declaringType;this.owner=owner; } } static final class RelationField { @@ -2885,6 +3003,12 @@ static final class EntityClass { final List indexes=new ArrayList(); final List relations = new ArrayList(); final List fields = new ArrayList(); + /// Every non-public field the generated code reads or writes, as + /// {declaring class internal name, field name, descriptor}. The + /// generated source is compiled against copies of those classes with the + /// fields made public, and OrmEnhancer then routes each access through a + /// static accessor it weaves into the declaring class. + final List hiddenFields = new ArrayList(); } static final class PersistedField { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmEnhancer.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmEnhancer.java index 6ca5c21f5c8..0bd01d04bda 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmEnhancer.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmEnhancer.java @@ -37,7 +37,98 @@ final class OrmEnhancer { private static final String STATE="com/codename1/impl/orm/EntityState"; private static final String STATE_DESC="L"+STATE+";"; private static final String MANIFEST="META-INF/cn1/orm-enhanced-dependencies.list"; + /// The raw accessors for non-public persistent fields. Unlike the + /// association bridges they do nothing but read or write the field: the + /// generated model is their only caller and it needs the stored value. + static final String GET="__cn1OrmGet_",SET="__cn1OrmSet_"; private OrmEnhancer() {} + + /// The non-public persistent fields of every entity, by declaring class: + /// internal name -> field name -> descriptor. + static Map> hiddenFields(Map entities) { + Map> result=new HashMap>(); + for(OrmAnnotationProcessor.EntityClass entity:entities.values()) for(String[] field:entity.hiddenFields) { + Map fields=result.get(field[0]); + if(fields==null) { fields=new HashMap();result.put(field[0],fields); } + fields.put(field[1],field[2]); + } + return result; + } + + /// The classes the processor generates for its entities, by internal name. + /// They are the only callers of the raw accessors, and the only classes + /// whose own field access is never redirected through association bridges. + static Set generatedClasses(Map entities) { + Set result=new HashSet(); + for(OrmAnnotationProcessor.EntityClass entity:entities.values()) { + String name=entity.binaryName.replace('.','/'); + result.add(name+"Cn1Model");result.add(name+"Cn1BackendModel"); + result.add(name+"Cn1Dao");result.add(name+"Cn1BackendDao"); + if(entity.daoBinaryName!=null) result.add(entity.daoBinaryName.replace('.','/')); + } + return result; + } + + /// Writes a copy of every class that declares a non-public persistent field, + /// with those fields made public, into a fresh temporary directory, for the + /// generated sources to compile against. Answers null when no entity has + /// such a field. The caller deletes the directory. + /// + /// Only javac sees these copies. The bytecode it emits is the same GETFIELD + /// and PUTFIELD it would emit for a public field, and [#enhance] then + /// rewrites each one into a call to an accessor the real class gets. + static File publicStubs(Map entities,ProcessorContext ctx) throws IOException { + final Map> hidden=hiddenFields(entities); + if(hidden.isEmpty()) return null; + File stubs=Files.createTempDirectory("cn1-orm-stubs").toFile(); + boolean done=false; + try { + for(final Map.Entry> owner:hidden.entrySet()) { + byte[] original=classBytes(owner.getKey(),ctx); + if(original==null) throw new IOException("Cannot find the class file of "+owner.getKey().replace('/','.')); + ClassWriter writer=new ClassWriter(0); + new ClassReader(original).accept(new ClassVisitor(Opcodes.ASM9,writer) { + @Override public FieldVisitor visitField(int access,String name,String descriptor,String signature,Object value) { + if(owner.getValue().containsKey(name) && (access&Opcodes.ACC_STATIC)==0) + access=(access&~(Opcodes.ACC_PRIVATE|Opcodes.ACC_PROTECTED))|Opcodes.ACC_PUBLIC; + return super.visitField(access,name,descriptor,signature,value); + } + },0); + File out=new File(stubs,owner.getKey()+".class"); + Files.createDirectories(out.getParentFile().toPath()); + Files.write(out.toPath(),writer.toByteArray()); + } + done=true; + return stubs; + } finally { + if(!done) org.apache.commons.io.FileUtils.deleteDirectory(stubs); + } + } + + /// The current bytes of a class: the output directory first, as javac and + /// the runtime would find it, then the compile classpath. + private static byte[] classBytes(String internalName,ProcessorContext ctx) throws IOException { + String name=internalName+".class"; + File own=new File(ctx.getOutputClassDir(),name); + if(own.isFile()) return Files.readAllBytes(own.toPath()); + for(String path:ctx.getCompileClasspath()) { + File file=new File(path); + if(file.isDirectory()) { + File candidate=new File(file,name); + if(candidate.isFile()) return Files.readAllBytes(candidate.toPath()); + } else if(file.isFile() && file.getName().endsWith(".jar")) { + ZipFile zip=new ZipFile(file); + try { + ZipEntry entry=zip.getEntry(name); + if(entry!=null) { + InputStream in=zip.getInputStream(entry); + try { return read(in); } finally { in.close(); } + } + } finally { zip.close(); } + } + } + return null; + } static Set prepare(ProcessorContext ctx) throws IOException { Path manifest=outputPath(ctx.getOutputClassDir(),MANIFEST); Set removed=new HashSet(); @@ -69,13 +160,18 @@ static void enhance(Map entities,Proc } if(!found) owner.relations.add(relation); } + final Map> hidden=hiddenFields(entities); + final Set generated=generatedClasses(entities); Path manifest=outputPath(ctx.getOutputClassDir(),MANIFEST); Map classes=new LinkedHashMap(); collect(ctx.getOutputClassDir(),ctx.getOutputClassDir(),classes); Set own=new HashSet(classes.keySet()),copied=new TreeSet(); if(Files.isRegularFile(manifest)) copied.addAll(readManifest(manifest.toFile()).keySet()); own.removeAll(copied); - if(!owners.isEmpty()) for(String path:ctx.getCompileClasspath()) { + // The classpath too when an entity there needs weaving: an entity shared + // by the client and the server lives in a module both depend on, and the + // woven copy is written over it into this module's output. + if(!owners.isEmpty() || !hidden.isEmpty()) for(String path:ctx.getCompileClasspath()) { File file=new File(path); if(file.isDirectory()) collect(file,file,classes); else if(file.isFile() && file.getName().endsWith(".jar")) { @@ -91,10 +187,11 @@ else if(file.isFile() && file.getName().endsWith(".jar")) { } finally { zip.close(); } } } + final Map all=classes; for(Map.Entry entry:classes.entrySet()) { String actual=new ClassReader(entry.getValue()).getClassName()+".class"; if(!actual.equals(entry.getKey()) || actual.contains("..") || actual.startsWith("/")) throw new IOException("Invalid class entry: "+entry.getKey()); - byte[] result=transform(entry.getValue(),owners); + byte[] result=transform(entry.getValue(),owners,hidden,generated,new Resolver(all,hidden)); if(result!=null) { Path output=outputPath(ctx.getOutputClassDir(),entry.getKey()); Files.createDirectories(output.getParent());Files.write(output,result); @@ -178,14 +275,23 @@ private static byte[] read(InputStream in) throws IOException { while((count=in.read(buffer))!=-1) out.write(buffer,0,count);return out.toByteArray(); } static byte[] transform(byte[] original,final Map owners) { - ClassReader reader=new ClassReader(original); - final String className=reader.getClassName(); + Map> none=Collections.emptyMap(); + Set generated=new HashSet(); for(String owner:owners.keySet()) { - if(className.equals(owner+"Cn1Model") || className.equals(owner+"Cn1BackendModel") - || className.equals(owner+"Cn1Dao") || className.equals(owner+"Cn1BackendDao")) return null; + generated.add(owner+"Cn1Model");generated.add(owner+"Cn1BackendModel"); + generated.add(owner+"Cn1Dao");generated.add(owner+"Cn1BackendDao"); } + return transform(original,owners,none,generated,new Resolver(Collections.emptyMap(),none)); + } + + static byte[] transform(byte[] original,final Map owners, + final Map> hidden,Set generatedClasses,final Resolver resolver) { + ClassReader reader=new ClassReader(original); + final String className=reader.getClassName(); + if(generatedClasses.contains(className)) return redirectHidden(reader,resolver); final boolean serializer=className.endsWith("Cn1Mapper"); final OrmAnnotationProcessor.EntityClass entity=owners.get(className); + final Map accessors=hidden.get(className); final boolean[] changed={false},already={false}; final ClassWriter writer=new ClassWriter(reader,ClassWriter.COMPUTE_MAXS); reader.accept(new ClassVisitor(Opcodes.ASM9,writer) { @@ -196,6 +302,10 @@ static byte[] transform(byte[] original,final Map field:new TreeMap(accessors).entrySet()) { + accessor(writer,className,field.getKey(),field.getValue(),false); + accessor(writer,className,field.getKey(),field.getValue(),true); + } + } super.visitEnd(); } },0); return changed[0]?writer.toByteArray():null; } + /// `public static T __cn1OrmGet_f(Owner o)` or + /// `public static void __cn1OrmSet_f(Owner o, T v)`, synthetic so that no + /// source can call them. + private static void accessor(ClassWriter writer,String owner,String field,String desc,boolean write) { + Type type=Type.getType(desc); + String signature=write?"(L"+owner+";"+desc+")V":"(L"+owner+";)"+desc; + MethodVisitor method=writer.visitMethod(Opcodes.ACC_PUBLIC|Opcodes.ACC_STATIC|Opcodes.ACC_SYNTHETIC, + (write?SET:GET)+field,signature,null,null); + method.visitCode();method.visitVarInsn(Opcodes.ALOAD,0); + if(write) method.visitVarInsn(type.getOpcode(Opcodes.ILOAD),1); + method.visitFieldInsn(write?Opcodes.PUTFIELD:Opcodes.GETFIELD,owner,field,desc); + method.visitInsn(write?Opcodes.RETURN:type.getOpcode(Opcodes.IRETURN)); + method.visitMaxs(0,0);method.visitEnd(); + } + + /// Rewrites a generated class's GETFIELD and PUTFIELD on a non-public + /// persistent field into a call to that field's accessor. The stack shape + /// is the same before and after -- the object, then the value for a write + /// -- so nothing around the instruction changes. + private static byte[] redirectHidden(ClassReader reader,final Resolver resolver) { + if(resolver.isEmpty()) return null; + final boolean[] changed={false}; + ClassWriter writer=new ClassWriter(reader,0); + reader.accept(new ClassVisitor(Opcodes.ASM9,writer) { + @Override public MethodVisitor visitMethod(int access,String name,String descriptor,String signature,String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9,super.visitMethod(access,name,descriptor,signature,exceptions)) { + @Override public void visitFieldInsn(int opcode,String owner,String field,String desc) { + if(opcode==Opcodes.GETFIELD || opcode==Opcodes.PUTFIELD) { + String declaring=resolver.declaring(owner,field); + if(declaring!=null) { + boolean write=opcode==Opcodes.PUTFIELD; + super.visitMethodInsn(Opcodes.INVOKESTATIC,declaring,(write?SET:GET)+field, + write?"(L"+declaring+";"+desc+")V":"(L"+declaring+";)"+desc,false); + changed[0]=true;return; + } + } + super.visitFieldInsn(opcode,owner,field,desc); + } + }; + } + },0); + return changed[0]?writer.toByteArray():null; + } + + /// Answers which class declares the field an instruction names. javac + /// names the static type of the expression, not the declaring class, so + /// `e.id` on an entity whose id lives in a mapped superclass says the + /// entity; this walks up to the class that holds the accessor. + static final class Resolver { + private final Map classes; + private final Map> hidden; + Resolver(Map classes,Map> hidden) { this.classes=classes;this.hidden=hidden; } + boolean isEmpty() { return hidden.isEmpty(); } + String declaring(String owner,final String field) { + String current=owner; + for(int depth=0;current!=null && depth<64;depth++) { + Map fields=hidden.get(current); + if(fields!=null && fields.containsKey(field)) return current; + byte[] bytes=classes.get(current+".class"); + if(bytes==null) return null; + ClassReader reader=new ClassReader(bytes); + final boolean[] declares={false}; + reader.accept(new ClassVisitor(Opcodes.ASM9) { + @Override public FieldVisitor visitField(int access,String name,String descriptor,String signature,Object value) { + if(name.equals(field)) declares[0]=true; + return null; + } + },ClassReader.SKIP_CODE|ClassReader.SKIP_DEBUG|ClassReader.SKIP_FRAMES); + // A class that declares a field of that name and is not in the + // table holds a different, accessible field. + if(declares[0]) return null; + current=reader.getSuperName(); + } + return null; + } + } + private static void bridge(ClassWriter writer,String owner,OrmAnnotationProcessor.RelationField relation,int operation) { boolean write=operation==1; String desc=relation.descriptor; diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java index 583174e61bd..afe10514f4c 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java @@ -38,6 +38,7 @@ final class RepositoryGenerator { static final String REPOSITORY = PREFIX + "Repository;"; private static final String BASE = "com/codename1/orm/repository/CrudRepository"; private static final String PAGE = "com.codename1.orm.repository.PageRequest"; + private static final String ROW = "[Ljava/lang/Object;"; private final OrmAnnotationProcessor.EntityClass entity; private final String entityType; private final String keyType; @@ -105,7 +106,14 @@ private String method(MethodInfo method) { Type result = Type.getReturnType(method.getDescriptor()); String name = method.getName(); AnnotationValues query = method.getAnnotation(PREFIX + "Query;"), lock = method.getAnnotation(PREFIX + "Lock;"); - boolean modifying = method.getAnnotation(PREFIX + "Modifying;") != null; + AnnotationValues modifyingValues = method.getAnnotation(PREFIX + "Modifying;"); + boolean modifying = modifyingValues != null; + // Spring Data's default: the session is left as it is, so an entity the + // caller read before the statement is still managed and a change made to it + // afterwards is still written. clearAutomatically empties it after the + // statement. flushAutomatically needs no code: executeUpdate always flushes + // first (see Modifying.flushAutomatically). + boolean clearAfter = modifying && modifyingValues.getBoolOrDefault("clearAutomatically", false); if (method.getSignature() != null && method.getSignature().startsWith("<")) throw invalid("Generic methods are unsupported"); if (lock != null) { if (!name.equals("findById") || args.length != 1 || !args[0].getClassName().equals(keyType) @@ -120,13 +128,23 @@ private String method(MethodInfo method) { throw invalid("Use the inherited CRUD method; only findById may be redeclared with @Lock"); } boolean list = result.getClassName().equals("java.util.List"); + // Optional is a single result that may be absent, as Spring Data + // reads it: the query's first row wrapped, or Optional.empty(). + boolean optional = result.getClassName().equals("java.util.Optional"); String scalar = result.getClassName(); - if (list) { + if (list || optional) { + String container = list ? "List" : "Optional"; String sig = method.getSignature(); - int pos = sig == null ? -1 : sig.indexOf(")Ljava/util/List<"); - if (pos < 0 || !sig.endsWith(">;")) throw invalid("Use List, not a raw List or wildcard"); - String item = sig.substring(pos + ")Ljava/util/List<".length(), sig.length() - 2); - if (!item.startsWith("L") || item.indexOf('<') >= 0 || !item.endsWith(";")) throw invalid("List needs a concrete element class"); + String marker = ")Ljava/util/" + container + "<"; + int pos = sig == null ? -1 : sig.indexOf(marker); + if (pos < 0 || !sig.endsWith(">;")) throw invalid("Use " + container + ", not a raw " + container + " or wildcard"); + String item = sig.substring(pos + marker.length(), sig.length() - 2); + // A row of several values -- `select a.x, count(a) ... group by a.x` -- + // is an Object[], as Spring Data returns it; checked below to be a + // declared JPQL select. + if (!item.equals(ROW) && (!item.startsWith("L") || item.indexOf('<') >= 0 || !item.endsWith(";"))) { + throw invalid(container + " needs a concrete element class"); + } scalar = Type.getType(item).getClassName(); } int page = -1; @@ -216,9 +234,12 @@ else if (name.matches("find(First|Top)[0-9]*By.+")) { if (count && !scalar.equals("long") || exists && !scalar.equals("boolean")) throw invalid("countBy returns long; existsBy returns boolean"); if (!delete && !count && !exists && !scalar.equals(entityType)) throw invalid("Derived find returns entity or List"); } + boolean row = scalar.equals("java.lang.Object[]"); + if (row && (query == null || nativeSql)) throw invalid("Object[] rows come from a declared JPQL @Query select"); boolean mutation = modifying || delete; - if (mutation && (!scalar.equals("int") && !scalar.equals("void") || list || page >= 0)) throw invalid("Modifying queries return int or void without pagination"); - if (!mutation && result.getSort() != Type.OBJECT && !count && !exists) throw invalid("Declared scalar selects must use boxed return types for SQL NULL"); + if (mutation && (!scalar.equals("int") && !scalar.equals("void") || list || optional || page >= 0)) throw invalid("Modifying queries return int or void without pagination"); + if (optional && (count || exists)) throw invalid("countBy returns long; existsBy returns boolean"); + if (!mutation && result.getSort() != Type.OBJECT && !row && !count && !exists) throw invalid("Declared scalar selects must use boxed return types for SQL NULL"); if (page >= 0 && limit > 0) throw invalid("Do not combine PageRequest with Top/First"); String valueType = mutation ? "Object" : count || exists ? "Long" : scalar; if (nativeSql && !mutation && !scalar.equals("java.lang.String") && !scalar.equals("java.lang.Long") @@ -226,14 +247,18 @@ else if (name.matches("find(First|Top)[0-9]*By.+")) { throw invalid("Native repository selects return String, Long, Integer, Double or Boolean (or List thereof)"); } String create = "session." + (nativeSql ? "createNativeQuery" : "createQuery") + "(" + quote(statement) + "," + valueType + ".class)"; - StringBuilder body = new StringBuilder(declaration(method, args, list ? "java.util.List<" + scalar + ">" : scalar)); + StringBuilder body = new StringBuilder(declaration(method, args, list ? "java.util.List<" + scalar + ">" + : optional ? "java.util.Optional<" + scalar + ">" : scalar)); body.append("com.codename1.orm.session.JpqlQuery<").append(valueType).append("> q=").append(create).append(';'); for (int i = 0; i < names.size(); i++) body.append("q.setParameter(").append(quote(names.get(i))).append(",p").append(i).append(");"); if (page >= 0) body.append("q.offset(p").append(page).append(".getOffset()).limit(p").append(page).append(".getSize());"); if (limit > 0) body.append("q.limit(").append(limit).append(");"); - body.append(mutation ? scalar.equals("void") ? "q.executeUpdate();" : "return q.executeUpdate();" + body.append(mutation && clearAfter ? scalar.equals("void") ? "q.executeUpdate();session.clear();" + : "int changed=q.executeUpdate();session.clear();return changed;" + : mutation ? scalar.equals("void") ? "q.executeUpdate();" : "return q.executeUpdate();" : count ? "return q.first().longValue();" : exists ? "return q.first().longValue()!=0;" - : list ? "return q.list();" : "return q.first();"); + : list ? "return q.list();" : optional ? "return java.util.Optional.ofNullable(q.first());" + : "return q.first();"); return body.append("}\n").toString(); } private void checkArgument(String field, String suffix, Type argument, MethodInfo method, int parameter) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java index d4e48d661e0..fc987f64cd8 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java @@ -60,7 +60,19 @@ /// `new cn1app.RestClientBootstrap();` into the iOS / Android per-build /// application stub before `Display.init`, just like the existing /// `cn1app.MapperBootstrap`. -public final class RestClientAnnotationProcessor extends AbstractAnnotationProcessor { +public final class RestClientAnnotationProcessor extends AbstractAnnotationProcessor + implements com.codename1.maven.annotations.ProcessesDependencyClasses { + + /// A contract in a library shared with the server gets its client here, in + /// the application. The server half of the same contract is generated by + /// [RestServerAnnotationProcessor] in the backend module, which has no + /// `Rest` and is therefore never offered the client's. + @Override + public boolean acceptsDependencyClasses(ProcessorContext ctx) { + return com.codename1.maven.annotations.DependencyClasses.onClasspath( + ctx.getCompileClasspath(), "com/codename1/io/rest/Rest.class"); + } + public static final String REST_CLIENT_DESC = "Lcom/codename1/annotations/rest/RestClient;"; @@ -105,6 +117,14 @@ public void start(ProcessorContext ctx) throws ProcessingException { public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { if (cls.isSynthetic()) return; if (cls.getClassAnnotation(REST_CLIENT_DESC) == null) return; + if (ctx.isDependencyClass(cls) + && com.codename1.maven.annotations.DependencyClasses.onClasspath( + ctx.getCompileClasspath(), ctx.getOutputClassDir(), + cls.getInternalName() + "Impl.class")) { + // The module that owns the contract, or one between it and this, + // already generated the client. See MappingAnnotationProcessor. + return; + } if (!cls.isInterface()) { ctx.error(cls, "@RestClient requires an interface; " + cls.getBinaryName() + " is not an interface"); @@ -351,15 +371,17 @@ private static void emitMethod(StringBuilder sb, RestMethod rm) { // Query params. for (RestParam p : rm.params) { if (p.kind != ParamKind.QUERY) continue; - sb.append(" if (").append(p.name).append(" != null) _rb.queryParam(\"") + sb.append(" ").append(nullGuard(p)).append("_rb.queryParam(\"") .append(escape(p.bindingName)).append("\", String.valueOf(") .append(p.name).append("));\n"); } // Header params. for (RestParam p : rm.params) { if (p.kind != ParamKind.HEADER) continue; - sb.append(" if (").append(p.name).append(" != null) _rb.header(\"") - .append(escape(p.bindingName)).append("\", ").append(p.name).append(");\n"); + sb.append(" ").append(nullGuard(p)).append("_rb.header(\"") + .append(escape(p.bindingName)).append("\", ") + .append(isPrimitive(p.javaType) ? "String.valueOf(" + p.name + ")" : p.name) + .append(");\n"); } // Cookie params: collapsed into a single `Cookie: a=1; b=2` header. boolean anyCookie = false; @@ -370,7 +392,8 @@ private static void emitMethod(StringBuilder sb, RestMethod rm) { sb.append(" StringBuilder _ck = new StringBuilder();\n"); for (RestParam p : rm.params) { if (p.kind != ParamKind.COOKIE) continue; - sb.append(" if (").append(p.name).append(" != null) {\n"); + sb.append(" ").append(isPrimitive(p.javaType) ? "" : "if (" + p.name + " != null) ") + .append("{\n"); sb.append(" if (_ck.length() > 0) _ck.append(\"; \");\n"); sb.append(" _ck.append(\"").append(escape(p.bindingName)) .append("=\").append(com.codename1.io.Util.encodeUrl(String.valueOf(") @@ -890,4 +913,19 @@ static final class RestParam { ParamKind kind; String bindingName; // path / query / header name } + + /// The `if (x != null) ` that keeps an absent parameter off the request, or + /// nothing for a primitive, which is never absent -- and for which the test + /// does not compile: `double lat` used to generate `if (lat != null)`, so a + /// contract with a primitive `@Query`, `@Header` or `@Cookie` parameter + /// failed the build in a source file the developer never wrote. + private static String nullGuard(RestParam p) { + return isPrimitive(p.javaType) ? "" : "if (" + p.name + " != null) "; + } + + private static boolean isPrimitive(String javaType) { + return "boolean".equals(javaType) || "byte".equals(javaType) || "char".equals(javaType) + || "short".equals(javaType) || "int".equals(javaType) || "long".equals(javaType) + || "float".equals(javaType) || "double".equals(javaType); + } } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestServerAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestServerAnnotationProcessor.java index d43661087b5..e7141514360 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestServerAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestServerAnnotationProcessor.java @@ -135,6 +135,183 @@ public void start(ProcessorContext ctx) throws ProcessingException { @Override public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { if (!isEnabled()) return; + accept(cls, ctx); + } + + /// Set while [#emitStubs] runs. The transfer objects of a contract are not + /// walked then: their codecs are generated later, from the compiled + /// controller, by the processor that generates every other controller's. + private boolean stubPhase; + + /// Generates the server half of every contract the module's DEPENDENCIES + /// declare, as SOURCE, before the module is compiled. + /// + /// This is what makes a contract shared between an application and its + /// server usable from an ordinary backend module. Two things were missing. + /// + /// The server interface did not exist when the class implementing it was + /// compiled. [#processClass] runs after javac, so `NotesApiServer` appeared + /// in `target/classes` one phase too late for `class NotesEndpoint + /// implements NotesApiServer`, and the only layouts that built were a + /// separate contract module post-processed by a script, or an interface + /// written out by hand -- which is the transcription a shared contract + /// exists to remove. + /// + /// And nothing connected the generated dispatcher to the server. It is + /// deliberately transport-free, so the routes, the injected beans, the + /// security filter chain and the JSON codecs of the annotation-driven + /// model all had to be wired to it by hand. + /// + /// So for each contract this writes two sources into the stub directory, + /// which is a compile source root: + /// + /// - `Server`, the synchronous interface the application implements + /// with a `@Component`. + /// - `Controller`, an ordinary `@RestController` that takes that + /// component in its constructor and maps each operation to it. Being an + /// ordinary controller is the point: everything downstream -- routing, + /// binding, codecs, the filter chain -- treats it exactly like one the + /// developer wrote, and there is no second request path to keep in step. + /// + /// No property turns this on. A backend module cannot hold a contract of its + /// own, because a contract names the client's callback types and the backend + /// does not have them; a contract reaching it through a dependency can only + /// be there to be served. A module carrying BOTH runtimes is left to the + /// `cn1.restServer` path, as it was. + @Override + public void emitStubs(ProcessorContext ctx) throws ProcessingException { + List classpath = ctx.getCompileClasspath(); + if (!com.codename1.maven.annotations.DependencyClasses.onClasspath(classpath, + "com/codename1/backend/Backend.class") + || com.codename1.maven.annotations.DependencyClasses.onClasspath(classpath, + "com/codename1/io/rest/Rest.class")) { + return; + } + Map contracts = com.codename1.maven.annotations.DependencyClasses + .scan(classpath, ctx.getOutputClassDir(), DESCRIPTORS); + if (contracts.isEmpty()) { + return; + } + accepted.clear(); + dtos.clear(); + context = ctx; + ctx.setDependencyIndex(contracts); + stubPhase = true; + try { + for (AnnotatedClass contract : contracts.values()) { + accept(contract, ctx); + } + for (Api api : accepted.values()) { + String controller = controllerSource(api, ctx); + if (controller == null) { + continue; + } + String base = api.packageName.length() == 0 ? "" + : api.packageName.replace('.', '/') + "/"; + ctx.emitStubSource(base + api.serverSimpleName, generateServerInterface(api, false)); + ctx.emitStubSource(base + api.simpleName + "Controller", controller); + } + } finally { + stubPhase = false; + accepted.clear(); + dtos.clear(); + } + } + + private static final String BACKEND_ANNOTATIONS = "com.codename1.backend.annotations."; + + /// The `@RestController` that serves `api` by delegating to its + /// `Server` component, or null after reporting why it cannot be + /// written. + private String controllerSource(Api api, ProcessorContext ctx) { + StringBuilder sb = new StringBuilder(2048); + if (api.packageName.length() > 0) { + sb.append("package ").append(api.packageName).append(";\n\n"); + } + String name = api.simpleName + "Controller"; + sb.append("// Generated by cn1:generate-annotation-stubs from ").append(api.binaryName) + .append(". Do not edit.\n"); + sb.append("//\n"); + sb.append("// Serves the contract by handing each request to the component that\n"); + sb.append("// implements ").append(api.serverSimpleName).append(".\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append('@').append(BACKEND_ANNOTATIONS).append("RestController\n"); + sb.append("public class ").append(name).append(" {\n"); + sb.append(" private final ").append(api.serverSimpleName).append(" impl;\n\n"); + sb.append(" public ").append(name).append('(').append(api.serverSimpleName) + .append(" impl) {\n this.impl = impl;\n }\n"); + for (Op op : api.ops) { + sb.append("\n @").append(BACKEND_ANNOTATIONS).append(mappingFor(op.verb)) + .append("(\"").append(javaString(op.pathTemplate)).append("\")\n"); + sb.append(" public ").append(op.sourceReturnType).append(' ').append(op.name).append('('); + StringBuilder call = new StringBuilder(); + for (int i = 0; i < op.params.size(); i++) { + Param p = op.params.get(i); + if (i > 0) { + sb.append(", "); + call.append(", "); + } + if ("path".equals(p.bindKind)) { + sb.append('@').append(BACKEND_ANNOTATIONS).append("PathVariable(\"") + .append(javaString(p.bindName)).append("\") "); + } else if ("query".equals(p.bindKind) || "header".equals(p.bindKind)) { + sb.append('@').append(BACKEND_ANNOTATIONS) + .append("query".equals(p.bindKind) ? "RequestParam" : "RequestHeader") + .append("(value = \"").append(javaString(p.bindName)).append('"'); + // The generated client leaves a null argument out of the + // request altogether, so a reference-typed one has to be + // allowed to be absent or the two halves disagree about a + // call the contract permits. A primitive cannot be null on + // the client and stays required. + if (p.javaType.indexOf('.') >= 0) { + sb.append(", required = false"); + } + sb.append(") "); + } else if ("body".equals(p.bindKind)) { + sb.append('@').append(BACKEND_ANNOTATIONS).append("RequestBody "); + } else { + ctx.error(api.binaryName + "." + op.name + " binds a parameter from a " + + p.bindKind + ", which a served contract has no binding for. " + + "Pass the value as a @Query, @Header or @Path instead."); + return null; + } + sb.append(p.sourceJavaType).append(' ').append(p.name); + call.append(p.name); + } + sb.append(") throws Exception {\n "); + if (!"void".equals(op.sourceReturnType)) { + sb.append("return "); + } + sb.append("impl.").append(op.name).append('(').append(call).append(");\n }\n"); + } + sb.append("}\n"); + return sb.toString(); + } + + private static String mappingFor(String verb) { + if ("GET".equals(verb)) return "GetMapping"; + if ("POST".equals(verb)) return "PostMapping"; + if ("PUT".equals(verb)) return "PutMapping"; + if ("DELETE".equals(verb)) return "DeleteMapping"; + return "PatchMapping"; + } + + /// `text` as the inside of a Java string literal. A route or a binding name + /// is ASCII by the checks in [#accept], so only the two characters that end + /// or escape a literal need attention. + private static String javaString(String text) { + StringBuilder out = new StringBuilder(text.length() + 4); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"' || c == '\\') { + out.append('\\'); + } + out.append(c); + } + return out.toString(); + } + + private void accept(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { if (cls.isSynthetic()) return; if (cls.getClassAnnotation(RestClientAnnotationProcessor.REST_CLIENT_DESC) == null) return; // Shape errors (not an interface, not public) are already reported by the @@ -590,14 +767,14 @@ private String sourceType(String javaType) { } String name = javaType.substring(start, at); AnnotatedClass known = name.indexOf('$') < 0 || context == null - ? null : context.lookup(name.replace('.', '/')); + ? null : context.lookupWithDependencies(name.replace('.', '/')); out.append(known == null ? name : known.getSourceName()); } return out.toString(); } private void collectDtos(String javaType, ProcessorContext ctx) { - if (javaType == null) return; + if (javaType == null || stubPhase) return; String t = javaType.trim(); int lt = t.indexOf('<'); if (lt >= 0) { @@ -699,6 +876,22 @@ private void collectDtos(String javaType, ProcessorContext ctx) { AnnotatedClass cls = ctx.lookup(t.replace('.', '/')); if (cls == null || cls.isInterface() || cls.isEnum()) return; if (dtos.containsKey(t)) return; + if (cls.isRecord()) { + // This codec transfers PUBLIC fields and builds the object with its + // no-arg constructor. A record has neither: its components are private + // final fields, so it would encode as {} and could not be decoded at + // all. The backend path (a contract in a dependency, served through + // the generated Server and Controller) encodes through + // BackendJsonCodecs, which reads the accessors and calls the canonical + // constructor -- refuse here rather than ship an empty object. + ctx.error(t + " is a record, which the cn1.restServer codec cannot " + + "transfer: it encodes public fields and decodes through a no-arg " + + "constructor, and a record has neither. Serve the contract from " + + "the backend, whose generated controller encodes records through " + + "their accessors and canonical constructor, or use a class with " + + "public fields here."); + return; + } requireAssignableFields(t, cls, ctx); dtos.put(t, cls); for (FieldInfo f : transferredFields(cls, ctx)) { @@ -929,11 +1122,23 @@ private static String codecFor(String dtoBinaryName) { } private String generateServerInterface(Api api) { + return generateServerInterface(api, true); + } + + /// @param marked whether the interface carries `@Generated`. It does when it + /// is written into the output directory, where the marker is how a + /// later pass tells its own class file from one the developer wrote. + /// A source in the stub directory needs no such test -- the directory + /// is the answer -- and the marker would hide it from the processors + /// that have to see it. + private String generateServerInterface(Api api, boolean marked) { StringBuilder sb = new StringBuilder(1024); if (api.packageName.length() > 0) sb.append("package ").append(api.packageName).append(";\n\n"); sb.append("// Auto-generated by cn1:process-annotations from ").append(api.binaryName).append(". Do not edit.\n"); sb.append("@SuppressWarnings({\"all\"})\n"); - sb.append("@com.codename1.backend.annotations.Generated\n"); + if (marked) { + sb.append("@com.codename1.backend.annotations.Generated\n"); + } sb.append("public interface ").append(api.serverSimpleName).append(" {\n"); for (Op op : api.ops) { sb.append(" ").append(op.sourceReturnType).append(' ').append(op.name).append('('); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/AnnotationProcessingDependencyTest.java b/maven/build-engine/src/test/java/com/codename1/maven/AnnotationProcessingDependencyTest.java new file mode 100644 index 00000000000..c1daa8f6cf7 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/AnnotationProcessingDependencyTest.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import com.codename1.build.SystemStreamLog; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.JavaSourceCompiler; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/// The whole driver, with the processors the build really loads: a contract and +/// a transfer object that live in a library the module depends on are offered to +/// the processors that asked for them, and what those generate lands in the +/// module being built -- never in the dependency. +public class AnnotationProcessingDependencyTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void aDependencysContractAndTransferObjectAreGeneratedIntoTheModule() throws Exception { + File shared = compileShared(); + File classes = compileApplication(); + String[] before = listing(shared); + + run(classes, Arrays.asList(classes.getPath(), shared.getPath(), clientRuntime().getPath())); + + assertTrue("the client of the dependency's contract", + new File(classes, "com/example/shared/NotesApiImpl.class").isFile()); + assertTrue(new File(classes, "cn1app/RestClientBootstrap.class").isFile()); + assertTrue("the mapper of the dependency's transfer object", + new File(classes, "com/example/shared/NoteCn1Mapper.class").isFile()); + assertTrue(new File(classes, "cn1app/MapperBootstrap.class").isFile()); + AnnotatedClass client = ClassScanner.readClass( + new File(classes, "com/example/shared/NotesApiImpl.class")); + assertTrue(client.getInterfaceInternalNames().contains("com/example/shared/NotesApi")); + assertEquals("a dependency is somebody else's output and is never written to", + Arrays.asList(before), Arrays.asList(listing(shared))); + } + + /// The second build of the module sees its own first build's output on its + /// classpath. That is not "a dependency already generated these". + @Test + public void theSecondBuildGeneratesThemAgain() throws Exception { + File shared = compileShared(); + File classes = compileApplication(); + List classpath = Arrays.asList(classes.getPath(), shared.getPath(), + clientRuntime().getPath()); + run(classes, classpath); + File client = new File(classes, "com/example/shared/NotesApiImpl.class"); + File bootstrap = new File(classes, "cn1app/RestClientBootstrap.class"); + assertTrue(client.delete()); + assertTrue(bootstrap.delete()); + // What is left -- the mapper, its bootstrap -- is the previous build's. + + run(classes, classpath); + + assertTrue(client.isFile()); + assertTrue(bootstrap.isFile()); + assertTrue(new File(classes, "com/example/shared/NoteCn1Mapper.class").isFile()); + } + + /// Without the client runtime the module is not an application, and code + /// generated for one could not link in it. + @Test + public void aModuleWithoutTheClientRuntimeIsOfferedNothing() throws Exception { + File shared = compileShared(); + File classes = compileApplication(); + + run(classes, Arrays.asList(classes.getPath(), shared.getPath())); + + assertFalse(new File(classes, "com/example/shared/NotesApiImpl.class").exists()); + assertFalse(new File(classes, "com/example/shared/NoteCn1Mapper.class").exists()); + assertFalse(new File(classes, "cn1app").exists()); + } + + /// A module between the library and the application that generated the + /// client already: the application must not define the same class twice. + @Test + public void whatADependencyAlreadyGeneratedIsNotGeneratedAgain() throws Exception { + File shared = compileShared(); + File middle = tmp.newFolder("middle"); + Map generated = new LinkedHashMap(); + generated.put("com.example.shared.NotesApiImpl", + "package com.example.shared;\npublic class NotesApiImpl {}\n"); + generated.put("com.example.shared.NoteCn1Mapper", + "package com.example.shared;\npublic class NoteCn1Mapper {}\n"); + JavaSourceCompiler.compile(generated, middle, Arrays.asList(testClassesDir())); + File classes = compileApplication(); + + run(classes, Arrays.asList(classes.getPath(), middle.getPath(), shared.getPath(), + clientRuntime().getPath())); + + assertFalse(new File(classes, "com/example/shared/NotesApiImpl.class").exists()); + assertFalse(new File(classes, "com/example/shared/NoteCn1Mapper.class").exists()); + } + + /// A project in two languages: the classes the other pass compiles are on + /// this pass's classpath and are the module's own. Mapping them here as well + /// gave the build two copies of one generated class. + @Test + public void theModulesOwnClassesFromAnotherPassAreNotOfferedAsADependencys() throws Exception { + File otherPass = compileShared(); + File classes = compileApplication(); + + new AnnotationProcessing(new SystemStreamLog(), classes, tmp.newFolder(), tmp.newFolder(), + new Properties(), null, Collections.emptyList(), "UTF-8", + Arrays.asList(classes.getPath(), otherPass.getPath(), clientRuntime().getPath())) + .withOwnClassDirectories(Collections.singletonList(otherPass)) + .run(); + + assertFalse(new File(classes, "com/example/shared/NotesApiImpl.class").exists()); + assertFalse(new File(classes, "com/example/shared/NoteCn1Mapper.class").exists()); + } + + private void run(File classes, List classpath) throws Exception { + new AnnotationProcessing(new SystemStreamLog(), classes, tmp.newFolder(), tmp.newFolder(), + new Properties(), null, Collections.emptyList(), "UTF-8", classpath).run(); + } + + private File compileShared() throws Exception { + File shared = tmp.newFolder("shared"); + Map sources = new LinkedHashMap(); + sources.put("com.example.shared.Note", + "package com.example.shared;\n" + + "@com.codename1.annotations.Mapped\n" + + "public class Note {\n" + + " public String title;\n" + + " public Note() {}\n" + + "}\n"); + sources.put("com.example.shared.NotesApi", + "package com.example.shared;\n" + + "import com.codename1.annotations.rest.*;\n" + + "import com.codename1.io.rest.Response;\n" + + "import com.codename1.util.OnComplete;\n" + + "@RestClient\n" + + "public interface NotesApi {\n" + + " @GET(\"/notes\")\n" + + " void list(OnComplete> callback);\n" + + "}\n"); + JavaSourceCompiler.compile(sources, shared, Arrays.asList(testClassesDir())); + return shared; + } + + /// The module itself, which declares nothing annotated. + private File compileApplication() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.app.Main", + "package com.example.app;\npublic class Main {}\n"), + classes, Arrays.asList(testClassesDir())); + return classes; + } + + private static String[] listing(File dir) { + java.util.List out = new java.util.ArrayList(); + collect(dir, dir, out); + Collections.sort(out); + return out.toArray(new String[0]); + } + + private static void collect(File root, File dir, java.util.List out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collect(root, child, out); + } else { + out.add(root.toURI().relativize(child.toURI()).getPath() + ":" + child.length()); + } + } + } + + private static File clientRuntime() throws Exception { + return new File(com.codename1.io.rest.Rest.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + } + + private static File testClassesDir() throws Exception { + return new File(AnnotationProcessingDependencyTest.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/maven/BackendJavaLevelTest.java b/maven/build-engine/src/test/java/com/codename1/maven/BackendJavaLevelTest.java index 4daf23e0529..cb71634ccdb 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/BackendJavaLevelTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/BackendJavaLevelTest.java @@ -111,6 +111,49 @@ class BackendJavaLevelTest { assertEquals(oldJdk, packager.resolveJdk(), "an explicit JDK choice still wins"); } + /// A Java 8 application may depend on a contract module compiled at release + /// 17. JDK_8_HOME's javac cannot read those class files, so it is passed over + /// for the running JDK -- but not for a module descriptor or a multi-release + /// entry, which a Java 8 library commonly carries and a Java 8 compile ignores. + @Test void aJava17DependencyPassesOverTheLegacyJdk8(@TempDir Path dir) throws Exception { + File oldJdk = Files.createDirectories(dir.resolve("legacy/bin")).getParent().toFile(); + Files.write(new File(oldJdk, "bin/javac").toPath(), new byte[0]); + File running = new BackendPackager(TestProjectHost.empty()).resolveJdk(); + + Path java8 = Files.createDirectories(dir.resolve("java8/pkg")); + Files.write(java8.resolve("Old.class"), header(52)); + Files.write(java8.getParent().resolve("module-info.class"), header(53)); + Files.createDirectories(java8.getParent().resolve("META-INF/versions/17")); + Files.write(java8.getParent().resolve("META-INF/versions/17/New.class"), header(61)); + Path jar = dir.resolve("contract.jar"); + try (java.util.zip.ZipOutputStream out = new java.util.zip.ZipOutputStream( + Files.newOutputStream(jar))) { + out.putNextEntry(new java.util.zip.ZipEntry("module-info.class")); + out.write(header(53)); + out.putNextEntry(new java.util.zip.ZipEntry("api/RideDto.class")); + out.write(header(61)); + } + + TestProjectHost host = TestProjectHost.empty(); + host.classpath.add(java8.getParent().toString()); + BackendPackager packager = new BackendPackager(host); + packager.jdk(null, oldJdk.toString()); + packager.javaLevel(null, null, "8"); + assertEquals(oldJdk, packager.resolveJdk(), "Java 8 dependencies keep JDK_8_HOME"); + + host.classpath.add(jar.toString()); + assertEquals(running, packager.resolveJdk(), "a release 17 dependency needs a newer javac"); + + host.classpath.set(1, Files.createDirectories(dir.resolve("classes17/api")).getParent().toString()); + Files.write(dir.resolve("classes17/api/RideDto.class"), header(61)); + assertEquals(running, packager.resolveJdk(), "a reactor module's classes directory too"); + } + + private static byte[] header(int major) { + return new byte[] {(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE, 0, 0, + (byte) (major >> 8), (byte) major}; + } + @Test void previewSourcesAndJava8Wiring(@TempDir Path dir) throws Exception { File jdk = jdk("25"); Path source = Files.createDirectories(dir.resolve("src")); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/BackendWebAppTest.java b/maven/build-engine/src/test/java/com/codename1/maven/BackendWebAppTest.java new file mode 100644 index 00000000000..263283d971d --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/BackendWebAppTest.java @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.SystemStreamLog; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.GZIPInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class BackendWebAppTest { + @TempDir + Path tmp; + + private final BackendWebApp webApp = new BackendWebApp(new SystemStreamLog()); + + /// Text long enough, and repetitive enough, to be worth a compressed copy. + private static String script() { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < 500; i++) { + out.append("function f").append(i).append("(){return ").append(i).append(";}\n"); + } + return out.toString(); + } + + private static File zip(File file, String... namesAndContents) throws IOException { + file.getParentFile().mkdirs(); + ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file)); + try { + for (int i = 0; i < namesAndContents.length; i += 2) { + out.putNextEntry(new ZipEntry(namesAndContents[i])); + out.write(namesAndContents[i + 1].getBytes("UTF-8")); + out.closeEntry(); + } + } finally { + out.close(); + } + return file; + } + + private static String read(File file) throws IOException { + return new String(Files.readAllBytes(file.toPath()), "UTF-8"); + } + + private static String gunzip(File file) throws IOException { + InputStream in = new GZIPInputStream(new FileInputStream(file)); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int n = in.read(chunk); + while (n >= 0) { + out.write(chunk, 0, n); + n = in.read(chunk); + } + return new String(out.toByteArray(), "UTF-8"); + } finally { + in.close(); + } + } + + @Test + void stagesABundleAndCompressesItsText() throws Exception { + File bundle = zip(tmp.resolve("app.zip").toFile(), + "index.html", "", + "translated_app.js", script(), + "js/", "", + "js/fontmetrics.js", "m();", + "assets/logo.png", script()); + File out = tmp.resolve("server/target/webapp").toFile(); + + assertEquals(4, webApp.stage(bundle, out)); + + assertEquals("", read(new File(out, "index.html"))); + assertEquals("m();", read(new File(out, "js/fontmetrics.js"))); + assertEquals(script(), read(new File(out, "translated_app.js"))); + // The original stays: a browser that refuses gzip, and a byte range, + // are both answered from it. + assertEquals(script(), gunzip(new File(out, "translated_app.js.gz"))); + assertTrue(new File(out, "translated_app.js.gz").length() < new File(out, "translated_app.js").length()); + // Too small to be worth it, and not text at all. + assertFalse(new File(out, "index.html.gz").exists()); + assertFalse(new File(out, "js/fontmetrics.js.gz").exists()); + assertFalse(new File(out, "assets/logo.png.gz").exists()); + } + + @Test + void replacesWhatWasStagedBefore() throws Exception { + File out = tmp.resolve("webapp").toFile(); + webApp.stage(zip(tmp.resolve("one.zip").toFile(), + "index.html", "one", "gone.js", "x"), out); + webApp.stage(zip(tmp.resolve("two.zip").toFile(), "index.html", "two"), out); + assertEquals("two", read(new File(out, "index.html"))); + // A file the new build no longer has must not go on being served. + assertFalse(new File(out, "gone.js").exists()); + } + + @Test + void unwrapsABundleThatSitsInOneDirectory() throws Exception { + File bundle = zip(tmp.resolve("wrapped.zip").toFile(), + "MyApp-1.0/index.html", "wrapped", "MyApp-1.0/js/a.js", "a"); + File out = tmp.resolve("webapp").toFile(); + assertEquals(2, webApp.stage(bundle, out)); + assertEquals("wrapped", read(new File(out, "index.html"))); + assertEquals("a", read(new File(out, "js/a.js"))); + } + + @Test + void stagesADirectory() throws Exception { + File dir = tmp.resolve("unpacked").toFile(); + new File(dir, "css").mkdirs(); + Files.write(new File(dir, "index.html").toPath(), "dir".getBytes("UTF-8")); + Files.write(new File(dir, "css/a.css").toPath(), "a{}".getBytes("UTF-8")); + File out = tmp.resolve("webapp").toFile(); + assertEquals(2, webApp.stage(dir, out)); + assertEquals("a{}", read(new File(out, "css/a.css"))); + } + + @Test + void refusesAnEntryThatClimbsOutOfTheDirectory() throws Exception { + File bundle = zip(tmp.resolve("slip.zip").toFile(), + "index.html", "x", "../../escaped.txt", "escaped"); + File out = tmp.resolve("a/b/webapp").toFile(); + BuildFailureException refused = assertThrows(BuildFailureException.class, + () -> webApp.stage(bundle, out)); + assertTrue(refused.getMessage().contains("escaped.txt"), refused.getMessage()); + assertFalse(tmp.resolve("a/escaped.txt").toFile().exists()); + assertFalse(tmp.resolve("escaped.txt").toFile().exists()); + } + + @Test + void refusesABundleThatIsNotAnApplication() throws Exception { + File bundle = zip(tmp.resolve("sources.zip").toFile(), "pom.xml", ""); + File out = tmp.resolve("webapp").toFile(); + BuildFailureException refused = assertThrows(BuildFailureException.class, + () -> webApp.stage(bundle, out)); + assertTrue(refused.getMessage().contains("index.html"), refused.getMessage()); + // Nothing half-staged is left for a server to find. + assertFalse(out.exists()); + assertThrows(BuildFailureException.class, + () -> webApp.stage(tmp.resolve("absent.zip").toFile(), out)); + } + + @Test + void refusesToStageADirectoryIntoItself() throws Exception { + File dir = tmp.resolve("unpacked").toFile(); + dir.mkdirs(); + Files.write(new File(dir, "index.html").toPath(), "dir".getBytes("UTF-8")); + assertThrows(BuildFailureException.class, () -> webApp.stage(dir, new File(dir, "webapp"))); + assertThrows(BuildFailureException.class, () -> webApp.stage(dir, dir)); + assertEquals("dir", read(new File(dir, "index.html"))); + // Nor into a directory that holds it, which is emptied first. + assertThrows(BuildFailureException.class, () -> webApp.stage(dir, tmp.toFile())); + assertEquals("dir", read(new File(dir, "index.html"))); + File archive = zip(tmp.resolve("site/app.zip").toFile(), "index.html", "x"); + assertThrows(BuildFailureException.class, + () -> webApp.stage(archive, tmp.resolve("site").toFile())); + assertTrue(archive.isFile()); + } + + /// A symbolic link in the old output, or in a copy destination, is + /// unlinked; the directory it points at, outside the tree, keeps its files. + @Test + void replacingTheOutputNeverFollowsALinkOutOfIt() throws Exception { + File outside = tmp.resolve("precious").toFile(); + outside.mkdirs(); + Files.write(new File(outside, "keep.txt").toPath(), "keep".getBytes("UTF-8")); + + File out = tmp.resolve("webapp").toFile(); + webApp.stage(zip(tmp.resolve("one.zip").toFile(), "index.html", "one"), out); + Path link = new File(out, "linked").toPath(); + Path dangling = new File(out, "dangling").toPath(); + try { + Files.createSymbolicLink(link, outside.toPath()); + Files.createSymbolicLink(dangling, tmp.resolve("no-such-dir")); + } catch (UnsupportedOperationException | IOException | SecurityException noLinks) { + assumeTrue(false, "this filesystem cannot create a symbolic link: " + noLinks); + } + + webApp.stage(zip(tmp.resolve("two.zip").toFile(), "index.html", "two"), out); + assertEquals("two", read(new File(out, "index.html"))); + assertFalse(Files.exists(link, LinkOption.NOFOLLOW_LINKS)); + assertFalse(Files.exists(dangling, LinkOption.NOFOLLOW_LINKS)); + assertEquals("keep", read(new File(outside, "keep.txt"))); + + // The same through copy(), whose destination is emptied the same way. + File destination = tmp.resolve("elsewhere").toFile(); + destination.mkdirs(); + Path copyLink = new File(destination, "linked").toPath(); + Files.createSymbolicLink(copyLink, outside.toPath()); + webApp.copy(out, destination); + assertEquals("two", read(new File(destination, "index.html"))); + assertFalse(Files.exists(copyLink, LinkOption.NOFOLLOW_LINKS)); + assertEquals("keep", read(new File(outside, "keep.txt"))); + } + + /// A directory bundle with a link back to one of its own ancestors is + /// refused when the link is reached, not copied into itself until the + /// stack or the path length gives out. + @Test + void aLinkBackIntoTheBundleIsRefused() throws Exception { + File dir = tmp.resolve("site").toFile(); + File assets = new File(dir, "assets"); + assets.mkdirs(); + Files.write(new File(dir, "index.html").toPath(), "dir".getBytes("UTF-8")); + try { + Files.createSymbolicLink(new File(assets, "loop").toPath(), dir.toPath()); + } catch (UnsupportedOperationException | IOException | SecurityException noLinks) { + assumeTrue(false, "this filesystem cannot create a symbolic link: " + noLinks); + } + File out = tmp.resolve("webapp").toFile(); + Exception refused = assertThrows(Exception.class, () -> webApp.stage(dir, out)); + assertTrue(String.valueOf(refused.getMessage()).contains("links back") + || String.valueOf(refused.getCause()).contains("links back"), String.valueOf(refused)); + } + + @Test + void findsTheBundleOfWhicheverModuleBuildsForTheBrowser() throws Exception { + File root = tmp.resolve("app").toFile(); + assertNull(BackendWebApp.locateBundle(root)); + // The archive of sources a cloud build uploads lives in the same directory. + zip(new File(root, "common/target/app-common-1.0-src.zip"), "pom.xml", ""); + assertNull(BackendWebApp.locateBundle(root)); + File common = zip(new File(root, "common/target/app-common-1.0.zip"), "index.html", "c"); + assertEquals(common, BackendWebApp.locateBundle(root)); + File javascript = zip(new File(root, "javascript/target/app-javascript-1.0.ZIP"), "index.html", "j"); + assertEquals(javascript, BackendWebApp.locateBundle(root)); + File newer = zip(new File(root, "javascript/target/app-javascript-1.1.zip"), "index.html", "j2"); + assertTrue(newer.setLastModified(javascript.lastModified() + 5000)); + assertEquals(newer, BackendWebApp.locateBundle(root)); + } + + /// A project with one build directory for every platform, as a Gradle + /// project has: the bundle is the newest archive there that is an app. + @Test + void findsTheBundleInABuildDirectory() throws Exception { + File build = new File(tmp.toFile(), "build"); + assertNull(BackendWebApp.locateBundleIn(build)); + assertNull(BackendWebApp.locateBundleIn(null)); + zip(new File(build, "sources.zip"), "src/Main.java", "class Main {}"); + assertNull(BackendWebApp.locateBundleIn(build)); + File app = zip(new File(build, "MyApp.zip"), "index.html", ""); + assertEquals(app, BackendWebApp.locateBundleIn(build)); + } + + @Test + void leavesAProjectsOwnProxyChoiceAlone() throws Exception { + File settings = tmp.resolve("codenameone_settings.properties").toFile(); + assertFalse(BackendWebApp.choosesProxy(settings)); + Files.write(settings.toPath(), "codename1.arg.java.version=17\n".getBytes("UTF-8")); + assertFalse(BackendWebApp.choosesProxy(settings)); + Files.write(settings.toPath(), "codename1.arg.javascript.inject_proxy=true\n".getBytes("UTF-8")); + assertTrue(BackendWebApp.choosesProxy(settings)); + Files.write(settings.toPath(), + "codename1.arg.javascript.proxy.url=https://p.example/?u=\n".getBytes("UTF-8")); + assertTrue(BackendWebApp.choosesProxy(settings)); + } + + @Test + void doesNotAnswerForAServerThatSaysWhereItsAppIs() throws Exception { + File module = tmp.resolve("backend").toFile(); + module.mkdirs(); + Map none = Collections.emptyMap(); + assertFalse(BackendWebApp.rootIsConfigured(module, none, null)); + assertFalse(BackendWebApp.rootIsConfigured(module, none, "-Xmx1g -Dcn1.webapp.path=/app")); + assertTrue(BackendWebApp.rootIsConfigured(module, none, "-Xmx1g -Dcn1.webapp.root=/srv/app")); + Map env = new HashMap(); + env.put("CN1_WEBAPP_ROOT", "/srv/app"); + assertTrue(BackendWebApp.rootIsConfigured(module, env, null)); + Files.write(new File(module, "application.properties").toPath(), + "server.port=8081\n".getBytes("UTF-8")); + assertFalse(BackendWebApp.rootIsConfigured(module, none, null)); + // A profile's file counts only when that profile is the one running. + Files.write(new File(module, "application-dev.properties").toPath(), + "cn1.webapp.root=../site\n".getBytes("UTF-8")); + assertFalse(BackendWebApp.rootIsConfigured(module, none, null)); + assertFalse(BackendWebApp.rootIsConfigured(module, none, "-Dcn1.profile=prod")); + assertTrue(BackendWebApp.rootIsConfigured(module, none, "-Xmx1g -Dcn1.profile=dev")); + Map dev = new HashMap(); + dev.put("CN1_PROFILE", "dev"); + assertTrue(BackendWebApp.rootIsConfigured(module, dev, null)); + // The base file names the profile, as itself or with a fallback. + Files.write(new File(module, "application.properties").toPath(), + "cn1.profile=dev\n".getBytes("UTF-8")); + assertTrue(BackendWebApp.rootIsConfigured(module, none, null)); + assertFalse(BackendWebApp.rootIsConfigured(module, none, "-Dcn1.profile=prod")); + Files.write(new File(module, "application.properties").toPath(), + "cn1.profile=${WHICH:dev}\n".getBytes("UTF-8")); + assertTrue(BackendWebApp.rootIsConfigured(module, none, null)); + Map prod = new HashMap(); + prod.put("WHICH", "prod"); + assertFalse(BackendWebApp.rootIsConfigured(module, prod, null)); + // A name this cannot work out: any profile's declaration counts. + Files.write(new File(module, "application.properties").toPath(), + "cn1.profile=${A}-${B}\n".getBytes("UTF-8")); + assertTrue(BackendWebApp.rootIsConfigured(module, none, null)); + } + + @Test + void copiesAStagedAppBesideABinaryPackagedElsewhere() throws Exception { + File out = tmp.resolve("webapp").toFile(); + webApp.stage(zip(tmp.resolve("app.zip").toFile(), + "index.html", "x", "translated_app.js", script()), out); + File beside = tmp.resolve("dist/webapp").toFile(); + assertEquals(2, webApp.copy(out, beside)); + assertEquals(script(), gunzip(new File(beside, "translated_app.js.gz"))); + assertEquals(new File(out, "translated_app.js").lastModified(), + new File(beside, "translated_app.js").lastModified()); + assertEquals(0, webApp.copy(out, out)); + assertEquals("x", read(new File(out, "index.html"))); + } + + /// A binary written under the staged app would have the app copied into + /// itself, and one whose directory holds the staged app would delete it. + @Test + void refusesACopyNestedInsideOrAroundTheStagedApp() throws Exception { + File out = tmp.resolve("site/webapp").toFile(); + webApp.stage(zip(tmp.resolve("app.zip").toFile(), + "index.html", "x", "translated_app.js", script()), out); + File inside = new File(out, "bin/webapp"); + assertThrows(BuildExecutionException.class, () -> webApp.copy(out, inside)); + assertFalse(inside.exists()); + assertThrows(BuildExecutionException.class, + () -> webApp.copy(out, tmp.resolve("site").toFile())); + assertEquals("x", read(new File(out, "index.html"))); + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/maven/BytecodeComplianceTest.java b/maven/build-engine/src/test/java/com/codename1/maven/BytecodeComplianceTest.java index 6ad60af532c..e28dbc77a34 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/BytecodeComplianceTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/BytecodeComplianceTest.java @@ -33,6 +33,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.io.ByteArrayOutputStream; +import java.io.File; import java.util.ArrayList; import java.nio.file.Files; import java.nio.file.Path; @@ -48,6 +49,30 @@ import static org.junit.jupiter.api.Assertions.assertFalse; class BytecodeComplianceTest { + /// A sibling module checked with the application is compiled by its own + /// module, so its classes have to count when deciding whether the last + /// check still holds; otherwise a change to it alone skips the check. + @Test + void siblingClassesCountTowardsTheCheckBeingStale(@TempDir Path dir) throws Exception { + TestProjectHost host = TestProjectHost.empty(); + host.buildDir = dir.resolve("build").toFile(); + host.outputDir = dir.resolve("build/classes").toFile(); + File own = new File(host.outputDir, "app/Main.class"); + own.getParentFile().mkdirs(); + Files.write(own.toPath(), new byte[] {1}); + assertTrue(own.setLastModified(1000000000000L)); + File shared = dir.resolve("shared/target/classes").toFile(); + File dto = new File(shared, "api/Dto.class"); + dto.getParentFile().mkdirs(); + Files.write(dto.toPath(), new byte[] {1}); + assertTrue(dto.setLastModified(2000000000000L)); + + BytecodeCompliance compliance = new BytecodeCompliance(host); + assertEquals(1000000000000L, compliance.getCompiledClassesModificationTime()); + compliance.checkedDependencyRoots(Collections.singletonList(shared)); + assertEquals(2000000000000L, compliance.getCompiledClassesModificationTime()); + } + @Test void backendAllowsThirdPartyJavaxButProtectsNativeApi(@TempDir Path dir) throws Exception { Path output = Files.createDirectories(dir.resolve("output")); @@ -558,6 +583,108 @@ void rerunsComplianceCheckAfterPreviousFailureReport(@TempDir Path tempDir) thro "Expected re-run when the previous check recorded violations, even with nothing newer"); } + /// A module of the same build resolves to its classes DIRECTORY when the + /// build stops short of `package`. Left out of the dependencies, every one + /// of its classes read as an API the device does not have. + @Test + void aDependencyThatIsAClassesDirectoryIsScanned(@TempDir Path tempDir) throws Exception { + Path shared = tempDir.resolve("shared").resolve("target").resolve("classes"); + Files.createDirectories(shared); + writeApiClass(shared, "shared/Dto", "m", "()V"); + Path notClasses = tempDir.resolve("notes.txt"); + Files.write(notClasses, new byte[] {1}); + + TestProjectHost host = TestProjectHost.empty(); + host.artifacts.add(TestProjectHost.artifact("shared", "compile", shared.toFile())); + host.artifacts.add(TestProjectHost.artifact("notes", "compile", notClasses.toFile())); + host.artifacts.add(TestProjectHost.artifact("gone", "compile", + tempDir.resolve("absent").toFile())); + BytecodeCompliance mojo = new BytecodeCompliance(host); + + List roots = getDependencyJarsForScanning(mojo); + assertEquals(Collections.singletonList(shared.toFile()), roots); + } + + @Test + void aReferenceIntoADependencyDirectoryIsTheProjectsOwn(@TempDir Path tempDir) throws Exception { + Path shared = tempDir.resolve("shared"); + Files.createDirectories(shared); + writeApiClass(shared, "shared/Dto", "m", "()V"); + TestProjectHost host = complianceHost(tempDir, "shared/Dto"); + host.artifacts.add(TestProjectHost.artifact("shared", "compile", shared.toFile())); + + new BytecodeCompliance(host).execute(); + + String report = new String(Files.readAllBytes(host.buildDir.toPath() + .resolve("codenameone").resolve("compliance_check.txt")), "UTF-8"); + assertTrue(report.contains("Completed compliance check"), report); + } + + /// A plain module the application shares with its server is checked by + /// nothing when it is built. Its classes ship in the application, so a + /// class there that names an API the device lacks is reported here. + @Test + void aCheckedDependencyRootIsHeldToTheSameApi(@TempDir Path tempDir) throws Exception { + Path shared = tempDir.resolve("shared"); + Files.createDirectories(shared); + writeApiClass(shared, "shared/Dto", "m", "()V"); + writeClass(shared, "shared/Offender", "forbidden/Api", "m", "()V"); + TestProjectHost host = complianceHost(tempDir, "shared/Dto"); + host.artifacts.add(TestProjectHost.artifact("shared", "compile", shared.toFile())); + + // As a dependency alone the module is trusted, as a jar always was. + new BytecodeCompliance(host).execute(); + + TestProjectHost checked = complianceHost(tempDir.resolve("again"), "shared/Dto"); + checked.artifacts.add(TestProjectHost.artifact("shared", "compile", shared.toFile())); + BytecodeCompliance mojo = new BytecodeCompliance(checked).checkedDependencyRoots( + java.util.Arrays.asList(shared.toFile(), null, tempDir.resolve("absent").toFile())); + com.codename1.build.BuildFailureException refused = org.junit.jupiter.api.Assertions + .assertThrows(com.codename1.build.BuildFailureException.class, () -> mojo.execute()); + assertTrue(refused.getMessage().contains("forbidden/Api") + || refused.getMessage().contains("forbidden.Api"), refused.getMessage()); + String report = new String(Files.readAllBytes(checked.buildDir.toPath() + .resolve("codenameone").resolve("compliance_check.txt")), "UTF-8"); + assertTrue(report.contains("shared/Offender") || report.contains("shared.Offender"), report); + } + + @Test + void noCheckedDependencyRootsIsTheDefaultAndNullMeansNone(@TempDir Path tempDir) throws Exception { + Path shared = tempDir.resolve("shared"); + Files.createDirectories(shared); + writeApiClass(shared, "shared/Dto", "m", "()V"); + TestProjectHost host = complianceHost(tempDir, "shared/Dto"); + host.artifacts.add(TestProjectHost.artifact("shared", "compile", shared.toFile())); + + BytecodeCompliance mojo = new BytecodeCompliance(host); + assertTrue(mojo == mojo.checkedDependencyRoots(null)); + mojo.execute(); + } + + /// A host whose own output is one class calling `callee.m()`, with a + /// runtime that knows `java.lang.Object` and a core that knows nothing more. + private TestProjectHost complianceHost(Path dir, String callee) throws Exception { + Path output = dir.resolve("target").resolve("classes"); + Path runtime = dir.resolve("runtime"); + Path core = dir.resolve("core"); + Files.createDirectories(output); + Files.createDirectories(runtime); + Files.createDirectories(core); + writeJavaLangObject(runtime); + writeClass(output, "app/Caller", callee, "m", "()V"); + + TestProjectHost host = TestProjectHost.empty(); + host.projectDir = dir.toFile(); + host.buildDir = dir.resolve("target").toFile(); + host.outputDir = output.toFile(); + host.sourcesModified = System.currentTimeMillis(); + host.artifacts.add(new com.codename1.build.BuildArtifact("com.codenameone", "java-runtime", + "1.0", null, "jar", "provided", runtime.toFile(), null)); + host.artifacts.add(new com.codename1.build.BuildArtifact("com.codenameone", + "codenameone-core", "1.0", null, "jar", "provided", core.toFile(), null)); + return host; + } + private BytecodeCompliance newMojoForSkipCheck(Path tempDir) throws Exception { Path iosDir = tempDir.resolve("ios"); Path commonDir = tempDir.resolve("common"); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/annotations/DependencyClassesTest.java b/maven/build-engine/src/test/java/com/codename1/maven/annotations/DependencyClassesTest.java new file mode 100644 index 00000000000..8085cafe296 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/annotations/DependencyClassesTest.java @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.annotations; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.Arrays; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class DependencyClassesTest { + + private static final String GENERATED = "com/example/api/NotesApiImpl.class"; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + /// The module's output directory is on its own compile classpath and holds + /// the previous build's generated classes. Counting it made every build + /// after the first conclude a dependency had generated them already. + @Test + public void ownOutputDoesNotCountAsADependency() throws Exception { + File own = tmp.newFolder("own"); + File sibling = tmp.newFolder("sibling"); + touch(new File(own, GENERATED)); + List classpath = Arrays.asList(own.getPath(), sibling.getPath()); + + assertTrue(DependencyClasses.onClasspath(classpath, GENERATED)); + assertFalse(DependencyClasses.onClasspath(classpath, own, GENERATED)); + } + + @Test + public void aSiblingDirectoryStillCounts() throws Exception { + File own = tmp.newFolder("own"); + File sibling = tmp.newFolder("sibling"); + touch(new File(own, GENERATED)); + touch(new File(sibling, GENERATED)); + + assertTrue(DependencyClasses.onClasspath( + Arrays.asList(own.getPath(), sibling.getPath()), own, GENERATED)); + } + + @Test + public void anArchiveStillCounts() throws Exception { + File own = tmp.newFolder("own"); + File jar = new File(tmp.getRoot(), "dependency.jar"); + ZipOutputStream out = new ZipOutputStream(new FileOutputStream(jar)); + try { + out.putNextEntry(new ZipEntry(GENERATED)); + out.closeEntry(); + } finally { + out.close(); + } + + assertTrue(DependencyClasses.onClasspath( + Arrays.asList(own.getPath(), jar.getPath()), own, GENERATED)); + assertFalse(DependencyClasses.onClasspath( + Arrays.asList(own.getPath(), jar.getPath()), own, "com/example/Missing.class")); + } + + /// The output directory is compared canonically: Maven hands the classpath + /// entry and the configured directory over spelled differently. + @Test + public void ownOutputIsRecognizedThroughADifferentSpelling() throws Exception { + File own = tmp.newFolder("own"); + touch(new File(own, GENERATED)); + File respelled = new File(new File(own, "com"), ".."); + + assertFalse(DependencyClasses.onClasspath( + Arrays.asList(respelled.getPath()), own, GENERATED)); + } + + /// Only a class carrying a wanted annotation is returned, the module's own + /// output is left out, and the first definition of a name wins -- from a + /// directory or an archive alike. + @Test + public void scanFindsAnnotatedClassesOfDependenciesOnly() throws Exception { + File own = tmp.newFolder("own"); + File sibling = tmp.newFolder("sibling"); + File packed = tmp.newFolder("packed"); + compile(own, "com.example.Mine", "@Deprecated public class Mine {}"); + compile(sibling, "com.example.Marked", "@Deprecated public class Marked {}"); + compile(sibling, "com.example.Plain", "public class Plain {}"); + compile(packed, "com.example.Marked", "@Deprecated public interface Marked {}"); + compile(packed, "com.example.Archived", "@Deprecated public class Archived {}"); + File jar = jar(packed, "com/example/Marked.class", "com/example/Archived.class"); + + java.util.Map found = DependencyClasses.scan( + Arrays.asList(own.getPath(), sibling.getPath(), jar.getPath(), + new File(tmp.getRoot(), "absent").getPath()), + own, java.util.Collections.singleton("Ljava/lang/Deprecated;")); + + assertEquals(new java.util.TreeSet(Arrays.asList( + "com/example/Marked", "com/example/Archived")), + new java.util.TreeSet(found.keySet())); + assertFalse("the directory comes first on the classpath, so its class wins", + found.get("com/example/Marked").isInterface()); + } + + /// A class is shadowed by an earlier definition of its name even when only + /// the later one carries the annotation: the JVM loads the earlier one, so + /// nothing is generated from the annotation it lacks. + @Test + public void anUnannotatedEarlierDefinitionShadowsAnAnnotatedLaterOne() throws Exception { + File older = tmp.newFolder("older"); + File newer = tmp.newFolder("newer"); + compile(older, "com.example.Marked", "public class Marked {}"); + compile(newer, "com.example.Marked", "@Deprecated public class Marked {}"); + File jar = jar(newer, "com/example/Marked.class"); + + assertTrue(DependencyClasses.scan(Arrays.asList(older.getPath(), jar.getPath()), null, + java.util.Collections.singleton("Ljava/lang/Deprecated;")).isEmpty()); + assertEquals(java.util.Collections.singleton("com/example/Marked"), + DependencyClasses.scan(Arrays.asList(jar.getPath(), older.getPath()), null, + java.util.Collections.singleton("Ljava/lang/Deprecated;")).keySet()); + } + + /// The runtimes are thousands of classes and none of them the application's. + @Test + public void scanSkipsARuntime() throws Exception { + File runtime = tmp.newFolder("runtime"); + compile(runtime, "com.example.Marked", "@Deprecated public class Marked {}"); + touch(new File(runtime, "com/codename1/ui/Display.class")); + + assertTrue(DependencyClasses.scan(Arrays.asList(runtime.getPath()), null, + java.util.Collections.singleton("Ljava/lang/Deprecated;")).isEmpty()); + assertTrue(DependencyClasses.scan(null, null, + java.util.Collections.singleton("Ljava/lang/Deprecated;")).isEmpty()); + assertTrue(DependencyClasses.scan(Arrays.asList(runtime.getPath()), null, + java.util.Collections.emptySet()).isEmpty()); + } + + /// A shaded jar carries the application's contract beside the runtime it + /// was bundled with: the runtime is passed over, the contract is not. + @Test + public void scanReadsTheApplicationInAnArchiveThatCarriesARuntime() throws Exception { + File shaded = tmp.newFolder("shaded"); + compile(shaded, "com.example.Marked", "@Deprecated public class Marked {}"); + // compile() only writes com.example, so the runtime's entry is placed by + // hand; a marked class there must not be read. + compile(shaded, "com.example.Display", "@Deprecated public class Display {}"); + File jar = new File(tmp.getRoot(), "shaded.jar"); + ZipOutputStream out = new ZipOutputStream(new FileOutputStream(jar)); + try { + out.putNextEntry(new ZipEntry("com/example/Marked.class")); + out.write(java.nio.file.Files.readAllBytes( + new File(shaded, "com/example/Marked.class").toPath())); + out.closeEntry(); + out.putNextEntry(new ZipEntry("com/codename1/ui/Display.class")); + out.write(java.nio.file.Files.readAllBytes( + new File(shaded, "com/example/Display.class").toPath())); + out.closeEntry(); + } finally { + out.close(); + } + + assertEquals(java.util.Collections.singleton("com/example/Marked"), + DependencyClasses.scan(Arrays.asList(jar.getPath()), null, + java.util.Collections.singleton("Ljava/lang/Deprecated;")).keySet()); + } + + @Test + public void readFindsAnUnannotatedClassInADirectoryOrAnArchive() throws Exception { + File sibling = tmp.newFolder("sibling"); + File packed = tmp.newFolder("packed"); + compile(sibling, "com.example.Level", "public enum Level { LOW, HIGH }"); + compile(packed, "com.example.Kind", "public enum Kind { A }"); + List classpath = Arrays.asList(sibling.getPath(), + jar(packed, "com/example/Kind.class").getPath()); + + assertTrue(DependencyClasses.read(classpath, "com/example/Level").isEnum()); + assertTrue(DependencyClasses.read(classpath, "com/example/Kind").isEnum()); + assertNull(DependencyClasses.read(classpath, "com/example/Missing")); + assertNull("the JDK is never read off the classpath", + DependencyClasses.read(classpath, "java/lang/String")); + assertNull(DependencyClasses.read(null, "com/example/Level")); + } + + /// `lookup` keeps meaning "this module's", which several processors rely + /// on; only `lookupWithDependencies` reaches a dependency, and it reads the + /// classpath only in a build where dependency classes were offered at all. + @Test + public void theContextKeepsOwnAndDependencyClassesApart() throws Exception { + File own = tmp.newFolder("own"); + File sibling = tmp.newFolder("sibling"); + compile(own, "com.example.Mine", "public class Mine {}"); + compile(sibling, "com.example.Marked", "@Deprecated public class Marked {}"); + compile(sibling, "com.example.Level", "public enum Level { LOW, HIGH }"); + List classpath = Arrays.asList(own.getPath(), sibling.getPath()); + java.util.Map index = ClassScanner.scan(own); + ProcessorContext ctx = new ProcessorContext(own, tmp.newFolder(), index, + new com.codename1.build.SystemStreamLog(), null, null, null, null, null, classpath); + + assertTrue(ctx.getDependencyIndex().isEmpty()); + assertNotNull(ctx.lookupWithDependencies("com/example/Mine")); + assertNull("nothing was offered, so the classpath is not read", + ctx.lookupWithDependencies("com/example/Level")); + + java.util.Map dependencies = DependencyClasses.scan(classpath, own, + java.util.Collections.singleton("Ljava/lang/Deprecated;")); + ctx.setDependencyIndex(dependencies); + AnnotatedClass marked = dependencies.get("com/example/Marked"); + + assertNull(ctx.lookup("com/example/Marked")); + assertSame(marked, ctx.lookupWithDependencies("com/example/Marked")); + assertTrue(ctx.isDependencyClass(marked)); + assertFalse(ctx.isDependencyClass(index.get("com/example/Mine"))); + assertFalse(ctx.isDependencyClass(null)); + AnnotatedClass level = ctx.lookupWithDependencies("com/example/Level"); + assertTrue("a type a dependency class refers to is read on demand", level.isEnum()); + assertSame("and read once", level, ctx.lookupWithDependencies("com/example/Level")); + assertNull(ctx.lookupWithDependencies("com/example/Missing")); + + ctx.setDependencyIndex(null); + assertTrue(ctx.getDependencyIndex().isEmpty()); + } + + private static void compile(File into, String fqn, String declaration) throws Exception { + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource(fqn, "package com.example;\n" + declaration + "\n"), + into, java.util.Collections.emptyList()); + } + + private File jar(File classes, String... entries) throws Exception { + File jar = new File(tmp.getRoot(), classes.getName() + ".jar"); + ZipOutputStream out = new ZipOutputStream(new FileOutputStream(jar)); + try { + for (String entry : entries) { + out.putNextEntry(new ZipEntry(entry)); + out.write(java.nio.file.Files.readAllBytes(new File(classes, entry).toPath())); + out.closeEntry(); + } + } finally { + out.close(); + } + return jar; + } + + private static void touch(File file) throws Exception { + assertTrue(file.getParentFile().isDirectory() || file.getParentFile().mkdirs()); + assertTrue(file.createNewFile()); + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index 927de3fd9d7..abdfd20e67c 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -1222,6 +1222,257 @@ public void aWovenOverrideCallingSuperRunsTheBaseBody() throws Exception { } } + @Test + public void anAspectOnAMethodNamingAPrivateNestedClassRuns() throws Exception { + // The helper is a top-level class, where javac refuses the name of a + // private member class -- in the return type, a parameter, an array, and + // the local the helper keeps the result in. Generic arguments and thrown + // types are here too, to show neither reaches the helper's source. + Map s = new LinkedHashMap(); + s.put("com.example.Mini", PKG + "public class Mini {\n" + + " static class Shown { String v; }\n" + + " private static class Hidden extends Shown { }\n" + + " private static final class Answer extends Hidden {\n" + + " Answer(String v) { this.v = v; }\n" + + " }\n" + + " private interface Label { String label(); }\n" + + " private static class Boom extends Exception { }\n" + + " @Timed(\"mini.work\") @Counted(\"mini.calls\")\n" + + " private Answer work(Answer in, List all, Answer[][] many, int n,\n" + + " long wide, Label label) throws Boom {\n" + + " if (n < 0) { throw new Boom(); }\n" + + " return new Answer(in.v + all.get(0).v + many[0][0].v + n + wide\n" + + " + label.label());\n" + + " }\n" + + " @Timed(\"mini.twice\") private static Answer[] twice(Answer a) {\n" + + " return new Answer[] {a, a};\n" + + " }\n" + + " @Counted(\"mini.label\") Label label(final String text) {\n" + + " return new Label() { public String label() { return text; } };\n" + + " }\n" + + " public String run() throws Exception {\n" + + " List all = new ArrayList();\n" + + " all.add(new Answer(\"b\"));\n" + + " Answer[][] many = {{new Answer(\"c\")}};\n" + + " Answer a = work(new Answer(\"a\"), all, many, 4, 5L, label(\"!\"));\n" + + " String thrown = \"none\";\n" + + " try { work(a, all, many, -1, 0L, null); } catch (Boom e) { thrown = \"boom\"; }\n" + + " return a.v + twice(a).length + thrown;\n" + + " }\n" + + "}\n"); + File classes = compile(s); + ProcessorContext ctx = process(classes); + assertNoErrors(ctx); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Class mini = loader.loadClass("com.example.Mini"); + assertEquals("abc45!2boom", mini.getMethod("run").invoke(mini.newInstance())); + // The helper is written against the nearest superclass it can name -- + // Shown, past the private Hidden -- and Object for the private interface. + Class helper = loader.loadClass("com.example.MiniCn1Aspects"); + Class shown = loader.loadClass("com.example.Mini$Shown"); + java.lang.reflect.Method work = null; + for (java.lang.reflect.Method m : helper.getDeclaredMethods()) { + if ("work".equals(m.getName())) { + work = m; + } + } + assertNotNull(work); + assertEquals(shown, work.getReturnType()); + assertEquals(Arrays.>asList(mini, shown, List.class, + java.lang.reflect.Array.newInstance(shown, 0, 0).getClass(), int.class, + long.class, Object.class), Arrays.asList(work.getParameterTypes())); + // And the method itself still has the signature its author wrote. + Class answer = loader.loadClass("com.example.Mini$Answer"); + assertEquals(answer, mini.getDeclaredMethod("twice", answer).getReturnType() + .getComponentType()); + } + + @Test + public void aTransactionOnAPrivateMethodReturningAPrivateClassCommitsAndRollsBack() + throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Rows", PKG + "@Component public class Rows {\n" + + " private static class Answer {\n" + + " final String v;\n" + + " Answer(String v) { this.v = v; }\n" + + " }\n" + + " private final DataSource db;\n" + + " public Rows(DataSource db) { this.db = db; }\n" + + " @PostConstruct public void schema() throws java.io.IOException {\n" + + " db.execute(\"CREATE TABLE r (v TEXT)\", null);\n" + + " }\n" + + " @Transactional private Answer work(Answer in, boolean fail)\n" + + " throws java.io.IOException {\n" + + " db.execute(\"INSERT INTO r (v) VALUES (?)\", new Object[] {in.v});\n" + + " if (fail) { throw new IllegalStateException(\"undo\"); }\n" + + " return new Answer(in.v + \"!\");\n" + + " }\n" + + " @Async private Future later(Answer in) { return AsyncResult.of(in.v + \"?\"); }\n" + + " public String run() throws Exception {\n" + + " String out = work(new Answer(\"a\"), false).v;\n" + + " try { work(new Answer(\"b\"), true); } catch (IllegalStateException e) { }\n" + + " Map row = db.queryOne(\"SELECT COUNT(*) AS n FROM r\", null);\n" + + " return out + row.get(\"n\") + later(new Answer(\"c\")).get();\n" + + " }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final Rows rows;\n" + + " public Api(Rows rows) { this.rows = rows; }\n" + + " @GetMapping(\"/run\") public String run() throws Exception {\n" + + " return rows.run();\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = withApplication(Backend.builder(Config.of(settings, "dev")).quiet() + .requiresDataSource(), (BackendApplication) loader + .loadClass("com.example.BackendWiring").newInstance()) + .start(); + try { + // One row: the first call committed, the second was undone. + assertEquals("a!1c?", http("GET", port, "/run")); + } finally { + backend.stop(); + } + } + + /// A `@Repository` interface is injected like a bean: built once from the + /// factory the ORM's bootstrap registered, over the injected Session, so it + /// works inside the caller's @Transactional method and every injection point + /// gets the same instance. + @Test + public void aRepositoryInterfaceIsInjectedOverTheTransactionSession() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Trip", "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Entity(table = \"injected_trips\") public class Trip {\n" + + " @Id(autoIncrement = true) public long id;\n" + + " private String rider;\n" + + " public String rider() { return rider; }\n" + + " public void rider(String r) { rider = r; }\n" + + "}\n"); + s.put("com.example.Trips", "package com.example;\n" + + "import com.codename1.orm.repository.*;\n" + + "import java.util.*;\n" + + "@Repository(entity = Trip.class, key = Long.class)\n" + + "public interface Trips extends CrudRepository {\n" + + " List findByRiderOrderByIdAsc(String rider);\n" + + " long countByRider(String rider);\n" + + " Optional findFirstByRiderOrderByIdDesc(String rider);\n" + + "}\n"); + s.put("com.example.TripService", PKG + + "@Component public class TripService {\n" + + " private final Trips trips;\n" + + " @Autowired Trips same;\n" + + " public TripService(Trips trips) { this.trips = trips; }\n" + + " @Transactional public long add(String rider) {\n" + + " Trip t = new Trip(); t.rider(rider); trips.insert(t); return 1;\n" + + " }\n" + + " @Transactional(readOnly = true) public String describe(String rider) {\n" + + " Optional last = trips.findFirstByRiderOrderByIdDesc(rider);\n" + + " Optional none = trips.findFirstByRiderOrderByIdDesc(\"nobody\");\n" + + " return trips.countByRider(rider) + \":\" + trips.findByRiderOrderByIdAsc(rider).size()\n" + + " + \":\" + (last.isPresent() ? last.get().id : -1) + \":\" + none.isPresent()\n" + + " + \":\" + (trips == same);\n" + + " }\n" + + " public String outside() {\n" + + " try { trips.count(); return \"ran\"; }\n" + + " catch (RuntimeException e) { return \"refused\"; }\n" + + " }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final TripService service;\n" + + " public Api(TripService service) { this.service = service; }\n" + + " @PostMapping(\"/trips/{rider}\") public String add(@PathVariable(\"rider\") String r) {\n" + + " return String.valueOf(service.add(r));\n" + + " }\n" + + " @GetMapping(\"/trips/{rider}\") public String get(@PathVariable(\"rider\") String r) {\n" + + " return service.describe(r);\n" + + " }\n" + + " @GetMapping(\"/outside\") public String outside() { return service.outside(); }\n" + + "}\n"); + File classes = compile(s); + // The ORM pass the build runs first: the entity's dao, the repository's + // implementation and the bootstrap that registers both. + Map index = ClassScanner.scan(classes); + List cp = new ArrayList(); + for (File f : backendClasspath()) { + cp.add(f.getAbsolutePath()); + } + ProcessorContext orm = new ProcessorContext(classes, tmp.newFolder(), index, + new SystemStreamLog(), null, null, null, Collections.emptyList(), "UTF-8", cp); + OrmAnnotationProcessor ormProcessor = new OrmAnnotationProcessor(); + ormProcessor.start(orm); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) { + ormProcessor.processClass(cls, orm); + } + } + ormProcessor.finish(orm); + assertNoErrors(orm); + assertNoErrors(process(classes)); + String wiring = new String(java.nio.file.Files.readAllBytes( + new File(classes, "com/example/BackendWiring.class").toPath()), "ISO-8859-1"); + assertTrue(wiring.contains("com/codename1/orm/repository/Repositories")); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + loader.loadClass("cn1app.BackendDaoBootstrap").newInstance(); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + settings.setProperty(Config.DATASOURCE_URL, ":memory:"); + Backend backend = withApplication(Backend.builder(Config.of(settings, "dev")).quiet() + .requiresDataSource(), (BackendApplication) loader + .loadClass("com.example.BackendWiring").newInstance()) + .start(); + try { + for (String rider : new String[] {"ada", "ada", "bob"}) { + assertEquals("1", http("POST", port, "/trips/" + rider)); + } + assertEquals("2:2:2:false:true", http("GET", port, "/trips/ada")); + // The same contract as the injected Session: no transaction, no work. + assertEquals("refused", http("GET", port, "/outside")); + } finally { + backend.stop(); + loader.close(); + } + } + + @Test + public void whatTheAspectClassCannotNameAtAllIsABuildError() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Outer", PKG + "public class Outer {\n" + + " private static class Hidden {\n" + + " @Timed(\"hidden.work\") void work() { }\n" + + " }\n" + + " private static class Boom extends RuntimeException { }\n" + + " @Transactional(noRollbackFor = Boom.class) void keep() { }\n" + + " private static class Key { }\n" + + " @Counted(\"outer.find\") void find(Key k) { }\n" + + " @Counted(\"outer.find2\") void find(Object k) { }\n" + + " void use() { new Hidden().work(); }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("com.example.Outer.Hidden.work has an aspect " + + "annotation")); + assertTrue(errors, errors.contains("com.example.Outer.Hidden is a private, local or " + + "anonymous class")); + assertTrue(errors, errors.contains("@Transactional on com.example.Outer.keep lists " + + "com.example.Outer.Boom in noRollbackFor, which is a private class")); + assertTrue(errors, errors.contains("remove `private` from its declaration")); + assertTrue(errors, errors.contains("com.example.Outer has two methods named find with " + + "aspect annotations whose parameters differ only in a private class " + + "(com.example.Outer.Key)")); + } + @Test public void sessionScopedBeansAreDestroyedWhenTheSessionIsInvalidated() throws Exception { Map s = new LinkedHashMap(); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java index 168f819d10a..06460819b80 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java @@ -253,6 +253,155 @@ public void rejectsMappedClassMissingNoArgConstructor() throws Exception { // Helpers // --------------------------------------------------------------- + /// A `@Mapped` type in a library the application depends on is mapped in the + /// application, and a type it refers to -- an enum, which carries no + /// annotation and so is in no index -- is still recognised for what it is. + @Test + public void aDependencyClassIsMappedIntoTheModule() throws Exception { + File shared = tmp.newFolder("shared"); + Map sources = new LinkedHashMap(); + sources.put("com.example.shared.Level", + "package com.example.shared;\npublic enum Level { LOW, HIGH }\n"); + sources.put("com.example.shared.Task", + "package com.example.shared;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped\n" + + "public class Task {\n" + + " public String title;\n" + + " public Level level;\n" + + " public Task() {}\n" + + "}\n"); + JavaSourceCompiler.compile(sources, shared, Arrays.asList(testClassesDir())); + File classes = tmp.newFolder("classes"); + + ProcessorContext ctx = runOverDependencies(classes, shared); + + assertFalse(String.valueOf(ctx.getErrors()), ctx.hasErrors()); + assertTrue(new File(classes, "com/example/shared/TaskCn1Mapper.class").exists()); + assertTrue(new File(classes, "cn1app/MapperBootstrap.class").exists()); + assertFalse("a dependency is never written to", + new File(shared, "com/example/shared/TaskCn1Mapper.class").exists()); + + URL[] urls = {classes.toURI().toURL(), shared.toURI().toURL(), + testClassesDir().toURI().toURL()}; + try (URLClassLoader cl = new URLClassLoader(urls, getClass().getClassLoader())) { + Class taskCls = cl.loadClass("com.example.shared.Task"); + Class mapperCls = cl.loadClass("com.example.shared.TaskCn1Mapper"); + Object mapper = mapperCls.newInstance(); + Map in = new LinkedHashMap(); + in.put("title", "Ship"); + in.put("level", "HIGH"); + Object task = mapperCls.getMethod("fromMap", Map.class).invoke(mapper, in); + assertEquals("Ship", taskCls.getField("title").get(task)); + assertEquals("the enum is read by name, as one of the module's own is", + "HIGH", String.valueOf(taskCls.getField("level").get(task))); + @SuppressWarnings("unchecked") + Map out = (Map) + mapperCls.getMethod("toMap", taskCls).invoke(mapper, task); + assertEquals("HIGH", out.get("level")); + } + } + + /// The module that owns the type, or one between it and this, mapped it + /// already: a second mapper here would define one class twice in the build. + @Test + public void aDependencyClassAlreadyMappedIsLeftAlone() throws Exception { + File shared = tmp.newFolder("shared"); + Map sources = new LinkedHashMap(); + sources.put("com.example.shared.Task", + "package com.example.shared;\n" + + "@com.codename1.annotations.Mapped\n" + + "public class Task {\n" + + " public String title;\n" + + " public Task() {}\n" + + "}\n"); + sources.put("com.example.shared.TaskCn1Mapper", + "package com.example.shared;\npublic class TaskCn1Mapper {}\n"); + JavaSourceCompiler.compile(sources, shared, Arrays.asList(testClassesDir())); + File classes = tmp.newFolder("classes"); + + ProcessorContext ctx = runOverDependencies(classes, shared); + + assertFalse(String.valueOf(ctx.getErrors()), ctx.hasErrors()); + assertFalse(new File(classes, "com/example/shared/TaskCn1Mapper.class").exists()); + } + + /// Runs the processor the way the driver does for a module that declares + /// nothing itself and depends on `shared`. + private ProcessorContext runOverDependencies(File classes, File shared) throws Exception { + File core = new File(com.codename1.mapping.Mappers.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + java.util.List classpath = Arrays.asList(classes.getPath(), shared.getPath(), + core.getPath()); + MappingAnnotationProcessor proc = new MappingAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classes, tmp.newFolder(), + java.util.Collections.emptyMap(), new SystemStreamLog(), + null, null, null, null, null, classpath); + assertTrue(proc.acceptsDependencyClasses(ctx)); + assertFalse("a module without the mapper runtime is not offered them", + proc.acceptsDependencyClasses(new ProcessorContext(classes, tmp.newFolder(), + java.util.Collections.emptyMap(), + new SystemStreamLog(), null, null, null, null, null, + Arrays.asList(classes.getPath(), shared.getPath())))); + Map dependencies = + com.codename1.maven.annotations.DependencyClasses.scan( + classpath, classes, proc.getAnnotationDescriptors()); + assertTrue(dependencies.containsKey("com/example/shared/Task")); + ctx.setDependencyIndex(dependencies); + JavaSourceCompiler.setProjectClasspath(Arrays.asList(shared, core)); + try { + proc.start(ctx); + for (AnnotatedClass cls : dependencies.values()) { + proc.processClass(cls, ctx); + } + proc.finish(ctx); + } finally { + JavaSourceCompiler.clearProjectClasspath(); + } + return ctx; + } + + /// A JSON parser hands every number over as a `Double`, so the elements of a + /// `List` have to be converted and not cast. Cast, this threw + /// ClassCastException here and, on ParparVM, where a cast is unchecked, left + /// a `Double` in the list. + @Test + public void scalarListElementsAreConvertedFromWhatTheParserProduces() throws Exception { + File classes = compileFixture( + "com.example.Series", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "import java.util.List;\n" + + "@Mapped public class Series {\n" + + " public List counts;\n" + + " public List totals;\n" + + " public List rates;\n" + + " public List names;\n" + + " public List flags;\n" + + " public Series() {}\n" + + "}\n"); + runProcessorOrFail(classes); + try (URLClassLoader cl = childLoader(classes)) { + Class seriesCls = cl.loadClass("com.example.Series"); + Class mapperCls = cl.loadClass("com.example.SeriesCn1Mapper"); + Object mapper = mapperCls.newInstance(); + Map json = new LinkedHashMap(); + json.put("counts", Arrays.asList(Double.valueOf(3), Long.valueOf(4), "five", null)); + json.put("totals", Arrays.asList(Double.valueOf(7))); + json.put("rates", Arrays.asList(Long.valueOf(2), Double.valueOf(0.5))); + json.put("names", Arrays.asList("a", null)); + json.put("flags", Arrays.asList(Boolean.TRUE, "yes")); + Object restored = mapperCls.getMethod("fromMap", Map.class).invoke(mapper, json); + assertEquals(Arrays.asList(Integer.valueOf(3), Integer.valueOf(4), null, null), + seriesCls.getField("counts").get(restored)); + assertEquals(Arrays.asList(Long.valueOf(7)), seriesCls.getField("totals").get(restored)); + assertEquals(Arrays.asList(Double.valueOf(2), Double.valueOf(0.5)), + seriesCls.getField("rates").get(restored)); + assertEquals(Arrays.asList("a", null), seriesCls.getField("names").get(restored)); + assertEquals(Arrays.asList(Boolean.TRUE, null), seriesCls.getField("flags").get(restored)); + } + } + private File compileFixture(String fqn, String src) throws Exception { File classes = tmp.newFolder("classes"); JavaSourceCompiler.compile( diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java index e314a33816c..d5549cb9459 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java @@ -72,22 +72,40 @@ private void generatedRepository(boolean backend) throws Exception { sources.put("repositories.Notes","package repositories; import com.codename1.orm.repository.*; import java.util.List; @Repository(entity=Note.class,key=Long.class) public interface Notes extends CrudRepository { " + "List findByNameAndScoreGreaterThanOrderByScoreDescIdAsc(String name,long score,PageRequest page); " + "List findByActiveTrue(); List findByArchivedFalse(); List findByNameAndScoreIn(String name,java.util.List scores); List findByScoreIn(Long[] scores); List findByNameIn(java.util.Set names); " - + "List findTop1ByNameOrderByScoreDesc(String name); long countByName(String name); boolean existsByName(String name); int deleteByName(String name); " + + "List findTop1ByNameOrderByScoreDesc(String name); java.util.Optional findFirstByNameOrderByScoreAsc(String name); long countByName(String name); boolean existsByName(String name); int deleteByName(String name); " + "@Lock(com.codename1.orm.session.LockMode.PESSIMISTIC_WRITE) Note findById(Long id); " + "@Query(\"select sum(case when e.score>=:floor then 1 else 0 end) from repositories.Note e\") Long qualified(@Param(\"floor\") long floor); " + + "@Query(\"select max(e.score) from repositories.Note e where e.name='other'\") java.util.Optional highest(); " + + "@Query(\"select e.name, count(e) from repositories.Note e group by e.name\") List countsByName(); " + + "@Query(\"select coalesce(sum(e.score),0), count(e) from repositories.Note e where e.name=:name\") Object[] totals(@Param(\"name\") String name); " + + "@Query(\"select e.name, e.score from repositories.Note e where e.name=:name order by e.score desc\") java.util.Optional best(@Param(\"name\") String name); " + "@Modifying @Query(\"update repositories.Note e set e.score=:score where e.name=:name\") int revise(@Param(\"name\") String name,@Param(\"score\") long score); " + + "@Modifying(clearAutomatically=true) @Query(\"update repositories.Note e set e.score=:score where e.name=:name\") void reviseAndClear(@Param(\"name\") String name,@Param(\"score\") long score); " + "@Query(value=\"SELECT COUNT(*) FROM repository_note WHERE name=:name\",nativeSql=true) Long nativeCount(@Param(\"name\") String name); }"); // Compile the caller with the interface, before generating any implementation. sources.put("repositories.Check","package repositories; import com.codename1.orm.repository.*; import com.codename1.orm.session.*; public class Check { public static void run(Session s) { " + "Notes repo=Repositories.create(Notes.class,s); s.createTables(); s.beginTransaction(); Note a=new Note(); a.name=\"same\";a.score=3;a.active=true;a.archived=false;repo.insert(a);Note b=new Note();b.name=\"same\";b.score=9;repo.insert(b);s.commitTransaction(); " + "if(repo.count()!=2 || !repo.existsByName(\"same\") || repo.countByName(\"same\")!=2 || repo.nativeCount(\"same\")!=2) throw new AssertionError(\"counts\"); " + "if(repo.findByNameAndScoreGreaterThanOrderByScoreDescIdAsc(\"same\",0,new PageRequest(1,1)).get(0)!=a || repo.findTop1ByNameOrderByScoreDesc(\"same\").get(0)!=b || repo.qualified(5)!=1) throw new AssertionError(\"queries\"); " + + "if(repo.findFirstByNameOrderByScoreAsc(\"same\").get()!=a || repo.findFirstByNameOrderByScoreAsc(\"none\").isPresent() || repo.highest().isPresent()) throw new AssertionError(\"optional\"); " + + "java.util.List byName=repo.countsByName(); Object[] totals=repo.totals(\"same\"); " + + "if(byName.size()!=1 || !\"same\".equals(byName.get(0)[0]) || ((Number)byName.get(0)[1]).longValue()!=2 || ((Number)totals[0]).longValue()!=12 || ((Number)totals[1]).longValue()!=2 " + + "|| ((Number)repo.best(\"same\").get()[1]).longValue()!=9 || repo.best(\"none\").isPresent()) throw new AssertionError(\"row projections\"); " + "if(repo.findByActiveTrue().get(0)!=a || repo.findByArchivedFalse().get(0)!=a || repo.findByNameAndScoreIn(\"same\",java.util.Arrays.asList(3L)).get(0)!=a || repo.findByScoreIn(new Long[]{9L}).get(0)!=b || repo.findByNameIn(java.util.Collections.singleton(\"same\")).size()!=2) throw new AssertionError(\"typed finders\"); " - + "s.beginTransaction(); a.score=4; if(repo.revise(\"same\",11)!=2 || s.contains(a)) throw new AssertionError(\"bulk\"); s.rollbackTransaction(); " + + "s.beginTransaction(); a.score=4; if(repo.revise(\"same\",11)!=2 || !s.contains(a) || a.score!=4) throw new AssertionError(\"bulk\"); s.rollbackTransaction(); " + "if(s.find(Note.class,a.id).score!=3) throw new AssertionError(\"rollback\"); " + "s.beginTransaction(); try { repo.findById(a.id); throw new AssertionError(\"SQLite should refuse row lock\"); } catch(UnsupportedOperationException expected) {} s.rollbackTransaction(); " + "s.beginTransaction(); if(repo.save(a)==a) throw new AssertionError(\"merge returns managed copy\"); repo.deleteById(b.id); s.commitTransaction(); " - + "s.beginTransaction(); if(repo.deleteByName(\"same\")!=1) throw new AssertionError(\"delete\");s.commitTransaction();if(repo.count()!=0) throw new AssertionError(); }}"); + + "s.beginTransaction(); if(repo.deleteByName(\"same\")!=1) throw new AssertionError(\"delete\");s.commitTransaction();if(repo.count()!=0) throw new AssertionError(); " + // A @Modifying statement leaves the session as it is, as Spring Data does by + // default: an entity read before it is still managed, and a change made to it + // afterwards is written. clearAutomatically empties the session instead. + + "s.beginTransaction(); Note c=new Note(); c.name=\"kept\"; c.score=1; repo.insert(c); s.commitTransaction(); " + + "s.beginTransaction(); if(s.find(Note.class,c.id)!=c || repo.revise(\"kept\",5)!=1 || !s.contains(c) || c.score!=1) throw new AssertionError(\"bulk keeps the session\"); c.name=\"renamed\"; s.commitTransaction(); " + + "s.refresh(c); if(c.score!=5) throw new AssertionError(\"refresh after bulk\"); s.clear(); Note again=s.find(Note.class,c.id); " + + "if(again==c || !\"renamed\".equals(again.name) || again.score!=5) throw new AssertionError(\"change after bulk was lost\"); " + + "s.beginTransaction(); repo.reviseAndClear(\"renamed\",7); if(s.contains(again)) throw new AssertionError(\"clearAutomatically\"); again.name=\"dropped\"; s.commitTransaction(); " + + "Note last=s.find(Note.class,c.id); if(last==again || last.score!=7 || !\"renamed\".equals(last.name)) throw new AssertionError(\"after clear\"); }}"); JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); assertFalse(new File(classes,"repositories/NotesCn1Repository.class").exists()); ProcessorContext ctx=runProcessor(classes,backend ? backendClasspath() : Collections.emptyList());assertFalse(ctx.getErrors().toString(),ctx.hasErrors()); @@ -100,6 +118,127 @@ private void generatedRepository(boolean backend) throws Exception { finally { session.close();if(server!=null)server.close();if(client!=null)client.close();loader.close(); } } + @Test + public void nonPublicFieldsArePersistedOnBothRuntimes() throws Exception { + nonPublicFields(false); + nonPublicFields(true); + } + private void nonPublicFields(boolean backend) throws Exception { + File classes=tmp.newFolder(); + Map sources=new java.util.LinkedHashMap(); + // A mapped superclass in ANOTHER package, so its fields are out of reach of + // the generated model even when package-private or protected. + sources.put("hiddenbase.Audited","package hiddenbase; import com.codename1.annotations.db.*; @MappedSuperclass public abstract class Audited { private long created; protected String createdBy; long revision; " + + "public long created() { return created; } public void created(long v) { created=v; } public String createdBy() { return createdBy; } public void createdBy(String v) { createdBy=v; } public long revision() { return revision; } public void revision(long v) { revision=v; } }"); + sources.put("hidden.Place","package hidden; import com.codename1.annotations.db.*; @Embeddable public class Place { private String city; private int zip; public Place() {} " + + "public String city() { return city; } public void city(String v) { city=v; } public int zip() { return zip; } public void zip(int v) { zip=v; } }"); + sources.put("hidden.Rider","package hidden; import com.codename1.annotations.*; import com.codename1.annotations.db.*; @Entity(table=\"hidden_rider\") public class Rider extends hiddenbase.Audited { " + + "@Id private long id; private String name; int score; protected Boolean active; @Embedded private Place home; " + + "private transient String cached=\"c\"; private final String constant=\"k\"; private static int counter; " + + "public Rider() {} public long id() { return id; } public String name() { return name; } public void name(String v) { name=v; } public int score() { return score; } public void score(int v) { score=v; } " + + "public Boolean active() { return active; } public void active(Boolean v) { active=v; } public Place home() { return home; } public void home(Place v) { home=v; } " + + "public String cached() { return cached; } public void cached(String v) { cached=v; } public String constant() { return constant; } }"); + sources.put("hidden.Riders","package hidden; import com.codename1.orm.repository.*; import java.util.List; @Repository(entity=Rider.class,key=Long.class) public interface Riders extends CrudRepository { " + + "List findByNameOrderByScoreDesc(String name); long countByScoreGreaterThan(int score); }"); + sources.put("hidden.Check","package hidden; import com.codename1.orm.repository.*; import com.codename1.orm.session.*; public class Check { public static void run(Session s) { " + + "Riders repo=Repositories.create(Riders.class,s); s.createTables(); s.beginTransaction(); " + + "Rider a=new Rider(); a.name(\"ann\"); a.score(7); a.active(Boolean.TRUE); a.created(1234L); a.createdBy(\"ops\"); a.revision(3); a.cached(\"changed\"); Place p=new Place(); p.city(\"Haifa\"); p.zip(31000); a.home(p); repo.insert(a); " + + "Rider b=new Rider(); b.name(\"ann\"); b.score(2); repo.insert(b); s.commitTransaction(); " + + "if(a.id()==0 || b.id()==0) throw new AssertionError(\"generated id written through the accessor\"); " + + "s.clear(); Rider back=repo.findById(a.id()); " + + "if(back==a) throw new AssertionError(\"expected a fresh instance\"); " + + "if(!\"ann\".equals(back.name()) || back.score()!=7 || !Boolean.TRUE.equals(back.active())) throw new AssertionError(\"own fields \"+back.name()+\" \"+back.score()+\" \"+back.active()); " + + "if(back.created()!=1234L || !\"ops\".equals(back.createdBy()) || back.revision()!=3) throw new AssertionError(\"inherited fields\"); " + + "if(back.home()==null || !\"Haifa\".equals(back.home().city()) || back.home().zip()!=31000) throw new AssertionError(\"embedded fields\"); " + + "if(!\"c\".equals(back.cached()) || !\"k\".equals(back.constant())) throw new AssertionError(\"transient and final fields must not be stored\"); " + + "java.util.List named=repo.findByNameOrderByScoreDesc(\"ann\"); if(named.size()!=2 || named.get(0).score()!=7 || repo.countByScoreGreaterThan(5)!=1) throw new AssertionError(\"derived queries\"); " + + "s.beginTransaction(); back.score(9); s.commitTransaction(); s.clear(); if(repo.findById(a.id()).score()!=9) throw new AssertionError(\"update\"); }}"); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); + ProcessorContext ctx=runProcessor(classes,backend ? backendClasspath() : Collections.emptyList());assertFalse(ctx.getErrors().toString(),ctx.hasErrors()); + // The accessors are woven into the class that declares each field, and the + // fields themselves keep the access the developer gave them. + java.util.Set riderMethods=methodNames(new File(classes,"hidden/Rider.class")); + assertTrue(riderMethods.toString(),riderMethods.contains("__cn1OrmGet_name") && riderMethods.contains("__cn1OrmSet_home")); + assertFalse(riderMethods.toString(),riderMethods.contains("__cn1OrmGet_cached") || riderMethods.contains("__cn1OrmGet_constant")); + assertTrue(methodNames(new File(classes,"hiddenbase/Audited.class")).contains("__cn1OrmSet_created")); + assertTrue(methodNames(new File(classes,"hidden/Place.class")).contains("__cn1OrmGet_zip")); + java.net.URLClassLoader loader=new java.net.URLClassLoader(new URL[]{classes.toURI().toURL()},getClass().getClassLoader()); + assertTrue(java.lang.reflect.Modifier.isPrivate(loader.loadClass("hidden.Rider").getDeclaredField("name").getModifiers())); + loader.loadClass(backend ? "cn1app.BackendDaoBootstrap" : "cn1app.DaoBootstrap").newInstance(); + com.codename1.backend.orm.EntityManager server=backend ? com.codename1.backend.orm.EntityManager.open(com.codename1.backend.Database.open(":memory:")) : null; + com.codename1.orm.EntityManager client=backend ? null : com.codename1.orm.EntityManager.open(new com.codename1.impl.javase.SEDatabase(java.sql.DriverManager.getConnection("jdbc:sqlite::memory:"))); + com.codename1.orm.session.Session session=backend ? server.openSession() : client.openSession(); + try { loader.loadClass("hidden.Check").getMethod("run",com.codename1.orm.session.Session.class).invoke(null,session); } + catch(java.lang.reflect.InvocationTargetException error) { throw new AssertionError((backend?"backend: ":"client: ")+error.getCause(),error.getCause()); } + finally { session.close();if(server!=null)server.close();if(client!=null)client.close();loader.close(); } + // A second pass over the same classes, as an incremental build makes, keeps + // exactly one accessor per field rather than adding another. + ctx=runProcessor(classes,backend ? backendClasspath() : Collections.emptyList());assertFalse(ctx.getErrors().toString(),ctx.hasErrors()); + assertEquals(riderMethods,methodNames(new File(classes,"hidden/Rider.class"))); + } + + @Test + public void snakeCaseFollowsSpringPhysicalNaming() { + String[][] cases={{"fareCents","fare_cents"},{"distanceMeters","distance_meters"},{"RideEvent","ride_event"}, + {"userID","userid"},{"URLValue","urlvalue"},{"home_zipCode","home_zip_code"},{"a1B2c","a1_b2c"},{"x","x"},{"already_snake","already_snake"},{"pdfURLs","pdfurls"}}; + for(String[] c:cases) assertEquals(c[0],c[1],OrmAnnotationProcessor.snakeCase(c[0])); + } + + @Test + public void backendNamesTablesAndColumnsInSnakeCaseAndTheClientKeepsFieldNames() throws Exception { + File classes=tmp.newFolder(); + Map sources=new java.util.LinkedHashMap(); + sources.put("naming.RiderAccount","package naming; import com.codename1.annotations.*; @Entity public class RiderAccount { @Id public long id; }"); + sources.put("naming.HomePlace","package naming; import com.codename1.annotations.db.*; @Embeddable public class HomePlace { public String cityName; public int zipCode; }"); + sources.put("naming.RideEvent","package naming; import com.codename1.annotations.*; import com.codename1.annotations.db.*; @Entity public class RideEvent { @Id public long id; public long fareCents; public double distanceMeters; public String userID; " + + "@Column(name=\"Kept_As_Written\") public String renamed; @Embedded public HomePlace homePlace; @ManyToOne public RiderAccount riderAccount; }"); + sources.put("naming.Fixed","package naming; import com.codename1.annotations.*; @Entity(table=\"FixedTable\") public class Fixed { @Id public long id; public String someValue; }"); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); + for(boolean backend:new boolean[]{false,true}) { + ProcessorContext ctx=runProcessor(classes,backend ? backendClasspath() : Collections.emptyList());assertFalse(ctx.getErrors().toString(),ctx.hasErrors()); + java.net.URLClassLoader loader=new java.net.URLClassLoader(new URL[]{classes.toURI().toURL()},getClass().getClassLoader()); + loader.loadClass(backend ? "cn1app.BackendDaoBootstrap" : "cn1app.DaoBootstrap").newInstance(); + com.codename1.backend.orm.EntityManager server=backend ? com.codename1.backend.orm.EntityManager.open(com.codename1.backend.Database.open(":memory:")) : null; + com.codename1.orm.EntityManager client=backend ? null : com.codename1.orm.EntityManager.open(new com.codename1.impl.javase.SEDatabase(java.sql.DriverManager.getConnection("jdbc:sqlite::memory:"))); + com.codename1.orm.session.Session session=backend ? server.openSession() : client.openSession(); + try { + session.createTables(); + java.util.Set tables=new java.util.TreeSet(session.createNativeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite%' AND name NOT LIKE 'cn1%'",String.class).list()); + // The entity registry is process-wide, so tables of entities other + // tests registered are there too; only this test's names count. + tables.retainAll(Arrays.asList("RideEvent","ride_event","RiderAccount","rider_account","FixedTable","fixed_table")); + String ride=backend?"ride_event":"RideEvent"; + assertEquals(new java.util.TreeSet(Arrays.asList(ride,backend?"rider_account":"RiderAccount","FixedTable")),tables); + java.util.Set columns=new java.util.TreeSet(session.createNativeQuery("SELECT name FROM pragma_table_info('"+ride+"')",String.class).list()); + assertEquals(backend + ? new java.util.TreeSet(Arrays.asList("id","fare_cents","distance_meters","userid","Kept_As_Written","home_place_city_name","home_place_zip_code","rider_account_id")) + : new java.util.TreeSet(Arrays.asList("id","fareCents","distanceMeters","userID","Kept_As_Written","homePlace_cityName","homePlace_zipCode","riderAccount_id")), + columns); + assertEquals(new java.util.TreeSet(Arrays.asList("id",backend?"some_value":"someValue")), + new java.util.TreeSet(session.createNativeQuery("SELECT name FROM pragma_table_info('FixedTable')",String.class).list())); + // JPQL keeps speaking in field names whatever the columns are called. + session.beginTransaction();Object event=loader.loadClass("naming.RideEvent").newInstance(); + event.getClass().getField("fareCents").setLong(event,250L);session.persist(event);session.commitTransaction(); + assertEquals(Long.valueOf(1),session.createQuery("SELECT COUNT(e) FROM naming.RideEvent e WHERE e.fareCents = :f",Long.class).setParameter("f",Long.valueOf(250)).first()); + } finally { session.close();if(server!=null)server.close();if(client!=null)client.close();loader.close(); } + } + } + + private static java.util.Set methodNames(File classFile) throws Exception { + final java.util.Set names=new java.util.TreeSet(); + java.util.List all=new java.util.ArrayList(); + org.objectweb.asm.ClassReader reader=new org.objectweb.asm.ClassReader(Files.readAllBytes(classFile.toPath())); + reader.accept(new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override public org.objectweb.asm.MethodVisitor visitMethod(int access,String name,String descriptor,String signature,String[] exceptions) { + if(!names.add(name+descriptor)) names.add("DUPLICATE "+name+descriptor); + return null; + } + },0); + for(String name:names) all.add(name.indexOf('(')>0 ? name.substring(0,name.indexOf('(')) : name); + names.addAll(all); + return names; + } + @Test public void clientRepositoryBuildDoesNotDependOnBackend() throws Exception { StringBuilder classpath = new StringBuilder(); @@ -149,6 +288,9 @@ public void generatedRepositoriesRejectUnsupportedSignatures() throws Exception "java.util.List findByScoreIn(java.util.List values);", "java.util.List findByScoreIn(java.util.List> values);", "@Query(\"select e from badrepo.Note e where e.name=:name\") Note lookup(String name);", + "java.util.List findByName(String name);", + "@Query(value=\"SELECT name, score FROM note\",nativeSql=true) java.util.List rows();", + "@Query(\"select e.name from badrepo.Note e\") java.util.List names();", "@Query(\"update badrepo.Note e set e.name=:name\") int rename(@Param(\"name\") String name);", "@Lock(com.codename1.orm.session.LockMode.PESSIMISTIC_WRITE) Note findByName(String name);" }) { @@ -436,7 +578,7 @@ public void polymorphicSingleTableInheritancePreservesIdentityAndSubtypeQueries( Object foundDog=s.find(base,dogId);assertFalse(s.isLoaded(foundDog,"keeper"));org.junit.Assert.assertNotNull(reader.getMethod("keeper",dogType).invoke(null,foundDog)); List loaded=(List)reader.getMethod("animals",shelterType).invoke(null,s.find(shelterType,shelterId));org.junit.Assert.assertEquals(2,loaded.size()); s.beginTransaction();catType.getField("lives").setInt(foundCat,8);s.commitTransaction();s.clear();org.junit.Assert.assertEquals(8,catType.getField("lives").getInt(s.find(base,catId))); - s.beginTransaction();org.junit.Assert.assertEquals(1,s.createQuery("update Cat c set c.name = :name").setParameter("name","Kitty").executeUpdate());s.commitTransaction(); + s.beginTransaction();org.junit.Assert.assertEquals(1,s.createQuery("update Cat c set c.name = :name").setParameter("name","Kitty").executeUpdate());s.commitTransaction();s.clear(); org.junit.Assert.assertEquals("Fido",base.getField("name").get(s.find(base,dogId))); s.beginTransaction();s.remove(s.find(shelterType,shelterId));s.commitTransaction();org.junit.Assert.assertEquals(0,s.query(base).count()); } finally { s.close();em.close();loader.close(); } @@ -601,14 +743,14 @@ public void scalarAggregatesPreserveNonNullGuarantees() throws Exception { try { session.createTables();session.beginTransaction();Object entity=entry.newInstance();session.persist(entity);session.commitTransaction();Object id=entry.getField("id").get(entity); for(String selection:Arrays.asList("count(i.id)","coalesce(sum(i.id),0)")) { - session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e.counter=(select "+selection+" from Other i)").executeUpdate());session.commitTransaction();org.junit.Assert.assertEquals(0,entry.getField("counter").getLong(session.find(entry,id))); + session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e.counter=(select "+selection+" from Other i)").executeUpdate());session.commitTransaction();session.clear();org.junit.Assert.assertEquals(0,entry.getField("counter").getLong(session.find(entry,id))); } session.beginTransaction();session.persist(other.newInstance());session.persist(other.newInstance());session.commitTransaction(); - session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e.counter=(select count(i.id)+:delta from Other i)").setParameter("delta",3L).executeUpdate());session.commitTransaction();org.junit.Assert.assertEquals(5,entry.getField("counter").getLong(session.find(entry,id))); + session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e.counter=(select count(i.id)+:delta from Other i)").setParameter("delta",3L).executeUpdate());session.commitTransaction();session.clear();org.junit.Assert.assertEquals(5,entry.getField("counter").getLong(session.find(entry,id))); session.beginTransaction();org.junit.Assert.assertThrows(IllegalArgumentException.class,()->session.createQuery("update Entry e set e.counter=(select count(i.id)+:delta from Other i)").setParameter("delta",null).executeUpdate());session.rollbackTransaction(); for(String selection:Arrays.asList("count(i.id)","coalesce(sum(i.id),0)")) org.junit.Assert.assertThrows(IllegalArgumentException.class,()->session.createQuery("update Entry e set e.counter=(select "+selection+" from Other i having count(i.id)<0)")); org.junit.Assert.assertThrows(IllegalArgumentException.class,()->session.createQuery("update Entry e set e.counter=(select sum(i.id) from Other i where i.id<0)")); - session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e.counter=coalesce((select count(i.id) from Other i having count(i.id)<0),7)").executeUpdate());session.commitTransaction();org.junit.Assert.assertEquals(7,entry.getField("counter").getLong(session.find(entry,id))); + session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e.counter=coalesce((select count(i.id) from Other i having count(i.id)<0),7)").executeUpdate());session.commitTransaction();session.clear();org.junit.Assert.assertEquals(7,entry.getField("counter").getLong(session.find(entry,id))); } finally { session.close();db.close(); } } } @@ -751,9 +893,9 @@ public void generatedIntegralMappingsEnforceJavaRanges() throws Exception { String[] fields={"amount","boxed","narrow","small","letter"};long[] lows={Integer.MIN_VALUE,Integer.MIN_VALUE,Byte.MIN_VALUE,Short.MIN_VALUE,Character.MIN_VALUE},highs={Integer.MAX_VALUE,Integer.MAX_VALUE,Byte.MAX_VALUE,Short.MAX_VALUE,Character.MAX_VALUE}; for(int i=0;isession.query(type).eq(predicateField,invalid));org.junit.Assert.assertThrows(IllegalArgumentException.class,()->session.createQuery("select e from Entry e where e."+predicateField+"=:value").setParameter("value",invalid).list());session.beginTransaction();try { session.createQuery("update Entry e set e."+fields[i]+"=:value").setParameter("value",invalid).executeUpdate();fail("Stored out-of-range "+fields[i]); } catch(com.codename1.orm.session.PersistenceException expected) { session.rollbackTransaction(); } } - for(long valid:new long[]{lows[i],highs[i]}) { session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e."+fields[i]+"=:value").setParameter("value",valid).executeUpdate());session.commitTransaction();Object stored=type.getField(fields[i]).get(session.find(type,1));org.junit.Assert.assertEquals(valid,stored instanceof Character?((Character)stored).charValue():((Number)stored).longValue()); } + for(long valid:new long[]{lows[i],highs[i]}) { session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e."+fields[i]+"=:value").setParameter("value",valid).executeUpdate());session.commitTransaction();session.clear();Object stored=type.getField(fields[i]).get(session.find(type,1));org.junit.Assert.assertEquals(valid,stored instanceof Character?((Character)stored).charValue():((Number)stored).longValue()); } } - session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e.boxed=NULL").executeUpdate());session.commitTransaction();org.junit.Assert.assertNull(type.getField("boxed").get(session.find(type,1))); + session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e.boxed=NULL").executeUpdate());session.commitTransaction();session.clear();org.junit.Assert.assertNull(type.getField("boxed").get(session.find(type,1))); try { session.find(type,2147483648L);fail("Out-of-range identifier"); } catch(IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("range")); } } finally { session.close();db.close(); } } @@ -992,7 +1134,7 @@ public void jpqlLiteralsUseMappedConvertersLikeNamedParameters() throws Exceptio } org.junit.Assert.assertEquals(1,session.createQuery("select e from Entry e where e.code=:code").setParameter("code","Paris").list().size()); org.junit.Assert.assertEquals("Paris",session.createQuery("select 'Paris' from Entry e",String.class).first()); - session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e.code='Rome' where e.code='Paris'").executeUpdate());session.commitTransaction();session.clear();org.junit.Assert.assertEquals("Rome",type.getField("code").get(session.find(type,id))); + session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update Entry e set e.code='Rome' where e.code='Paris'").executeUpdate());session.commitTransaction();session.clear();session.clear();org.junit.Assert.assertEquals("Rome",type.getField("code").get(session.find(type,id))); } finally { session.close();db.close(); } } } @@ -1171,9 +1313,9 @@ public void convertersAndUniqueIndexesPreserveDomainValues() throws Exception { org.junit.Assert.assertSame(s.find(type,id),s.createQuery("select e from converted.Entry e where e.code like :pattern escape :escape",type).setParameter("pattern",pattern).setParameter("escape","!").first()); org.junit.Assert.assertNull(s.createQuery("select e from converted.Entry e where e.code like :pattern",type).setParameter("pattern",null).first()); s.beginTransaction();Object replacement=codeType.getConstructor(String.class).newInstance("BULK"); - org.junit.Assert.assertEquals(1,s.createQuery("update converted.Entry e set e.code = :code where e.id = :id").setParameter("code",replacement).setParameter("id",id).executeUpdate());s.commitTransaction(); + org.junit.Assert.assertEquals(1,s.createQuery("update converted.Entry e set e.code = :code where e.id = :id").setParameter("code",replacement).setParameter("id",id).executeUpdate());s.commitTransaction();s.clear(); org.junit.Assert.assertEquals("BULK",codeType.getField("value").get(type.getField("code").get(s.find(type,id)))); - s.beginTransaction();org.junit.Assert.assertEquals(1,s.createQuery("update converted.Entry e set e.code = :code where e.id = :id").setParameter("code",null).setParameter("id",id).executeUpdate());s.commitTransaction(); + s.beginTransaction();org.junit.Assert.assertEquals(1,s.createQuery("update converted.Entry e set e.code = :code where e.id = :id").setParameter("code",null).setParameter("id",id).executeUpdate());s.commitTransaction();s.clear(); org.junit.Assert.assertNull(type.getField("code").get(s.find(type,id))); s.beginTransaction();s.createQuery("update converted.Entry e set e.code = :code").setParameter("code",codeType.getConstructor(String.class).newInstance("DEF")).executeUpdate();s.commitTransaction(); s.beginTransaction();Object duplicate=type.newInstance();type.getField("code").set(duplicate,codeType.getConstructor(String.class).newInstance("DEF"));s.persist(duplicate); @@ -1290,7 +1432,7 @@ public void subtypeQueriesRejectSiblingOnlyPathsWhileHydratingTheSharedTable() t org.junit.Assert.assertThrows(IllegalArgumentException.class,()->session.query(b).containsElement("tags","x")); org.junit.Assert.assertThrows(IllegalArgumentException.class,()->session.query(holder).eq("member.aOnly",5L)); for(String query:Arrays.asList("select b.aOnly from querysiblings.B b","select b from querysiblings.B b join fetch b.aRelation","update querysiblings.B b set b.aOnly=7")) org.junit.Assert.assertThrows(IllegalArgumentException.class,()->session.createQuery(query)); - session.beginTransaction();org.junit.Assert.assertThrows(IllegalArgumentException.class,()->session.increment(b,id,"aOnly",1));org.junit.Assert.assertEquals(1,session.createQuery("update querysiblings.B b set b.bOnly=10").executeUpdate());session.commitTransaction(); + session.beginTransaction();org.junit.Assert.assertThrows(IllegalArgumentException.class,()->session.increment(b,id,"aOnly",1));org.junit.Assert.assertEquals(1,session.createQuery("update querysiblings.B b set b.bOnly=10").executeUpdate());session.commitTransaction();session.clear(); org.junit.Assert.assertEquals(Long.valueOf(5),session.createQuery("select a.aOnly from querysiblings.A a",Long.class).first());org.junit.Assert.assertEquals(10,b.getField("bOnly").getLong(session.find(b,id))); } finally { session.close();db.close(); } } @@ -1838,7 +1980,7 @@ public void subtypeRequirementsSurviveNullableSingleTableColumns() throws Except try { session.createQuery("update requiredsub.Child c set c.code = NULL");fail("Bulk NULL must reject a required subtype column"); } catch(IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("required subtype")); } try { session.createQuery("update requiredsub.Base c set c.code = coalesce(NULL, NULL)");fail("Unknown-nullability bulk expressions must be rejected"); } catch(IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("required subtype")); } session.beginTransaction();try { session.createQuery("update requiredsub.Child c set c.code = :value").setParameter("value",null).executeUpdate();fail("Null bulk parameters must be rejected"); } catch(IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("required subtype")); }session.rollbackTransaction(); - session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update requiredsub.Child c set c.code = :value").setParameter("value","bulk").executeUpdate());session.commitTransaction();org.junit.Assert.assertEquals("bulk",childType.getField("code").get(session.find(childType,id))); + session.beginTransaction();org.junit.Assert.assertEquals(1,session.createQuery("update requiredsub.Child c set c.code = :value").setParameter("value","bulk").executeUpdate());session.commitTransaction();session.clear();org.junit.Assert.assertEquals("bulk",childType.getField("code").get(session.find(childType,id))); } finally { session.close();em.close(); } } } diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmEnhancerTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmEnhancerTest.java index a5a1e3071cd..9e3d3a1301e 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmEnhancerTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmEnhancerTest.java @@ -138,6 +138,111 @@ public void cleanupDoesNotReadOrDeleteExternalManifest() throws Exception { assertTrue(Files.exists(output.resolve("overlay/Reader.class"))); } + @Test + public void routesGeneratedAccessToAPrivateInheritedFieldOfADependencyThroughAWovenAccessor() throws Exception { + // model.Base declares a private id; model.Thing extends it; both live in a + // dependency jar. The generated model, compiled against public stand-ins, + // reads the field through Thing -- the static type javac names. + Path output = temporary.newFolder("classes").toPath(); + File jar = temporary.newFile("entities.jar"); + try (JarOutputStream stream = new JarOutputStream(Files.newOutputStream(jar.toPath()))) { + stream.putNextEntry(new JarEntry("model/Base.class")); + stream.write(entityClass("model/Base", "java/lang/Object", true)); + stream.closeEntry(); + stream.putNextEntry(new JarEntry("model/Thing.class")); + stream.write(entityClass("model/Thing", "model/Base", false)); + stream.closeEntry(); + } + Files.createDirectories(output.resolve("model")); + Files.write(output.resolve("model/ThingCn1BackendModel.class"), generatedModel()); + ProcessorContext context = new ProcessorContext(output.toFile(), temporary.newFolder(), null, + new SystemStreamLog(), null, null, null, Collections.emptyList(), "UTF-8", + Collections.singletonList(jar.getAbsolutePath())); + OrmAnnotationProcessor.EntityClass thing = new OrmAnnotationProcessor.EntityClass(); + thing.binaryName = "model.Thing"; + thing.daoBinaryName = "model.ThingCn1BackendDao"; + thing.hiddenFields.add(new String[] {"model/Base", "id", "J"}); + + File stubs = OrmEnhancer.publicStubs(Collections.singletonMap(thing.binaryName, thing), context); + try { + assertTrue(Files.isRegularFile(stubs.toPath().resolve("model/Base.class"))); + final int[] access = {0}; + new org.objectweb.asm.ClassReader(Files.readAllBytes(stubs.toPath().resolve("model/Base.class"))) + .accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) { + @Override public org.objectweb.asm.FieldVisitor visitField(int a, String n, String d, String sig, Object v) { + if (n.equals("id")) access[0] = a; + return null; + } + }, 0); + assertEquals(Opcodes.ACC_PUBLIC, access[0] & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PRIVATE)); + } finally { + org.apache.commons.io.FileUtils.deleteDirectory(stubs); + } + + OrmEnhancer.enhance(Collections.singletonMap(thing.binaryName, thing), context); + // The declaring class is woven and written over the dependency's copy; + // the subclass, which declares nothing hidden, is left alone. + assertTrue(Files.isRegularFile(output.resolve("model/Base.class"))); + assertFalse(Files.exists(output.resolve("model/Thing.class"))); + java.net.URLClassLoader loader = new java.net.URLClassLoader( + new java.net.URL[] {output.toUri().toURL(), jar.toURI().toURL()}, null); + try { + Class type = loader.loadClass("model.Thing"); + Object instance = type.newInstance(); + Class model = loader.loadClass("model.ThingCn1BackendModel"); + model.getMethod("write", type, long.class).invoke(null, instance, Long.valueOf(42L)); + assertEquals(Long.valueOf(42L), model.getMethod("read", type).invoke(null, instance)); + assertTrue(java.lang.reflect.Modifier.isPrivate( + loader.loadClass("model.Base").getDeclaredField("id").getModifiers())); + } finally { + loader.close(); + } + } + + /// A class with a private long `id` when `declaresId`, and a public no-arg + /// constructor. + private static byte[] entityClass(String name, String superName, boolean declaresId) { + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, name, null, superName, null); + if (declaresId) { + writer.visitField(Opcodes.ACC_PRIVATE, "id", "J", null, null).visitEnd(); + } + MethodVisitor init = writer.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + init.visitCode(); + init.visitVarInsn(Opcodes.ALOAD, 0); + init.visitMethodInsn(Opcodes.INVOKESPECIAL, superName, "", "()V", false); + init.visitInsn(Opcodes.RETURN); + init.visitMaxs(0, 0); + init.visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } + + /// What javac emits for `e.id` and `e.id = v` against the public stand-in. + private static byte[] generatedModel() { + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "model/ThingCn1BackendModel", null, "java/lang/Object", null); + MethodVisitor read = writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "read", + "(Lmodel/Thing;)J", null, null); + read.visitCode(); + read.visitVarInsn(Opcodes.ALOAD, 0); + read.visitFieldInsn(Opcodes.GETFIELD, "model/Thing", "id", "J"); + read.visitInsn(Opcodes.LRETURN); + read.visitMaxs(0, 0); + read.visitEnd(); + MethodVisitor write = writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "write", + "(Lmodel/Thing;J)V", null, null); + write.visitCode(); + write.visitVarInsn(Opcodes.ALOAD, 0); + write.visitVarInsn(Opcodes.LLOAD, 1); + write.visitFieldInsn(Opcodes.PUTFIELD, "model/Thing", "id", "J"); + write.visitInsn(Opcodes.RETURN); + write.visitMaxs(0, 0); + write.visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } + private ProcessorContext context(Path output, String entry) throws IOException { File jar = temporary.newFile("dependency-" + System.nanoTime() + ".jar"); try (JarOutputStream stream = new JarOutputStream(Files.newOutputStream(jar.toPath()))) { diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/RecordDtoTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RecordDtoTest.java new file mode 100644 index 00000000000..daf64f32464 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RecordDtoTest.java @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import com.codename1.backend.Backend; +import com.codename1.backend.Config; +import com.codename1.build.SystemStreamLog; +import com.codename1.impl.backend.BackendAccess; +import com.codename1.impl.backend.BackendApplication; +import com.codename1.mapping.Mapper; +import com.codename1.mapping.Mappers; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.ProcessorContext; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/// A Java record as the transfer object of a shared `@RestClient` contract, end +/// to end: the contract module is compiled at the running JDK's level (records +/// need 16 or newer), the app's mappers are generated from it, the backend's +/// server interface and controller are generated from it as a dependency, and a +/// record the client encodes is posted to a running server, built there through +/// its canonical constructor, and answered back to the client's decoder. The +/// record carries an enum, a nested record, a list of records, a list of +/// strings, primitives, and a component the server ignores. +public class RecordDtoTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String PKG = "package com.example;\n"; + + @Test + public void aRecordDtoRoundTripsBetweenTheClientAndTheServer() throws Exception { + assumeTrue("records need Java 16 or newer", javaSpecAtLeast(16)); + File shared = compileContract(); + + // -- the app: a mapper per @Mapped record, as the app module generates. + File app = tmp.newFolder("app"); + copy(shared.toPath(), app.toPath()); + ProcessorContext mapping = new ProcessorContext(app, tmp.newFolder(), ClassScanner.scan(app), + new SystemStreamLog()); + MappingAnnotationProcessor mapper = new MappingAnnotationProcessor(); + mapper.start(mapping); + for (AnnotatedClass cls : ClassScanner.scan(app).values()) { + if (!cls.getClassAnnotations().isEmpty()) { + mapper.processClass(cls, mapping); + } + } + mapper.finish(mapping); + assertFalse(String.valueOf(mapping.getErrors()), mapping.hasErrors()); + // The generated client implementation compiles against the record contract. + Map appIndex = ClassScanner.scan(app); + RestClientAnnotationProcessor restClient = new RestClientAnnotationProcessor(); + ProcessorContext clientCtx = new ProcessorContext(app, tmp.newFolder(), appIndex, + new SystemStreamLog()); + restClient.start(clientCtx); + for (AnnotatedClass cls : appIndex.values()) { + if (!cls.getClassAnnotations().isEmpty()) { + restClient.processClass(cls, clientCtx); + } + } + restClient.finish(clientCtx); + assertFalse(String.valueOf(clientCtx.getErrors()), clientCtx.hasErrors()); + assertTrue(new File(app, "com/example/RidesApiImpl.class").isFile()); + URLClassLoader client = new URLClassLoader(new URL[] {app.toURI().toURL()}, + getClass().getClassLoader()); + + // -- the server: the contract arrives as a dependency. + File classes = tmp.newFolder("classes"); + List classpath = Arrays.asList(classes.getPath(), shared.getPath(), + backendRuntime().getPath()); + ProcessorContext stubs = new ProcessorContext(classes, tmp.newFolder(), + Collections.emptyMap(), new SystemStreamLog(), + null, null, null, null, null, classpath); + new RestServerAnnotationProcessor().emitStubs(stubs); + assertFalse(String.valueOf(stubs.getErrors()), stubs.hasErrors()); + Map server = new LinkedHashMap(); + for (Map.Entry e : stubs.getEmittedStubSources().entrySet()) { + server.put(e.getKey().replace('/', '.'), e.getValue()); + } + server.put("com.example.RidesEndpoint", PKG + + "import java.util.*;\n" + + "@com.codename1.backend.annotations.Component\n" + + "public class RidesEndpoint implements RidesApiServer {\n" + + " private final List rides = new ArrayList();\n" + + " public Ride create(Ride r) {\n" + + " String id = \"r\" + (rides.size() + 1) + (r.note() == null ? \"\" : \"!\");\n" + + " Ride saved = new Ride(id, r.kind(), r.pickup(),\n" + + " r.stops(), r.fareCents() * 2, !r.cash(), r.tags(), \"kept\");\n" + + " rides.add(saved);\n" + + " return saved;\n" + + " }\n" + + " public List list() { return rides; }\n" + + "}\n"); + JavaSourceCompiler.compile(server, classes, Arrays.asList(shared, backendRuntime()), null); + ProcessorContext ctx = new ProcessorContext(classes, tmp.newFolder(), ClassScanner.scan(classes), + new SystemStreamLog(), tmp.newFolder(), new Properties(), null, + Collections.emptyList(), "UTF-8", classpath); + BackendBeanAnnotationProcessor beans = new BackendBeanAnnotationProcessor(); + RestControllerAnnotationProcessor controllers = new RestControllerAnnotationProcessor(); + beans.start(ctx); + controllers.start(ctx); + for (AnnotatedClass cls : ctx.getClassIndex().values()) { + if (!cls.getClassAnnotations().isEmpty()) { + controllers.processClass(cls, ctx); + } + } + beans.finish(ctx); + controllers.finish(ctx); + assertFalse(String.valueOf(ctx.getErrors()), ctx.hasErrors()); + URLClassLoader serverLoader = new URLClassLoader(new URL[] {classes.toURI().toURL(), + shared.toURI().toURL()}, getClass().getClassLoader()); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend.Builder builder = Backend.builder(Config.of(settings, "dev")).quiet(); + BackendAccess.get().application(builder, (BackendApplication) serverLoader + .loadClass("com.example.BackendWiring").newInstance()); + Backend backend = builder.start(); + try { + for (String name : new String[] {"Place", "Ride"}) { + Mappers.register((Mapper) client.loadClass("com.example." + name + "Cn1Mapper") + .newInstance()); + } + Class place = client.loadClass("com.example.Place"); + Class ride = client.loadClass("com.example.Ride"); + Class kind = client.loadClass("com.example.Kind"); + Object home = place.getConstructor(double.class, double.class, String.class) + .newInstance(32.5, 34.75, "home"); + Object stop = place.getConstructor(double.class, double.class, String.class) + .newInstance(1.0, -2.5, null); + Object sent = ride.getConstructors()[0].newInstance(null, kind.getEnumConstants()[1], home, + new ArrayList(Arrays.asList(stop)), 1250L, true, + new ArrayList(Arrays.asList("quiet", "pet")), null); + // An ignored component is neither read nor written: the server gets + // its default however the body spells it, and never answers it. + String body = "{\"note\":\"secret\"," + Mappers.toJson(sent).substring(1); + + String created = http("POST", port, "/rides", body); + Object back = Mappers.fromJson(created, ride); + assertEquals(created, "r1", call(back, "id")); + assertEquals(created, kind.getEnumConstants()[1], call(back, "kind")); + assertEquals(created, Long.valueOf(2500), call(back, "fareCents")); + assertEquals(created, Boolean.FALSE, call(back, "cash")); + assertEquals(created, Arrays.asList("quiet", "pet"), call(back, "tags")); + assertEquals(created, home, call(back, "pickup")); + assertFalse(created, created.contains("note")); + assertEquals(created, Collections.singletonList(stop), call(back, "stops")); + + String listed = http("GET", port, "/rides", null); + assertTrue(listed, listed.startsWith("[{\"id\":\"r1\",\"kind\":\"XL\",\"pickup\":{\"lat\":32.5,")); + assertTrue(listed, listed.contains("\"stops\":[{\"lat\":1.0,\"lng\":-2.5,\"label\":null}]")); + } finally { + backend.stop(); + serverLoader.close(); + client.close(); + } + } + + /// The legacy `cn1.restServer` codec moves public fields and decodes through a + /// no-arg constructor, so a record would encode as `{}`. It must refuse one, + /// naming the backend path that does handle records. + @Test + public void theLegacyRestServerCodecRefusesARecord() throws Exception { + assumeTrue("records need Java 16 or newer", javaSpecAtLeast(16)); + File shared = compileContract(); + String before = System.getProperty("cn1.restServer"); + System.setProperty("cn1.restServer", "true"); + try { + Map index = ClassScanner.scan(shared); + RestServerAnnotationProcessor proc = new RestServerAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(shared, tmp.newFolder(), index, + new SystemStreamLog(), tmp.newFolder(), new Properties(), null, + Collections.emptyList(), "UTF-8", + Arrays.asList(shared.getPath(), testClassesDir().getPath())); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) { + proc.processClass(cls, ctx); + } + } + proc.finish(ctx); + assertTrue("expected a refusal", ctx.hasErrors()); + assertTrue(String.valueOf(ctx.getErrors()), + String.valueOf(ctx.getErrors()).contains("is a record")); + } finally { + if (before == null) { + System.clearProperty("cn1.restServer"); + } else { + System.setProperty("cn1.restServer", before); + } + } + } + + /// The shared contract module, compiled at the running JDK's level (records + /// need 16 or newer, which every caller assumes first). + private File compileContract() throws Exception { + Map contract = new LinkedHashMap(); + contract.put("com.example.Kind", PKG + "public enum Kind { STANDARD, XL }\n"); + contract.put("com.example.Place", PKG + "import com.codename1.annotations.Mapped;\n" + + "@Mapped public record Place(double lat, double lng, String label) { }\n"); + contract.put("com.example.Ride", PKG + "import com.codename1.annotations.Mapped;\n" + + "@Mapped public record Ride(String id, Kind kind, Place pickup,\n" + + " java.util.List stops, long fareCents, boolean cash,\n" + + " java.util.List tags,\n" + + " @com.codename1.annotations.JsonIgnore String note) { }\n"); + contract.put("com.example.RidesApi", PKG + + "import com.codename1.annotations.rest.*;\n" + + "import com.codename1.io.rest.Response;\n" + + "import com.codename1.util.OnComplete;\n" + + "@RestClient public interface RidesApi {\n" + + " @POST(\"/rides\") void create(@Body Ride ride, OnComplete> callback);\n" + + " @GET(\"/rides\") void list(OnComplete>> callback);\n" + + "}\n"); + File shared = tmp.newFolder("shared"); + // null: the running JDK's level. + JavaSourceCompiler.compile(contract, shared, Arrays.asList(testClassesDir()), null); + return shared; + } + + private static Object call(Object target, String accessor) throws Exception { + Method m = target.getClass().getMethod(accessor); + return m.invoke(target); + } + + private static String http(String method, int port, String path, String body) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path) + .openConnection(); + c.setRequestMethod(method); + if (body != null) { + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + OutputStream out = c.getOutputStream(); + out.write(body.getBytes("UTF-8")); + out.close(); + } + int status = c.getResponseCode(); + InputStream in = status >= 400 ? c.getErrorStream() : c.getInputStream(); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + if (in != null) { + byte[] chunk = new byte[4096]; + int n; + while ((n = in.read(chunk)) > 0) { + buffer.write(chunk, 0, n); + } + } + String text = new String(buffer.toByteArray(), "UTF-8"); + return status >= 400 ? "HTTP " + status + ": " + text : text; + } + + private static void copy(final Path from, final Path to) throws Exception { + try (java.util.stream.Stream all = Files.walk(from)) { + for (Path p : (Iterable) all::iterator) { + Path target = to.resolve(from.relativize(p).toString()); + if (Files.isDirectory(p)) { + Files.createDirectories(target); + } else { + Files.copy(p, target); + } + } + } + } + + private static int freePort() throws Exception { + ServerSocket s = new ServerSocket(0); + try { + return s.getLocalPort(); + } finally { + s.close(); + } + } + + private static File backendRuntime() throws Exception { + return new File(Backend.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + + private static File testClassesDir() throws Exception { + return new File(RecordDtoTest.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + + private static boolean javaSpecAtLeast(int target) { + String spec = System.getProperty("java.specification.version", ""); + try { + return spec.startsWith("1.") ? Integer.parseInt(spec.substring(2)) >= target + : Integer.parseInt(spec) >= target; + } catch (NumberFormatException nfe) { + return false; + } + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java index 0452539e4e5..3ae241694f2 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java @@ -176,6 +176,146 @@ public void rejectsRestClientOnNonInterface() throws Exception { assertTrue("expected error on @RestClient applied to a class", ctx.hasErrors()); } + /// A primitive parameter cannot be compared with null, so the guard that + /// leaves an absent query parameter or header off the request has to be + /// written for reference types only. Guarding both generated a client that + /// did not compile, for any contract with an `int` page or a `double` + /// coordinate in it. + @Test + public void primitiveQueryAndHeaderParametersAreNotNullChecked() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.GeoApi", + "package com.example;\n" + + "import com.codename1.annotations.rest.*;\n" + + "import com.codename1.io.rest.Response;\n" + + "import com.codename1.util.OnComplete;\n" + + "@RestClient\n" + + "public interface GeoApi {\n" + + " @GET(\"/geo/search\")\n" + + " void search(@Query(\"q\") String text,\n" + + " @Query(\"lat\") double lat,\n" + + " @Query(\"limit\") int limit,\n" + + " @Header(\"X-Page\") long page,\n" + + " OnComplete> callback);\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + + ProcessorContext ctx = runProcessor(classes); + assertFalse("the generated client must compile: " + ctx.getErrors(), ctx.hasErrors()); + assertTrue(new File(classes, "com/example/GeoApiImpl.class").exists()); + + String implSrc = generateImplSourceForFixture(classes); + assertTrue("a reference parameter keeps its guard: " + implSrc, + implSrc.contains("!= null) _rb.queryParam(\"q\"")); + assertFalse(implSrc, implSrc.contains("!= null) _rb.queryParam(\"lat\"")); + assertFalse(implSrc, implSrc.contains("!= null) _rb.queryParam(\"limit\"")); + assertFalse(implSrc, implSrc.contains("!= null) _rb.header(\"X-Page\"")); + } + + /// The cookie guard is a block, not a statement, and the header value is a + /// String: both had their own way of not compiling for a primitive. + @Test + public void primitiveCookieAndHeaderParametersCompile() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.VisitApi", + "package com.example;\n" + + "import com.codename1.annotations.rest.*;\n" + + "import com.codename1.io.rest.Response;\n" + + "import com.codename1.util.OnComplete;\n" + + "@RestClient\n" + + "public interface VisitApi {\n" + + " @GET(\"/visits\")\n" + + " void count(@Cookie(\"shard\") int shard,\n" + + " @Cookie(\"sid\") String session,\n" + + " @Header(\"X-Dry-Run\") boolean dryRun,\n" + + " @Header(\"X-Trace\") String trace,\n" + + " OnComplete> callback);\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + + ProcessorContext ctx = runProcessor(classes); + assertFalse("the generated client must compile: " + ctx.getErrors(), ctx.hasErrors()); + assertTrue(new File(classes, "com/example/VisitApiImpl.class").exists()); + + String implSrc = generateImplSourceForFixture(classes); + assertFalse(implSrc, implSrc.contains("if (shardCookie != null)")); + assertTrue(implSrc, implSrc.contains("if (sidCookie != null) {")); + assertTrue(implSrc, implSrc.contains("_rb.header(\"X-Dry-Run\", String.valueOf(")); + assertTrue(implSrc, implSrc.contains("!= null) _rb.header(\"X-Trace\", ")); + } + + /// A contract that lives in a library the application depends on gets its + /// client generated in the application -- on every build, not only the + /// first. The application's output directory is on its own compile + /// classpath and holds the client the previous build generated; taking that + /// for "a dependency already supplies the client" left the second build + /// with a bootstrap that registered nothing, and an application that threw + /// on its first call. + @Test + public void regeneratesADependencyContractOverThePreviousBuildsOutput() throws Exception { + File shared = tmp.newFolder("shared"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.NotesApi", + "package com.example;\n" + + "import com.codename1.annotations.rest.*;\n" + + "import com.codename1.io.rest.Response;\n" + + "import com.codename1.util.OnComplete;\n" + + "@RestClient\n" + + "public interface NotesApi {\n" + + " @GET(\"/notes\")\n" + + " void list(OnComplete> callback);\n" + + "}\n"), + shared, Arrays.asList(testClassesDir())); + + // What the previous build left behind. + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.NotesApiImpl", + "package com.example;\npublic class NotesApiImpl {}\n"), + classes, Arrays.asList(testClassesDir())); + + // The client runtime is what tells the processor this module is an + // application, and so one that wants the client half of a contract. + File core = new File(com.codename1.io.rest.Rest.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + java.util.List classpath = Arrays.asList(classes.getPath(), shared.getPath(), + core.getPath()); + RestClientAnnotationProcessor proc = new RestClientAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classes, tmp.newFolder(), + java.util.Collections.emptyMap(), new SystemStreamLog(), + null, null, null, null, null, classpath); + assertTrue(proc.acceptsDependencyClasses(ctx)); + Map dependencies = + com.codename1.maven.annotations.DependencyClasses.scan( + classpath, classes, proc.getAnnotationDescriptors()); + assertTrue("the contract is found in the dependency", + dependencies.containsKey("com/example/NotesApi")); + ctx.setDependencyIndex(dependencies); + + // The build hands the module's classpath to the generated-source + // compile, which is how the client sees the contract it implements. + JavaSourceCompiler.setProjectClasspath(Arrays.asList(shared, core)); + try { + proc.start(ctx); + for (AnnotatedClass cls : dependencies.values()) { + proc.processClass(cls, ctx); + } + proc.finish(ctx); + } finally { + JavaSourceCompiler.clearProjectClasspath(); + } + + assertFalse(String.valueOf(ctx.getErrors()), ctx.hasErrors()); + assertTrue("the client is registered", + new File(classes, "cn1app/RestClientBootstrap.class").exists()); + AnnotatedClass regenerated = ClassScanner.readClass( + new File(classes, "com/example/NotesApiImpl.class")); + assertTrue("the stale client is replaced by one that implements the contract", + regenerated.getInterfaceInternalNames().contains("com/example/NotesApi")); + } + // --------------------------------------------------------------- // Helpers // --------------------------------------------------------------- diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/RestServerStubsTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestServerStubsTest.java new file mode 100644 index 00000000000..66e7ca0ef89 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestServerStubsTest.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import com.codename1.build.SystemStreamLog; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.ProcessorContext; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/// The source half of a served contract: for a backend module whose classpath +/// holds a `@RestClient` interface in a dependency, `emitStubs` writes the +/// `Server` interface and the `Controller` that maps each operation to +/// it. What is written is COMPILED here, against the backend runtime and without +/// the client's, because that is the classpath it is compiled on in a build. +public class RestServerStubsTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String NOTE = + "package com.example;\n" + + "public class Note {\n" + + " public String title;\n" + + "}\n"; + + private static final String API = + "package com.example;\n" + + "import com.codename1.annotations.rest.*;\n" + + "import com.codename1.io.rest.Response;\n" + + "import com.codename1.util.OnComplete;\n" + + "@RestClient\n" + + "public interface NotesApi {\n" + + " public static class Draft {\n" + + " public String text;\n" + + " }\n" + + " @GET(\"/notes/{id}\")\n" + + " void get(@Path(\"id\") long id, OnComplete> callback);\n" + + " @GET(\"/notes\")\n" + + " void search(@Query(\"q\") String text, @Query(\"limit\") int limit,\n" + + " @Header(\"X-Tenant\") String tenant,\n" + + " OnComplete>> callback);\n" + + " @POST(\"/notes\")\n" + + " void create(@Body Draft draft, OnComplete> callback);\n" + + " @PUT(\"/notes/{id}\")\n" + + " void replace(@Path(\"id\") long id, @Body Note note,\n" + + " OnComplete> callback);\n" + + " @PATCH(\"/notes/{id}\")\n" + + " void rename(@Path(\"id\") long id, @Query(\"title\") String title,\n" + + " OnComplete> callback);\n" + + " @DELETE(\"/notes/{id}\")\n" + + " void remove(@Path(\"id\") long id, OnComplete> callback);\n" + + "}\n"; + + @Test + public void aDependencyContractGetsItsServerInterfaceAndController() throws Exception { + File shared = compileShared(API); + File classes = tmp.newFolder("classes"); + ProcessorContext ctx = emit(classes, Arrays.asList(classes.getPath(), shared.getPath(), + backendRuntime().getPath())); + + assertFalse(String.valueOf(ctx.getErrors()), ctx.hasErrors()); + Map stubs = ctx.getEmittedStubSources(); + assertEquals("one interface and one controller: " + stubs.keySet(), 2, stubs.size()); + String server = stubs.get("com/example/NotesApiServer"); + String controller = stubs.get("com/example/NotesApiController"); + assertNotNull(stubs.keySet().toString(), server); + assertNotNull(stubs.keySet().toString(), controller); + + // A source in the stub directory is the developer's to implement and + // every later processor's to see; the marker would hide it from them. + assertFalse(server, server.contains("Generated\n")); + assertTrue(server, server.contains("public interface NotesApiServer")); + assertTrue(server, server.contains("com.example.Note get(long id)")); + // A member class is written the way source spells it. + assertTrue(server, server.contains("com.example.NotesApi.Draft")); + assertFalse(server, server.contains("NotesApi$Draft")); + + assertTrue(controller, controller.contains("@com.codename1.backend.annotations.RestController\n")); + assertTrue(controller, controller.contains("public NotesApiController(NotesApiServer impl)")); + assertTrue(controller, controller.contains( + "@com.codename1.backend.annotations.GetMapping(\"/notes/{id}\")")); + assertTrue(controller, controller.contains( + "@com.codename1.backend.annotations.PostMapping(\"/notes\")")); + assertTrue(controller, controller.contains( + "@com.codename1.backend.annotations.PutMapping(\"/notes/{id}\")")); + assertTrue(controller, controller.contains( + "@com.codename1.backend.annotations.PatchMapping(\"/notes/{id}\")")); + assertTrue(controller, controller.contains( + "@com.codename1.backend.annotations.DeleteMapping(\"/notes/{id}\")")); + assertTrue(controller, controller.contains( + "@com.codename1.backend.annotations.PathVariable(\"id\") long id")); + assertTrue(controller, controller.contains( + "@com.codename1.backend.annotations.RequestBody com.example.NotesApi.Draft body")); + // The client leaves a null reference argument off the request, so the + // server must not require it; a primitive is always sent. + assertTrue(controller, controller.contains( + "RequestParam(value = \"q\", required = false) java.lang.String q")); + assertTrue(controller, controller.contains("RequestParam(value = \"limit\") int limit")); + assertTrue(controller, controller.contains( + "RequestHeader(value = \"X-Tenant\", required = false) java.lang.String")); + assertTrue(controller, controller.contains("return impl.search(q, limit, ")); + + // And the two compile together, beside a component implementing the + // interface, on the classpath a backend module has. + Map sources = new LinkedHashMap(); + sources.put("com.example.NotesApiServer", server); + sources.put("com.example.NotesApiController", controller); + sources.put("com.example.NotesEndpoint", + "package com.example;\n" + + "public class NotesEndpoint implements NotesApiServer {\n" + + " public Note get(long id) { return new Note(); }\n" + + " public java.util.List search(String q, int limit, String tenant) {\n" + + " return new java.util.ArrayList();\n" + + " }\n" + + " public Note create(NotesApi.Draft draft) { return new Note(); }\n" + + " public Note replace(long id, Note note) { return note; }\n" + + " public Note rename(long id, String title) { return new Note(); }\n" + + " public String remove(long id) { return \"gone\"; }\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, + Arrays.asList(shared, backendRuntime(), testClassesDir())); + assertTrue(new File(classes, "com/example/NotesApiController.class").isFile()); + assertTrue(new File(classes, "com/example/NotesEndpoint.class").isFile()); + } + + /// A module with the client runtime is an application: the contract there is + /// to be CALLED, and its server half stays behind `cn1.restServer`. + @Test + public void nothingIsWrittenForAModuleWithTheClientRuntime() throws Exception { + File shared = compileShared(API); + File classes = tmp.newFolder("classes"); + ProcessorContext ctx = emit(classes, Arrays.asList(classes.getPath(), shared.getPath(), + backendRuntime().getPath(), clientRuntime().getPath())); + assertFalse(String.valueOf(ctx.getErrors()), ctx.hasErrors()); + assertTrue(ctx.getEmittedStubSources().isEmpty()); + } + + @Test + public void nothingIsWrittenForAModuleWithoutTheBackendRuntime() throws Exception { + File shared = compileShared(API); + File classes = tmp.newFolder("classes"); + ProcessorContext ctx = emit(classes, Arrays.asList(classes.getPath(), shared.getPath())); + assertFalse(String.valueOf(ctx.getErrors()), ctx.hasErrors()); + assertTrue(ctx.getEmittedStubSources().isEmpty()); + } + + @Test + public void aBackendWithNoContractOnItsClasspathWritesNothing() throws Exception { + File classes = tmp.newFolder("classes"); + ProcessorContext ctx = emit(classes, Arrays.asList(classes.getPath(), + backendRuntime().getPath())); + assertFalse(String.valueOf(ctx.getErrors()), ctx.hasErrors()); + assertTrue(ctx.getEmittedStubSources().isEmpty()); + } + + /// A served contract has no binding for a cookie. Said at build time, and + /// neither half written, in place of a controller that does not compile. + @Test + public void aCookieParameterIsRefusedWithAReason() throws Exception { + File shared = compileShared( + "package com.example;\n" + + "import com.codename1.annotations.rest.*;\n" + + "import com.codename1.io.rest.Response;\n" + + "import com.codename1.util.OnComplete;\n" + + "@RestClient\n" + + "public interface NotesApi {\n" + + " @GET(\"/notes\")\n" + + " void list(@Cookie(\"sid\") String session,\n" + + " OnComplete> callback);\n" + + "}\n"); + File classes = tmp.newFolder("classes"); + ProcessorContext ctx = emit(classes, Arrays.asList(classes.getPath(), shared.getPath(), + backendRuntime().getPath())); + assertTrue("a cookie binding must be reported", ctx.hasErrors()); + assertTrue(ctx.getErrors().toString(), ctx.getErrors().toString().contains("cookie")); + assertTrue(ctx.getEmittedStubSources().toString(), ctx.getEmittedStubSources().isEmpty()); + } + + /// The processor is one instance per build and `emitStubs` shares its state + /// with the class pass, so a second module must not inherit the first's. + @Test + public void emittingTwiceDoesNotCarryStateOver() throws Exception { + File shared = compileShared(API); + File classes = tmp.newFolder("classes"); + List classpath = Arrays.asList(classes.getPath(), shared.getPath(), + backendRuntime().getPath()); + RestServerAnnotationProcessor proc = new RestServerAnnotationProcessor(); + ProcessorContext first = context(classes, classpath); + proc.emitStubs(first); + ProcessorContext second = context(classes, + Arrays.asList(classes.getPath(), backendRuntime().getPath())); + proc.emitStubs(second); + assertEquals(2, first.getEmittedStubSources().size()); + assertTrue(second.getEmittedStubSources().isEmpty()); + } + + private File compileShared(String api) throws Exception { + File shared = tmp.newFolder("shared"); + Map sources = new LinkedHashMap(); + sources.put("com.example.Note", NOTE); + sources.put("com.example.NotesApi", api); + JavaSourceCompiler.compile(sources, shared, Arrays.asList(testClassesDir())); + return shared; + } + + private ProcessorContext emit(File classes, List classpath) throws Exception { + ProcessorContext ctx = context(classes, classpath); + new RestServerAnnotationProcessor().emitStubs(ctx); + return ctx; + } + + private ProcessorContext context(File classes, List classpath) throws Exception { + return new ProcessorContext(classes, tmp.newFolder(), + Collections.emptyMap(), new SystemStreamLog(), + null, null, null, null, null, classpath); + } + + private static File backendRuntime() throws Exception { + return new File(com.codename1.backend.Backend.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + } + + private static File clientRuntime() throws Exception { + return new File(com.codename1.io.rest.Rest.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + } + + private static File testClassesDir() throws Exception { + return new File(RestServerStubsTest.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + } +} diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 3b99a486f3b..683e999390a 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -694,7 +694,12 @@ static void register(List h) { h.add(new Hint("android.messagingService") .group(HintGroup.ANDROID) .type(HintType.STRING) - .platform("android")); + .platform("android") + .doc("The Android push provider. Left unset, a build that uses PushClient picks FCM, " + + "Huawei Push Kit or both from the google-services.json and " + + "agconnect-services.json files present, and fails when neither is. " + + "fcm or huawei forces one provider; none builds without push, for an " + + "app that hasn't got its push configuration yet.")); h.add(new Hint("android.migrateToAndroidX") .group(HintGroup.ANDROID) diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml index 018d1afad0f..9c610c4059a 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml @@ -34,6 +34,13 @@ with no runtime to install, which is what lets the deployed container be the size of the binary and a serverless cold start be a process exec. + The server can also host the app itself, so it runs in a browser with + nothing to install: + + mvn -pl backend -Dcodename1.platform=backend cn1:backend-webapp + builds the app for the browser into target/webapp; the first two + commands then serve it at /, after this module's own routes + This module deliberately does NOT depend on codenameone-core: a server has no display, and the compiler saying so at the point of the import is more useful than a crash at start-up. Share types with the app by putting them in a module @@ -91,6 +98,19 @@ server writes its own main instead. --> + + cn1-generate-annotation-stubs + generate-sources + + + generate-annotation-stubs + + cn1-process-annotations process-classes diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java index 638a5d23827..4a53ac147e4 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java @@ -1,24 +1,20 @@ /* - * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. + * Copyright (c) 2012, Codename One and/or its affiliates. * - * 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). + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. * - * 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. */ package ${package}; diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java index d330f234810..3b587602c93 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java @@ -1,24 +1,20 @@ /* - * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. + * Copyright (c) 2012, Codename One and/or its affiliates. * - * 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). + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. * - * 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. */ package ${package}; diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ApiTest.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ApiTest.java index e8632393ce2..36b5424c231 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ApiTest.java +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ApiTest.java @@ -1,24 +1,20 @@ /* - * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. + * Copyright (c) 2012, Codename One and/or its affiliates. * - * 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). + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. * - * 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. */ package ${package}; diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ServedApiTest.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ServedApiTest.java index 5fb729867c2..e802e9f9bd6 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ServedApiTest.java +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ServedApiTest.java @@ -1,24 +1,20 @@ /* - * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. + * Copyright (c) 2012, Codename One and/or its affiliates. * - * 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). + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. * - * 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. */ package ${package}; diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml index 9436dc57d79..a4e08617d4b 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml @@ -21,6 +21,19 @@ ${project.basedir}/../common/src/test/java + + + + ${project.basedir}/../common/src/test/resources + + + ${project.basedir}/src/test/resources + + org.apache.maven.plugins diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/src/desktop/java/__mainName__Stub.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/src/desktop/java/__mainName__Stub.java index 344c0369bbd..2e83bade9f1 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/src/desktop/java/__mainName__Stub.java +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/src/desktop/java/__mainName__Stub.java @@ -1,24 +1,20 @@ /* - * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. + * Copyright (c) 2012, Codename One and/or its affiliates. * - * 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). + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. * - * 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. */ #set( $symbol_pound = '#' ) diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java index b5ffaa83345..1715cfb3634 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java @@ -75,6 +75,10 @@ final class AppSupport { {"buildJavascriptLocal", "javascript", "local-javascript", "Builds the JavaScript port locally"}, }; + /// The local JavaScript build `backendWebApp` stages for a backend: as + /// `buildJavascriptLocal`, made for a server that is not a servlet container. + static final String WEB_APP_BUILD_TASK = "buildJavascriptWebApp"; + private AppSupport() { } @@ -352,6 +356,18 @@ static void apply(final Project project, final ProjectLayout layout, final Coden "Runs the build given by -Pcodename1.platform and -Pcodename1.buildTarget", layout, main, javase, framework, ext, userProperties, queue); + final File settingsFile = layout.settingsFile(); + final Provider> hosted = userProperties.map( + given -> hostedProperties(given, settingsFile)); + registerBuild(project, WEB_APP_BUILD_TASK, "javascript", "local-javascript", + "Builds the JavaScript port locally for the backend to host (see backendWebApp)", + layout, main, javase, framework, ext, hosted, queue); + // The bundle is found by looking in the build directory, so one an + // earlier build left there must not be this build's answer. + final Provider bundle = project.getLayout().getBuildDirectory() + .file(project.getName() + ".zip").map(f -> f.getAsFile()); + project.getTasks().named(WEB_APP_BUILD_TASK).configure(t -> t.doFirst(new DeleteStaleBundle(bundle))); + final SourceSet test = sourceSets.getByName(SourceSet.TEST_SOURCE_SET_NAME); project.getTasks().register("cn1Test", com.codename1.gradle.tasks.Cn1TestTask.class, t -> { common(t, project, layout, ext, userProperties); @@ -874,6 +890,46 @@ public Iterable asArguments() { } } + /// The build properties of a browser build a backend will host. + /// + /// The JavaScript build sends cross-origin requests through a proxy servlet + /// it expects beside the page. A backend is not a servlet container and has + /// no such path, and an app talking to the server that hosts it is + /// same-origin and never proxied anyway -- so the build is told not to wire + /// one in, unless the project has chosen for itself: on the command line, in + /// `codenameone { buildHints }` or in its settings file. + static Map hostedProperties(Map given, File settingsFile) { + Map out = new java.util.LinkedHashMap(given); + try { + if (!out.containsKey("codename1.arg.javascript.inject_proxy") + && !out.containsKey("codename1.arg.javascript.proxy.url") + && !com.codename1.maven.BackendWebApp.choosesProxy(settingsFile)) { + out.put("codename1.arg.javascript.inject_proxy", "false"); + } + } catch (com.codename1.build.BuildExecutionException ex) { + throw new org.gradle.api.GradleException(ex.getMessage(), ex); + } + return out; + } + + /// Removes the bundle a previous browser build wrote, before the next one + /// runs: a build that then writes none leaves none to be mistaken for its own. + static final class DeleteStaleBundle implements org.gradle.api.Action { + private final Provider bundle; + + DeleteStaleBundle(Provider bundle) { + this.bundle = bundle; + } + + @Override + public void execute(org.gradle.api.Task task) { + File stale = bundle.get(); + if (stale.isFile() && !stale.delete()) { + throw new org.gradle.api.GradleException("Could not remove the earlier browser build " + stale); + } + } + } + static final class MainClassArgument implements org.gradle.process.CommandLineArgumentProvider { private final File settingsFile; private final Provider> userProperties; diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java index 32ba71a8bf2..6a2548b9e02 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java @@ -26,17 +26,20 @@ import com.codename1.build.ProjectHost; import com.codename1.gradle.tasks.Cn1Task; import com.codename1.maven.BackendPackager; +import com.codename1.maven.BackendWebApp; import com.codename1.project.ProjectLayout; import org.gradle.api.GradleException; import org.gradle.api.Project; import org.gradle.api.artifacts.Configuration; import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Classpath; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.OutputFile; import org.gradle.api.tasks.PathSensitive; @@ -69,6 +72,15 @@ static void register(Project project, ProjectLayout layout, SourceSet main, Code configure(t, project, layout, main, ext, runtime); t.setDescription("Builds the backend as a single native binary"); t.getBinary().set(new File(layout.buildDir(), project.getName())); + // The application backendWebApp staged ships beside the binary. Not + // an input: the binary does not change with it, and a new browser + // build should not cost a native link. + t.getWebAppDirectory().set(BackendSupport.webAppDirectory(project)); + t.mustRunAfter(BackendSupport.WEB_APP_TASK); + t.getOutputs().upToDateWhen(task -> { + PackageTask p = (PackageTask) task; + return webAppIsBeside(p.getWebAppDirectory().get().getAsFile(), p.getBinary().get().getAsFile()); + }); }); // The compiled test run: the backend's tests translated with it into one @@ -174,6 +186,80 @@ private static void configure(PackageTask t, Project project, ProjectLayout layo .map(Boolean::parseBoolean).orElse(Boolean.FALSE)); } + /// Where the web app staged in `staged` belongs for a binary at `binary`: + /// the `webapp` directory beside it, or null when nothing is staged. + static File webAppBeside(File staged, File binary) { + if (staged == null || !new File(staged, BackendWebApp.INDEX).isFile()) { + return null; + } + File parent = binary.getAbsoluteFile().getParentFile(); + return parent == null ? staged : new File(parent, BackendSupport.WEB_APP_DIRECTORY); + } + + /// Whether the binary already has the staged web app beside it, so a + /// package whose inputs are unchanged has nothing left to do. A copy keeps + /// each file's time, so the copy is current when it holds the same files, + /// each the same length and time, and nothing else. Every file is compared, + /// not only index.html: a browser build may change a script or a style + /// and leave the page as it was, and the staged app is not a task input + /// that Gradle would compare on its own. + /// + /// With nothing staged there is nothing to do, even when a `webapp` from + /// an earlier package is still beside the binary. That directory is not + /// removed: it sits beside a binary written outside the build directory, + /// where nothing records whether this plugin made it or somebody put it + /// there, and deleting a directory of somebody's files is worse than + /// leaving one. A deployment that stops serving its web app removes it. + static boolean webAppIsBeside(File staged, File binary) { + File beside = webAppBeside(staged, binary); + if (beside == null || beside.getAbsoluteFile().equals(staged.getAbsoluteFile())) { + return true; + } + return sameTree(staged, beside); + } + + private static boolean sameTree(File from, File to) { + File[] children = from.listFiles(); + String[] copied = to.list(); + if (children == null || copied == null || children.length != copied.length) { + return false; + } + for (File child : children) { + File other = new File(to, child.getName()); + if (child.isDirectory()) { + if (!other.isDirectory() || !sameTree(child, other)) { + return false; + } + } else if (!other.isFile() || other.length() != child.length() + || other.lastModified() != child.lastModified()) { + return false; + } + } + return true; + } + + /// Puts the web app `backendWebApp` staged beside the binary. + /// + /// A packaged server looks for its application in `webapp` in its working + /// directory, the way it looks for application.properties, so a deployment + /// is the binary with that directory next to it. The binary is written to + /// the build directory and the app is staged in `webapp` there, so they are + /// neighbours already; a binary written anywhere else has the app copied to + /// it. The files are not linked into the binary: it serves them straight + /// from the page cache, which needs them to be files. + /// + /// @return the directory to deploy, or null when no web app is staged + static File webAppBesideTheBinary(com.codename1.build.Log log, File staged, File binary) + throws BuildExecutionException { + File beside = webAppBeside(staged, binary); + if (beside == null) { + return null; + } + new BackendWebApp(log).copy(staged, beside); + log.info("The web app is in " + beside + "; deploy that directory beside the binary"); + return beside; + } + /// See [BackendPackageSupport]. @DisableCachingByDefault(because = "Runs a native toolchain") public abstract static class PackageTask extends Cn1Task { @@ -251,6 +337,12 @@ public abstract static class PackageTask extends Cn1Task { @OutputFile public abstract RegularFileProperty getBinary(); + /// Where `backendWebApp` stages the application's browser build, which + /// is deployed beside the binary. Unset for a task whose binary is not + /// a server to deploy. + @Internal + public abstract DirectoryProperty getWebAppDirectory(); + /// The source directories the packager compiles: the source set's own, as /// the build script configured them, not only the conventional one -- a /// directory added in `sourceSets` held controllers the binary then lacked. @@ -282,6 +374,10 @@ public void build() { .target(getTarget().getOrNull()).jdk(getJdk().getOrNull(), null).cflags(getCflags().getOrNull()) .sqlite(getSqlite().get()).checkedCasts(getCheckedCasts().get()) .devTools(getDevTools().get()).execute(); + if (getWebAppDirectory().isPresent()) { + webAppBesideTheBinary(log(), getWebAppDirectory().get().getAsFile(), + getBinary().get().getAsFile()); + } } catch (BuildExecutionException ex) { throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); } diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java index 6c5b050e144..d8097f2a79a 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java @@ -22,6 +22,7 @@ */ package com.codename1.gradle; +import com.codename1.gradle.tasks.BackendWebAppTask; import com.codename1.gradle.tasks.ProcessAnnotationsAction; import com.codename1.gradle.tasks.ProcessTestAnnotationsAction; import com.codename1.gradle.tasks.RunBackendTask; @@ -62,6 +63,25 @@ final class BackendSupport { /// translates them against codenameone-backend-test's subset of the same API. static final String JUNIT_JUPITER = "org.junit.jupiter:junit-jupiter:5.9.3"; static final String JUNIT_LAUNCHER = "org.junit.platform:junit-platform-launcher:1.9.3"; + /// The task that stages the application's browser build for this backend. + static final String WEB_APP_TASK = "backendWebApp"; + /// The directory it stages into, under the backend's build directory. + static final String WEB_APP_DIRECTORY = "webapp"; + + /// Where the web app is staged: `cn1.backend.webapp.output`, relative to + /// the project, or `build/webapp`. The staging task writes it and the run + /// and package tasks read it, so all three take it from here -- a custom + /// output staged by one and looked for at the default by the others was + /// never served or packaged. + /// + /// The project directory is looked up here, outside the lambda: a lambda + /// that held the Project could not be stored in the configuration cache. + static Provider webAppDirectory(Project project) { + final org.gradle.api.file.Directory projectDirectory = project.getLayout().getProjectDirectory(); + return project.getProviders().gradleProperty("cn1.backend.webapp.output") + .map(projectDirectory::dir) + .orElse(project.getLayout().getBuildDirectory().dir(WEB_APP_DIRECTORY)); + } private BackendSupport() { } @@ -128,7 +148,12 @@ static void apply(final Project project, final ProjectLayout layout, final Coden t.getJvmArgs().set(project.getProviders().gradleProperty("cn1.backend.jvmArgs") .map(BackendSupport::split).orElse(Collections.emptyList())); t.getWorkingDirectory().set(layout.projectDir()); + // What backendWebApp staged, when it has; never a dependency, so a + // restart does not pay for a browser build. + t.getWebAppDirectory().set(webAppDirectory(project)); + t.mustRunAfter(WEB_APP_TASK); }); + registerWebApp(project, layout); registerMigrate(project, layout, main, "backendMigrate", "migrate", "Applies pending schema migrations to the configured database"); registerMigrate(project, layout, main, "backendMigrateInfo", "info", @@ -143,6 +168,49 @@ static void apply(final Project project, final ProjectLayout layout, final Coden UpdateSupport.register(project, layout); } + /// `backendWebApp`: the application's browser build, staged where + /// `runBackend` serves it and `backendPackage` ships it. + /// + /// In an application's `backend/` subproject the bundle is the root + /// project's, so the task depends on the root's [AppSupport#WEB_APP_BUILD_TASK] + /// -- the local JavaScript build, made without the proxy servlet a backend + /// does not have. Not when a bundle is given: staging one that is already + /// built needs no build. A backend that is the root project has no + /// application to build and takes a bundle only. + private static void registerWebApp(Project project, ProjectLayout layout) { + final Provider bundle = project.getProviders().gradleProperty("cn1.backend.webapp.bundle"); + final Project root = project.getRootProject(); + final boolean hasApp = hostsApplication(project != root, + project != root ? ProjectSupport.kind(root) : null); + project.getTasks().register(WEB_APP_TASK, BackendWebAppTask.class, t -> { + t.setGroup(AppSupport.GROUP); + t.setDescription("Builds the app for the browser and stages it for this backend to serve"); + // Never up to date. The bundle is a path that may name an archive + // or a directory, and with none given it is whichever the + // application's build directory holds, so none of it is an input + // Gradle could snapshot: a browser build rebuilt in place, or an + // archive replaced at the same path, would otherwise leave the old + // scripts and styles staged. Nothing depends on this task, so it + // runs only when asked for, and then it stages. + t.getOutputs().upToDateWhen(task -> false); + t.getBundle().set(bundle); + t.getWorkingDirectory().set(layout.projectDir()); + t.getOutputDirectory().set(webAppDirectory(project)); + if (hasApp) { + t.getAppBuildDirectory().set(root.getLayout().getBuildDirectory()); + if (!bundle.isPresent()) { + t.dependsOn(root.getPath() + AppSupport.WEB_APP_BUILD_TASK); + } + } + }); + } + + /// Whether a backend has an application whose browser build it can stage: + /// it is a subproject, and the root project is an application. + static boolean hostsApplication(boolean subproject, com.codename1.project.ProjectKind rootKind) { + return subproject && rootKind == com.codename1.project.ProjectKind.APP; + } + /// One migration command, as the Maven `cn1:migrate` goals run it: the entry point the /// build generates beside `cn1app.BackendMigrations`, on this JVM, with every `cn1.*` /// project property passed through and the processed `application.properties` as the diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/BackendWebAppTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/BackendWebAppTask.java new file mode 100644 index 00000000000..821483d01cc --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/BackendWebAppTask.java @@ -0,0 +1,125 @@ +/* + * 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.gradle.tasks; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; +import com.codename1.gradle.GradleLog; +import com.codename1.maven.BackendWebApp; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import java.io.File; + +/// `backendWebApp`: puts the browser build of the application where its backend +/// serves it from, as `mvn cn1:backend-webapp` does. +/// +/// A backend that finds an application in its `webapp` directory answers with +/// it at `/`, so one address is both the API and the app. This task fills that +/// directory, `build/webapp`: it takes the bundle the application's local +/// JavaScript build left in its build directory -- or one named with +/// `-Pcn1.backend.webapp.bundle` -- and unpacks it with a compressed copy beside +/// each text file, through the same [BackendWebApp] the Maven goal runs. +/// +/// `runBackend` then serves it without being told to, and `backendPackage` +/// leaves it beside the binary. It is a task of its own, and neither of those +/// depends on it, because the browser build takes about a minute and a server +/// edited and restarted all afternoon should not pay for a translation each +/// time. What it staged stays until `clean`. +@DisableCachingByDefault(because = "Unpacks the result of a build that is never cached") +public abstract class BackendWebAppTask extends DefaultTask { + /// A browser build to stage instead of the application's own: the archive a + /// JavaScript build produced, or a directory unpacked from it. A path, and + /// not a tracked file, because it may be either. The contents are not + /// inputs either, so `BackendSupport` registers this task as never up to + /// date: a bundle rebuilt at the same path is staged again. + @Input + @Optional + public abstract Property getBundle(); + + /// The build directory of the application this backend belongs to, where + /// its JavaScript build writes the bundle. Absent in a backend that is a + /// project of its own. + @Internal + public abstract DirectoryProperty getAppBuildDirectory(); + + /// The backend's directory, which a relative [getBundle()] is resolved in. + @Internal + public abstract DirectoryProperty getWorkingDirectory(); + + /// Where the application is staged. `runBackend` looks here. + @OutputDirectory + public abstract DirectoryProperty getOutputDirectory(); + + @TaskAction + public void stage() { + String given = getBundle().getOrNull(); + File bundle = null; + if (given != null && !given.trim().isEmpty()) { + bundle = new File(given.trim()); + if (!bundle.isAbsolute()) { + bundle = new File(getWorkingDirectory().get().getAsFile(), given.trim()); + } + } + File appBuild = getAppBuildDirectory().isPresent() ? getAppBuildDirectory().get().getAsFile() : null; + try { + stage(new GradleLog(getLogger()), bundle, appBuild, getOutputDirectory().get().getAsFile()); + } catch (BuildFailureException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + } + + /// Stages `bundle`, or with none the bundle the application's JavaScript + /// build left in `appBuildDirectory`, into `output`. + /// + /// @return the number of files staged + static int stage(Log log, File bundle, File appBuildDirectory, File output) + throws BuildFailureException, BuildExecutionException { + File source = bundle; + if (source == null) { + if (appBuildDirectory == null) { + throw new BuildFailureException("backendWebApp builds the application this backend belongs " + + "to, and this backend is a project of its own. Pass a bundle that is already " + + "built with -Pcn1.backend.webapp.bundle=."); + } + source = BackendWebApp.locateBundleIn(appBuildDirectory); + if (source == null) { + throw new BuildFailureException("The browser build left no bundle in " + appBuildDirectory + + ". A project whose browser build is made elsewhere can stage it with " + + "-Pcn1.backend.webapp.bundle=."); + } + } + return new BackendWebApp(log).stage(source, output); + } +} diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1TestTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1TestTask.java index 1d562abd37b..81d447dae31 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1TestTask.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/Cn1TestTask.java @@ -103,7 +103,7 @@ public void runTests() { } int result = runner.run(layout().projectDir(), all, main, getReportsDirectory().get().getAsFile()); if (result != 0) { - throw new GradleException("Tests failed; see " + getReportsDirectory().get().getAsFile()); + throw new GradleException(Cn1TestRunner.describeFailure(result) + "; see " + getReportsDirectory().get().getAsFile()); } } } diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java index bdd94590286..5b272673225 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java @@ -213,7 +213,13 @@ public void execute(Task task) { try (JavaSourceCompiler.CompilerScope ignored = JavaSourceCompiler.useJdk( compilerJdk == null ? null : compilerJdk.get())) { new AnnotationProcessing(new GradleLog(task.getLogger()), classesDir, stubDir, projectDir, raw, - mainClass, new ArrayList(sourceRoots.get()), encoding, classpath).run(); + mainClass, new ArrayList(sourceRoots.get()), encoding, classpath) + // The Java this pass compiled only to resolve types against is + // the module's own, and javac's pass processes it. + .withOwnClassDirectories(pending == null + ? java.util.Collections.emptyList() + : java.util.Collections.singletonList(pending)) + .run(); } catch (BuildExecutionException ex) { throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); } diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/RunBackendTask.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/RunBackendTask.java index 042961c30d7..0a2a8eae988 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/RunBackendTask.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/RunBackendTask.java @@ -22,9 +22,11 @@ */ package com.codename1.gradle.tasks; +import com.codename1.build.BuildExecutionException; import com.codename1.build.BuildFailureException; import com.codename1.gradle.GradleLog; import com.codename1.maven.BackendMainClass; +import com.codename1.maven.BackendWebApp; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; import org.gradle.api.file.ConfigurableFileCollection; @@ -85,6 +87,36 @@ public abstract class RunBackendTask extends DefaultTask { @Internal public abstract DirectoryProperty getWorkingDirectory(); + /// Where `backendWebApp` stages the application's browser build. Unset for + /// a task that runs something other than the server. + @Internal + public abstract DirectoryProperty getWebAppDirectory(); + + /// The JVM option that points the server at the application staged in + /// `webApp`, or null when it should be given none. + /// + /// The server looks for its application in `webapp` in its working + /// directory, which is the backend's own directory -- and build output does + /// not belong in a source directory, so it is under the build directory and + /// the server is told. Not when the project says where its application is: + /// a system property outranks the backend's own files, and answering for it + /// would override a setting somebody wrote down. + static String webAppOption(java.io.File webApp, java.io.File workingDirectory, + java.util.Map environment, java.util.List jvmArgs) + throws BuildExecutionException { + if (webApp == null || !new java.io.File(webApp, BackendWebApp.INDEX).isFile()) { + return null; + } + StringBuilder options = new StringBuilder(); + for (String option : jvmArgs) { + options.append(option).append(' '); + } + if (BackendWebApp.rootIsConfigured(workingDirectory, environment, options.toString())) { + return null; + } + return "-D" + BackendWebApp.ROOT_PROPERTY + "=" + webApp.getAbsolutePath(); + } + /// The explicit main class, else the generated entry point in whichever /// classes directory holds it, else the one class with a main method. private String resolveMainClass() throws BuildFailureException { @@ -132,12 +164,25 @@ public void run() { } catch (BuildFailureException ex) { throw new GradleException(ex.getMessage(), ex); } + final String webApp; + try { + webApp = webAppOption(getWebAppDirectory().isPresent() ? getWebAppDirectory().get().getAsFile() : null, + getWorkingDirectory().get().getAsFile(), System.getenv(), getJvmArgs().get()); + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex); + } + if (webApp != null) { + getLogger().lifecycle("Serving the web app staged in " + getWebAppDirectory().get().getAsFile()); + } getLogger().lifecycle("Running " + main + " on " + System.getProperty("java.version")); getExecOperations().javaexec(spec -> { spec.classpath(getClasspath()); spec.getMainClass().set(main); spec.args(getArgs().get()); spec.jvmArgs(getJvmArgs().get()); + if (webApp != null) { + spec.jvmArgs(webApp); + } spec.setWorkingDir(getWorkingDirectory().get().getAsFile()); spec.setStandardInput(System.in); }); diff --git a/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/BackendWebAppSupportTest.java b/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/BackendWebAppSupportTest.java new file mode 100644 index 00000000000..1ff02acf16e --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/BackendWebAppSupportTest.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gradle; + +import com.codename1.build.SystemStreamLog; +import com.codename1.project.ProjectKind; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The wiring around `backendWebApp`: the browser build it stages, which +/// backend has an application to build, and the staged app beside a binary. +class BackendWebAppSupportTest { + @TempDir + Path tmp; + + private File write(String path, String text) throws IOException { + File file = new File(tmp.toFile(), path); + file.getParentFile().mkdirs(); + Files.write(file.toPath(), text.getBytes(StandardCharsets.UTF_8)); + return file; + } + + /// A backend is not a servlet container, so the build it hosts is made + /// without the proxy servlet the JavaScript port expects beside the page. + @Test + void theHostedBuildIsMadeWithoutAProxy() throws Exception { + File settings = write("codenameone_settings.properties", "codename1.mainName=Main\n"); + Map given = Collections.singletonMap("codename1.arg.ios.foo", "bar"); + + Map hosted = AppSupport.hostedProperties(given, settings); + + assertEquals("false", hosted.get("codename1.arg.javascript.inject_proxy")); + assertEquals("bar", hosted.get("codename1.arg.ios.foo")); + assertEquals(1, given.size(), "the project's own properties are not changed"); + assertEquals("false", AppSupport.hostedProperties(Collections.emptyMap(), + new File(tmp.toFile(), "absent.properties")).get("codename1.arg.javascript.inject_proxy")); + } + + @Test + void aProjectsOwnProxyChoiceStands() throws Exception { + File plain = write("plain.properties", "codename1.mainName=Main\n"); + Map onTheCommandLine = new LinkedHashMap(); + onTheCommandLine.put("codename1.arg.javascript.inject_proxy", "true"); + assertEquals("true", AppSupport.hostedProperties(onTheCommandLine, plain) + .get("codename1.arg.javascript.inject_proxy")); + + Map aUrl = Collections.singletonMap("codename1.arg.javascript.proxy.url", "https://p/"); + assertFalse(AppSupport.hostedProperties(aUrl, plain).containsKey("codename1.arg.javascript.inject_proxy")); + + File chosen = write("chosen.properties", "codename1.arg.javascript.proxy.url=https://p/\n"); + assertFalse(AppSupport.hostedProperties(Collections.emptyMap(), chosen) + .containsKey("codename1.arg.javascript.inject_proxy")); + } + + @Test + void anEarlierBundleIsNotLeftForTheNextBuildToBeMistakenFor() throws Exception { + final File stale = write("build/MyApp.zip", "old"); + org.gradle.api.provider.Provider bundle = provider(stale); + new AppSupport.DeleteStaleBundle(bundle).execute(null); + assertFalse(stale.exists()); + // And nothing to remove is not a failure. + new AppSupport.DeleteStaleBundle(bundle).execute(null); + } + + @SuppressWarnings("unchecked") + private static org.gradle.api.provider.Provider provider(final File value) { + return (org.gradle.api.provider.Provider) java.lang.reflect.Proxy.newProxyInstance( + BackendWebAppSupportTest.class.getClassLoader(), + new Class[] {org.gradle.api.provider.Provider.class}, (proxy, m, args) -> { + if (m.getName().equals("get")) { + return value; + } + throw new UnsupportedOperationException(m.getName()); + }); + } + + @Test + void onlyAnApplicationsBackendHasAnAppToBuild() { + assertTrue(BackendSupport.hostsApplication(true, ProjectKind.APP)); + assertFalse(BackendSupport.hostsApplication(false, null), "a backend at the root of its build"); + assertFalse(BackendSupport.hostsApplication(true, ProjectKind.LIB)); + assertFalse(BackendSupport.hostsApplication(true, ProjectKind.BACKEND)); + } + + /// The default layout: the binary is build/ and the app is staged in + /// build/webapp, so they are neighbours and nothing is copied. + @Test + void theStagedAppIsAlreadyBesideABinaryInTheBuildDirectory() throws Exception { + File staged = write("backend/build/webapp/index.html", "").getParentFile(); + File binary = new File(tmp.toFile(), "backend/build/backend"); + + assertEquals(staged.getAbsoluteFile(), BackendPackageSupport.webAppBeside(staged, binary)); + assertTrue(BackendPackageSupport.webAppIsBeside(staged, binary)); + assertEquals(staged.getAbsoluteFile(), + BackendPackageSupport.webAppBesideTheBinary(new SystemStreamLog(), staged, binary)); + assertTrue(new File(staged, "index.html").isFile()); + } + + @Test + void aBinaryPackagedElsewhereGetsTheAppCopiedToIt() throws Exception { + File staged = write("backend/build/webapp/index.html", "app").getParentFile(); + write("backend/build/webapp/js/app.js", "run();"); + File binary = new File(tmp.toFile(), "dist/server"); + File beside = new File(tmp.toFile(), "dist/webapp").getAbsoluteFile(); + + // Not there yet, so a package with unchanged inputs still has work to do. + assertFalse(BackendPackageSupport.webAppIsBeside(staged, binary)); + assertEquals(beside, BackendPackageSupport.webAppBesideTheBinary(new SystemStreamLog(), staged, binary)); + assertEquals("run();", new String(Files.readAllBytes(new File(beside, "js/app.js").toPath()), + StandardCharsets.UTF_8)); + assertTrue(BackendPackageSupport.webAppIsBeside(staged, binary)); + + // A later browser build that changed only a script, the page as it was. + File script = write("backend/build/webapp/js/app.js", "run(2);"); + assertFalse(BackendPackageSupport.webAppIsBeside(staged, binary)); + BackendPackageSupport.webAppBesideTheBinary(new SystemStreamLog(), staged, binary); + assertTrue(BackendPackageSupport.webAppIsBeside(staged, binary)); + + // One that added an asset, and one that took it away again. + File added = write("backend/build/webapp/css/app.css", "a{}"); + assertFalse(BackendPackageSupport.webAppIsBeside(staged, binary)); + BackendPackageSupport.webAppBesideTheBinary(new SystemStreamLog(), staged, binary); + assertTrue(BackendPackageSupport.webAppIsBeside(staged, binary)); + assertTrue(added.delete()); + assertFalse(BackendPackageSupport.webAppIsBeside(staged, binary)); + + // A file the same length with only a newer time. + BackendPackageSupport.webAppBesideTheBinary(new SystemStreamLog(), staged, binary); + assertTrue(script.setLastModified(script.lastModified() + 60000)); + assertFalse(BackendPackageSupport.webAppIsBeside(staged, binary)); + } + + @Test + void aBackendWithNoStagedAppShipsNone() throws Exception { + File staged = new File(tmp.toFile(), "backend/build/webapp"); + File binary = new File(tmp.toFile(), "dist/server"); + + assertNull(BackendPackageSupport.webAppBeside(staged, binary)); + assertTrue(BackendPackageSupport.webAppIsBeside(staged, binary)); + assertNull(BackendPackageSupport.webAppBesideTheBinary(new SystemStreamLog(), staged, binary)); + assertFalse(new File(tmp.toFile(), "dist/webapp").exists()); + } +} diff --git a/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/tasks/BackendWebAppTaskTest.java b/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/tasks/BackendWebAppTaskTest.java new file mode 100644 index 00000000000..aa548cee40d --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/test/java/com/codename1/gradle/tasks/BackendWebAppTaskTest.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gradle.tasks; + +import com.codename1.build.BuildFailureException; +import com.codename1.build.SystemStreamLog; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// `backendWebApp` and the half of `runBackend` that serves what it staged. +/// The Gradle API jar cannot build a real Project here, so the tasks' work is +/// in static methods and those are what is tested. +class BackendWebAppTaskTest { + @TempDir + Path tmp; + + private File zip(String path, String... namesAndContents) throws IOException { + File file = new File(tmp.toFile(), path); + file.getParentFile().mkdirs(); + ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file)); + try { + for (int i = 0; i < namesAndContents.length; i += 2) { + out.putNextEntry(new ZipEntry(namesAndContents[i])); + out.write(namesAndContents[i + 1].getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + } finally { + out.close(); + } + return file; + } + + private File write(String path, String text) throws IOException { + File file = new File(tmp.toFile(), path); + file.getParentFile().mkdirs(); + Files.write(file.toPath(), text.getBytes(StandardCharsets.UTF_8)); + return file; + } + + private static String read(File file) throws IOException { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + + /// A Gradle project has one build directory for every platform, so the + /// bundle is the archive there that is an application -- not the source + /// archive a cloud build left beside it. + @Test + void stagesTheBundleTheApplicationsBuildLeft() throws Exception { + zip("app/build/sources.zip", "src/Main.java", "class Main {}"); + zip("app/build/MyApp.zip", "index.html", "app", "js/app.js", "run();"); + File output = new File(tmp.toFile(), "app/backend/build/webapp"); + + int staged = BackendWebAppTask.stage(new SystemStreamLog(), null, + new File(tmp.toFile(), "app/build"), output); + + assertEquals(2, staged); + assertEquals("app", read(new File(output, "index.html"))); + assertEquals("run();", read(new File(output, "js/app.js"))); + } + + @Test + void aGivenBundleIsStagedInPlaceOfTheApplicationsOwn() throws Exception { + zip("app/build/MyApp.zip", "index.html", "built here"); + File elsewhere = zip("ci/bundle.zip", "index.html", "built elsewhere"); + File output = new File(tmp.toFile(), "out"); + + BackendWebAppTask.stage(new SystemStreamLog(), elsewhere, new File(tmp.toFile(), "app/build"), output); + + assertEquals("built elsewhere", read(new File(output, "index.html"))); + } + + /// A backend at the root of its build has no application to build, and + /// still hosts a bundle it is handed. + @Test + void aBackendOfItsOwnStagesOnlyAGivenBundle() throws Exception { + File output = new File(tmp.toFile(), "out"); + BuildFailureException refused = assertThrows(BuildFailureException.class, + () -> BackendWebAppTask.stage(new SystemStreamLog(), null, null, output)); + assertTrue(refused.getMessage().contains("-Pcn1.backend.webapp.bundle"), refused.getMessage()); + + File unpacked = write("unpacked/index.html", "dir").getParentFile(); + assertEquals(1, BackendWebAppTask.stage(new SystemStreamLog(), unpacked, null, output)); + assertEquals("dir", read(new File(output, "index.html"))); + } + + @Test + void aBuildThatLeftNoBundleIsReported() throws Exception { + zip("app/build/sources.zip", "src/Main.java", "class Main {}"); + File build = new File(tmp.toFile(), "app/build"); + BuildFailureException refused = assertThrows(BuildFailureException.class, + () -> BackendWebAppTask.stage(new SystemStreamLog(), null, build, new File(tmp.toFile(), "out"))); + assertTrue(refused.getMessage().contains(build.getPath()), refused.getMessage()); + } + + @Test + void runBackendServesWhatWasStaged() throws Exception { + File backend = new File(tmp.toFile(), "backend"); + File webApp = new File(backend, "build/webapp"); + assertTrue(webApp.mkdirs()); + // Nothing staged: the server is told nothing, and serves no app. + assertNull(RunBackendTask.webAppOption(webApp, backend, Collections.emptyMap(), + Collections.emptyList())); + assertNull(RunBackendTask.webAppOption(null, backend, Collections.emptyMap(), + Collections.emptyList())); + + write("backend/build/webapp/index.html", ""); + assertEquals("-Dcn1.webapp.root=" + webApp.getAbsolutePath(), RunBackendTask.webAppOption(webApp, backend, + Collections.emptyMap(), Arrays.asList("-Xmx1g", "-Dcn1.profile=dev"))); + } + + /// A system property outranks the backend's own files, so supplying one + /// would override a project that says where its application is. + @Test + void runBackendLeavesAConfiguredRootAlone() throws Exception { + File backend = new File(tmp.toFile(), "backend"); + File webApp = write("backend/build/webapp/index.html", "").getParentFile(); + + assertNull(RunBackendTask.webAppOption(webApp, backend, Collections.emptyMap(), + Arrays.asList("-Xmx1g", "-Dcn1.webapp.root=/srv/app"))); + assertNull(RunBackendTask.webAppOption(webApp, backend, + Collections.singletonMap("CN1_WEBAPP_ROOT", "/srv/app"), Collections.emptyList())); + + write("backend/application.properties", "cn1.webapp.root=public\n"); + assertNull(RunBackendTask.webAppOption(webApp, backend, Collections.emptyMap(), + Collections.emptyList())); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendModules.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendModules.java new file mode 100644 index 00000000000..3e985fea24a --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendModules.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.maven; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.project.MavenProject; + +import java.util.List; +import java.util.Set; + +/// Which modules of a reactor the server goals apply to. +final class BackendModules { + + private BackendModules() { + } + + /// Whether a server goal invoked from the command line should pass over + /// `project` without doing anything. + /// + /// A goal named on the command line runs in every module of the reactor. + /// A project whose server shares a library with its app has to build that + /// library first, so the natural command is + /// `mvn -pl backend -am cn1:backend-package` -- and that used to stop in the + /// shared module, which has no server in it, with an instruction to add a + /// dependency it must not have. The only way round was to install the + /// library separately before every build. + /// + /// So a module without the backend runtime is skipped when it is one of + /// several. On its own it is still an error: a single module asked to + /// package a server it does not contain is a mistake worth reporting. + static boolean passOver(MavenProject project, List reactor, Log log, String goal) { + if (reactor == null || reactor.size() < 2 || dependsOnBackend(project)) { + return false; + } + log.info("cn1:" + goal + " skipped for " + project.getArtifactId() + + ": it does not depend on codenameone-backend, so it is not a server."); + return true; + } + + private static boolean dependsOnBackend(MavenProject project) { + Set artifacts = project.getArtifacts(); + if (artifacts == null) { + return false; + } + for (Artifact artifact : artifacts) { + if ("com.codenameone".equals(artifact.getGroupId()) + && "codenameone-backend".equals(artifact.getArtifactId())) { + return true; + } + } + return false; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java index 7987f49925f..1f4d1f7467b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java @@ -85,6 +85,9 @@ public class BackendPackageMojo extends AbstractMojo { @Parameter(defaultValue = "${project}", readonly = true, required = true) private MavenProject project; + @Parameter(defaultValue = "${reactorProjects}", readonly = true) + private java.util.List reactorProjects; + @Parameter(defaultValue = "${session}", readonly = true, required = true) private org.apache.maven.execution.MavenSession session; @@ -113,6 +116,13 @@ public class BackendPackageMojo extends AbstractMojo { @Parameter(property = "cn1.backend.output") private File output; + /** + * Where cn1:backend-webapp staged the application: that goal's own + * parameter, read here so a non-default directory is the one served. + */ + @Parameter(property = "cn1.backend.webapp.output", defaultValue = "${project.build.directory}/webapp") + private File webApp; + /** * A Linux deployment target -- musl-x86_64, musl-arm64, glibc-x86_64, * glibc-arm64 -- built in a container. Omitted, it compiles for this machine @@ -174,8 +184,12 @@ public class BackendPackageMojo extends AbstractMojo { private boolean devTools; public void execute() throws MojoExecutionException, MojoFailureException { + if (BackendModules.passOver(project, reactorProjects, getLog(), "backend-package")) { + return; + } try { packager().execute(); + webAppBesideTheBinary(); } catch (BuildFailureException ex) { throw new MojoFailureException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); } catch (BuildExecutionException ex) { @@ -183,6 +197,31 @@ public void execute() throws MojoExecutionException, MojoFailureException { } } + /** + * Puts the web app cn1:backend-webapp staged beside the binary. + * + * A packaged server looks for its application in `webapp` in its working + * directory, the way it looks for application.properties, so a deployment is + * the binary with that directory next to it. The default output is + * target/<artifactId> and the app is staged in target/webapp, so they are + * neighbours already; a binary written anywhere else, or an app staged + * anywhere else, has the app copied to the binary's directory. + * The files are not linked into the binary: it serves them straight from the + * page cache, which needs them to be files. + */ + private void webAppBesideTheBinary() throws BuildExecutionException { + File staged = webApp; + if (staged == null || !new File(staged, BackendWebApp.INDEX).isFile()) { + return; + } + File binaryDir = output != null && output.getAbsoluteFile().getParentFile() != null + ? output.getAbsoluteFile().getParentFile() : new File(project.getBuild().getDirectory()); + File beside = new File(binaryDir, "webapp"); + // A no-op when the app was staged there already, the default. + new BackendWebApp(MavenLog.of(getLog())).copy(staged, beside); + getLog().info("The web app is in " + beside + "; deploy that directory beside the binary"); + } + /** The engine packager, answering from this mojo's project and parameters. */ private BackendPackager packager() throws MojoExecutionException { BackendPackager p = new BackendPackager(new MavenModuleHost(project, MavenLog.of(getLog()))) { @@ -466,17 +505,17 @@ private String sourceEncoding() { /// sources and has to read them the same way. static String sourceEncodingOf(MavenProject project) { Plugin compiler = compilerPlugin(project); - if (compiler != null && compiler.getConfiguration() instanceof Xpp3Dom) { - Xpp3Dom encoding = ((Xpp3Dom) compiler.getConfiguration()).getChild("encoding"); - if (encoding != null && encoding.getValue() != null) { - String declared = encoding.getValue().trim(); - // A configuration that is still a property reference is one Maven - // would have interpolated; an uninterpolated ${...} names no - // charset, so fall through rather than hand javac a literal. - if (declared.length() > 0 && declared.indexOf("${") < 0) { - return declared; - } - } + // The execution that compiles these sources first -- the one + // compilerExecutionConfiguration picks for every other compiler option + // -- and only then the plugin-level value. Read the other way, a module + // that declares its encoding on the execution was recompiled here with + // the plugin's, or with UTF-8. + String declared = encodingIn(compilerExecutionConfiguration(compiler, "compile")); + if (declared == null && compiler != null && compiler.getConfiguration() instanceof Xpp3Dom) { + declared = encodingIn((Xpp3Dom) compiler.getConfiguration()); + } + if (declared != null) { + return declared; } String property = project.getProperties() == null ? null : project.getProperties().getProperty("project.build.sourceEncoding"); @@ -486,6 +525,18 @@ static String sourceEncodingOf(MavenProject project) { return "UTF-8"; } + private static String encodingIn(Xpp3Dom configuration) { + Xpp3Dom encoding = configuration == null ? null : configuration.getChild("encoding"); + if (encoding == null || encoding.getValue() == null) { + return null; + } + String declared = encoding.getValue().trim(); + // A configuration that is still a property reference is one Maven + // would have interpolated; an uninterpolated ${...} names no + // charset, so fall through rather than hand javac a literal. + return declared.length() > 0 && declared.indexOf("${") < 0 ? declared : null; + } + private File resolve(String groupId, String artifactId, String version, String classifier) throws MojoExecutionException { return resolve(repositorySystem, localRepository, project, groupId, artifactId, version, diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java index 0335969e38c..6d7aad57e05 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java @@ -70,6 +70,9 @@ public class BackendRunMojo extends AbstractMojo { @Parameter(defaultValue = "${project}", readonly = true, required = true) private MavenProject project; + @Parameter(defaultValue = "${reactorProjects}", readonly = true) + private java.util.List reactorProjects; + /** * The class to run. Found automatically when the module has exactly one class * with a main method, which is the usual shape. @@ -85,7 +88,17 @@ public class BackendRunMojo extends AbstractMojo { @Parameter(property = "cn1.backend.jvmArgs") private String jvmArgs; + /** + * Where cn1:backend-webapp staged the application: that goal's own + * parameter, read here so a non-default directory is the one served. + */ + @Parameter(property = "cn1.backend.webapp.output", defaultValue = "${project.build.directory}/webapp") + private File webApp; + public void execute() throws MojoExecutionException, MojoFailureException { + if (BackendModules.passOver(project, reactorProjects, getLog(), "backend")) { + return; + } File classes = new File(project.getBuild().getOutputDirectory()); if (!classes.isDirectory()) { throw new MojoFailureException("Nothing is compiled in " @@ -122,6 +135,21 @@ public void execute() throws MojoExecutionException, MojoFailureException { if (jvmArgs != null && jvmArgs.trim().length() > 0) { command.addAll(Arrays.asList(jvmArgs.trim().split("\\s+"))); } + // What cn1:backend-webapp staged. The server looks for its application in + // `webapp` in its working directory, which here is the module -- and build + // output does not belong in a source directory, so it is under target and + // the server is told. Not when the project says where its application is: + // a system property outranks the module's own files, and answering for it + // would override a setting somebody wrote down. + try { + if (webApp != null && new File(webApp, BackendWebApp.INDEX).isFile() + && !BackendWebApp.rootIsConfigured(project.getBasedir(), System.getenv(), jvmArgs)) { + command.add("-D" + BackendWebApp.ROOT_PROPERTY + "=" + webApp.getAbsolutePath()); + getLog().info("Serving the web app staged in " + webApp); + } + } catch (com.codename1.build.BuildExecutionException err) { + throw new MojoExecutionException(err.getMessage(), err); + } command.add("-cp"); command.add(join(classpath, File.pathSeparator)); command.add(main); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendWebAppMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendWebAppMojo.java new file mode 100644 index 00000000000..6d6f42bd107 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendWebAppMojo.java @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import org.apache.maven.artifact.repository.ArtifactRepository; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Dependency; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; +import org.apache.maven.shared.invoker.DefaultInvocationRequest; +import org.apache.maven.shared.invoker.DefaultInvoker; +import org.apache.maven.shared.invoker.InvocationRequest; +import org.apache.maven.shared.invoker.InvocationResult; +import org.apache.maven.shared.invoker.MavenInvocationException; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +/** + * Builds the application for the browser and stages it where this server + * serves it from: `mvn cn1:backend-webapp`. + * + * A server that finds an application in its `webapp` directory answers with it + * at `/`, so one address is both the API and the app and a person can use it + * with nothing installed. This goal is what fills that directory. It runs the + * project's own JavaScript build -- the one `mvn package + * -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript` + * runs -- finds the bundle, and unpacks it into `target/webapp` with a + * compressed copy beside each text file. + * + * `cn1:backend` then serves it without being told to, and `cn1:backend-package` + * leaves it beside the binary, which is where a deployed server looks. + * + * It is a goal of its own rather than part of every build because the browser + * build takes about a minute and not every server publishes its app: a server + * edited and restarted all afternoon should not pay for a translation each + * time. Run it when the app changed; what it staged stays until `mvn clean`. + */ +// No dependency resolution on purpose. This goal is useful before anything is +// compiled, and resolving a server module's classpath from a clean checkout +// fails on the sibling module it shares with the app. +@Mojo(name = "backend-webapp", threadSafe = false) +public class BackendWebAppMojo extends AbstractMojo { + + /** How much of a quiet browser build's output is kept to show if it fails. */ + private static final int QUIET_TAIL = 200; + + /** Passed to the nested build, so a build that runs this goal cannot start itself. */ + static final String NESTED = "cn1.backend.webapp.nested"; + + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + @Parameter(defaultValue = "${reactorProjects}", readonly = true) + private List reactorProjects; + + @Parameter(defaultValue = "${session}", readonly = true) + private MavenSession session; + + @Parameter(defaultValue = "${localRepository}", readonly = true) + private ArtifactRepository localRepository; + + /** + * A browser build to stage instead of building one: the archive a + * JavaScript build produced, or a directory unpacked from it. This is how a + * bundle built somewhere else -- by a build server, or an earlier CI job -- + * is hosted. + */ + @Parameter(property = "cn1.backend.webapp.bundle") + private File bundle; + + /** Where the application is staged. The server looks here when run with cn1:backend. */ + @Parameter(property = "cn1.backend.webapp.output", defaultValue = "${project.build.directory}/webapp") + private File output; + + /** + * The application's root project, the one whose modules include the client. + * Defaults to the directory above the server module. + */ + @Parameter(property = "cn1.backend.webapp.appRoot") + private File appRoot; + + /** Skips the goal, for a build that binds it and sometimes has no use for it. */ + @Parameter(property = "cn1.backend.webapp.skip", defaultValue = "false") + private boolean skip; + + @Parameter(property = NESTED, defaultValue = "false", readonly = true) + private boolean nested; + + public void execute() throws MojoExecutionException, MojoFailureException { + if (skip || nested) { + return; + } + if (passOver()) { + return; + } + try { + File source = bundle; + if (source == null) { + source = build(); + } + new BackendWebApp(MavenLog.of(getLog())).stage(source, output); + } catch (BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } catch (BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + } + + /** + * As {@link BackendModules#passOver}, from the declared dependencies: this + * goal resolves none, so there is no resolved set to ask. + */ + private boolean passOver() { + if (reactorProjects == null || reactorProjects.size() < 2) { + return false; + } + List declared = project.getDependencies(); + for (Dependency dependency : declared == null ? Collections.emptyList() : declared) { + if ("com.codenameone".equals(dependency.getGroupId()) + && "codenameone-backend".equals(dependency.getArtifactId())) { + return false; + } + } + getLog().info("cn1:backend-webapp skipped for " + project.getArtifactId() + + ": it does not depend on codenameone-backend, so it is not a server."); + return true; + } + + /** Runs the application's browser build and answers the bundle it produced. */ + private File build() throws BuildFailureException, BuildExecutionException { + File root = appRoot != null ? appRoot : project.getBasedir().getParentFile(); + if (root == null || !new File(root, "pom.xml").isFile() || !new File(root, "common").isDirectory()) { + throw new BuildFailureException("cn1:backend-webapp builds the application this server " + + "belongs to, and " + root + " is not a Codename One project (no pom.xml with a " + + "common module). Name it with -Dcn1.backend.webapp.appRoot=, or pass a " + + "bundle that is already built with -Dcn1.backend.webapp.bundle=."); + } + long started = System.currentTimeMillis(); + Properties props = new Properties(); + Properties user = session == null ? null : session.getUserProperties(); + if (user != null) { + // The application's own build settings: hints given on the command + // line, the version overrides a snapshot build is run with. + for (String key : user.stringPropertyNames()) { + if (key.startsWith("codename1.") + || (key.startsWith("cn1.") && !key.startsWith("cn1.backend."))) { + props.setProperty(key, user.getProperty(key)); + } + } + } + props.setProperty("codename1.platform", "javascript"); + props.setProperty("codename1.buildTarget", "local-javascript"); + props.setProperty("skipTests", "true"); + props.setProperty(NESTED, "true"); + // The JavaScript build sends cross-origin requests through a proxy + // servlet it expects beside the page. This server is not a servlet + // container and has no such path, and an app talking to the server that + // hosts it is same-origin and never proxied anyway -- so the build is + // told not to wire one in, unless the project has chosen for itself. + if (props.getProperty("codename1.arg.javascript.inject_proxy") == null + && props.getProperty("codename1.arg.javascript.proxy.url") == null + && !BackendWebApp.choosesProxy(new File(root, "common/codenameone_settings.properties"))) { + props.setProperty("codename1.arg.javascript.inject_proxy", "false"); + } + // Or the nested build resolves from a different repository than this one. + if (localRepository != null && localRepository.getBasedir() != null) { + props.setProperty("maven.repo.local", localRepository.getBasedir()); + } + + InvocationRequest request = new DefaultInvocationRequest(); + request.setBaseDirectory(root); + request.setGoals(Collections.singletonList("package")); + request.setProperties(props); + request.setBatchMode(true); + // The profiles asked for with -P, and those turned off with -P !id: + // a separate invocation inherits neither, and the application's + // profiles can carry the dependencies its browser build needs. + if (session != null && session.getRequest() != null) { + List profiles = new ArrayList(session.getRequest().getActiveProfiles()); + for (String off : session.getRequest().getInactiveProfiles()) { + profiles.add("!" + off); + } + if (!profiles.isEmpty()) { + request.setProfiles(profiles); + } + } + // A build asked to be quiet means all of it: the browser build logs a + // line per translated class. Its last lines are kept, because a failure + // with nothing above it says nothing. + final java.util.ArrayDeque tail = new java.util.ArrayDeque(); + if (!getLog().isInfoEnabled()) { + org.apache.maven.shared.invoker.InvocationOutputHandler quiet = + new org.apache.maven.shared.invoker.InvocationOutputHandler() { + @Override + public void consumeLine(String line) { + if (tail.size() >= QUIET_TAIL) { + tail.removeFirst(); + } + tail.addLast(line); + } + }; + request.setOutputHandler(quiet); + request.setErrorHandler(quiet); + } + if (session != null) { + request.setOffline(session.isOffline()); + File settings = session.getRequest() == null ? null : session.getRequest().getUserSettingsFile(); + if (settings != null && settings.isFile()) { + request.setUserSettingsFile(settings); + } + } + getLog().info("Building " + root.getName() + " for the browser"); + try { + InvocationResult result = new DefaultInvoker().execute(request); + if (result.getExitCode() != 0) { + for (String line : tail) { + getLog().error(line); + } + throw new BuildFailureException("The browser build of " + root + " failed (exit " + + result.getExitCode() + "); its output is above. Run `mvn package " + + "-Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript` " + + "there to see it on its own."); + } + } catch (MavenInvocationException ex) { + throw new BuildExecutionException("Could not run the browser build of " + root, ex); + } + File built = BackendWebApp.locateBundle(root); + // One from an earlier build is not this build's answer. + if (built == null || built.lastModified() < started - 2000) { + throw new BuildFailureException("The browser build of " + root + " succeeded and left no " + + "bundle in javascript/target or common/target. A project whose browser " + + "build is made elsewhere can stage it with -Dcn1.backend.webapp.bundle=."); + } + return built; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java index 68370a32e01..522122f9452 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BytecodeComplianceMojo.java @@ -28,7 +28,15 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.project.MavenProject; + +import java.io.File; import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; /** * Checks the compiled application against the Codename One Java runtime API; see @@ -54,6 +62,7 @@ protected void beforeCheck() { copyKotlinIncrementalCompileOutputToOutputDir(); } }; + check.checkedDependencyRoots(plainSiblingModules()); try { check.execute(); } catch (com.codename1.build.BuildFailureException ex) { @@ -62,4 +71,48 @@ protected void beforeCheck() { throw new MojoExecutionException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); } } + + /// The class directories of the plain jar modules of this build that the + /// project depends on -- a `shared` module of DTOs and contracts. A Codename + /// One module (an application or a cn1lib) is left out: it runs this check + /// itself, on its own classes. + /// + /// Only what the application ships: a module on its compile or runtime + /// class path. The project's references list every module it names, + /// whatever the scope, and one named with `test` is a + /// fixture that is free to use the whole JDK -- checked here, it failed the + /// application's build over classes that never reach a device. + private List plainSiblingModules() { + List roots = new ArrayList(); + if (project.getProjectReferences() == null) { + return roots; + } + Set shipped = new HashSet(); + if (project.getArtifacts() != null) { + for (Artifact artifact : project.getArtifacts()) { + String scope = artifact.getScope(); + if (scope == null || Artifact.SCOPE_COMPILE.equals(scope) + || Artifact.SCOPE_RUNTIME.equals(scope)) { + shipped.add(artifact.getGroupId() + ":" + artifact.getArtifactId()); + } + } + } + for (MavenProject sibling : project.getProjectReferences().values()) { + if (sibling == null || !"jar".equals(sibling.getPackaging()) || sibling.getBasedir() == null) { + continue; + } + if (!shipped.contains(sibling.getGroupId() + ":" + sibling.getArtifactId())) { + continue; + } + if (new File(sibling.getBasedir(), "codenameone_settings.properties").exists() + || new File(sibling.getBasedir(), "codenameone_library_appended.properties").exists()) { + continue; + } + File classes = new File(sibling.getBuild().getOutputDirectory()); + if (classes.isDirectory()) { + roots.add(classes); + } + } + return roots; + } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java index 9bc45a22277..1af0f8481b1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java @@ -58,8 +58,14 @@ /// If `process-annotations` is not configured the stubs remain as no-ops so the /// app still builds — but it sees no registered routes at runtime. This is the /// least-surprise default for users experimenting with annotations. +/// +/// COMPILE resolution, because one kind of stub is generated from the module's +/// DEPENDENCIES: the server half of a REST contract that lives in a library the +/// application and its backend share. See +/// `RestServerAnnotationProcessor.emitStubs`. @Mojo(name = "generate-annotation-stubs", defaultPhase = LifecyclePhase.GENERATE_SOURCES, + requiresDependencyResolution = org.apache.maven.plugins.annotations.ResolutionScope.COMPILE, threadSafe = true) public class GenerateAnnotationStubsMojo extends AbstractCN1Mojo { @@ -90,9 +96,22 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } File outputDir = new File(project.getBuild().getOutputDirectory()); + List classpath; + try { + classpath = project.getCompileClasspathElements(); + } catch (org.apache.maven.artifact.DependencyResolutionRequiredException unresolved) { + // Not fatal for the stubs that need no classpath, and said out loud + // for the one that does: without it no served contract is generated, + // and the class implementing it then fails to compile for a reason + // that is nowhere near this message. + getLog().warn("cn1: the compile classpath for " + project.getArtifactId() + + " is unresolved, so no shared REST contract can be read from its " + + "dependencies: " + unresolved.getMessage()); + classpath = java.util.Collections.emptyList(); + } ProcessorContext ctx = new ProcessorContext(outputDir, stubSourceDirectory, /*classIndex*/ java.util.Collections.emptyMap(), - MavenLog.of(getLog())); + MavenLog.of(getLog()), null, null, null, null, null, classpath); for (Iterator it = processors.iterator(); it.hasNext(); ) { AnnotationProcessor p = it.next(); @@ -105,7 +124,16 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } } + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("Codename One stub generation failed:\n"); + for (ProcessorContext.ProcessingError error : ctx.getErrors()) { + sb.append(" - ").append(error).append('\n'); + } + throw new MojoFailureException(sb.toString()); + } + Map stubs = ctx.getEmittedStubSources(); + removeStubsNoLongerEmitted(stubs); for (Map.Entry e : stubs.entrySet()) { File f = new File(stubSourceDirectory, e.getKey() + ".java"); File parent = f.getParentFile(); @@ -126,6 +154,43 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } } + /// Deletes the stubs an earlier run wrote and this one did not, and records + /// what this one writes. + /// + /// The directory is a compile source root, so a stub left in it is compiled + /// again: after a contract was removed or renamed, a build without `clean` + /// went on compiling against the stub of an API that no longer existed. + /// Only this goal's own files are touched -- the list of them is kept + /// beside the directory -- because `process-annotations` writes there too. + void removeStubsNoLongerEmitted(Map stubs) throws MojoExecutionException { + File list = new File(stubSourceDirectory.getParentFile(), + stubSourceDirectory.getName() + ".stubs"); + try { + if (list.isFile()) { + String[] before = new String(java.nio.file.Files.readAllBytes(list.toPath()), + java.nio.charset.StandardCharsets.UTF_8).split("\n"); + for (String name : before) { + // A name is a class name with slashes, as the processors + // emit it: nothing that leaves the directory is followed. + if (name.length() == 0 || stubs.containsKey(name) || name.indexOf("..") >= 0) { + continue; + } + File stale = new File(stubSourceDirectory, name + ".java"); + if (stale.isFile() && !stale.delete()) { + throw new MojoExecutionException("Could not delete the stale stub " + stale); + } + } + } + StringBuilder now = new StringBuilder(); + for (String name : stubs.keySet()) { + now.append(name).append('\n'); + } + writeUtf8(list, now.toString()); + } catch (IOException e) { + throw new MojoExecutionException("Could not keep the list of stubs " + list, e); + } + } + private List loadProcessors() { // ServiceLoader against this plugin's classloader. The processors live // in this artifact, so the plugin's loader sees them by default. We diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java index baafff4d733..5828a01c1fb 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java @@ -115,7 +115,7 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException properties.getProperty("codename1.packageName")+"."+properties.getProperty("codename1.mainName"), new File(project.getBuild().getDirectory(), "cn1-reports")); if (result != 0) { - throw new MojoExecutionException("Tests failed"); + throw new MojoExecutionException(Cn1TestRunner.describeFailure(result)); } } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendModulesTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendModulesTest.java new file mode 100644 index 00000000000..36d9750ea40 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendModulesTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.DefaultArtifact; +import org.apache.maven.artifact.handler.DefaultArtifactHandler; +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// `mvn -pl backend -am cn1:backend-package` runs the goal in the shared +/// library too, which has no server in it. That module is passed over; a module +/// built on its own that is not a server is still told so. +class BackendModulesTest { + + @Test + void aModuleWithoutTheBackendIsPassedOverInAReactor() { + MavenProject shared = module("shared", artifact("com.example", "notes")); + MavenProject server = module("backend", + artifact("com.example", "shared"), artifact("com.codenameone", "codenameone-backend")); + List reactor = Arrays.asList(shared, server); + Recorder log = new Recorder(); + + assertTrue(BackendModules.passOver(shared, reactor, log, "backend-package")); + assertFalse(BackendModules.passOver(server, reactor, log, "backend-package")); + + assertEquals(1, log.lines.size(), "only the skipped module is mentioned: " + log.lines); + assertTrue(log.lines.get(0).contains("cn1:backend-package skipped for shared"), + log.lines.get(0)); + } + + /// On its own the module is the one the developer asked for, and the goal + /// goes on to report what is wrong with it. + @Test + void aModuleOnItsOwnIsNeverPassedOver() { + MavenProject shared = module("shared"); + Recorder log = new Recorder(); + + assertFalse(BackendModules.passOver(shared, Collections.singletonList(shared), log, "backend")); + assertFalse(BackendModules.passOver(shared, Collections.emptyList(), log, "backend")); + assertFalse(BackendModules.passOver(shared, null, log, "backend")); + assertTrue(log.lines.isEmpty(), log.lines.toString()); + } + + /// The artifact id alone is not the runtime: somebody else's + /// `codenameone-backend` does not make a module a server. + @Test + void theBackendIsRecognizedByGroupAndArtifact() { + MavenProject lookalike = module("lookalike", artifact("org.other", "codenameone-backend"), + artifact("com.codenameone", "codenameone-core")); + MavenProject unresolved = new MavenProject(); + unresolved.setArtifactId("unresolved"); + unresolved.setArtifacts(null); + MavenProject server = module("backend", artifact("com.codenameone", "codenameone-backend")); + List reactor = Arrays.asList(lookalike, unresolved, server); + Recorder log = new Recorder(); + + assertTrue(BackendModules.passOver(lookalike, reactor, log, "backend")); + assertTrue(BackendModules.passOver(unresolved, reactor, log, "backend")); + assertFalse(BackendModules.passOver(server, reactor, log, "backend")); + } + + private static MavenProject module(String artifactId, Artifact... dependencies) { + MavenProject project = new MavenProject(); + project.setArtifactId(artifactId); + Set artifacts = new LinkedHashSet(Arrays.asList(dependencies)); + project.setArtifacts(artifacts); + return project; + } + + private static Artifact artifact(String groupId, String artifactId) { + return new DefaultArtifact(groupId, artifactId, "1.0", Artifact.SCOPE_COMPILE, "jar", null, + new DefaultArtifactHandler("jar")); + } + + private static final class Recorder extends SystemStreamLog { + final List lines = new java.util.ArrayList(); + + @Override + public void info(CharSequence content) { + lines.add(String.valueOf(content)); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJavaLevelTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJavaLevelTest.java index 1acbf5ac508..d6ef0b9ea40 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJavaLevelTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJavaLevelTest.java @@ -226,6 +226,9 @@ class BackendPackageJavaLevelTest { assertEquals("17", option(project, new Properties(), "release", false)); assertEquals("25", option(project, new Properties(), "testRelease", true)); assertEquals("UTF-16", BackendPackageMojo.sourceEncodingOf(project)); + // The compile execution's own encoding is what the lifecycle used. + compiler.addExecution(execution("default-compile", null, "compile", "encoding", "ISO-8859-1")); + assertEquals("ISO-8859-1", BackendPackageMojo.sourceEncodingOf(project)); Properties cli = new Properties(); cli.setProperty("backend.java", "25"); assertEquals("25", option(project, cli, "release", false)); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendWebAppMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendWebAppMojoTest.java new file mode 100644 index 00000000000..8e612c71151 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendWebAppMojoTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import org.apache.maven.model.Dependency; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// `cn1:backend-webapp` without the minute-long browser build: what it does +/// with a bundle it is handed, and when it does nothing at all. +class BackendWebAppMojoTest { + @TempDir + Path tmp; + + @Test + void aBundleBuiltElsewhereIsStagedWithoutABuild() throws Exception { + File out = tmp.resolve("backend/target/webapp").toFile(); + BackendWebAppMojo mojo = mojo(server("backend"), out); + set(mojo, "bundle", bundle()); + + mojo.execute(); + + assertEquals("", new String( + Files.readAllBytes(new File(out, "index.html").toPath()), "UTF-8")); + assertTrue(new File(out, "js/app.js").isFile()); + } + + @Test + void aModuleThatIsNotAServerIsPassedOverInAReactor() throws Exception { + MavenProject shared = new MavenProject(); + shared.setArtifactId("shared"); + shared.setFile(tmp.resolve("shared/pom.xml").toFile()); + File out = tmp.resolve("shared/target/webapp").toFile(); + BackendWebAppMojo mojo = mojo(shared, out); + set(mojo, "bundle", bundle()); + set(mojo, "reactorProjects", Arrays.asList(shared, server("backend"))); + + mojo.execute(); + + assertFalse(out.exists(), "the shared library is not a server and hosts nothing"); + } + + @Test + void aServerWithNoApplicationAboveItIsToldWhatToPass() throws Exception { + // The directory above the module is not a Codename One project, and + // running a build there would build whatever happens to be in it. + BackendWebAppMojo mojo = mojo(server("backend"), tmp.resolve("backend/target/webapp").toFile()); + MojoFailureException refused = assertThrows(MojoFailureException.class, mojo::execute); + assertTrue(refused.getMessage().contains("cn1.backend.webapp.appRoot"), refused.getMessage()); + assertTrue(refused.getMessage().contains("cn1.backend.webapp.bundle"), refused.getMessage()); + } + + @Test + void theBuildItStartsDoesNotStartItAgain() throws Exception { + File out = tmp.resolve("backend/target/webapp").toFile(); + BackendWebAppMojo mojo = mojo(server("backend"), out); + set(mojo, "bundle", bundle()); + set(mojo, "nested", Boolean.TRUE); + mojo.execute(); + assertFalse(out.exists()); + + set(mojo, "nested", Boolean.FALSE); + set(mojo, "skip", Boolean.TRUE); + mojo.execute(); + assertFalse(out.exists()); + } + + private MavenProject server(String artifactId) { + MavenProject project = new MavenProject(); + project.setArtifactId(artifactId); + project.setFile(tmp.resolve(artifactId + "/pom.xml").toFile()); + Dependency runtime = new Dependency(); + runtime.setGroupId("com.codenameone"); + runtime.setArtifactId("codenameone-backend"); + project.getModel().addDependency(runtime); + return project; + } + + private static BackendWebAppMojo mojo(MavenProject project, File output) throws Exception { + BackendWebAppMojo mojo = new BackendWebAppMojo(); + set(mojo, "project", project); + set(mojo, "output", output); + return mojo; + } + + private File bundle() throws IOException { + File file = tmp.resolve("app.zip").toFile(); + ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file)); + try { + out.putNextEntry(new ZipEntry("index.html")); + out.write("".getBytes("UTF-8")); + out.closeEntry(); + out.putNextEntry(new ZipEntry("js/app.js")); + out.write("run();".getBytes("UTF-8")); + out.closeEntry(); + } finally { + out.close(); + } + return file; + } + + private static void set(Object target, String field, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(field); + f.setAccessible(true); + f.set(target, value); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BytecodeComplianceSiblingModulesTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BytecodeComplianceSiblingModulesTest.java new file mode 100644 index 00000000000..9457b7f160a --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BytecodeComplianceSiblingModulesTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.DefaultArtifact; +import org.apache.maven.artifact.handler.DefaultArtifactHandler; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Which modules of the build the compliance check takes in beside the +/// project's own classes: the plain jar modules it depends on, which nothing +/// else checks, and not the Codename One modules, which check themselves. +class BytecodeComplianceSiblingModulesTest { + + @Test + void onlyPlainJarModulesWithCompiledClassesAreChecked(@TempDir File tmp) throws Exception { + MavenProject app = new MavenProject(); + app.setGroupId("com.example"); + app.setArtifactId("app-common"); + app.setVersion("1.0"); + + MavenProject shared = sibling(tmp, "shared", "jar", true); + MavenProject uncompiled = sibling(tmp, "uncompiled", "jar", false); + MavenProject parent = sibling(tmp, "parent", "pom", true); + MavenProject application = sibling(tmp, "common", "jar", true); + assertTrue(new File(application.getBasedir(), "codenameone_settings.properties") + .createNewFile()); + MavenProject library = sibling(tmp, "lib", "jar", true); + assertTrue(new File(library.getBasedir(), "codenameone_library_appended.properties") + .createNewFile()); + // Named by the application for its tests alone: never shipped, so + // free to use what a device does not have. + MavenProject fixtures = sibling(tmp, "fixtures", "jar", true); + Set artifacts = new LinkedHashSet(); + for (MavenProject sibling : new MavenProject[] {shared, uncompiled, parent, application, library, + fixtures}) { + app.addProjectReference(sibling); + artifacts.add(new DefaultArtifact("com.example", sibling.getArtifactId(), "1.0", + sibling == fixtures ? Artifact.SCOPE_TEST : Artifact.SCOPE_COMPILE, "jar", null, + new DefaultArtifactHandler("jar"))); + } + app.setArtifacts(artifacts); + + assertEquals(Collections.singletonList(new File(shared.getBuild().getOutputDirectory())), + plainSiblingModules(app)); + } + + @Test + void aProjectWithNoSiblingsChecksOnlyItself() throws Exception { + MavenProject alone = new MavenProject(); + alone.setArtifactId("alone"); + assertTrue(plainSiblingModules(alone).isEmpty()); + } + + private static MavenProject sibling(File tmp, String artifactId, String packaging, + boolean compiled) throws Exception { + File basedir = new File(tmp, artifactId); + File classes = new File(basedir, "target/classes"); + assertTrue(compiled ? classes.mkdirs() : basedir.mkdirs()); + MavenProject project = new MavenProject(); + project.setGroupId("com.example"); + project.setArtifactId(artifactId); + project.setVersion("1.0"); + project.setPackaging(packaging); + project.setBuild(new org.apache.maven.model.Build()); + project.setFile(new File(basedir, "pom.xml")); + project.getBuild().setOutputDirectory(classes.getPath()); + return project; + } + + @SuppressWarnings("unchecked") + private static List plainSiblingModules(MavenProject project) throws Exception { + BytecodeComplianceMojo mojo = new BytecodeComplianceMojo(); + Field field = AbstractCN1Mojo.class.getDeclaredField("project"); + field.setAccessible(true); + field.set(mojo, project); + Method method = BytecodeComplianceMojo.class.getDeclaredMethod("plainSiblingModules"); + method.setAccessible(true); + return (List) method.invoke(mojo); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateAnnotationStubsStaleTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateAnnotationStubsStaleTest.java new file mode 100644 index 00000000000..6b185fec16b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateAnnotationStubsStaleTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A stub the goal wrote for a contract that has since gone is deleted by the +/// next run, and nothing else in the directory is: it is a compile source +/// root that another goal writes into as well. +class GenerateAnnotationStubsStaleTest { + + @Test + void aStubNoLongerEmittedIsDeletedAndOtherFilesAreKept(@TempDir File tmp) throws Exception { + GenerateAnnotationStubsMojo mojo = new GenerateAnnotationStubsMojo(); + mojo.stubSourceDirectory = new File(tmp, "generated-sources/cn1-annotations"); + File pkg = new File(mojo.stubSourceDirectory, "com/example"); + assertTrue(pkg.mkdirs()); + File kept = new File(pkg, "KeptApi.java"); + File removed = new File(pkg, "RemovedApi.java"); + File someoneElses = new File(pkg, "WrittenByAnotherGoal.java"); + for (File f : new File[] {kept, removed, someoneElses}) { + assertTrue(f.createNewFile()); + } + + Map first = new LinkedHashMap(); + first.put("com/example/KeptApi", ""); + first.put("com/example/RemovedApi", ""); + mojo.removeStubsNoLongerEmitted(first); + assertTrue(removed.isFile(), "emitted by this run"); + + Map second = new LinkedHashMap(); + second.put("com/example/KeptApi", ""); + mojo.removeStubsNoLongerEmitted(second); + assertFalse(removed.exists(), "its contract is gone, and so is its stub"); + assertTrue(kept.isFile()); + assertTrue(someoneElses.isFile(), "not this goal's to delete"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/location/LocationManagerTest.java b/maven/core-unittests/src/test/java/com/codename1/location/LocationManagerTest.java index bcf059aef80..9f543be0fac 100644 --- a/maven/core-unittests/src/test/java/com/codename1/location/LocationManagerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/location/LocationManagerTest.java @@ -177,6 +177,21 @@ void setLocationListenerWithRequestStoresRequest() { assertNotNull(manager.getCurrentListener()); } + /// The ports read the request while binding, so it has to be there by then, + /// both for the first listener and for one that replaces another. + @FormTest + void theRequestIsInPlaceWhenTheListenerBinds() { + manager.notifyOnBind = false; + LocationRequest first = new LocationRequest(); + manager.setLocationListener(new DummyLocationListener(), first); + assertSame(first, manager.requestAtBind); + + LocationRequest second = new LocationRequest(); + manager.setLocationListener(new DummyLocationListener(), second); + assertSame(second, manager.requestAtBind); + assertSame(second, manager.getStoredRequest()); + } + @FormTest void getLastKnownLocationReturnsStoredLocation() { Location expected = new Location(9.0, 10.0); @@ -248,6 +263,7 @@ private static class TestLocationManager extends LocationManager { private Location lastLocation; private Class backgroundListenerClass; int bindCount; + LocationRequest requestAtBind; int clearCount; int getCurrentLocationCalls; boolean notifyOnBind = true; @@ -276,6 +292,7 @@ public Location getLastKnownLocation() { @Override protected void bindListener() { bindCount++; + requestAtBind = getRequest(); if (notifyOnBind) { LocationListener l = getLocationListener(); if (l != null && currentLocation != null) { diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/MapViewMotionTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/MapViewMotionTest.java new file mode 100644 index 00000000000..e5b808d6727 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/maps/MapViewMotionTest.java @@ -0,0 +1,298 @@ +/* + * 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.maps; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.maps.vector.MapStyle; +import com.codename1.maps.vector.TileCallback; +import com.codename1.maps.vector.TileSource; +import com.codename1.ui.Form; +import com.codename1.ui.events.WheelEvent; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// How the map moves once the gesture that moved it is over, and what a zoom +/// is centred on. +class MapViewMotionTest extends UITestBase { + + @FormTest + void aMapLetGoWhileMovingCarriesOnAndThenStops() throws Exception { + MapView map = sizedMap(); + final int[] heard = {0}; + map.addCameraChangeListener(new CameraChangeListener() { + public void cameraChanged(MapSurface source, CameraPosition position) { + heard[0]++; + } + }); + map.pointerPressed(50, 150); + for (int i = 1; i <= 8; i++) { + Thread.sleep(8); + map.pointerDragged(50 + i * 12, 150); + } + map.pointerReleased(146, 150); + double released = map.getCenter().getLongitude(); + assertEquals(0, heard[0], "the camera has not come to rest yet"); + + settle(map); + assertTrue(map.getCenter().getLongitude() < released - 1e-6, + "content thrown to the right keeps going right"); + assertEquals(1, heard[0], "and the listeners hear once, when it stops"); + double rest = map.getCenter().getLongitude(); + Thread.sleep(30); + assertFalse(map.animate()); + assertEquals(rest, map.getCenter().getLongitude(), 0.0); + } + + @FormTest + void aMapPutDownStaysWhereItWasPut() throws Exception { + MapView map = sizedMap(); + map.pointerPressed(50, 150); + for (int i = 1; i <= 8; i++) { + Thread.sleep(8); + map.pointerDragged(50 + i * 12, 150); + } + // The finger rests before it lifts. + Thread.sleep(200); + map.pointerReleased(146, 150); + double released = map.getCenter().getLongitude(); + Thread.sleep(30); + assertFalse(map.animate()); + assertEquals(released, map.getCenter().getLongitude(), 0.0); + } + + @FormTest + void aFingerPutDownOnAMovingMapStopsIt() throws Exception { + MapView map = sizedMap(); + map.pointerPressed(50, 150); + for (int i = 1; i <= 8; i++) { + Thread.sleep(8); + map.pointerDragged(50 + i * 12, 150); + } + map.pointerReleased(146, 150); + Thread.sleep(20); + assertTrue(map.animate()); + map.pointerPressed(100, 100); + double held = map.getCenter().getLongitude(); + Thread.sleep(30); + assertFalse(map.animate()); + assertEquals(held, map.getCenter().getLongitude(), 0.0); + } + + @FormTest + void aDoubleTapGlidesOneLevelInAboutTheTap() throws Exception { + MapView map = sizedMap(); + double zoom = map.getZoom(); + LatLng under = map.screenToLatLng(70, 220); + map.pointerPressed(70, 220); + map.pointerReleased(70, 220); + map.pointerPressed(70, 220); + map.pointerReleased(70, 220); + assertTrue(map.getZoom() < zoom + 1, "glided to, not jumped to"); + + settle(map); + assertEquals(zoom + 1, map.getZoom(), 0.0); + assertSamePlace(under, map.screenToLatLng(70, 220)); + } + + @FormTest + void theWheelZoomsAboutThePointerOnlyWhenAskedTo() throws Exception { + MapView map = sizedMap(); + double zoom = map.getZoom(); + assertFalse(map.isWheelZoom()); + map.mouseWheel(new WheelEvent(map, 40, 60, 0, 200, true, 0)); + assertEquals(zoom, map.getZoom(), 0.0, "a wheel pans unless told otherwise"); + + map.setWheelZoom(true); + LatLng under = map.screenToLatLng(40, 60); + double step = 200 / (200 * map.getEngine().getPixelRatio()); + assertTrue(map.mouseWheel(new WheelEvent(map, 40, 60, 0, 200, true, 0))); + // A second notch before the first has finished adds to it. + assertTrue(map.mouseWheel(new WheelEvent(map, 40, 60, 0, 200, true, 0))); + settle(map); + assertEquals(zoom + 2 * step, map.getZoom(), 1e-9); + assertSamePlace(under, map.screenToLatLng(40, 60)); + + // At the end of the range the wheel is not the map's to keep. + map.setZoom(map.getMaxZoom()); + assertFalse(map.mouseWheel(new WheelEvent(map, 40, 60, 0, 200, true, 0))); + assertTrue(map.mouseWheel(new WheelEvent(map, 40, 60, 0, -200, true, 0))); + } + + @FormTest + void aPinchZoomsAboutTheFingersAndTravelsWithThem() { + MapView map = sizedMap(); + double zoom = map.getZoom(); + LatLng under = map.screenToLatLng(60, 80); + map.pinch(new int[]{40, 80}, new int[]{80, 80}); + assertTrue(map.pinch(1f)); + map.pinch(new int[]{20, 100}, new int[]{80, 80}); + assertTrue(map.pinch(2f)); + assertEquals(zoom + 1, map.getZoom(), 1e-6); + assertSamePlace(under, map.screenToLatLng(60, 80)); + + // The fingers move on together: the ground between them goes along. + map.pinch(new int[]{60, 140}, new int[]{120, 120}); + assertTrue(map.pinch(2f)); + assertSamePlace(under, map.screenToLatLng(100, 120)); + } + + @FormTest + void aPinchThatLeavesNoPointerBehindStillEnds() { + MapView map = sizedMap(); + double zoom = map.getZoom(); + final int[] heard = {0}; + map.addCameraChangeListener(new CameraChangeListener() { + public void cameraChanged(MapSurface source, CameraPosition position) { + heard[0]++; + } + }); + // A trackpad: scales, and a release, and no pointer events at all. + map.pinch(1f); + map.pinch(2f); + map.pinchReleased(0, 0); + assertEquals(1, heard[0]); + assertEquals(zoom + 1, map.getZoom(), 1e-6); + + map.pinch(1f); + assertEquals(zoom + 1, map.getZoom(), 1e-6, + "the next pinch starts from where the last one ended"); + } + + @FormTest + void aMapRemovedWhileMovingIsTakenOffItsForm() throws Exception { + MapView map = sizedMap(); + Form form = new Form(new BorderLayout()); + form.add(BorderLayout.CENTER, map); + form.show(); + map.pointerPressed(50, 150); + for (int i = 1; i <= 8; i++) { + Thread.sleep(8); + map.pointerDragged(50 + i * 12, 150); + } + map.pointerReleased(146, 150); + assertTrue(animatedBy(form).contains(map), + "a map let go while moving is animated by its form"); + + map.remove(); + assertFalse(animatedBy(form).contains(map), + "and not once it has been removed from it"); + } + + @FormTest + void aMapInADesktopWindowIsAnimatedByThatWindow() throws Exception { + // getComponentForm() is null inside a Window, so registering with the form + // left a map there unanimated: the glide after a fling, double-tap and + // wheel zoom all stopped dead after their first frame. + implementation.setMultiWindowSupported(true); + Form main = new Form(new BorderLayout()); + main.show(); + com.codename1.ui.Window window = new com.codename1.ui.Window("map", new BorderLayout()); + MapView map = sizedMap(); + window.add(BorderLayout.CENTER, map); + window.show(); + assertSame(window, map.getTopLevelContainer()); + + map.setWheelZoom(true); + assertTrue(map.mouseWheel(new WheelEvent(map, 40, 60, 0, 200, true, 0))); + assertTrue(animatedBy(window).contains(map), + "a wheel zoom in a window is animated by that window"); + + map.remove(); + assertFalse(animatedBy(window).contains(map), + "and not once the map has been removed from it"); + window.dispose(); + } + + private static java.util.List animatedBy(com.codename1.ui.Window window) throws Exception { + java.lang.reflect.Field field = com.codename1.ui.Window.class.getDeclaredField("animatableComponents"); + field.setAccessible(true); + Object value = field.get(window); + return value == null ? java.util.Collections.emptyList() : (java.util.List) value; + } + + // The form keeps what it animates to itself; nothing public says whether a + // component is still among them. + private static java.util.List animatedBy(Form form) throws Exception { + java.lang.reflect.Field field = Form.class.getDeclaredField("animatableComponents"); + field.setAccessible(true); + Object value = field.get(form); + return value == null ? java.util.Collections.emptyList() : (java.util.List) value; + } + + private static void assertSamePlace(LatLng expected, LatLng actual) { + assertEquals(expected.getLatitude(), actual.getLatitude(), 0.05); + assertEquals(expected.getLongitude(), actual.getLongitude(), 0.05); + } + + private static void settle(MapView map) throws Exception { + long deadline = System.currentTimeMillis() + 10000; + Thread.sleep(10); + while (map.animate()) { + assertTrue(System.currentTimeMillis() < deadline, "the map never came to rest"); + Thread.sleep(10); + } + } + + private MapView sizedMap() { + MapView map = new MapView(new NoTiles(), MapStyle.light()); + map.setSize(new Dimension(300, 300)); + map.setWidth(300); + map.setHeight(300); + map.getEngine().setViewport(300, 300); + map.moveCamera(new LatLng(37.7749, -122.4194), 12); + return map; + } + + /// A source that serves nothing: none of this waits for a tile. + private static final class NoTiles implements TileSource { + + public boolean isVector() { + return true; + } + + public int getTileSize() { + return 256; + } + + public int getMinZoom() { + return 0; + } + + public int getMaxZoom() { + return 18; + } + + public String getAttribution() { + return ""; + } + + public void fetchTile(int z, int x, int y, TileCallback callback) { + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java index 156a3096643..8f0248d42f3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java @@ -41,13 +41,18 @@ void builtInStylesIncludeStreetParkAndLandmarkNamesAtDetailZooms() { layer("poi", named("Museum", VectorFeature.GEOM_POINT, new int[]{1500, 1500})))); for (MapStyle style : new MapStyle[]{MapStyle.light(), MapStyle.dark()}) { assertEquals(Arrays.asList("Town"), texts(labels(tile, style, 11))); - assertEquals(Arrays.asList("Town", "Main Street", "City Park"), texts(labels(tile, style, 12))); - List detail = labels(tile, style, 14); - assertEquals(Arrays.asList("Town", "Main Street", "City Park", "Museum"), texts(detail)); + // A park from the first, a side street once there is room for its + // name, a landmark only close in: in the order they give way. + assertEquals(Arrays.asList("Town", "City Park"), texts(labels(tile, style, 12))); + assertEquals(Arrays.asList("Town", "City Park", "Main Street"), texts(labels(tile, style, 14))); + List detail = labels(tile, style, 16); + assertEquals(Arrays.asList("Town", "City Park", "Main Street", "Museum"), texts(detail)); for (LabelCandidate label : detail) { - assertEquals(detail.get(0).textColor, label.textColor); assertEquals(detail.get(0).haloColor, label.haloColor); } + // The name of a town is the stronger: a street's is set back. + assertTrue(detail.get(0).textColor != detail.get(2).textColor); + assertTrue(detail.get(0).sizePx > detail.get(2).sizePx); } } @@ -56,7 +61,7 @@ void alternateRoadLayersSupplyNames() { for (String source : new String[]{"road", "road_label"}) { VectorTile tile = new VectorTile(Arrays.asList(layer(source, named("High Street", VectorFeature.GEOM_LINESTRING, new int[]{0, 100, 4096, 100})))); - assertEquals(Arrays.asList("High Street"), texts(labels(tile, MapStyle.light(), 13))); + assertEquals(Arrays.asList("High Street"), texts(labels(tile, MapStyle.light(), 14))); } } @@ -66,10 +71,10 @@ void curvedRoadLabelStaysOnTheRoadAndUsesWorldCoordinates() { VectorTile tile = new VectorTile(Arrays.asList(layer("transportation_name", named("Bent Street", VectorFeature.GEOM_LINESTRING, new int[]{0, 0, 100, 0, 200, 0, 1000, 0, 1000, 3000})))); - LabelCandidate label = labels(tile, MapStyle.light(), 13).get(0); + LabelCandidate label = labels(tile, MapStyle.light(), 14).get(0); assertEquals(2 * 256 + 1000 / 16.0, label.worldX, 1e-9); assertEquals(3 * 256 + 1000 / 16.0, label.worldY, 1e-9); - assertEquals(13, label.tileZoom); + assertEquals(14, label.tileZoom); } @Test @@ -77,7 +82,7 @@ void multipartRoadUsesLongestPartAndSkipsZeroLengthSegments() { VectorTile tile = new VectorTile(Arrays.asList(layer("transportation_name", named("Long Street", VectorFeature.GEOM_LINESTRING, new int[]{0, 0, 20, 0}, new int[]{100, 200, 100, 200, 1100, 200})))); - LabelCandidate label = labels(tile, MapStyle.light(), 13).get(0); + LabelCandidate label = labels(tile, MapStyle.light(), 14).get(0); assertEquals(2 * 256 + 600 / 16.0, label.worldX, 1e-9); assertEquals(3 * 256 + 200 / 16.0, label.worldY, 1e-9); } @@ -90,7 +95,7 @@ void absentNamesDegenerateRoadsAndBufferedAnchorsProduceNoLabels() { named("Empty", VectorFeature.GEOM_LINESTRING, new int[0]), named("Zero", VectorFeature.GEOM_LINESTRING, new int[]{100, 100, 100, 100}), named("Outside", VectorFeature.GEOM_LINESTRING, new int[]{-300, 100, -100, 100})))); - assertTrue(labels(tile, MapStyle.light(), 13).isEmpty()); + assertTrue(labels(tile, MapStyle.light(), 14).isEmpty()); } @Test diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/vector/RoadLabelTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/vector/RoadLabelTest.java index 9ba27e60c24..d7b00cca725 100644 --- a/maven/core-unittests/src/test/java/com/codename1/maps/vector/RoadLabelTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/maps/vector/RoadLabelTest.java @@ -89,7 +89,7 @@ void roadNameStaysUprightOnARoadDrawnRightToLeft() { } @FormTest - void aGentleBendPlacesEachGlyphOnTheCurve() { + void aGentleBendDrawsTheNameInPiecesThatFollowTheCurve() { Graphics g = graphics(true); List points = new ArrayList(); for (int i = 0; i <= 20; i++) { @@ -104,14 +104,39 @@ void aGentleBendPlacesEachGlyphOnTheCurve() { assertTrue(new LabelEngine().placeAlongLine(g, "Curved Road Name", 13, TEXT, HALO, road, 5000, 0, 0, 800, 800)); verify(drawing, never()).drawString(any(), eq("Curved Road Name"), anyInt(), anyInt()); - verify(drawing, atLeastOnce()).drawString(any(), eq("C"), anyInt(), anyInt()); + // The name goes down in pieces, each on one straight stretch of the + // road and turned to it: together they spell the name, and a stretch + // long enough for several glyphs is drawn as one string, not several. + ArgumentCaptor drawn = ArgumentCaptor.forClass(String.class); + verify(drawing, atLeastOnce()).drawString(any(), drawn.capture(), anyInt(), anyInt()); + List pieces = new ArrayList(); + for (String piece : drawn.getAllValues()) { + if (pieces.isEmpty() || !pieces.get(pieces.size() - 1).equals(piece)) { + pieces.add(piece); + } + } + StringBuilder spelled = new StringBuilder(); + for (String piece : pieces) { + spelled.append(piece); + } + assertEquals("Curved Road Name", spelled.toString()); + assertTrue(pieces.size() > 1, "one piece for a road that bends"); + assertTrue(pieces.size() < "Curved Road Name".length(), "a string for every glyph"); } @FormTest void aSharpCornerUnderTheNameDropsIt() { Graphics g = graphics(true); - // The corner sits exactly under the middle of the name. - double[] road = {100, 100, 300, 100, 300, 300}; + // A staircase: wherever the name is put, there is a corner under it. + // (One corner in a road long enough either side of it is no longer + // reason to leave the road unnamed: the name goes beside the corner.) + double[] road = new double[44]; + for (int i = 0; i < 11; i++) { + road[i * 4] = 100 + 30 * i; + road[i * 4 + 1] = 100 + 30 * i; + road[i * 4 + 2] = 130 + 30 * i; + road[i * 4 + 3] = 100 + 30 * i; + } assertFalse(new LabelEngine().placeAlongLine(g, "Corner Street", 13, TEXT, HALO, road, 1000, 0, 0, 800, 800)); verify(drawing, never()).drawString(any(), anyString(), anyInt(), anyInt()); @@ -148,6 +173,93 @@ void aLongRoadRepeatsItsNameAndTheSameNameKeepsItsDistance() { assertTrue(engine.placeAlongLine(g, "Other Road", 13, TEXT, HALO, neighbour, 300, 0, 0, 2000, 800)); } + /// A wider outline -- a dense display's -- is room the label takes: from + /// the label beside it, and from the edge of the map. + @FormTest + void aWiderHaloKeepsLabelsFurtherApartAndInsideTheView() { + Graphics g = Image.createImage(800, 800).getGraphics(); + int thinGap = closestNeighbour(g, 1); + int wideGap = closestNeighbour(g, 4); + assertTrue(wideGap >= thinGap + 4, "neighbours at " + thinGap + " and " + wideGap); + int thinEdge = closestToEdge(g, 1); + int wideEdge = closestToEdge(g, 4); + assertTrue(wideEdge >= thinEdge + 2, "edge at " + thinEdge + " and " + wideEdge); + } + + // The nearest a second label's centre may come to the first's, sideways. + private static int closestNeighbour(Graphics g, int halo) { + for (int dx = 1; dx < 400; dx++) { + LabelEngine engine = new LabelEngine(); + engine.setHalo(halo); + engine.reset(0, 0, 800, 800); + assertTrue(engine.place(g, "Name", 13, TEXT, HALO, 200, 200)); + if (engine.place(g, "Name", 13, TEXT, HALO, 200 + dx, 200)) { + return dx; + } + } + throw new AssertionError("never placed"); + } + + // The nearest a label's centre may come to the left edge of the view. + private static int closestToEdge(Graphics g, int halo) { + for (int cx = 0; cx < 400; cx++) { + LabelEngine engine = new LabelEngine(); + engine.setHalo(halo); + engine.reset(0, 0, 800, 800); + if (engine.place(g, "Name", 13, TEXT, HALO, cx, 200)) { + return cx; + } + } + throw new AssertionError("never placed"); + } + + /// The collision index against the definition it replaces: a label is + /// placed exactly when it overlaps none of those placed before it, taken + /// one pair at a time. Points on both sides of zero, so that cells with + /// negative coordinates are covered, and dense enough that most are turned + /// away. + @FormTest + void aLabelIsPlacedExactlyWhenItOverlapsNoEarlierOne() { + // Drawn for real rather than recorded: nothing here asks what was drawn. + Graphics g = Image.createImage(800, 800).getGraphics(); + LabelEngine engine = new LabelEngine(); + List placed = new ArrayList(); + List names = new ArrayList(); + long seed = 42; + int accepted = 0; + int refused = 0; + for (int i = 0; i < 48; i++) { + seed = seed * 6364136223846793005L + 1442695040888963407L; + int x = (int) ((seed >>> 33) % 300) - 140; + seed = seed * 6364136223846793005L + 1442695040888963407L; + int y = (int) ((seed >>> 33) % 120) - 50; + String name = "P" + (100 + i); + boolean free = true; + for (int at = 0; at < placed.size(); at++) { + int[] earlier = placed.get(at); + LabelEngine pair = new LabelEngine(); + assertTrue(pair.place(g, names.get(at), 13, TEXT, HALO, earlier[0], earlier[1])); + if (!pair.place(g, name, 13, TEXT, HALO, x, y)) { + free = false; + break; + } + } + assertEquals(free, engine.place(g, name, 13, TEXT, HALO, x, y), + name + " at " + x + "," + y + " after " + placed.size() + " placed"); + if (free) { + placed.add(new int[]{x, y}); + names.add(name); + accepted++; + } else { + refused++; + } + } + assertTrue(accepted > 6 && refused > 6, accepted + " placed, " + refused + " refused"); + // A new frame starts with nothing placed. + engine.reset(); + assertTrue(engine.place(g, "Again", 13, TEXT, HALO, placed.get(0)[0], placed.get(0)[1])); + } + @FormTest void withoutAffineTransformsTheNameIsHorizontalAtTheMidpoint() { Graphics g = graphics(false); @@ -164,7 +276,7 @@ void roadCandidatesCarryTheRoadInWorldPixels() { layer("transportation_name", named("Main Street", VectorFeature.GEOM_LINESTRING, new int[]{160, 320, 1760, 320})), layer("place", named("Town", VectorFeature.GEOM_POINT, new int[]{100, 100})))); - List labels = TileRenderer.extractLabels(tile, MapStyle.light(), 13, 2, 3, 256); + List labels = TileRenderer.extractLabels(tile, MapStyle.light(), 14, 2, 3, 256); LabelCandidate road = null; LabelCandidate town = null; for (Object o : labels) { @@ -309,7 +421,8 @@ void anOverzoomedPieceDrawsOnlyItsQuarterOfTheTileAtFullScale() { g = graphics(true); TileRenderer.renderTile(g, tile, MapStyle.light(), 15, 256, 0, 0, 1); ArgumentCaptor shape = ArgumentCaptor.forClass(Shape.class); - verify(drawing).drawShape(any(), shape.capture(), any(Stroke.class)); + // Twice: its edge, and then the road over it. + verify(drawing, times(2)).drawShape(any(), shape.capture(), any(Stroke.class)); Rectangle bounds = shape.getValue().getBounds(); assertEquals(0, bounds.getX()); assertEquals(0, bounds.getY()); @@ -366,8 +479,7 @@ void zoomingInWhileTheDeepestTileLoadsReusesItsFetch() { assertFalse(engine.hasRenderedVisibleTiles()); assertEquals(first, source.requests, "no second download of a tile already loading"); // When the deepest tiles land, the zoom-16 pieces are cut from them. - source.release(); - awaitRendered(engine); + awaitRendered(engine, source); } @FormTest @@ -385,8 +497,7 @@ void zoomingOutWhileAnOverzoomFetchLoadsReusesItToo() { for (Object r : source.requests.subList(first.size(), source.requests.size())) { assertFalse(first.contains(r), "fetched " + r + " twice"); } - source.release(); - awaitRendered(engine); + awaitRendered(engine, source); } @FormTest @@ -423,16 +534,93 @@ void withoutAffineTheNameGoesOnTheVisiblePartOfALongRoad() { new double[]{2000, 400, 20000, 400}, 1000, 0, 0, 800, 800)); } + @FormTest + void aRoadThatRunsOffTheScreenIsNamedWhereTheWholeNameFits() { + Graphics g = graphics(true); + // Its middle is exactly at the right-hand edge of an 800 pixel view. + double[] road = {-400, 400, 2000, 400}; + assertTrue(new LabelEngine().placeAlongLine(g, "Main Street", 13, TEXT, HALO, road, 5000, + 0, 0, 800, 800)); + ArgumentCaptor x = ArgumentCaptor.forClass(Integer.class); + verify(drawing, atLeastOnce()).drawString(any(), eq("Main Street"), x.capture(), anyInt()); + int width = implementation.stringWidth(drawing.getDefaultFont(), "Main Street"); + for (Integer left : x.getAllValues()) { + assertTrue(left >= -2 && left <= 802 - Math.min(width, 400), "drawn from x=" + left); + } + // With too little of it in view to hold the name, it is not named at + // all rather than named in part. + Graphics other = graphics(true); + assertFalse(new LabelEngine().placeAlongLine(other, "Main Street", 13, TEXT, HALO, + new double[]{790, 400, 2000, 400}, 5000, 0, 0, 800, 800)); + verify(drawing, never()).drawString(any(), anyString(), anyInt(), anyInt()); + } + + @FormTest + void aNameThatWouldBeCutOffByTheEdgeOfTheMapIsLeftOut() { + Graphics g = graphics(true); + LabelEngine engine = new LabelEngine(); + engine.reset(0, 0, 800, 800); + assertFalse(engine.place(g, "Pier 41 Gate 2", 13, TEXT, HALO, 799, 400)); + assertFalse(engine.place(g, "Pier 41 Gate 2", 13, TEXT, HALO, 400, 1)); + verify(drawing, never()).drawString(any(), anyString(), anyInt(), anyInt()); + assertTrue(engine.place(g, "Pier 41 Gate 2", 13, TEXT, HALO, 400, 400)); + // And a wider outline costs no more to draw than a thin one. + engine.setHalo(4); + assertTrue(engine.place(g, "Hyde Street", 13, TEXT, HALO, 400, 600)); + verify(drawing, times(5)).drawString(any(), eq("Hyde Street"), anyInt(), anyInt()); + } + + @FormTest + void aMainRoadIsDrawnWiderThanASideStreetAndAPierIsNotOutlined() { + VectorFeature main = new VectorFeature(0, VectorFeature.GEOM_LINESTRING, new HashMap(), + Arrays.asList(new Object[]{new int[]{0, 1000, 4096, 1000}})); + main.getAttributes().put("class", "primary"); + VectorFeature side = new VectorFeature(0, VectorFeature.GEOM_LINESTRING, new HashMap(), + Arrays.asList(new Object[]{new int[]{0, 3000, 4096, 3000}})); + side.getAttributes().put("class", "minor"); + VectorFeature pier = new VectorFeature(0, VectorFeature.GEOM_POLYGON, new HashMap(), + Arrays.asList(new Object[]{new int[]{100, 100, 900, 100, 900, 300, 100, 300, 100, 100}})); + pier.getAttributes().put("class", "pier"); + for (MapStyle style : new MapStyle[]{MapStyle.light(), MapStyle.dark()}) { + Graphics g = graphics(true); + TileRenderer.renderTile(g, new VectorTile(Arrays.asList(layer("transportation", pier))), + style, 15, 256); + verify(drawing, never()).drawShape(any(), any(Shape.class), any(Stroke.class)); + + g = graphics(true); + TileRenderer.renderTile(g, new VectorTile(Arrays.asList(layer("transportation", side))), + style, 15, 256); + float street = widest(); + g = graphics(true); + TileRenderer.renderTile(g, new VectorTile(Arrays.asList(layer("transportation", main))), + style, 15, 256); + assertTrue(widest() > street, style.getName() + ": " + widest() + " against " + street); + } + } + + private float widest() { + ArgumentCaptor stroke = ArgumentCaptor.forClass(Stroke.class); + verify(drawing, atLeastOnce()).drawShape(any(), any(Shape.class), stroke.capture()); + float widest = 0; + for (Stroke one : stroke.getAllValues()) { + widest = Math.max(widest, one.getLineWidth()); + } + return widest; + } + @FormTest void overzoomStopsAtTwentyTwoButNeverBelowTheSourcesOwnDeepestLevel() { assertEquals(22, new VectorMapEngine(new RecordingSource(true, 18), MapStyle.light()).getMaxZoom(), 0); assertEquals(24, new VectorMapEngine(new RecordingSource(true, 24), MapStyle.light()).getMaxZoom(), 0); } - private void awaitRendered(VectorMapEngine engine) { + // The engine asks for a few tiles at a time, so the source is released + // again for each lot the last answers made room for. + private void awaitRendered(VectorMapEngine engine, HoldingSource source) { long deadline = System.currentTimeMillis() + 5000; while (!engine.hasRenderedVisibleTiles()) { assertTrue(System.currentTimeMillis() < deadline, "tiles never rendered"); + source.release(); flushSerialCalls(); try { Thread.sleep(10); diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/vector/TileQueueTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/vector/TileQueueTest.java new file mode 100644 index 00000000000..86f06fe107e --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/maps/vector/TileQueueTest.java @@ -0,0 +1,326 @@ +/* + * 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.maps.vector; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.maps.LatLng; +import com.codename1.testing.TestCodenameOneImplementation; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import org.mockito.invocation.Invocation; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockingDetails; +import static org.mockito.Mockito.spy; + +/// What the map asks its source for, and what it shows while it waits. +/// +/// A view that has just moved wants every tile it shows. Handed to the source +/// all at once they are fetched in the order they were asked for -- the ones a +/// drag left behind included, each ahead of whatever the view is showing now. +class TileQueueTest extends UITestBase { + + @FormTest + void onlyAFewTilesAreAskedForAtOnceAndTheMiddleOfTheViewGoesFirst() { + HoldingSource source = new HoldingSource(14); + VectorMapEngine engine = engine(source, 14); + assertFalse(engine.hasRenderedVisibleTiles()); + assertEquals(6, source.requests.size(), "asked for " + source.requests); + assertEquals(tileAt(SAN_FRANCISCO, 14), source.requests.get(0), "the middle first"); + assertTrue(engine.hasPendingTiles()); + + // Asking again changes nothing: they are on their way or in the queue. + assertFalse(engine.hasRenderedVisibleTiles()); + assertEquals(6, source.requests.size()); + + // And each answer makes room for one more. + source.answerOne(); + long deadline = System.currentTimeMillis() + 4000; + while (source.requests.size() < 7) { + assertTrue(System.currentTimeMillis() < deadline, "an answer made no room"); + flushSerialCalls(); + } + assertEquals(7, source.requests.size()); + } + + @FormTest + void tilesTheViewLeftBehindAreNeverAskedFor() { + HoldingSource source = new HoldingSource(14); + VectorMapEngine engine = engine(source, 14); + engine.hasRenderedVisibleTiles(); + List first = new ArrayList(source.requests); + + // Somewhere else entirely, before the queue behind those six was sent. + engine.setCenter(LONDON); + engine.hasRenderedVisibleTiles(); + source.failAll(); + flushSerialCalls(); + + List later = source.requests.subList(first.size(), source.requests.size()); + assertFalse(later.isEmpty(), "the new view is fetched once there is room"); + assertEquals(tileAt(LONDON, 14), later.get(0), "its middle first"); + for (Object request : later) { + assertTrue(((String) request).startsWith("14/81"), request + " is not in the new view"); + } + // Nothing waits for a tile that was dropped unsent: back where it was, + // the map asks again for what it never got. + while (!source.held.isEmpty()) { + source.failAll(); + flushSerialCalls(); + } + engine.setCenter(SAN_FRANCISCO); + int before = source.requests.size(); + engine.hasRenderedVisibleTiles(); + assertTrue(source.requests.size() > before, "a dropped tile is asked for again"); + } + + @FormTest + void aSourceThatAnswersAtOnceIsDrainedWithoutRecursion() { + // Every tile fails before fetchTile returns, as an offline source's does. + final List requests = new ArrayList(); + VectorMapEngine engine = engine(new Source(14) { + public void fetchTile(int z, int x, int y, TileCallback callback) { + requests.add(z + "/" + x + "/" + y); + callback.tileFailed(z, x, y); + } + }, 14); + assertFalse(engine.hasRenderedVisibleTiles()); + assertTrue(requests.size() > 6, "the whole view was asked for: " + requests); + assertFalse(engine.hasPendingTiles()); + } + + @FormTest + void anAncestorThatFailedIsNotAskedForAgainByItsOtherPieces() { + // Level 16 over a source whose deepest level is 14: a level-14 tile + // is sixteen pieces of the map, and the view shows only some of them. + HoldingSource source = new HoldingSource(14); + VectorMapEngine engine = engine(source, 16); + engine.setViewport(200, 200); + engine.hasRenderedVisibleTiles(); + while (!source.held.isEmpty()) { + source.failAll(); + flushSerialCalls(); + } + List failedOnce = new ArrayList(source.requests); + assertFalse(failedOnce.isEmpty(), "the ancestors were asked for"); + + // A short pan brings other pieces of the same ancestors into view. + // Theirs is the failure the ancestor already had, and its back-off. + engine.setCenter(new LatLng(SAN_FRANCISCO.getLatitude(), + SAN_FRANCISCO.getLongitude() + 0.004)); + engine.hasRenderedVisibleTiles(); + flushSerialCalls(); + List later = source.requests.subList(failedOnce.size(), source.requests.size()); + for (Object request : later) { + assertFalse(failedOnce.contains(request), + request + " failed a moment ago and was asked for again"); + } + } + + @FormTest + void aTileThatFailedIsAskedForAgainAfterAWhile() throws Exception { + HoldingSource source = new HoldingSource(14); + VectorMapEngine engine = engine(source, 14); + engine.setViewport(200, 200); + engine.hasRenderedVisibleTiles(); + while (!source.held.isEmpty()) { + source.failAll(); + flushSerialCalls(); + } + int asked = source.requests.size(); + + engine.hasRenderedVisibleTiles(); + assertEquals(asked, source.requests.size(), "not at once"); + + Thread.sleep(2200); + engine.hasRenderedVisibleTiles(); + assertTrue(source.requests.size() > asked, "one lost request is not a hole for good"); + } + + @FormTest + void aTileStillLoadingShowsTheShallowerTileThatCoversIt() { + // Level 13 is answered and level 14 is not. + HoldingSource source = new HoldingSource(14); + source.answersTo = 13; + VectorMapEngine engine = engine(source, 13); + engine.setViewport(200, 200); + long deadline = System.currentTimeMillis() + 4000; + while (!engine.hasRenderedVisibleTiles()) { + assertTrue(System.currentTimeMillis() < deadline, "level 13 never rendered"); + flushSerialCalls(); + } + + TestCodenameOneImplementation drawing = spy(implementation); + Graphics g = graphics(drawing); + VectorMapEngine empty = engine(new HoldingSource(14), 14); + empty.paintTiles(g, 0, 0, 200, 200); + assertEquals(0, imagesDrawn(drawing), "nothing to show where nothing has arrived"); + + engine.setZoom(14); + engine.paintTiles(g, 0, 0, 200, 200); + assertTrue(engine.hasPendingTiles(), "level 14 is still on its way"); + assertTrue(imagesDrawn(drawing) > 0, "the level above stands in for it"); + } + + @FormTest + void overzoomedTilesTheViewLeftBehindAreNotDrawnWhenTheirAncestorArrives() throws Exception { + // Level 16 over a source whose deepest level is 14: every tile in view + // is a piece of a level-14 tile that is fetched once. + HoldingSource source = new HoldingSource(14); + VectorMapEngine engine = engine(source, 16); + engine.setViewport(200, 200); + engine.hasRenderedVisibleTiles(); + String sfTile = tileAt(SAN_FRANCISCO, 16); + int sent = source.held.size(); + assertTrue(sent > 0, "the ancestors were asked for"); + + // The view moves on while those fetches are at the source, and then + // they arrive. + engine.setCenter(LONDON); + engine.hasRenderedVisibleTiles(); + for (int i = 0; i < sent; i++) { + source.answerOne(); + } + long deadline = System.currentTimeMillis() + 1500; + while (System.currentTimeMillis() < deadline) { + flushSerialCalls(); + Thread.sleep(20); + } + java.lang.reflect.Field field = VectorMapEngine.class.getDeclaredField("rendered"); + field.setAccessible(true); + TileCache rendered = (TileCache) field.get(engine); + assertTrue(rendered.get(sfTile) == null, "a tile no longer in view was drawn"); + } + + // However the port in use draws an image at a size. + private static int imagesDrawn(TestCodenameOneImplementation drawing) { + int drawn = 0; + for (Invocation invocation : mockingDetails(drawing).getInvocations()) { + if (invocation.getMethod().getName().startsWith("drawImage")) { + drawn++; + } + } + return drawn; + } + + private static final LatLng SAN_FRANCISCO = new LatLng(37.7749, -122.4194); + private static final LatLng LONDON = new LatLng(51.5074, -0.1278); + + private static VectorMapEngine engine(TileSource source, int zoom) { + VectorMapEngine engine = new VectorMapEngine(source, MapStyle.light()); + engine.setCenter(SAN_FRANCISCO); + engine.setZoom(zoom); + engine.setViewport(1024, 1024); + return engine; + } + + private static String tileAt(LatLng where, int zoom) { + int x = (int) (WebMercator.lonToWorldX(where.getLongitude(), zoom) / 256); + int y = (int) (WebMercator.latToWorldY(where.getLatitude(), zoom) / 256); + return zoom + "/" + x + "/" + y; + } + + private Graphics graphics(TestCodenameOneImplementation drawing) { + Graphics graphics = Image.createImage(1024, 1024).getGraphics(); + try { + java.lang.reflect.Field field = Graphics.class.getDeclaredField("impl"); + field.setAccessible(true); + field.set(graphics, drawing); + } catch (Exception e) { + throw new AssertionError(e); + } + return graphics; + } + + private abstract static class Source implements TileSource { + private final int deepest; + + Source(int deepest) { + this.deepest = deepest; + } + + public boolean isVector() { + return true; + } + + public int getTileSize() { + return 256; + } + + public int getMinZoom() { + return 0; + } + + public int getMaxZoom() { + return deepest; + } + + public String getAttribution() { + return ""; + } + } + + // Holds every request until the test answers it. + private static final class HoldingSource extends Source { + private final List requests = new ArrayList(); + private final List held = new ArrayList(); + private final DemoTileSource bytes = new DemoTileSource(); + // Levels up to this one are answered as they are asked for. + private int answersTo = -1; + + HoldingSource(int deepest) { + super(deepest); + } + + public void fetchTile(int z, int x, int y, TileCallback callback) { + requests.add(z + "/" + x + "/" + y); + if (z <= answersTo) { + bytes.fetchTile(z, x, y, callback); + return; + } + held.add(new Object[]{new int[]{z, x, y}, callback}); + } + + void answerOne() { + Object[] h = (Object[]) held.remove(0); + int[] a = (int[]) h[0]; + bytes.fetchTile(a[0], a[1], a[2], (TileCallback) h[1]); + } + + void failAll() { + List now = new ArrayList(held); + held.clear(); + for (Object o : now) { + Object[] h = (Object[]) o; + int[] a = (int[]) h[0]; + ((TileCallback) h[1]).tileFailed(a[0], a[1], a[2]); + } + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/ComboBoxPopupTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/ui/ComboBoxPopupTestSupport.java index 56eb320a111..7fde3caef40 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/ComboBoxPopupTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/ComboBoxPopupTestSupport.java @@ -25,16 +25,15 @@ import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /// Drives an open `ComboBox` popup from a test. /// -/// The popup is modal: showing it parks the caller until it goes away. So the click -/// happens on a thread of its own, and everything the test wants to do to the open -/// popup runs from the popup's show listener, which is dispatch-thread work. Whatever -/// the script leaves open is disposed afterwards, because a caller left parked in -/// invokeAndBlock outlives the test. +/// The popup is modal: showing it parks the caller until it goes away. The click is +/// made on the dispatch thread, as a real one is, and the modal wait nests the event +/// loop there; everything the test wants to do to the open popup runs from the popup's +/// show listener, inside that loop. Whatever the script leaves open is disposed +/// afterwards, which is what lets the click return. final class ComboBoxPopupTestSupport { private ComboBoxPopupTestSupport() { @@ -100,21 +99,15 @@ static void open(final ScriptedComboBox combo, PopupScript script) throws Except combo.script = script; combo.shown = false; combo.failure = null; - Thread caller = new Thread(new Runnable() { - @Override - public void run() { - combo.fireClicked(); - } - }, "cn1-test-combo-popup"); - caller.start(); - for (int i = 0; i < 400 && caller.isAlive(); i++) { - DisplayTest.flushEdt(); - Thread.sleep(5); - } - caller.join(2000); + // On the dispatch thread, which is where a @FormTest body runs. This used to click + // from a thread of its own while the test thread pumped the queue, so the popup was + // built and measured off the dispatch thread at the same time as that thread laid + // the form out. What the popup measured then depended on which side got there + // first, and the width test failed now and then in CI (a popup 80 wide against a + // 64 wide combo) while it passed every time when run on its own. + assertTrue(Display.getInstance().isEdt(), "a popup is opened from the dispatch thread"); + combo.fireClicked(); DisplayTest.flushEdt(); - assertFalse(caller.isAlive(), - "the popup has to come back down, or its caller is parked for good"); assertTrue(combo.shown, "the popup never showed"); if (combo.failure != null) { throw new AssertionError("the popup script failed", combo.failure); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathTest.java index d0aecfdffb8..933ccd34d59 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/geom/GeneralPathTest.java @@ -1,3 +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. + */ package com.codename1.ui.geom; import com.codename1.junit.UITestBase; @@ -264,4 +286,40 @@ void testCubicCurveContains() { assertTrue(path.contains(2, 5)); assertFalse(path.contains(-2, 5)); } + + /// A path far past its first buffers keeps every segment it was given, + /// in order: the buffers grow by a share of their size, many times over. + @Test + void aLongPathKeepsEverySegment() { + GeneralPath path = new GeneralPath(); + int count = 5000; + path.moveTo(0f, 0f); + for (int i = 1; i < count; i++) { + if (i % 7 == 0) { + path.quadTo(i, i + 0.5f, i + 0.25f, -i); + } else { + path.lineTo(i, -i); + } + } + assertEquals(count, path.getTypesSize()); + float[] points = new float[path.getPointsSize()]; + byte[] types = new byte[path.getTypesSize()]; + path.getPoints(points); + path.getTypes(types); + int at = 2; + for (int i = 1; i < count; i++) { + if (i % 7 == 0) { + assertEquals(PathIterator.SEG_QUADTO, types[i]); + assertEquals(i + 0.5f, points[at + 1], 0f); + at += 2; + assertEquals(i + 0.25f, points[at], 0f); + } else { + assertEquals(PathIterator.SEG_LINETO, types[i]); + assertEquals((float) i, points[at], 0f); + } + assertEquals((float) -i, points[at + 1], 0f); + at += 2; + } + assertEquals(points.length, at); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/layouts/GridLayoutTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/layouts/GridLayoutTest.java index 8cd3a1764c5..463ac005ad4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/layouts/GridLayoutTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/layouts/GridLayoutTest.java @@ -1,3 +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. + */ package com.codename1.ui.layouts; import com.codename1.junit.FormTest; @@ -445,4 +467,37 @@ void testPreferredSizeWithMultipleComponents() { assertTrue(preferredSize.getWidth() > 0); assertTrue(preferredSize.getHeight() > 0); } + + /// GridLayout(int columns) is one row that grows as components are added, + /// and layoutContainer() has always laid the further rows out. The + /// preferred size answered the height of one row all the same, so a grid + /// of four in two columns was given half the height it needs and the + /// bottom of every cell was cut off. + @Test + void testPreferredHeightCountsImplicitRows() { + GridLayout planned = new GridLayout(2, 2); + Container expected = new Container(planned); + GridLayout implicit = new GridLayout(2); + Container actual = new Container(implicit); + for (int i = 0; i < 4; i++) { + expected.add(new Button("Button " + i)); + actual.add(new Button("Button " + i)); + } + + Dimension twoRows = planned.getPreferredSize(expected); + Dimension grown = implicit.getPreferredSize(actual); + assertEquals(twoRows.getHeight(), grown.getHeight(), + "a one-row grid that ran onto a second row is as tall as a two-row grid"); + assertEquals(twoRows.getWidth(), grown.getWidth()); + + // An odd one out starts a row of its own. + actual.add(new Button("Button 4")); + actual.setShouldCalcPreferredSize(true); + assertEquals(twoRows.getHeight() / 2 * 3, implicit.getPreferredSize(actual).getHeight()); + + // And a grid that fits its one row is still one row. + Container single = new Container(new GridLayout(3)); + single.add(new Button("Button 0")).add(new Button("Button 1")); + assertEquals(twoRows.getHeight() / 2, single.getLayout().getPreferredSize(single).getHeight()); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/xml/XMLParserBufferTest.java b/maven/core-unittests/src/test/java/com/codename1/xml/XMLParserBufferTest.java new file mode 100644 index 00000000000..6a67db2155c --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/xml/XMLParserBufferTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.xml; + +import com.codename1.io.CharArrayReader; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// A parse that a callback ends early must not leave its unread input behind +/// for the next parse. The read-ahead buffer was static and only `parse` +/// emptied it, so after RSSService stopped a feed at its item limit, the next +/// `eventParser` call anywhere in the process read the rest of that feed first. +class XMLParserBufferTest { + + private static final class Stop extends RuntimeException { + } + + /// Records text elements; throws after `stopAfter` of them, 0 for never. + private static final class Recorder extends XMLParser { + final List texts = new ArrayList(); + private final int stopAfter; + + Recorder(int stopAfter) { + this.stopAfter = stopAfter; + } + + @Override + protected void textElement(String text) { + texts.add(text); + if (stopAfter > 0 && texts.size() >= stopAfter) { + throw new Stop(); + } + } + } + + @Test + void anAbandonedEventParseLeavesNothingForTheNextOne() throws Exception { + Recorder first = new Recorder(1); + try { + first.eventParser(reader("123")); + } catch (Stop expected) { + // the callback ended the parse, as RSSService's limit does + } + assertEquals("[1]", first.texts.toString()); + + Recorder second = new Recorder(0); + second.eventParser(reader("x")); + assertEquals("[x]", second.texts.toString()); + + Recorder third = new Recorder(0); + Element doc = third.parse(reader("y")); + assertEquals("y", doc.getChildAt(0).getChildAt(0).getText()); + } + + @Test + void aParseStartedFromACallbackDoesNotDisturbTheOuterOne() throws Exception { + final List outer = new ArrayList(); + XMLParser p = new XMLParser() { + @Override + protected void textElement(String text) { + outer.add(text); + new XMLParser().parse(reader("inner")); + } + }; + p.eventParser(reader("12")); + assertEquals("[1, 2]", outer.toString()); + } + + private static CharArrayReader reader(String s) { + return new CharArrayReader(s.toCharArray()); + } +} diff --git a/maven/integration-tests/gradle-app-test.sh b/maven/integration-tests/gradle-app-test.sh index f86a87073f6..d0a25b5c40e 100755 --- a/maven/integration-tests/gradle-app-test.sh +++ b/maven/integration-tests/gradle-app-test.sh @@ -14,6 +14,8 @@ # the app and that platform's native sources -- and no other platform's. # 5. addBackend adds backend/, whose runBackend serves /healthz and whose # sample tests pass on the JVM and, with clang, compiled. +# 6. backendWebApp stages a browser build in backend/build/webapp, which +# runBackend then serves at /. # # Needs the reactor installed (mvn install) and a JDK 17+ for Gradle; see # inc/gradle.sh for MAVEN_REPO_LOCAL and GRADLE_JAVA_HOME. @@ -165,6 +167,31 @@ run_gradle "$APP" addBackend > "$WORKDIR/add-backend.log" 2>&1 || { cat "$WORKDI [ ! -e "$APP/backend/pom.xml" ] || fail "a Gradle backend has no pom" check_backend_healthz "$APP" ":backend:runBackend" "$WORKDIR/backend-run.log" +echo "== the backend hosts the app's browser build" +# With no bundle given the task builds one: it depends on the root project's +# browser build, the one made without a proxy servlet. Checked on the task graph; +# the translation itself is the JavaScript port's to test. +run_gradle "$APP" :backend:backendWebApp --dry-run > "$WORKDIR/webapp-graph.log" 2>&1 \ + || { cat "$WORKDIR/webapp-graph.log"; fail ":backend:backendWebApp --dry-run"; } +grep -q "^:buildJavascriptWebApp SKIPPED" "$WORKDIR/webapp-graph.log" \ + || { cat "$WORKDIR/webapp-graph.log"; fail "backendWebApp does not build the app for the browser"; } +# A bundle that is already built is staged as it is, and nothing is built for it. +python3 - "$WORKDIR/bundle.zip" <<'EOF' +import sys, zipfile +with zipfile.ZipFile(sys.argv[1], "w") as bundle: + bundle.writestr("index.html", "hosted-by-gradle") + bundle.writestr("js/app.js", "// app\n" + "function f(){return 1;}\n" * 200) +EOF +run_gradle "$APP" :backend:backendWebApp "-Pcn1.backend.webapp.bundle=$WORKDIR/bundle.zip" > "$WORKDIR/webapp.log" 2>&1 \ + || { cat "$WORKDIR/webapp.log"; fail ":backend:backendWebApp"; } +if grep -q "buildJavascriptWebApp" "$WORKDIR/webapp.log"; then + cat "$WORKDIR/webapp.log" + fail "staging a given bundle ran the browser build" +fi +[ -f "$APP/backend/build/webapp/index.html" ] || fail "backendWebApp staged no index.html in backend/build/webapp" +[ -f "$APP/backend/build/webapp/js/app.js.gz" ] || fail "backendWebApp wrote no compressed copy of the app's script" +check_backend_webapp "$APP" ":backend:runBackend" "$WORKDIR/backend-webapp-run.log" "hosted-by-gradle" + echo "== backend tests" # The sample tests addBackend writes, on the JVM and (with clang) compiled. Counted # from the reports: a test task that found no engine runs nothing and succeeds. diff --git a/maven/integration-tests/inc/gradle.sh b/maven/integration-tests/inc/gradle.sh index b5895cb96b1..d9797d553dc 100644 --- a/maven/integration-tests/inc/gradle.sh +++ b/maven/integration-tests/inc/gradle.sh @@ -143,6 +143,30 @@ check_backend_healthz() { [ "$greeting" = "Hello, gradle" ] || { cat "$log"; fail "$task did not answer /greet through its injected service (got '$greeting')"; } } +# Starts a backend with the given task and checks it answers / with the web +# app staged for it. +# check_backend_webapp +check_backend_webapp() { + local dir="$1" task="$2" log="$3" marker="$4" + local port=$((18000 + RANDOM % 2000)) + (cd "$dir" && CN1_PROFILE=dev PORT=$port JAVA_HOME="$GRADLE_JDK" exec ./gradlew --no-daemon "$task") > "$log" 2>&1 & + local pid=$! + local health="" i + for i in $(seq 1 180); do + health=$(curl -fsS "http://127.0.0.1:$port/healthz" 2>/dev/null) && break + kill -0 $pid 2>/dev/null || break + sleep 1 + done + local page="" + [ -n "$health" ] && page=$(curl -fsS "http://127.0.0.1:$port/" 2>/dev/null || true) + stop_tree $pid + [ -n "$health" ] || { cat "$log"; fail "$task never answered /healthz on port $port"; } + case "$page" in + *"$marker"*) echo " $task /: the staged web app" ;; + *) cat "$log"; fail "$task did not answer / with the staged web app (got '$page')" ;; + esac +} + # Checks a packaged backend binary answers /healthz, and /greet through the # Greeter service the build wired into it. # check_binary_healthz diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortMixedFontWidthTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortMixedFontWidthTest.java new file mode 100644 index 00000000000..439a02958ab --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEPortMixedFontWidthTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if + * you need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import org.junit.jupiter.api.Test; + +import java.awt.Font; +import java.awt.font.FontRenderContext; +import java.awt.font.TextAttribute; +import java.awt.font.TextLayout; +import java.awt.geom.Rectangle2D; +import java.text.AttributedString; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A string the bundled fonts cannot display -- Hebrew, Arabic, CJK, emoji -- +/// is drawn as an attributed string in a fallback font, and was measured by +/// the ink of that drawing rather than by its advance. The ink leaves out the +/// side bearings and starts after the pen, so the text ended past the width +/// stringWidth() answered: right aligned Hebrew in a label with no side +/// padding lost the edge of its first letter. +public class JavaSEPortMixedFontWidthTest { + private static final FontRenderContext FRC = new FontRenderContext(null, true, true); + + private static AttributedString text(String value) { + AttributedString text = new AttributedString(value); + text.addAttribute(TextAttribute.FONT, new Font(Font.SANS_SERIF, Font.PLAIN, 36)); + return text; + } + + @Test + void widthCoversEverythingTheDrawingPaints() { + for (String value : new String[]{"Account", "\u05d7\u05e9\u05d1\u05d5\u05df", "WAVE", "jelly"}) { + TextLayout layout = new TextLayout(text(value).getIterator(), FRC); + Rectangle2D ink = layout.getBounds(); + Rectangle2D measured = JavaSEPort.mixedFontBounds(text(value).getIterator(), FRC); + assertEquals(0d, measured.getX(), 0d, "measured from the pen, where drawString starts"); + assertTrue(measured.getWidth() >= ink.getX() + ink.getWidth(), + value + ": the ink ends at " + (ink.getX() + ink.getWidth()) + + " and the width answered was " + measured.getWidth()); + assertTrue(measured.getWidth() >= layout.getAdvance(), value + ": narrower than its advance"); + } + } + + @Test + void trailingSpaceHasWidth() { + // No ink at all in a space, so measuring the ink made "a " as wide as "a" + // and a string of spaces zero wide. + double word = JavaSEPort.mixedFontBounds(text("word").getIterator(), FRC).getWidth(); + double spaced = JavaSEPort.mixedFontBounds(text("word ").getIterator(), FRC).getWidth(); + assertTrue(spaced > word, "two trailing spaces added nothing: " + word + " then " + spaced); + } + + @Test + void agreesWithTheSingleFontMeasure() { + // The same string in one font is measured by Font.getStringBounds; the + // two paths must not disagree about it, or a label changes width the + // moment one unsupported character is typed into it. + Font font = new Font(Font.SANS_SERIF, Font.PLAIN, 36); + double single = font.getStringBounds("Account", FRC).getWidth(); + double mixed = JavaSEPort.mixedFontBounds(text("Account").getIterator(), FRC).getWidth(); + assertEquals(single, mixed, 1.5d); + } +} diff --git a/maven/javase/src/test/java/com/codename1/orm/ManagedSessionTest.java b/maven/javase/src/test/java/com/codename1/orm/ManagedSessionTest.java index f899e4521d5..c67aea251d1 100644 --- a/maven/javase/src/test/java/com/codename1/orm/ManagedSessionTest.java +++ b/maven/javase/src/test/java/com/codename1/orm/ManagedSessionTest.java @@ -180,7 +180,7 @@ private Record seed(Session session) { assertThrows(IllegalArgumentException.class,()->s.createQuery("select unsupported(r.name) from ManagedSessionTest$Record r",String.class)); s.beginTransaction();r.name="flushed"; assertEquals(1,s.createQuery("update ManagedSessionTest$Record r set r.counter = r.counter + :delta where r.name = :name").setParameter("delta",4L).setParameter("name","flushed").executeUpdate()); - assertFalse(s.contains(r));s.commitTransaction();assertEquals(4,s.find(Record.class,r.id).counter); + assertTrue(s.contains(r));s.commitTransaction();s.clear();assertEquals(4,s.find(Record.class,r.id).counter); s.beginTransaction();assertEquals(1,s.createQuery("delete from ManagedSessionTest$Record r where r.counter between 3 and 5").executeUpdate());s.commitTransaction(); assertEquals(0,s.query(Record.class).count());s.close(); } finally { em.close(); } @@ -191,10 +191,10 @@ private Record seed(Session session) { try { Session s=em.openSession();Record first=seed(s);s.beginTransaction();Record kept=new Record();kept.name="keep";s.persist(kept);s.commitTransaction(); s.beginTransaction(); - assertEquals(1,s.createQuery("update ManagedSessionTest$Record r set r.counter = r.counter + :delta where exists (select x.id from ManagedSessionTest$Record x where x.id = r.id and x.name = :name)").setParameter("delta",2L).setParameter("name","first").executeUpdate());s.commitTransaction();assertEquals(2,s.find(Record.class,first.id).counter);assertEquals(0,s.find(Record.class,kept.id).counter); + assertEquals(1,s.createQuery("update ManagedSessionTest$Record r set r.counter = r.counter + :delta where exists (select x.id from ManagedSessionTest$Record x where x.id = r.id and x.name = :name)").setParameter("delta",2L).setParameter("name","first").executeUpdate());s.commitTransaction();s.clear();assertEquals(2,s.find(Record.class,first.id).counter);assertEquals(0,s.find(Record.class,kept.id).counter); s.beginTransaction(); assertEquals(1,s.createQuery("delete from ManagedSessionTest$Record r where exists (select x.id from ManagedSessionTest$Record x where x.id = r.id and x.name = :name)").setParameter("name","first").executeUpdate()); - s.commitTransaction();assertNull(s.find(Record.class,first.id));assertNotNull(s.find(Record.class,kept.id));assertEquals(1,s.query(Record.class).count());s.close(); + s.commitTransaction();s.clear();assertNull(s.find(Record.class,first.id));assertNotNull(s.find(Record.class,kept.id));assertEquals(1,s.query(Record.class).count());s.close(); } finally { em.close(); } } @@ -296,7 +296,7 @@ private Record seed(Session session) { for(String query:new String[]{"update ManagedSessionTest$Record r set r.counter=sum(r.counter)","update ManagedSessionTest$Record r set r.counter=coalesce(max(r.counter),0)","update ManagedSessionTest$Record r set r.counter=1 where count(r.id)>0","delete from ManagedSessionTest$Record r where count(r.id)>0","delete from ManagedSessionTest$Record r where coalesce(sum(r.counter),0)>=0"}) { assertThrows(IllegalArgumentException.class,()->session.createQuery(query),query); } - session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=(select max(i.counter)+1 from ManagedSessionTest$Record i)").executeUpdate());session.commitTransaction();assertEquals(1,session.find(Record.class,record.id).counter); + session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=(select max(i.counter)+1 from ManagedSessionTest$Record i)").executeUpdate());session.commitTransaction();session.clear();assertEquals(1,session.find(Record.class,record.id).counter); session.beginTransaction();assertEquals(1,session.createQuery("delete from ManagedSessionTest$Record r where r.id in (select i.id from ManagedSessionTest$Record i where i.counter=1)").executeUpdate());session.commitTransaction();assertEquals(0,session.query(Record.class).count());session.close(); } finally { em.close(); } } @@ -334,10 +334,10 @@ static void assertIntegerRanges(Session session) { assertThrows(PersistenceException.class,query::executeUpdate,expression);session.rollbackTransaction();assertEquals(0L,session.find(Record.class,id).counter); } for(long boundary:new long[]{Integer.MIN_VALUE,Integer.MAX_VALUE,0}) { - session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",boundary).executeUpdate());session.commitTransaction();assertEquals(boundary,session.find(Record.class,id).counter); + session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",boundary).executeUpdate());session.commitTransaction();session.clear();assertEquals(boundary,session.find(Record.class,id).counter); } for(long[] change:new long[][]{{Integer.MIN_VALUE,2147483648L,0},{Integer.MIN_VALUE,4294967295L,Integer.MAX_VALUE},{Integer.MAX_VALUE,-4294967295L,Integer.MIN_VALUE},{Integer.MAX_VALUE,-2147483649L,-2}}) { - session.beginTransaction();session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",change[0]).executeUpdate();session.commitTransaction(); + session.beginTransaction();session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",change[0]).executeUpdate();session.commitTransaction();session.clear(); Record current=session.find(Record.class,id);long version=current.version; session.beginTransaction();assertTrue(session.increment(Record.class,id,"counter",change[1]));assertEquals(change[2],current.counter);assertEquals(version+1,current.version);session.commitTransaction(); session.clear();assertEquals(change[2],session.find(Record.class,id).counter); @@ -375,7 +375,7 @@ static void assertRealAssignments(Session session) { } session.beginTransaction();session.createQuery("update ManagedSessionTest$RealRecord r set r."+field+"=1").executeUpdate();session.commitTransaction(); } - session.beginTransaction();session.createQuery("update ManagedSessionTest$RealRecord r set r.boxed=NULL,r.wide=1e-100").executeUpdate();session.commitTransaction();assertNull(session.find(RealRecord.class,id).boxed);assertEquals(1e-100,session.find(RealRecord.class,id).wide); + session.beginTransaction();session.createQuery("update ManagedSessionTest$RealRecord r set r.boxed=NULL,r.wide=1e-100").executeUpdate();session.commitTransaction();session.clear();assertNull(session.find(RealRecord.class,id).boxed);assertEquals(1e-100,session.find(RealRecord.class,id).wide); session.beginTransaction();assertThrows(PersistenceException.class,()->session.createQuery("update ManagedSessionTest$RealRecord r set r.narrow=r.wide/2").executeUpdate());session.rollbackTransaction(); for(String zero:new String[]{"0e-400","-0e-400","0.000e-400"}) assertEquals(0.0,session.createQuery("select "+zero+" from ManagedSessionTest$RealRecord r",Double.class).first().doubleValue(),0.0); } @@ -385,7 +385,7 @@ static void assertRealAssignments(Session session) { static void assertMinimumLongLiteral(Session session) { session.createTables();session.beginTransaction();Record record=new Record();record.name="minimum";session.persist(record);session.commitTransaction(); - session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=-9223372036854775808").executeUpdate());session.commitTransaction(); + session.beginTransaction();assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=-9223372036854775808").executeUpdate());session.commitTransaction();session.clear(); assertEquals(Long.MIN_VALUE,session.find(Record.class,record.id).counter); assertEquals(1,session.createQuery("select r from ManagedSessionTest$Record r where r.counter=(-9223372036854775808)").list().size()); assertEquals(Long.valueOf(Long.MIN_VALUE),session.createQuery("select -9223372036854775808 from ManagedSessionTest$Record r",Long.class).first()); @@ -437,7 +437,7 @@ static void assertMinimumLongLiteral(Session session) { assertEquals(0L,session.find(Record.class,record.id).counter); assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=:value").setParameter("value",7L).executeUpdate()); assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=r.counter+2").executeUpdate()); - session.commitTransaction();assertEquals(9L,session.find(Record.class,record.id).counter);session.close(); + session.commitTransaction();session.clear();assertEquals(9L,session.find(Record.class,record.id).counter);session.close(); } finally { em.close(); } } @@ -552,7 +552,7 @@ static void assertMinimumLongLiteral(Session session) { assertThrows(IllegalArgumentException.class,()->session.createQuery("update ManagedSessionTest$Record r set r.name=:value").setParameter("value",7L).executeUpdate()); assertThrows(IllegalArgumentException.class,()->session.createQuery("update ManagedSessionTest$Record r set r.bytes=:value").setParameter("value","oops").executeUpdate()); assertEquals(1,session.createQuery("update ManagedSessionTest$Record r set r.counter=:value,r.name=:name,r.bytes=:bytes").setParameter("value",7L).setParameter("name",null).setParameter("bytes",new byte[]{1,2}).executeUpdate()); - session.commitTransaction();Record loaded=session.find(Record.class,record.id); + session.commitTransaction();session.clear();Record loaded=session.find(Record.class,record.id); assertEquals(7,loaded.counter);assertNull(loaded.name);assertArrayEquals(new byte[]{1,2},loaded.bytes); session.close(); } finally { em.close(); } @@ -625,7 +625,7 @@ static void assertMinimumLongLiteral(Session session) { assertEquals(Long.valueOf(-1),session.createQuery("select -r.counter / 2 from ManagedSessionTest$Record r",Long.class).first()); assertEquals(Double.valueOf(1.5),session.createQuery("select r.counter / 2.0 from ManagedSessionTest$Record r",Double.class).first()); assertNull(session.createQuery("select r.counter / 0 from ManagedSessionTest$Record r",Long.class).first()); - session.beginTransaction();session.createQuery("update ManagedSessionTest$Record r set r.counter = r.counter / 2").executeUpdate();session.commitTransaction(); + session.beginTransaction();session.createQuery("update ManagedSessionTest$Record r set r.counter = r.counter / 2").executeUpdate();session.commitTransaction();session.clear(); assertEquals(1,session.find(Record.class,r.id).counter);session.close(); } finally { em.close(); } } diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index 9c5cca006b8..7d01fa8f5cc 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -107,11 +107,15 @@ ba_log "Building Android gradle project ($APP_DIR) using Codename One port" ) cd "$REPO_ROOT" -GRADLE_PROJECT_DIR=$(find "$APP_DIR/android/target" -maxdepth 2 -type d -name "*-android-source" | head -n 1 || true) +# The android module builds the project where there is one. An application in +# the minimal layout has none, and common builds every platform itself. +ANDROID_MODULE_DIR="$APP_DIR/android" +[ -f "$ANDROID_MODULE_DIR/pom.xml" ] || ANDROID_MODULE_DIR="$APP_DIR/common" +GRADLE_PROJECT_DIR=$(find "$ANDROID_MODULE_DIR/target" -maxdepth 2 -type d -name "*-android-source" | head -n 1 || true) if [ -z "$GRADLE_PROJECT_DIR" ]; then ba_log "Failed to locate generated Android project" >&2 - ba_log "Contents of $APP_DIR/android/target:" >&2 - ls -R "$APP_DIR/android/target" >&2 || ba_log "Unable to list $APP_DIR/android/target" >&2 + ba_log "Contents of $ANDROID_MODULE_DIR/target:" >&2 + ls -R "$ANDROID_MODULE_DIR/target" >&2 || ba_log "Unable to list $ANDROID_MODULE_DIR/target" >&2 exit 1 fi diff --git a/scripts/build-ios-app.sh b/scripts/build-ios-app.sh index 2c338dd4884..a2fb1fcb0c0 100755 --- a/scripts/build-ios-app.sh +++ b/scripts/build-ios-app.sh @@ -199,8 +199,15 @@ bia_log "Running $APP_MAIN_NAME Maven build with JAVA_HOME=$JAVA17_HOME" export JAVA_HOME="$JAVA17_HOME" export PATH="$JAVA_HOME/bin:$MAVEN_HOME/bin:$BASE_PATH" MVN_IOS_LOG="$ARTIFACTS_DIR/cn1-ios-build.log" + # The application's own wrapper where it has one. An application without one + # is built with the Maven this script has already checked is there, as + # build-android-app.sh builds every application. + MVN_BIN="$MAVEN_HOME/bin/mvn" + if [ -x ./mvnw ]; then + MVN_BIN=./mvnw + fi MVN_CMD=( - ./mvnw package + "$MVN_BIN" package -DskipTests -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source @@ -255,7 +262,10 @@ if [ -f "$ARTIFACTS_DIR/iphone-builder-stats.txt" ]; then fi fi +# The ios module builds the project where there is one. An application in the +# minimal layout has none, and common builds every platform itself. IOS_TARGET_DIR="$APP_DIR/ios/target" +[ -f "$APP_DIR/ios/pom.xml" ] || IOS_TARGET_DIR="$APP_DIR/common/target" if [ ! -d "$IOS_TARGET_DIR" ]; then bia_log "iOS target directory not found at $IOS_TARGET_DIR" >&2 exit 1 diff --git a/scripts/check-copyright-headers.sh b/scripts/check-copyright-headers.sh index 85887319445..61a2c2def93 100755 --- a/scripts/check-copyright-headers.sh +++ b/scripts/check-copyright-headers.sh @@ -36,6 +36,13 @@ is_source_file() { is_excluded() { local candidate="$1" + # What Initializr puts into a generated project is the developer's, not + # Codename One's GPLv2 + Classpath Exception source: these trees are released + # under MIT No Attribution (scripts/wayline/LICENSE), and carry that header + # or none. + case "$candidate" in + scripts/wayline/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; + esac awk -F '\\|' -v candidate="$candidate" ' /^[[:space:]]*(#|$)/ { next } { diff --git a/scripts/cn1playground/tools/verify-playground-demos.mjs b/scripts/cn1playground/tools/verify-playground-demos.mjs index f84481be6d4..05cb3f80409 100644 --- a/scripts/cn1playground/tools/verify-playground-demos.mjs +++ b/scripts/cn1playground/tools/verify-playground-demos.mjs @@ -263,7 +263,14 @@ async function selectSample(page, sample, title) { if (box.y >= top && box.y + box.height <= viewport.height) break; await page.mouse.move(searchBox.x + searchBox.width / 2, Math.min(viewport.height - 50, top + 150)); await page.mouse.wheel(0, box.y < top ? -240 : 240); - await page.waitForTimeout(150); + // The bounds read above come from the ARIA overlay, which the port brings + // up to date once the screen has been quiet for a moment, and at most a + // second late on a screen that never is (HTML5Implementation's + // SEMANTIC_STALE_LIMIT_MILLIS) -- which a sample that animates, like the + // cube, makes of every screen. Reading sooner answers where the button was + // part way through the scroll, and the click below then lands on whichever + // sample has since moved there. + await page.waitForTimeout(1200); } await clickControl(page, sample); await (title === 'Hello World' ? appFrame.getByLabel('Hello, World!', {exact: true}) diff --git a/scripts/developer-guide/check-guide-api-names.py b/scripts/developer-guide/check-guide-api-names.py index 9a842ad4bce..f6efa9c3dbf 100755 --- a/scripts/developer-guide/check-guide-api-names.py +++ b/scripts/developer-guide/check-guide-api-names.py @@ -60,8 +60,14 @@ # which the workflow watches. Triggering on Ports/**, vm/** and maven/** # instead would run this job -- maven install, the demo build and the # screenshots -- for nearly every change in the repository. +# +# scripts/wayline is the sample application two chapters of the guide walk +# through class by class. Its names -- AppConfig, MapStage, the WlRoute style -- +# are real identifiers of this repository that live outside the four framework +# trees, and several are a short edit from a framework class. IDENTIFIER_ROOTS = [os.path.join(ROOT, d) - for d in ('CodenameOne', 'Ports', 'vm', 'maven')] + for d in ('CodenameOne', 'Ports', 'vm', 'maven', + os.path.join('scripts', 'wayline'))] IDENTIFIER_SUFFIXES = ('.java', '.m', '.h', '.mm', '.kt', '.js', '.c', '.cpp', '.xml', '.gradle', '.swift', '.cs') diff --git a/scripts/developer-guide/validate-guide-snippets.py b/scripts/developer-guide/validate-guide-snippets.py index 0bc3dcb4c40..2b26d3694df 100755 --- a/scripts/developer-guide/validate-guide-snippets.py +++ b/scripts/developer-guide/validate-guide-snippets.py @@ -15,6 +15,7 @@ LISTING_DELIMITER_RE = re.compile(r"^-{4,}\s*$") TAG_BLOCK_RE = re.compile(r"// tag::([^\[]+)\[\]\n(.*?)\n// end::\1\[\]", re.S) JAVA_IDENTIFIER_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*\.java$") +INCLUDE_ROOTS = ("../demos/", "../../scripts/wayline/") def parse_args(): @@ -134,9 +135,13 @@ def validate_includes(includes): cache = {} java_snippets = [] for item in includes: - if not item["target"].startswith("../demos/"): + # The Wayline chapters show the sample application itself, which is + # built and tested by wayline.yml, so they include its sources directly + # rather than through copies under docs/demos that could drift from it. + if not item["target"].startswith(INCLUDE_ROOTS): errors.append( - f"{item['sourceFile']}:{item['line']}: source include must target ../demos/: {item['target']}" + f"{item['sourceFile']}:{item['line']}: source include must target one of " + f"{', '.join(INCLUDE_ROOTS)}: {item['target']}" ) continue diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 83898996bf1..b1e72d1f2dc 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -248,10 +248,15 @@ private static int testTimeoutMs(BaseTest testClass) { // for a screenshot that then matched its reference. Two serial // phases means paying the stall window twice. // - // Three times the default covers the work plus a stall window per - // phase. The suite caps are 2100s absolute and 720s idle, so - // neither is threatened by a 90s test. - return TEST_TIMEOUT_MS_NATIVE * 3; + // Three times the default was not enough either: on the + // cross-compiled Windows build (run 38758239027) VideoIODecodedFrames + // timed out at 90s "stage=created" and its PNG then arrived during + // VideoIORoundTripTest, the test after next -- late, not hung, and + // it matched its reference. Five times the default leaves room for + // the work plus a stall in each phase on the slowest runner. The + // suite caps are 2100s absolute and 720s idle, so neither is + // threatened by a 150s test. + return TEST_TIMEOUT_MS_NATIVE * 5; } return "HTML5".equals(Display.getInstance().getPlatformName()) ? TEST_TIMEOUT_MS_HTML5 diff --git a/scripts/initializr/common/src/main/css/images/grub.png b/scripts/initializr/common/src/main/css/images/grub.png deleted file mode 100644 index 14394ee3e8b..00000000000 Binary files a/scripts/initializr/common/src/main/css/images/grub.png and /dev/null differ diff --git a/scripts/initializr/common/src/main/css/images/tweet.png b/scripts/initializr/common/src/main/css/images/tweet.png deleted file mode 100644 index d573409beee..00000000000 Binary files a/scripts/initializr/common/src/main/css/images/tweet.png and /dev/null differ diff --git a/scripts/initializr/common/src/main/css/theme.css b/scripts/initializr/common/src/main/css/theme.css index 9fbaf04d8c6..60c8748ad48 100644 --- a/scripts/initializr/common/src/main/css/theme.css +++ b/scripts/initializr/common/src/main/css/theme.css @@ -838,11 +838,6 @@ ScrollThumb { } /* ---------- Live (phone) preview internals ---------- */ -Images { - background-image: url(images/grub.png), - url(images/tweet.png); -} - InitializrLiveFrame { margin: 0; border: 2px solid #D2D9E6; diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java index 154a520d24f..f2f00ee55fa 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java @@ -31,6 +31,7 @@ import com.codename1.initializr.model.IDE; import com.codename1.initializr.model.ProjectOptions; import com.codename1.initializr.model.Template; +import com.codename1.initializr.ui.AppIcon; import com.codename1.initializr.ui.TemplatePreviewPanel; import com.codename1.system.Lifecycle; import com.codename1.system.NativeLookup; @@ -43,6 +44,7 @@ import com.codename1.ui.Display; import com.codename1.ui.FontImage; import com.codename1.ui.Form; +import com.codename1.ui.Image; import com.codename1.ui.Label; import com.codename1.ui.RadioButton; import com.codename1.ui.TextField; @@ -123,6 +125,14 @@ public void runApp() { final ProjectOptions.BuildTool[] buildTool = new ProjectOptions.BuildTool[]{ProjectOptions.BuildTool.MAVEN}; final ProjectOptions.ProjectType[] projectType = new ProjectOptions.ProjectType[]{ProjectOptions.ProjectType.APP}; final boolean[] allPlatformModules = new boolean[]{false}; + // The colour scheme of a full-stack template: {0xRRGGBB, or -1 for its own}. + final int[] brandColor = new int[]{-1}; + final boolean[] roundedCorners = new boolean[]{true}; + // The panels a full-stack template settles for itself, {build, localization, + // java}, and the one only it has, {look}; filled in below, read by refresh. + final Container[] fixedByTemplate = new Container[3]; + final Container[] lookPanelHolder = new Container[1]; + final Label lookIcon = new Label(); // {Java 17, Java 8}: the build panel disables Java 8 while Gradle is selected. final RadioButton[] javaButtons = new RadioButton[2]; final SpanLabel summaryLabel = new SpanLabel(); @@ -133,6 +143,7 @@ public void runApp() { final Label localeSubtitle = panelSubtitle("No bundles"); final Label javaSubtitle = panelSubtitle(ProjectOptions.JavaVersion.JAVA_17.label); final Label buildSubtitle = panelSubtitle(ProjectOptions.BuildTool.MAVEN.label); + final Label lookSubtitle = panelSubtitle(SCHEME_LABELS[0]); appNameField.setUIID("InitializrField"); packageField.setUIID("InitializrField"); @@ -153,6 +164,28 @@ public void run() { ProjectOptions options = currentOptions(includeLocalizationBundles, previewLanguage, javaVersion) .withBuild(buildTool[0], projectType[0]) .withPlatformModules(allPlatformModules[0]); + boolean fullStack = selectedTemplate[0].isFullStack(); + if (fullStack) { + options = options.forFullStack().withScheme(brandColor[0], roundedCorners[0]); + } + // A full-stack template is one build, one project type and one Java + // level, so those panels have nothing to ask; it has a look instead. + boolean buildChoices = GeneratorModel.isGradleOffered() || GeneratorModel.isMavenLayoutChoiceOffered(); + for (int i = 0; i < fixedByTemplate.length; i++) { + if (fixedByTemplate[i] != null) { + boolean shown = !fullStack && (i != 0 || buildChoices); + fixedByTemplate[i].setHidden(!shown); + fixedByTemplate[i].setVisible(shown); + } + } + if (lookPanelHolder[0] != null) { + lookPanelHolder[0].setHidden(!fullStack); + lookPanelHolder[0].setVisible(fullStack); + } + lookSubtitle.setText(schemeLabel(brandColor[0]) + + (roundedCorners[0] ? "" : " . Square corners")); + lookIcon.setIcon(AppIcon.create(appNameField.getText(), schemeColor(brandColor[0]), + convertToPixels(12))); previewPanel.setTemplate(selectedTemplate[0]); previewPanel.setOptions(options); @@ -190,6 +223,12 @@ public void run() { Container buildPanel = makePanel("Build Tool", buildSubtitle, true, false, createBuildToolPanel(buildTool, projectType, allPlatformModules, javaVersion, javaButtons, refresh), form); + Container lookPanel = makePanel("Look", lookSubtitle, true, true, + createLookPanel(brandColor, roundedCorners, lookIcon, refresh), form); + fixedByTemplate[0] = buildPanel; + fixedByTemplate[1] = localePanel; + fixedByTemplate[2] = javaPanel; + lookPanelHolder[0] = lookPanel; Container settingsPanel = makePanel("Current Settings", panelSubtitle("Generated artifacts"), true, true, BoxLayout.encloseY(summaryLabel), form); @@ -199,10 +238,8 @@ public void run() { // Gradle is offered once the plugin is published at the version the // generated projects use, and the Maven project types once that plugin can // build them; with neither, the Maven default stands alone. - boolean buildChoices = GeneratorModel.isGradleOffered() || GeneratorModel.isMavenLayoutChoiceOffered(); - buildPanel.setHidden(!buildChoices); - buildPanel.setVisible(buildChoices); - Container column = BoxLayout.encloseY(hero, essentials, idePanel, buildPanel, localePanel, + // Which of these panels show is refresh's to say: it depends on the template. + Container column = BoxLayout.encloseY(hero, essentials, lookPanel, idePanel, buildPanel, localePanel, javaPanel, settingsPanel, previewWrap); column.setUIID("InitializrColumn"); column.setScrollableY(true); @@ -229,6 +266,10 @@ public void run() { ProjectOptions options = downloadOptions(includeLocalizationBundles, previewLanguage, javaVersion) .withBuild(buildTool[0], projectType[0]) .withPlatformModules(allPlatformModules[0]); + if (selectedTemplate[0].isFullStack()) { + options = options.forFullStack().withScheme(brandColor[0], roundedCorners[0]) + .withIcon(AppIcon.png(appName, schemeColor(brandColor[0]))); + } GeneratorModel model = GeneratorModel.create(selectedIde[0], selectedTemplate[0], appName, packageName, options); if (model.generate()) { @@ -353,7 +394,8 @@ private Container createEssentialsPanel(TextField appNameField, TextField packag + "unique. Unique package identifiers are critical for app store submissions because " + "they distinguish your app from others and prevent install/update conflicts.")); fields.add(packageError); - fields.add(labeledField("Language", languageSelector)); + fields.add(labeledField(GeneratorModel.isTemplateOffered(Template.WAYLINE) ? "Start from" : "Language", + languageSelector)); Container bodyWrap = new Container(new BorderLayout()); bodyWrap.setUIID("InitializrPanelBody"); @@ -364,12 +406,20 @@ private Container createEssentialsPanel(TextField appNameField, TextField packag return panel; } + /// What the project starts as: an empty app in Java or in Kotlin, or -- once the + /// plugin the Initializr generates against can build it -- the ride-hailing app + /// with its server. private Container createLanguageSelector(Template[] selectedTemplate, Runnable onSelectionChanged) { - Container selector = new Container(new GridLayout(1, 2)); + boolean fullStack = GeneratorModel.isTemplateOffered(Template.WAYLINE); + Container selector = new Container(new GridLayout(fullStack ? 3 : 1, fullStack ? 1 : 2)); selector.setUIID("InitializrChoicesGrid"); ButtonGroup group = new ButtonGroup(); - Template[] languages = {Template.BAREBONES, Template.KOTLIN}; - String[] labels = {"Java", "Kotlin"}; + Template[] languages = fullStack + ? new Template[] {Template.BAREBONES, Template.KOTLIN, Template.WAYLINE} + : new Template[] {Template.BAREBONES, Template.KOTLIN}; + String[] labels = fullStack + ? new String[] {"Empty app, Java", "Empty app, Kotlin", "Ride-hailing app with its server, Java"} + : new String[] {"Java", "Kotlin"}; for (int i = 0; i < languages.length; i++) { final Template template = languages[i]; RadioButton button = new RadioButton(labels[i]); @@ -390,6 +440,71 @@ private Container createLanguageSelector(Template[] selectedTemplate, Runnable o return selector; } + /// The colour schemes a full-stack template is offered in. The first is the + /// template's own, which leaves its stylesheet as it is. + private static final String[] SCHEME_LABELS = {"Blue", "Teal", "Violet", "Orange", "Rose", "Graphite"}; + private static final int[] SCHEME_COLORS = {-1, 0x0f766e, 0x6d3fd1, 0xd9540b, 0xc8235d, 0x2f3a4a}; + + private static String schemeLabel(int brandColor) { + for (int i = 0; i < SCHEME_COLORS.length; i++) { + if (SCHEME_COLORS[i] == brandColor) { + return SCHEME_LABELS[i]; + } + } + return SCHEME_LABELS[0]; + } + + /// The colour a scheme paints with, which for the template's own is not -1. + private static int schemeColor(int brandColor) { + return brandColor < 0 ? GeneratorModel.FULL_STACK_BRAND : brandColor; + } + + /// The look of a full-stack template: its brand colour, the corners, and the + /// icon that follows from the colour and the name. The stylesheet takes every + /// colour from a block of variables, so this is all it takes to re-skin it. + private Container createLookPanel(int[] brandColor, boolean[] roundedCorners, Label icon, + Runnable onSelectionChanged) { + Container colors = new Container(new GridLayout(2, 3)); + colors.setUIID("InitializrChoicesGrid"); + ButtonGroup group = new ButtonGroup(); + int dot = convertToPixels(3); + for (int i = 0; i < SCHEME_COLORS.length; i++) { + final int color = SCHEME_COLORS[i]; + RadioButton button = new RadioButton(SCHEME_LABELS[i]); + button.setToggle(true); + button.setUIID("InitializrChoice"); + Image swatch = Image.createImage(dot, dot, 0); + swatch.getGraphics().setAntiAliased(true); + swatch.getGraphics().setColor(schemeColor(color)); + swatch.getGraphics().fillArc(0, 0, dot, dot, 0, 360); + button.setIcon(swatch); + group.add(button); + colors.add(button); + if (color == brandColor[0]) { + button.setSelected(true); + } + button.addActionListener(evt -> { + if (button.isSelected()) { + brandColor[0] = color; + onSelectionChanged.run(); + } + }); + } + final CheckBox rounded = new CheckBox("Rounded corners"); + rounded.setUIID("InitializrChoice"); + rounded.setSelected(roundedCorners[0]); + rounded.addActionListener(evt -> { + roundedCorners[0] = rounded.isSelected(); + onSelectionChanged.run(); + }); + SpanLabel hint = new SpanLabel("The icon is the first letter of the main class on the color you choose. " + + "Replace common/icon.png with your own artwork whenever you have it; the colors are the " + + "variables theme.css opens with."); + hint.setUIID("InitializrTip"); + hint.setTextUIID("InitializrTip"); + return BoxLayout.encloseY(labeledField("Color", colors), rounded, labeledField("Icon", icon), hint); + } + private Container createIdeSelectorPanel(IDE[] selectedIde, Runnable onSelectionChanged) { Container selector = new Container(new GridLayout(2, 2)); selector.setUIID("InitializrChoicesGrid"); @@ -995,6 +1110,7 @@ private String createSummary(String appName, String packageName, Template templa String safePackage = packageName == null ? "" : packageName.trim(); return "App " + safeApp + "\n" + "Package " + safePackage + "\n" + + (template.isFullStack() ? "Template RIDE-HAILING APP + SERVER\n" : "") + "Language " + (template.IS_KOTLIN ? "KOTLIN" : "JAVA") + "\n" + "IDE " + ide.name() + "\n" + "Build " + options.buildTool.label diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index 0bb4122796a..744ee384657 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -71,6 +71,24 @@ public static boolean isMavenLayoutChoiceOffered() { return isVersionAtLeast(CN1_PLUGIN_VERSION, MAVEN_LAYOUTS_SINCE); } + /// The first release whose build plugin handles a contract kept in a module of + /// its own: it generates the app's clients and the server's dispatchers from a + /// dependency's classes, and passes over the module that is neither. A + /// full-stack template ([Template#isFullStack()]) is built that way, and is + /// neither offered nor generated for a release before this one. + static final String FULL_STACK_SINCE = "7.0.277"; + + /// The brand colour of the full-stack template as it ships: the `--brand` its + /// theme.css declares, which is what the UI paints the icon with when no other + /// colour is chosen. GeneratorModelMatrixTest holds the two together. + public static final int FULL_STACK_BRAND = 0x1f5eff; + + /// Whether `template` can be generated at [CN1_PLUGIN_VERSION]: the UI offers + /// only the templates this answers true for. + public static boolean isTemplateOffered(Template template) { + return !template.isFullStack() || isVersionAtLeast(CN1_PLUGIN_VERSION, FULL_STACK_SINCE); + } + /// The plugin version a download is generated against. static String cn1PluginVersion() { return CN1_PLUGIN_VERSION; @@ -266,7 +284,7 @@ public boolean generate() { cleanupGeneratedZips(); String fileName = toLowerCaseInvariant(appName) + ".zip"; - // Collect the project's entries (read source/template/cn1lib bytes). + // Collect the project's entries (read source/template bytes). Map entries; try { entries = collectProjectEntries(); @@ -604,7 +622,7 @@ void writeProjectZip(OutputStream outputStream) throws IOException { writeEntriesToZip(outputStream, collectProjectEntries()); } - /// Reads every entry (IDE scaffold, common files, template sources, cn1libs, + /// Reads every entry (IDE scaffold, common files, template sources, /// localization, generated README/.gitignore/skills) into an ordered map of /// path -> bytes. This is the I/O phase, kept separate from the zip assembly. Map collectProjectEntries() throws IOException { @@ -625,13 +643,15 @@ Map collectProjectEntries() throws IOException { addAgentSkillEntries(mergedEntries); } copySingleTextEntryToMap("common/pom.xml", readResourceToString(template.POM_XML), mergedEntries, ZipEntryType.TEMPLATE_POM); - if (template.CN1LIB_ZIP != null) { - copyZipEntriesToMap(template.CN1LIB_ZIP, mergedEntries, ZipEntryType.TEMPLATE_CN1LIB); + if (template.isFullStack()) { + copyZipEntriesToMap(template.MODULES_ZIP, mergedEntries, ZipEntryType.TEMPLATE_MODULES); } copyZipEntriesToMap(template.CSS, mergedEntries, ZipEntryType.TEMPLATE_CSS); copyZipEntriesToMap(template.SOURCE_ZIP, mergedEntries, ZipEntryType.TEMPLATE_SOURCE); addLocalizationEntries(mergedEntries); addLauncherTelemetry(mergedEntries); + addCustomIcon(mergedEntries); + applyColorScheme(mergedEntries); validateGeneratedPomCoordinates(mergedEntries); return mergedEntries; } @@ -784,7 +804,7 @@ private String gradleTemplate(Map scaffold, String name, String /// The application's build.gradle.kts: the template, plus what the chosen /// template's pom adds -- the Kotlin plugin for a Kotlin project, and the - /// template's dependencies (cn1libs by coordinates, libraries). + /// template's dependencies. String gradleAppBuildScript(String script) { String[] dependencies = template.GRADLE_DEPENDENCIES; if (dependencies.length > 0) { @@ -875,6 +895,22 @@ private Map readZipResource(String zipResource) throws IOExcepti /// offers these; the check is here so that no caller (tests, fixtures, a future UI) /// can produce a download that fails on its first build. void validateOptions() throws IOException { + // A Gradle project is refused below, for the reason the template gives; a + // backend-only project is no template's, so none of this is about it. + if (template.isFullStack() && !options.isGradle() + && options.projectType != ProjectOptions.ProjectType.BACKEND_ONLY) { + if (!isVersionAtLeast(pluginVersion, FULL_STACK_SINCE)) { + throw new IOException("This template shares a contract module between the app and its server, " + + "which needs Codename One " + FULL_STACK_SINCE + " or newer."); + } + if (options.projectType != ProjectOptions.ProjectType.APP_WITH_BACKEND) { + throw new IOException("This template is an app and its server; choose \"" + + ProjectOptions.ProjectType.APP_WITH_BACKEND.label + "\"."); + } + if (options.javaVersion != ProjectOptions.JavaVersion.JAVA_17) { + throw new IOException("This template's sources are Java 17; choose Java 17."); + } + } if (options.isGradle()) { if (options.javaVersion != ProjectOptions.JavaVersion.JAVA_17) { throw new IOException("Gradle projects target Java 17. Choose Java 17, or the Maven build " @@ -947,6 +983,9 @@ void validateGeneratedPomCoordinates(Map entries) throws IOExcep } else { requireAbsentModule(entries, rootPom, "backend"); } + if (template.isFullStack()) { + validateSharedModule(entries, rootPom, rootArtifactId, version); + } if (mavenLayoutsEnabled()) { String commonPom = normalizedPom(entries, "common/pom.xml"); @@ -963,6 +1002,28 @@ void validateGeneratedPomCoordinates(Map entries) throws IOExcep } } + /// A full-stack project's contract module: in the reactor ahead of the app, + /// which depends on it, and depended on by the server too. Either dependency + /// missing is a project that compiles its two halves against nothing in common. + private void validateSharedModule(Map entries, String rootPom, String rootArtifactId, + String version) throws IOException { + validateModulePomCoordinates(entries, "shared", rootArtifactId + "-shared", false, version); + requirePomFragment("pom.xml", rootPom, "sharedcommon", + "shared module ahead of common"); + String dependency = "" + rootArtifactId + "-shared"; + requirePomFragment("common/pom.xml", normalizedPom(entries, "common/pom.xml"), dependency, + "dependency on the shared module"); + requirePomFragment("backend/pom.xml", normalizedPom(entries, "backend/pom.xml"), dependency, + "dependency on the shared module"); + String packagePath = packageName.replace('.', '/'); + String sourcePath = template.SOURCE_PACKAGE.replace('.', '/'); + for (String path : entries.keySet()) { + if (path.indexOf(sourcePath) >= 0 && !sourcePath.equals(packagePath)) { + throw new IOException("Refusing to generate project: " + path + " is still in the template's package"); + } + } + } + /// An optional module that was left out: no files of it in the download, and /// the root pom adds it only once its pom exists. private void requireAbsentModule(Map entries, String rootPom, String module) throws IOException { @@ -1063,6 +1124,14 @@ static String toLowerCaseInvariant(String value) { return out.toString(); } + static String toUpperCaseInvariant(String value) { + StringBuilder out = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + out.append(Character.toUpperCase(value.charAt(i))); + } + return out.toString(); + } + /// Writes the collected entries as a STORED (uncompressed) zip. STORED rather /// than DEFLATED keeps the byte work minimal (CRC + copy, no compression /// engine); the size cost is irrelevant for a one-off scaffold download. @@ -1189,6 +1258,14 @@ private void copySingleTextEntryToMap(String targetPath, String content, Map\n" + + indent + "shared\n" + indent + + pom.substring(at); + } + + /// A full-stack template's stylesheet in the colours and shape that were asked + /// for. Its rules name no colour of their own: each takes a variable from the + /// block the file opens with, so the scheme is changed by rewriting those + /// declarations, and every screen follows. Nothing is appended, which is what + /// keeps the stylesheet's own dark-mode block working: an override written + /// after it would win in both modes. + static String applyThemeVariables(String css, ProjectOptions options) throws IOException { + ProjectOptions effective = options == null ? ProjectOptions.defaults() : options; + if (effective.brandColor >= 0) { + int brand = effective.brandColor & 0xffffff; + // Dark mode shows the brand on a near-black surface, where the colour as + // chosen is usually too deep to read: it is lightened there. + int brandDark = lightenColor(brand, 0.35f); + css = setCssVariable(css, "--brand", toCssColor(brand)); + css = setCssVariable(css, "--brand-pressed", toCssColor(darkenColor(brand, 0.22f))); + css = setCssVariable(css, "--brand-contrast", toCssColor(contrastColor(brand))); + css = setCssVariable(css, "--brand-dark", toCssColor(brandDark)); + css = setCssVariable(css, "--brand-pressed-dark", toCssColor(lightenColor(brand, 0.15f))); + css = setCssVariable(css, "--brand-contrast-dark", toCssColor(contrastColor(brandDark))); + // The pale ground of a selected card or a badge: the brand nearly + // washed out to white, and in dark mode barely lifted off the + // near-black surface. The stock values are these same mixes of the + // stock brand, so every scheme keeps the template's proportions. + css = setCssVariable(css, "--brand-tint", toCssColor(lightenColor(brand, 0.92f))); + css = setCssVariable(css, "--brand-tint-dark", toCssColor(mixColor(DARK_SURFACE, brand, 0.16f))); + } + if (!effective.roundedButtons) { + css = setCssVariable(css, "--radius", "0.6mm"); + css = setCssVariable(css, "--radius-small", "0.4mm"); + } + return css; + } + + /// `css` with the declaration of the variable `name` given `value`. A variable + /// the stylesheet does not declare is an error and not a no-op: it means the + /// template was restyled without this method, and the choice made in the UI + /// would otherwise be dropped without a word. + private static String setCssVariable(String css, String name, String value) throws IOException { + int at = css.indexOf(name + ":"); + int end = at < 0 ? -1 : css.indexOf(';', at); + if (end < 0) { + throw new IOException("Refusing to generate project: theme.css does not declare " + name); + } + return css.substring(0, at) + name + ": " + value + css.substring(end); + } + + /// Near-black or white, whichever reads on `color`. + private static int contrastColor(int color) { + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + // Perceived brightness; the weights are the usual ones for sRGB. + return (r * 299 + g * 587 + b * 114) / 1000 > 140 ? 0x0b1020 : 0xffffff; + } + + /// `from` moved `ratio` of the way towards `to`. + private static int mixColor(int from, int to, float ratio) { + int r = (from >> 16) & 0xff; + int g = (from >> 8) & 0xff; + int b = from & 0xff; + r += (int) ((((to >> 16) & 0xff) - r) * ratio); + g += (int) ((((to >> 8) & 0xff) - g) * ratio); + b += (int) (((to & 0xff) - b) * ratio); + return (r << 16) | (g << 8) | b; + } + + /// The full-stack template's dark-mode surface, `--surface-dark`. + private static final int DARK_SURFACE = 0x12161c; + + private static int lightenColor(int color, float ratio) { + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + r = Math.min(255, (int)(r + (255 - r) * ratio)); + g = Math.min(255, (int)(g + (255 - g) * ratio)); + b = Math.min(255, (int)(b + (255 - b) * ratio)); + return (r << 16) | (g << 8) | b; + } + + /// The template's stylesheet in the scheme that was chosen. Done to the finished + /// project and not as the file is copied: common.zip has a theme.css of its own + /// at the same path, which the template's replaces and which declares none of + /// the variables. + private void applyColorScheme(Map entries) throws IOException { + String path = appDir() + "src/main/css/theme.css"; + if (!template.isFullStack() || !entries.containsKey(path)) { + return; + } + entries.put(path, applyThemeVariables(StringUtil.newString(entries.get(path)), options).getBytes("UTF-8")); + } + + /// The icon chosen in the UI in place of the stock one, wherever the project + /// keeps its icon. + private void addCustomIcon(Map entries) { + String path = appDir() + "icon.png"; + if (options.iconPng != null && options.iconPng.length > 0 && entries.containsKey(path)) { + entries.put(path, options.iconPng); + } + } + private boolean isBareTemplate() { return template == Template.BAREBONES || template == Template.KOTLIN; } @@ -1678,14 +1883,42 @@ private String buildReadmeMarkdown() { .append("`./mvnw -pl backend -Dcodename1.platform=backend cn1:backend` and package it as a native ") .append("binary with `./mvnw -pl backend -Dcodename1.platform=backend cn1:backend-package`.\n\n"); } - - if (template.USES_CODERAD) { - out.append("### Additional Eclipse Steps for CodeRAD Projects\n\n") - .append("CodeRAD uses annotation processing, so Eclipse needs two extra settings:\n\n") - .append("1. Add `org.eclipse.m2e.apt.mode=jdt_apt` to `./common/.settings/org.eclipse.m2e.apt.prefs`\n") - .append("2. Add `target/generated-sources/rad-views` to `.classpath`\n\n") - .append("More details:\n") - .append("https://github.com/codenameone/CodenameOne/issues/3724\n\n"); + if (template.isFullStack()) { + out.append("## The app and its server\n\n") + .append("This project is a working ride-hailing app: a rider, a driver and an admin mode in one ") + .append("app, chosen by the role of whoever signs in, and the server they talk to.\n\n") + .append("- `shared/` is the contract: the REST interfaces and the objects they carry. The app's ") + .append("client and the server's dispatcher are both generated from it, so the two cannot drift.\n") + .append("- `backend/` is the server: accounts and roles, phone verification, driver applications, ") + .append("rides, matching, payments, the live channel and the database migrations.\n") + .append("- `common/` is the app. `src/main/css/theme.css` opens with the colours and corner ") + .append("radius every screen takes its look from.\n\n") + .append("Start the server, then, in a second terminal, the app:\n\n```\n") + .append("CN1_PROFILE=dev backend/server.sh run\n") + .append("./run.sh\n```\n\n") + .append("`server.sh` is a few lines of shell around Maven; on Windows run what it runs, ") + .append("with `mvnw.cmd`, or use a Unix shell such as Git Bash or WSL.\n\n") + .append("On the `dev` profile the server creates demonstration accounts, all with the ") + .append("password `").append(toLowerCaseInvariant(appName)).append("-demo`: `rider@") + .append(toLowerCaseInvariant(appName)).append(".example`, `driver@") + .append(toLowerCaseInvariant(appName)).append(".example`, `admin@") + .append(toLowerCaseInvariant(appName)).append(".example` and `applicant@") + .append(toLowerCaseInvariant(appName)).append(".example`, who has applied to drive ") + .append("and waits for the admin's decision. ") + .append("Verification codes are written to the server's log until an SMS provider is ") + .append("configured. Cards are simulated (`4242 4242 4242 4242` is accepted) until the ") + .append("server is given a Stripe key; `backend/application.properties` says how.\n\n") + .append("`./mvnw -pl backend -am -Dcodename1.platform=backend test` runs the server's tests.\n\n") + .append("The server can also host the app, so it runs in a browser with nothing to install: ") + .append("`backend/server.sh web` builds the browser version, and a server started after that ") + .append("serves it at `http://localhost:8080/`. In a desktop window or a desktop browser the ") + .append("admin mode becomes a console with the operating system's own look.\n\n") + .append("Two chapters of the developer guide, at https://www.codenameone.com/developer-guide/, ") + .append("cover the sample this project started as: \"A ride-hailing app from three sides\" ") + .append("shows it as a rider, a driver and an admin see it, and \"Building on the sample\" ") + .append("explains how it is built and what to change: billing, maps and demo data, native ") + .append("maps and the ") + .append("database.\n\n"); } out.append("## Signing\n\n") diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java index 9edd4f7a8b3..0f5a56861c2 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java @@ -140,6 +140,12 @@ public String toString() { /// builds every platform itself. Ignored for Gradle and for a backend-only /// project, and against a plugin that predates the minimal layout. public final boolean allPlatformModules; + /// The brand colour of a template whose stylesheet is built on variables + /// ([Template#isFullStack()]), as 0xRRGGBB, or -1 for the template's own. + public final int brandColor; + /// The application's icon as a PNG, or null for the stock one. Not copied: + /// the options are a value, and nothing writes to the array. + public final byte[] iconPng; public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons, boolean includeLocalizationBundles, PreviewLanguage previewLanguage, @@ -166,6 +172,17 @@ public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons boolean includeLocalizationBundles, PreviewLanguage previewLanguage, JavaVersion javaVersion, String customThemeCss, BuildTool buildTool, ProjectType projectType, boolean allPlatformModules) { + this(themeMode, accent, roundedButtons, includeLocalizationBundles, previewLanguage, javaVersion, + customThemeCss, buildTool, projectType, allPlatformModules, -1, null); + } + + private ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons, + boolean includeLocalizationBundles, PreviewLanguage previewLanguage, + JavaVersion javaVersion, String customThemeCss, + BuildTool buildTool, ProjectType projectType, boolean allPlatformModules, + int brandColor, byte[] iconPng) { + this.brandColor = brandColor; + this.iconPng = iconPng; this.themeMode = themeMode; this.accent = accent; this.roundedButtons = roundedButtons; @@ -181,14 +198,39 @@ public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons /// A copy of these options with a different build tool and project type. public ProjectOptions withBuild(BuildTool buildTool, ProjectType projectType) { return new ProjectOptions(themeMode, accent, roundedButtons, includeLocalizationBundles, previewLanguage, - javaVersion, customThemeCss, buildTool, projectType, allPlatformModules); + javaVersion, customThemeCss, buildTool, projectType, allPlatformModules, brandColor, iconPng); } /// A copy of these options that does or does not ask for every platform module; /// see [#allPlatformModules]. public ProjectOptions withPlatformModules(boolean all) { return new ProjectOptions(themeMode, accent, roundedButtons, includeLocalizationBundles, previewLanguage, - javaVersion, customThemeCss, buildTool, projectType, all); + javaVersion, customThemeCss, buildTool, projectType, all, brandColor, iconPng); + } + + /// A copy of these options in another colour scheme: `brand` as 0xRRGGBB or -1 + /// for the template's own colour, and square or rounded corners. Read by the + /// templates styled through variables; see [#brandColor]. + public ProjectOptions withScheme(int brand, boolean rounded) { + return new ProjectOptions(themeMode, accent, rounded, includeLocalizationBundles, previewLanguage, + javaVersion, customThemeCss, buildTool, projectType, allPlatformModules, brand, iconPng); + } + + /// These options as a full-stack template takes them + /// ([Template#isFullStack()]): a Maven project with its server, in Java 17, + /// without the language bundles a bare project can be given. What the template + /// leaves open -- the platform modules, the colours, the icon -- is kept. + public ProjectOptions forFullStack() { + return new ProjectOptions(themeMode, accent, roundedButtons, false, previewLanguage, + JavaVersion.JAVA_17, customThemeCss, BuildTool.MAVEN, ProjectType.APP_WITH_BACKEND, + allPlatformModules, brandColor, iconPng); + } + + /// A copy of these options with `png` as the application's icon, or the stock + /// icon when it is null. + public ProjectOptions withIcon(byte[] png) { + return new ProjectOptions(themeMode, accent, roundedButtons, includeLocalizationBundles, previewLanguage, + javaVersion, customThemeCss, buildTool, projectType, allPlatformModules, brandColor, png); } public boolean isGradle() { diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectZipPermissions.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectZipPermissions.java index f12cca78fc1..346f57649e1 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectZipPermissions.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectZipPermissions.java @@ -50,8 +50,9 @@ static void apply(byte[] zip) throws IOException { if (next > end) throw new IOException("Truncated project ZIP entry"); String name = new String(zip, offset + 46, nameLength, "UTF-8"); // The launchers and wrappers, Maven's and Gradle's, must extract executable. + // So must the script a full-stack template starts its server with. boolean executable = "build.sh".equals(name) || "run.sh".equals(name) || "mvnw".equals(name) - || "gradlew".equals(name); + || "gradlew".equals(name) || "backend/server.sh".equals(name); int mode = name.endsWith("/") ? 040755 : (executable ? 0100755 : 0100644); zip[offset + 5] = 3; // version-made-by host: Unix int attributes = (mode << 16) | (name.endsWith("/") ? 0x10 : 0); diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java index b8a9f3d8238..2f821007a9d 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java @@ -23,52 +23,43 @@ package com.codename1.initializr.model; public enum Template { - BAREBONES(false, false, "/barebones-src.zip", "/barebones-css.zip", "/barebones-pom.xml", null, "com.example.myapp", "MyAppName", null, + BAREBONES(false, "/barebones-src.zip", "/barebones-css.zip", "/barebones-pom.xml", "com.example.myapp", "MyAppName", null, null, new String[0]), // The same two libraries kotlin-pom.xml's kotlin profile declares (at the Gradle // Kotlin version, see GeneratorModel.KOTLIN_VERSION). The Kotlin Gradle plugin // itself is added to the plugins {} block by GeneratorModel. - KOTLIN(true, false, "/kotlin-src.zip", "/barebones-css.zip", "/kotlin-pom.xml", null, "com.example.myapp", "MyAppName", null, + KOTLIN(true, "/kotlin-src.zip", "/barebones-css.zip", "/kotlin-pom.xml", "com.example.myapp", "MyAppName", null, null, new String[] { "implementation(\"org.jetbrains.kotlin:kotlin-stdlib:" + GeneratorModel.KOTLIN_VERSION + "\")", "implementation(\"org.jetbrains:annotations:13.0\")" }), - // Grub is written against CodeRAD 1 (its models extend the CodeRAD 1 Entity - // class, which CodeRAD 2 turned into an interface), and that CodeRAD is the - // bundled grub-cn1libs.zip build -- a jar with no published coordinates. A - // Gradle project consumes cn1libs by Maven coordinates only, so there is - // nothing to point cn1lib(...) at. - GRUB(false, true, "/grub-src.zip", "/grub-css.zip", "/grub-pom.xml", "/grub-cn1libs.zip", "com.codename1.demos.grub", "Grub", "grub.png", - "Grub needs the bundled CodeRAD 1 library, which is not published to a Maven repository; " - + "Gradle projects can only use cn1libs by Maven coordinates.", - new String[0]), - // Tweet's cn1libs are published, so its pom's dependencies translate directly, - // and the Gradle plugin's generateGuiSources turns src/main/rad/views into the - // Abstract* view classes. What stops it is the next step: the classes its sources - // import (SignupPage, *ModelWrapper, *Controller) are written by - // coderad-annotation-processor 2.0.5 inside javac, and that processor locates the - // project by walking up from user.dir for a pom.xml - // (HelperFunctions.findPom(new File(System.getProperty("user.dir"))) then - // .getParentFile()) -- under Gradle there is none, so compileJava fails with a - // NullPointerException. Measured against the 8.0-SNAPSHOT plugin. Clear the - // reason once CodeRAD's processor can find a Gradle project; the lines below are - // already the right dependencies. - TWEET(false, true, "/tweet-src.zip", "/tweet-css.zip", "/tweet-pom.xml", null, "com.example.myapp", "MyAppName", "tweet.png", - "The Tweet template's CodeRAD annotation processor (2.0.5) finds its project through a pom.xml, " - + "so it cannot run in a Gradle build yet.", - new String[] { - "cn1lib(\"com.codenameone:coderad-lib:2.0.5\")", - "cn1lib(\"com.codenameone:tweet-app-ui-kit-lib:1.0-pre1\")", - "annotationProcessor(\"com.codenameone:coderad-annotation-processor:2.0.5\")" - }); + // A whole application rather than a starting point: a ride-hailing app with a + // rider, a driver and an admin mode, and the server it talks to. It is the + // project in scripts/wayline, which CI builds and tests; the resources named + // here are derived from it by scripts/sync-initializr-wayline.py and never + // edited by hand. + // + // Its preview is a resource of its own, read when the template is chosen, + // and not an image of the theme: the theme is loaded at start-up, so a + // preview kept there is paid for by everyone who opens the page, whichever + // template they pick. + // + // Its `shared` module is what makes it Maven only. The contract the app and + // the server both compile against is a module of its own, and a Gradle project + // from the Initializr is a single project with nowhere to put one. + WAYLINE(false, "/wayline-src.zip", "/wayline-css.zip", "/wayline-pom.xml", + "com.codenameone.examples.wayline", "Wayline", "/wayline-preview.jpg", + "The ride-hailing template is an app, a server and the contract module they share; " + + "an Initializr Gradle project is a single project.", + new String[0], "/wayline-modules.zip", "/wayline-settings.properties"); + /// The resource holding the picture shown for a template that has no live + /// preview, or null for one that has. public final String IMAGE_NAME; public final boolean IS_KOTLIN; - public final boolean USES_CODERAD; public final String SOURCE_ZIP; public final String CSS; public final String POM_XML; - public final String CN1LIB_ZIP; public final String SOURCE_PACKAGE; public final String SOURCE_MAIN_CLASS; /// Why this template cannot be generated as a Gradle project, or null when it can. @@ -76,20 +67,39 @@ public enum Template { /// Kotlin DSL lines for the `dependencies {}` block of a Gradle project's /// build.gradle.kts -- the Gradle form of what the template's pom declares. public final String[] GRADLE_DEPENDENCIES; + /// The modules of a full-stack template, a zip laid out as the project is -- + /// `shared/...`, `backend/...` -- or null for a template that is an app alone. + /// Its `backend/` replaces the generic server every other project gets. + public final String MODULES_ZIP; + /// Build hints the template's application needs on top of a plain project's + /// `codenameone_settings.properties`, or null. + public final String SETTINGS; - Template(boolean isKotlin, boolean usesCodeRad, String sourceZip, String css, String pomXml, String cn1libZip, String sourcePackage, String sourceMainClass, + Template(boolean isKotlin, String sourceZip, String css, String pomXml, String sourcePackage, String sourceMainClass, String imageName, String gradleUnsupportedReason, String[] gradleDependencies) { + this(isKotlin, sourceZip, css, pomXml, sourcePackage, sourceMainClass, imageName, + gradleUnsupportedReason, gradleDependencies, null, null); + } + + Template(boolean isKotlin, String sourceZip, String css, String pomXml, String sourcePackage, String sourceMainClass, + String imageName, String gradleUnsupportedReason, String[] gradleDependencies, String modulesZip, String settings) { IS_KOTLIN = isKotlin; - USES_CODERAD = usesCodeRad; SOURCE_ZIP = sourceZip; CSS = css; POM_XML = pomXml; - CN1LIB_ZIP = cn1libZip; SOURCE_PACKAGE = sourcePackage; SOURCE_MAIN_CLASS = sourceMainClass; IMAGE_NAME = imageName; GRADLE_UNSUPPORTED_REASON = gradleUnsupportedReason; GRADLE_DEPENDENCIES = gradleDependencies; + MODULES_ZIP = modulesZip; + SETTINGS = settings; + } + + /// Whether the template is an app together with its server: the project is + /// then Maven, has the `backend` and `shared` modules, and targets Java 17. + public boolean isFullStack() { + return MODULES_ZIP != null; } public boolean supportsGradle() { diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ZipEntryType.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ZipEntryType.java index 8f583ad943d..670bd670b84 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ZipEntryType.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ZipEntryType.java @@ -27,7 +27,8 @@ enum ZipEntryType { COMMON, COMMON_ARCHIVE, TEMPLATE_POM, - TEMPLATE_CN1LIB, TEMPLATE_CSS, - TEMPLATE_SOURCE + TEMPLATE_SOURCE, + /// A full-stack template's modules, which land where the archive has them. + TEMPLATE_MODULES } diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/AppIcon.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/AppIcon.java new file mode 100644 index 00000000000..a7978d7c81c --- /dev/null +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/AppIcon.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.initializr.ui; + +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.util.ImageIO; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/// The icon a generated project starts with when its colour is chosen here: the +/// first letter of the application's name on that colour. +/// +/// It is a placeholder a developer can ship with until they have artwork, and it +/// is the one thing that makes two generated apps tell apart on a home screen. +/// The image is square and opaque on purpose: every platform masks an icon to its +/// own shape, and iOS refuses one with transparency. +public final class AppIcon { + /// The size the build tools scale every platform's icons down from. + public static final int SIZE = 512; + + private AppIcon() { + } + + /// The icon for `appName` on `color` (0xRRGGBB), `size` pixels square. + public static Image create(String appName, int color, int size) { + Image image = Image.createImage(size, size, 0xff000000 | color); + Graphics g = image.getGraphics(); + g.setAntiAliased(true); + g.setAntiAliasedText(true); + g.setColor(color); + g.fillRect(0, 0, size, size); + String letter = initial(appName); + Font font = Font.createTrueTypeFont(Font.NATIVE_MAIN_BOLD, Font.NATIVE_MAIN_BOLD) + .derive(size * 0.56f, Font.STYLE_BOLD); + g.setFont(font); + g.setColor(readableOn(color)); + g.drawString(letter, (size - font.stringWidth(letter)) / 2, (size - font.getHeight()) / 2); + return image; + } + + /// The icon as the PNG a project keeps in `icon.png`, or null where this + /// platform cannot encode one; the project then keeps the stock icon. + public static byte[] png(String appName, int color) { + ImageIO io = ImageIO.getImageIO(); + if (io == null || !io.isFormatSupported(ImageIO.FORMAT_PNG)) { + return null; + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + io.save(create(appName, color, SIZE), out, ImageIO.FORMAT_PNG, 1f); + } catch (IOException failed) { + return null; + } + byte[] data = out.toByteArray(); + return data.length == 0 ? null : data; + } + + /// The letter on the icon: the first letter or digit of the name, in upper + /// case when it is ASCII, and `A` for a name that has none. + static String initial(String appName) { + String name = appName == null ? "" : appName.trim(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c >= 'a' && c <= 'z') { + return String.valueOf((char) (c - 'a' + 'A')); + } + if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + return String.valueOf(c); + } + } + return "A"; + } + + /// Near-black or white, whichever reads on `color`. + static int readableOn(int color) { + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + return (r * 299 + g * 587 + b * 114) / 1000 > 140 ? 0x0b1020 : 0xffffff; + } +} diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/TemplatePreviewPanel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/TemplatePreviewPanel.java index f6229c47b15..b2302a0c42f 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/TemplatePreviewPanel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/TemplatePreviewPanel.java @@ -1,3 +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. + */ package com.codename1.initializr.ui; import com.codename1.components.ImageViewer; @@ -14,6 +36,7 @@ import com.codename1.ui.Dialog; import com.codename1.ui.FontImage; import com.codename1.ui.Form; +import com.codename1.ui.Image; import com.codename1.ui.InterFormContainer; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.BoxLayout; @@ -30,6 +53,7 @@ public class TemplatePreviewPanel { private final Container root; private final Container previewHolder; private final ImageViewer staticPreview; + private final Hashtable loadedPreviews = new Hashtable(); private InterFormContainer liveFormPreview; private Template template; @@ -219,6 +243,35 @@ private void restoreThemeDefaults() { UIManager.getInstance().setThemeProps(resources.getTheme(names[0])); } + /// The picture shown for a template that has no live preview: a resource of + /// its own, read here, when the template is first shown. + private Image previewImage(Template shown) { + String name = shown.IMAGE_NAME; + if (name == null) { + return null; + } + Image loaded = (Image) loadedPreviews.get(name); + if (loaded == null) { + InputStream in = getResourceAsStream(name); + if (in == null) { + return null; + } + try { + loaded = Image.createImage(in); + loadedPreviews.put(name, loaded); + } catch (java.io.IOException unreadable) { + Log.e(unreadable); + } finally { + try { + in.close(); + } catch (java.io.IOException ignored) { + // Nothing to do about a stream that will not close. + } + } + } + return loaded; + } + private void updateMode() { if (template == Template.BAREBONES || template == Template.KOTLIN) { Form liveForm = createBarebonesPreviewForm(options); @@ -228,7 +281,7 @@ private void updateMode() { previewHolder.removeAll(); previewHolder.add(BorderLayout.CENTER, liveFormPreview); } else { - staticPreview.setImage(Resources.getGlobalResources().getImage(template.IMAGE_NAME)); + staticPreview.setImage(previewImage(template)); previewHolder.removeAll(); previewHolder.add(BorderLayout.CENTER, staticPreview); } diff --git a/scripts/initializr/common/src/main/resources/barebones-pom.xml b/scripts/initializr/common/src/main/resources/barebones-pom.xml index 00902d8594c..023d12354a5 100644 --- a/scripts/initializr/common/src/main/resources/barebones-pom.xml +++ b/scripts/initializr/common/src/main/resources/barebones-pom.xml @@ -356,6 +356,12 @@ bytecode-compliance css + + process-annotations diff --git a/scripts/initializr/common/src/main/resources/common.zip b/scripts/initializr/common/src/main/resources/common.zip index aca55473aaa..5b5d7091a55 100644 Binary files a/scripts/initializr/common/src/main/resources/common.zip and b/scripts/initializr/common/src/main/resources/common.zip differ diff --git a/scripts/initializr/common/src/main/resources/grub-cn1libs.zip b/scripts/initializr/common/src/main/resources/grub-cn1libs.zip deleted file mode 100644 index 40be59c70f2..00000000000 Binary files a/scripts/initializr/common/src/main/resources/grub-cn1libs.zip and /dev/null differ diff --git a/scripts/initializr/common/src/main/resources/grub-css.zip b/scripts/initializr/common/src/main/resources/grub-css.zip deleted file mode 100644 index 335c9174f84..00000000000 Binary files a/scripts/initializr/common/src/main/resources/grub-css.zip and /dev/null differ diff --git a/scripts/initializr/common/src/main/resources/grub-src.zip b/scripts/initializr/common/src/main/resources/grub-src.zip deleted file mode 100644 index 4df7d0b9c3b..00000000000 Binary files a/scripts/initializr/common/src/main/resources/grub-src.zip and /dev/null differ diff --git a/scripts/initializr/common/src/main/resources/kotlin-pom.xml b/scripts/initializr/common/src/main/resources/kotlin-pom.xml index 51f4bbeef1d..ad510baec2d 100644 --- a/scripts/initializr/common/src/main/resources/kotlin-pom.xml +++ b/scripts/initializr/common/src/main/resources/kotlin-pom.xml @@ -352,6 +352,12 @@ bytecode-compliance css + + process-annotations diff --git a/scripts/initializr/common/src/main/resources/skill/templates/MockupComparisonTest.java b/scripts/initializr/common/src/main/resources/skill/templates/MockupComparisonTest.java index b81a24a2085..5638fb873a9 100644 --- a/scripts/initializr/common/src/main/resources/skill/templates/MockupComparisonTest.java +++ b/scripts/initializr/common/src/main/resources/skill/templates/MockupComparisonTest.java @@ -1,24 +1,20 @@ /* - * 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. + * Copyright (c) 2026, Codename One and/or its affiliates. * - * 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). + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. * - * 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. */ // =========================================================================== // COPY-PASTE TEMPLATE - not compiled where it ships. diff --git a/scripts/initializr/common/src/main/resources/skill/tools/DumpForm.java b/scripts/initializr/common/src/main/resources/skill/tools/DumpForm.java index 111ad16f523..3ca939e3aac 100644 --- a/scripts/initializr/common/src/main/resources/skill/tools/DumpForm.java +++ b/scripts/initializr/common/src/main/resources/skill/tools/DumpForm.java @@ -1,24 +1,20 @@ /* - * 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. + * Copyright (c) 2026, Codename One and/or its affiliates. * - * 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). + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. * - * 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. */ import com.codename1.impl.javase.JavaSEPort; import com.codename1.system.Lifecycle; diff --git a/scripts/initializr/common/src/main/resources/skill/tools/IsApiSupported.java b/scripts/initializr/common/src/main/resources/skill/tools/IsApiSupported.java index 9862eafbf16..b54d47a5bf1 100644 --- a/scripts/initializr/common/src/main/resources/skill/tools/IsApiSupported.java +++ b/scripts/initializr/common/src/main/resources/skill/tools/IsApiSupported.java @@ -1,24 +1,20 @@ /* - * 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. + * Copyright (c) 2026, Codename One and/or its affiliates. * - * 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). + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. * - * 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. */ import java.io.IOException; import java.nio.file.DirectoryStream; diff --git a/scripts/initializr/common/src/main/resources/skill/tools/IsCssValid.java b/scripts/initializr/common/src/main/resources/skill/tools/IsCssValid.java index 2dab5e35f6c..2e68ac97b3c 100644 --- a/scripts/initializr/common/src/main/resources/skill/tools/IsCssValid.java +++ b/scripts/initializr/common/src/main/resources/skill/tools/IsCssValid.java @@ -1,24 +1,20 @@ /* - * 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. + * Copyright (c) 2026, Codename One and/or its affiliates. * - * 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). + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. * - * 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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. */ import java.io.IOException; import java.lang.reflect.Constructor; diff --git a/scripts/initializr/common/src/main/resources/tweet-css.zip b/scripts/initializr/common/src/main/resources/tweet-css.zip deleted file mode 100644 index ac77bc2dccc..00000000000 Binary files a/scripts/initializr/common/src/main/resources/tweet-css.zip and /dev/null differ diff --git a/scripts/initializr/common/src/main/resources/tweet-pom.xml b/scripts/initializr/common/src/main/resources/tweet-pom.xml deleted file mode 100644 index cb8f999963d..00000000000 --- a/scripts/initializr/common/src/main/resources/tweet-pom.xml +++ /dev/null @@ -1,395 +0,0 @@ - - - 4.0.0 - - com.example.myapp - myappname - 1.0-SNAPSHOT - - com.example.myapp - myappname-common - 1.0-SNAPSHOT - jar - - - - - com.codenameone - codenameone-core - - - com.codenameone - codenameone-javase - test - - - com.codenameone - coderad-lib - 2.0.5 - pom - - - com.codenameone - tweet-app-ui-kit-lib - 1.0-pre1 - pom - - - - - - - - - - - install-codenameone - ${user.home}/.codenameone/guibuilder.jar - - - - org.apache.maven.plugins - maven-antrun-plugin - - - - validate - - run - - - - - - - - - - - - - - - - - - - - - - - - - - - kotlin - - - - ${basedir}/src/main/kotlin - - - - 1.6.0 - true - - - - org.jetbrains.kotlin - kotlin-stdlib - ${kotlin.version} - - - - - - org.jetbrains - annotations - 13.0 - - - com.codenameone - java-runtime - provided - - - - - - org.codehaus.mojo - properties-maven-plugin - 1.0.0 - - - initialize - - read-project-properties - - - - ${basedir}/codenameone_settings.properties - - - - - - - org.jetbrains.kotlin - kotlin-maven-plugin - ${kotlin.version} - - - compile - - compile - - - - ${project.basedir}/src/main/kotlin - ${project.basedir}/src/main/java - - - -no-reflect - -no-jdk - - - - - test-compile - - test-compile - - - - ${project.basedir}/src/test/kotlin - ${project.basedir}/src/test/java - - - -no-reflect - -no-jdk - - - - - - - - - - - - - javase - - - codename1.platform - javase - - - - javase - - - - - org.codehaus.mojo - exec-maven-plugin - - java - true - - -Xmx1024M - - -classpath - - ${exec.mainClass} - ${cn1.mainClass} - - - - - - - - - - simulator - - javase - - - - - - ios-debug - - - iphone - - - ios - - - - - ios-release - - - iphone - true - - - ios - true - - - - - javascript - - javascript - javascript - - - - - android - - android - android - - - - - windows - - desktop_windows - javase - - - - - mac - - desktop_macosx - javase - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - - 1.8 - 1.8 - - - com.codenameone - coderad-annotation-processor - 2.0.5 - - - - - - org.codehaus.mojo - properties-maven-plugin - 1.0.0 - - - initialize - - read-project-properties - - - - ${basedir}/codenameone_settings.properties - - - - - - - - com.codenameone - codenameone-maven-plugin - - - xalan - serializer - 2.7.3 - - - - - - generate-gui-sources - process-sources - - generate-gui-sources - - - - cn1-process-classes - process-classes - - bytecode-compliance - css - - - - - attach-test-artifact - test - - attach-test-artifact - - - - - - - - - - - - - - diff --git a/scripts/initializr/common/src/main/resources/tweet-src.zip b/scripts/initializr/common/src/main/resources/tweet-src.zip deleted file mode 100644 index add354048e9..00000000000 Binary files a/scripts/initializr/common/src/main/resources/tweet-src.zip and /dev/null differ diff --git a/scripts/initializr/common/src/main/resources/wayline-css.zip b/scripts/initializr/common/src/main/resources/wayline-css.zip new file mode 100644 index 00000000000..8ee8ad61fea Binary files /dev/null and b/scripts/initializr/common/src/main/resources/wayline-css.zip differ diff --git a/scripts/initializr/common/src/main/resources/wayline-modules.zip b/scripts/initializr/common/src/main/resources/wayline-modules.zip new file mode 100644 index 00000000000..8337819443a Binary files /dev/null and b/scripts/initializr/common/src/main/resources/wayline-modules.zip differ diff --git a/scripts/initializr/common/src/main/resources/grub-pom.xml b/scripts/initializr/common/src/main/resources/wayline-pom.xml similarity index 91% rename from scripts/initializr/common/src/main/resources/grub-pom.xml rename to scripts/initializr/common/src/main/resources/wayline-pom.xml index bcbf61f9d1f..781c692b810 100644 --- a/scripts/initializr/common/src/main/resources/grub-pom.xml +++ b/scripts/initializr/common/src/main/resources/wayline-pom.xml @@ -13,6 +13,12 @@ + + + ${project.groupId} + myappname-shared + ${project.version} + com.codenameone codenameone-core @@ -23,14 +29,7 @@ test - - myappname-CodeRAD - com.example.myapp - 1.0-SNAPSHOT - pom - - - + @@ -60,6 +59,10 @@ + bytecode-compliance css + + process-annotations @@ -372,7 +381,13 @@ - + + org.apache.maven.plugins + maven-surefire-plugin + + true + + diff --git a/scripts/initializr/common/src/main/resources/wayline-preview.jpg b/scripts/initializr/common/src/main/resources/wayline-preview.jpg new file mode 100644 index 00000000000..8c3cb8c9d4b Binary files /dev/null and b/scripts/initializr/common/src/main/resources/wayline-preview.jpg differ diff --git a/scripts/initializr/common/src/main/resources/wayline-settings.properties b/scripts/initializr/common/src/main/resources/wayline-settings.properties new file mode 100644 index 00000000000..bb4cedb3b33 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/wayline-settings.properties @@ -0,0 +1,28 @@ +# A server started on this machine is reached over plain HTTP, which Android +# refuses by default. Remove this once the app talks to a server with a +# certificate. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" +# What iOS shows when someone applying to drive photographs a document. +codename1.arg.ios.NSCameraUsageDescription=The camera photographs the documents of your application to drive. +# What iOS shows when they pick a picture of a document they already have. +codename1.arg.ios.NSPhotoLibraryUsageDescription=Your photo library is where you pick a picture of a document from. +# What iOS shows before Face ID unlocks the sign-in kept in secure storage. +codename1.arg.ios.NSFaceIDUsageDescription=Authenticate to securely access your account +# What iOS shows when the app first asks where the device is. +codename1.arg.ios.NSLocationWhenInUseUsageDescription=Your location is where a ride picks you up, and where riders find you while you drive. +# While a driver is online the app keeps reporting where they are from the +# background: iOS needs the location background mode and the Always prompt's +# text, Android a foreground service of the location type. +codename1.arg.ios.background_modes=location +codename1.arg.ios.locationUsageDescription=Your location is where a ride picks you up, and where riders find you while you drive. +codename1.arg.ios.NSLocationAlwaysAndWhenInUseUsageDescription=While you are online as a driver, Wayline keeps sharing your location with riders and dispatch, even with the app in the background. +codename1.arg.android.foregroundServiceType=location +# Push notifications. With push included the build adds the +# remote-notification background mode and the push entitlement itself. +codename1.arg.ios.includePush=true +# Android push is off until the app has its Firebase file: a build that +# uses PushClient without google-services.json stops with an error. Once +# native/android/google-services.json is in place, delete this line. +codename1.arg.android.messagingService=none +# The ride on the lock screen updates as the driver moves. +codename1.arg.ios.surfaces.frequentUpdates=true diff --git a/scripts/initializr/common/src/main/resources/wayline-src.zip b/scripts/initializr/common/src/main/resources/wayline-src.zip new file mode 100644 index 00000000000..89edf2e9545 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/wayline-src.zip differ diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java index 104ea95bf3b..b50dc940178 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java @@ -49,6 +49,15 @@ * using selected JDK homes. */ public class GeneratorModelIntegrationBuildTest extends AbstractTest { + /// Each generated project is built by a Maven of its own, in a home of its + /// own, so every build starts by downloading its plugins and the framework. + /// On a machine that has never built one, two of those do not fit in the two + /// minutes a test gets unless it says otherwise. + @Override + public int getTimeoutMillis() { + return 20 * 60 * 1000; + } + @Override public boolean runTest() throws Exception { Path java8Or11 = findJava8Or11Home(); @@ -139,23 +148,16 @@ private void buildGeneratedGradleProjects(Path java17) throws Exception { Path repoDir = Paths.get(repo).toAbsolutePath(); String version = System.getenv("CN1_GRADLE_PLUGIN_VERSION"); // Every project type with the Java template, plus each template whose build - // script adds something of its own (the Kotlin plugin, Tweet's cn1libs). + // script adds something of its own (the Kotlin plugin). Object[][] cases = new Object[][] { {Template.BAREBONES, ProjectOptions.ProjectType.APP}, {Template.BAREBONES, ProjectOptions.ProjectType.APP_WITH_BACKEND}, {Template.BAREBONES, ProjectOptions.ProjectType.BACKEND_ONLY}, - {Template.KOTLIN, ProjectOptions.ProjectType.APP}, - // cn1libs by coordinates, CodeRAD XML views and an annotation processor. - {Template.TWEET, ProjectOptions.ProjectType.APP} + {Template.KOTLIN, ProjectOptions.ProjectType.APP} }; for (int i = 0; i < cases.length; i++) { Template template = (Template) cases[i][0]; ProjectOptions.ProjectType type = (ProjectOptions.ProjectType) cases[i][1]; - if (!template.supportsGradle()) { - // Listed so it is built as soon as the template is enabled for Gradle. - System.out.println("[WARN] Skipping " + template + ": " + template.GRADLE_UNSUPPORTED_REASON); - continue; - } String suffix = "gradle" + i; ProjectOptions options = new ProjectOptions(ProjectOptions.ThemeMode.LIGHT, ProjectOptions.Accent.DEFAULT, true, true, ProjectOptions.PreviewLanguage.ENGLISH, ProjectOptions.JavaVersion.JAVA_17, null, @@ -224,6 +226,40 @@ private void buildGeneratedMavenLayouts(Path java17, Path buildClient) throws Ex assertTrue(exit == 0, "The minimal app's simulator profile should resolve | exitCode=" + exit); assertTrue(Files.isRegularFile(app.resolve("common/target/codenameone/simulator-classpath.args")), "The simulator should be prepared from common"); + + buildGeneratedFullStackProject(homeDir, java17, repoLocal, version); + } + + /// The full-stack template, in another colour scheme than its own: the server's + /// tests pass against the contract module, and the app compiles against the same + /// module with its stylesheet. Both halves take their half of the contract from + /// `shared`, so a template that reached a download with the two out of step + /// fails here in whichever did not follow. + private void buildGeneratedFullStackProject(Path homeDir, Path java17, String repoLocal, String version) + throws Exception { + ProjectOptions options = layoutOptions(ProjectOptions.ProjectType.APP_WITH_BACKEND).forFullStack() + .withScheme(0x6d3fd1, false); + Path project = generateLayoutProject(Template.WAYLINE, options, "LayoutRideApp", "com.acme.initializr.ride", + version); + assertTrue(Files.isRegularFile(project.resolve("shared/pom.xml")), + "A full-stack project should have its contract module"); + int exit = runMaven(project, homeDir, java17, repoLocal, "test", "-Dcodename1.platform=backend", + "-pl", "shared,backend", "-am"); + assertTrue(exit == 0, "The full-stack project's server should build and pass its tests | exitCode=" + exit); + File[] reports = project.resolve("backend/target/surefire-reports").toFile().listFiles( + (d, n) -> n.startsWith("TEST-") && n.endsWith(".xml")); + assertTrue(reports != null && reports.length > 0, "The server's tests should have run, not been skipped"); + assertTrue(Files.isRegularFile(project.resolve( + "backend/target/generated-sources/cn1-annotations/com/acme/initializr/ride/api/RiderApiServer.java")), + "The server's half of the contract should be generated from the shared module"); + exit = runMaven(project, homeDir, java17, repoLocal, "package", "-DskipTests=true", + "-Dcodename1.platform=javase"); + assertTrue(exit == 0, "The full-stack project's app should build from common | exitCode=" + exit); + assertTrue(Files.isRegularFile(project.resolve("common/target/classes/theme.res")), + "The app's stylesheet should compile in the chosen scheme"); + assertTrue(Files.isRegularFile(project.resolve( + "common/target/classes/com/acme/initializr/ride/api/RiderApiImpl.class")), + "The app's half of the contract should be generated from the shared module"); } private ProjectOptions layoutOptions(ProjectOptions.ProjectType type) { @@ -235,9 +271,15 @@ private ProjectOptions layoutOptions(ProjectOptions.ProjectType type) { /// A project generated on the layouts side of the gate, with the poms pointed at `version`. private Path generateLayoutProject(ProjectOptions options, String appName, String packageName, String version) throws Exception { + return generateLayoutProject(Template.BAREBONES, options, appName, packageName, version); + } + + private Path generateLayoutProject(Template template, ProjectOptions options, String appName, String packageName, + String version) throws Exception { ByteArrayOutputStream output = new ByteArrayOutputStream(); - GeneratorModel.createForPluginVersion(IDE.INTELLIJ, Template.BAREBONES, appName, packageName, options, - GeneratorModel.MAVEN_LAYOUTS_SINCE).writeProjectZip(output); + GeneratorModel.createForPluginVersion(IDE.INTELLIJ, template, appName, packageName, options, + template.isFullStack() ? GeneratorModel.FULL_STACK_SINCE : GeneratorModel.MAVEN_LAYOUTS_SINCE) + .writeProjectZip(output); Path dir = Files.createTempDirectory("initializr-layout-" + appName + "-"); unzipProject(output.toByteArray(), dir); Path pom = dir.resolve("pom.xml"); diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index 255ffe0e268..94684d5a5a5 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -103,8 +103,16 @@ public boolean runTest() throws Exception { validateVersionGate(); validateMavenProjectTypeBeforeTheGate(); validateMavenRefusals(); + validateFullStackTemplate(); for (Template template : Template.values()) { for (ProjectOptions.ProjectType type : ProjectOptions.ProjectType.values()) { + if (template.isFullStack() && type == ProjectOptions.ProjectType.APP) { + // An app with its server is not offered without the server. + assertRefused(GeneratorModel.createForPluginVersion(IDE.INTELLIJ, template, "NoServer", + "com.acme.noserver", mavenOptions(type, false), FULL_STACK_VERSION), + "an app and its server", template + " must refuse to leave its server out"); + continue; + } for (IDE ide : IDE.values()) { validateMavenLayoutCombination(template, type, false, ide); if (type != ProjectOptions.ProjectType.BACKEND_ONLY) { @@ -127,6 +135,11 @@ public boolean runTest() throws Exception { } } for (Template template : Template.values()) { + if (template.isFullStack()) { + // Not generated against a plugin from before the layouts, which is + // what this loop uses; the layout loop above covers it for every IDE. + continue; + } for (IDE ide : IDE.values()) { validateCombination(template, ide); } @@ -159,15 +172,22 @@ private void validateGradleCombination(Template template, ProjectOptions.Project assertFalse(path.startsWith("mvnw") || path.startsWith(".mvn/"), label + "Maven wrapper leaked: " + path); assertFalse(path.equals("build.sh") || path.equals("run.sh") || path.equals("build.bat") || path.equals("run.bat"), label + "Maven launcher leaked: " + path); - String[] mavenModules = {"android/", "ios/", "javase/", "javascript/", "linux/", "win/", "cn1libs/"}; + String[] mavenModules = {"android/", "ios/", "javase/", "javascript/", "linux/", "win/"}; for (int i = 0; i < mavenModules.length; i++) { assertFalse(path.startsWith(mavenModules[i]), label + "Maven module leaked: " + path); } // Native directories (src//) are created on demand by // generateNativeInterfaces, never shipped empty or pre-populated. - if (path.startsWith("src/") || path.startsWith("backend/src/")) { - String rel = path.startsWith("backend/") ? path.substring("backend/".length()) : path; - assertTrue(rel.startsWith("src/main/"), label + "unexpected source set: " + path); + // The server is the exception: its @BackendTest samples go along, as + // they do in every other backend layout. A backend-only project is + // the server, so there they sit at the root. + if (path.startsWith("src/")) { + boolean serverTest = type == ProjectOptions.ProjectType.BACKEND_ONLY + && path.startsWith("src/test/java/"); + assertTrue(path.startsWith("src/main/") || serverTest, label + "unexpected source set: " + path); + } else if (path.startsWith("backend/src/")) { + assertTrue(path.startsWith("backend/src/main/") || path.startsWith("backend/src/test/java/"), + label + "unexpected source set: " + path); } assertFalse(path.indexOf("com/example/myapp") >= 0, label + "Unrefactored placeholder path found: " + path); } @@ -347,6 +367,133 @@ private void validateMavenRefusals() { "platform modules", "A backend-only project with every platform module makes no sense"); } + /// What a full-stack template adds to an app with a backend: the contract + /// module both halves compile against, the template's own server in place of + /// the generic one, and nothing left that still carries the template's name. + private void assertFullStackProject(Map entries, byte[] zipData, String packageName, + String mainClassName, String label) throws IOException { + String packagePath = StringUtil.replaceAll(packageName, ".", "/"); + String artifact = GeneratorModel.toLowerCaseInvariant(mainClassName); + String rootPom = removeWhitespace(getText(entries, "pom.xml")); + assertContains(rootPom, "sharedcommon", + label + "the shared module is built ahead of the app"); + assertTrue(rootPom.indexOf("shared") == rootPom.lastIndexOf("shared"), + label + "the shared module is in the reactor once"); + String sharedPom = getText(entries, "shared/pom.xml"); + assertContains(sharedPom, "" + artifact + "-shared", label + "shared module id"); + assertContains(getText(entries, "common/pom.xml"), "" + artifact + "-shared", + label + "the app depends on the contract"); + assertContains(getText(entries, "backend/pom.xml"), "" + artifact + "-shared", + label + "the server depends on the contract"); + assertContains(getText(entries, "backend/pom.xml"), "generate-annotation-stubs", + label + "the server generates its half of the contract"); + + assertContains(getText(entries, "shared/src/main/java/" + packagePath + "/api/RiderApi.java"), + "package " + packageName + ".api;", label + "the contract moves to the project's package"); + assertContains(getText(entries, "backend/src/main/java/" + packagePath + "/SecurityConfig.java"), + "package " + packageName + ";", label + "the server moves to the project's package"); + assertNotNull(entries.get("backend/src/main/resources/db/migration/V1__accounts.sql"), + label + "the server keeps its migrations"); + assertNotNull(entries.get("backend/src/test/java/" + packagePath + "/RideFlowTest.java"), + label + "the server keeps its tests"); + assertNotNull(entries.get("common/src/main/java/" + packagePath + "/rider/RiderForm.java"), + label + "the app's screens move to the project's package"); + assertNotNull(entries.get("common/src/main/l10n/Bundle.properties"), label + "the app keeps its bundle"); + // The generic server every other project gets, which this one replaces. + assertNull(entries.get("backend/src/main/java/" + packagePath + "/Greeter.java"), + label + "the generic server's sources were left beside the template's"); + assertNull(entries.get("backend/src/test/java/" + packagePath + "/ApiTest.java"), + label + "the generic server's tests were left beside the template's"); + assertTrue(unixMode(zipData, "backend/server.sh") == 0100755, label + "server.sh must extract executable"); + + String settings = getText(entries, "common/codenameone_settings.properties"); + assertContains(settings, "codename1.arg.android.xapplication_attr=", label + "the template's Android hint"); + assertContains(settings, "codename1.arg.ios.NSLocationWhenInUseUsageDescription=", + label + "the template's iOS location prompt"); + assertContains(settings, "codename1.arg.java.version=17", label + "a full-stack project is Java 17"); + assertContains(getText(entries, "README.md"), "## The app and its server", + label + "README should explain the app and its server"); + + for (Map.Entry entry : entries.entrySet()) { + String path = entry.getKey(); + assertFalse(path.indexOf("wayline") >= 0 || path.indexOf("Wayline") >= 0, + label + "the template's name is still in the path " + path); + if (path.endsWith(".png") || path.endsWith(".jar") || path.endsWith(".res") || path.endsWith(".ttf")) { + continue; + } + String text = StringUtil.newString(entry.getValue()); + assertFalse(text.indexOf("wayline") >= 0 || text.indexOf("Wayline") >= 0 || text.indexOf("WAYLINE") >= 0, + label + "the template's name is still in " + path); + assertFalse(text.indexOf("myappname") >= 0 || text.indexOf("MyAppName") >= 0 + || text.indexOf("MYAPPNAME") >= 0, + label + "an unreplaced placeholder is in " + path); + } + } + + /// The choices a full-stack template adds -- a colour scheme and an icon -- and + /// the combinations it refuses. + private void validateFullStackTemplate() throws Exception { + ProjectOptions base = mavenOptions(ProjectOptions.ProjectType.APP_WITH_BACKEND, false); + assertRefused(GeneratorModel.createForPluginVersion(IDE.INTELLIJ, Template.WAYLINE, "TooEarly", + "com.acme.tooearly", base, LEGACY_VERSION), + "contract module", "A full-stack template needs a plugin that builds its shared module"); + assertFalse(GeneratorModel.isVersionAtLeast(LEGACY_VERSION, GeneratorModel.FULL_STACK_SINCE), + "The gate should be closed before the release that builds a shared module"); + assertFalse(GeneratorModel.isVersionAtLeast(LAYOUTS_VERSION, GeneratorModel.FULL_STACK_SINCE), + "The layouts alone should not open the gate"); + assertTrue(GeneratorModel.isVersionAtLeast(FULL_STACK_VERSION, GeneratorModel.FULL_STACK_SINCE), + "The gate should be open from the release that builds a shared module"); + ProjectOptions java8 = new ProjectOptions(ProjectOptions.ThemeMode.LIGHT, ProjectOptions.Accent.DEFAULT, + true, false, ProjectOptions.PreviewLanguage.ENGLISH, ProjectOptions.JavaVersion.JAVA_8, null, + ProjectOptions.BuildTool.MAVEN, ProjectOptions.ProjectType.APP_WITH_BACKEND); + assertRefused(GeneratorModel.createForPluginVersion(IDE.INTELLIJ, Template.WAYLINE, "NoJava8", + "com.acme.nojava8", java8, FULL_STACK_VERSION), "Java 17", "A full-stack template is Java 17"); + ProjectOptions forced = java8.withBuild(ProjectOptions.BuildTool.GRADLE, ProjectOptions.ProjectType.APP) + .forFullStack(); + assertTrue(!forced.isGradle() && forced.projectType == ProjectOptions.ProjectType.APP_WITH_BACKEND + && forced.javaVersion == ProjectOptions.JavaVersion.JAVA_17, + "forFullStack should settle the build, the project type and the Java level"); + + String themePath = "common/src/main/css/theme.css"; + String stock = getText(mavenEntries(FULL_STACK_VERSION, Template.WAYLINE, IDE.INTELLIJ, "StockApp", + "com.acme.stock", base), themePath); + String brand = Integer.toHexString(GeneratorModel.FULL_STACK_BRAND); + assertContains(stock, "--brand: #" + brand + ";", + "GeneratorModel.FULL_STACK_BRAND should be the brand colour the template ships with"); + assertContains(stock, "--radius: 2.4mm;", "The stock scheme keeps the template's corners"); + assertTrue(stock.equals(GeneratorModel.applyThemeVariables(stock, base)), + "The stock scheme must leave the stylesheet exactly as the template has it"); + + byte[] icon = new byte[] {(byte) 0x89, 'P', 'N', 'G', 1, 2, 3}; + Map styled = mavenEntries(FULL_STACK_VERSION, Template.WAYLINE, IDE.INTELLIJ, "StyledApp", + "com.acme.styled", base.withScheme(0x0f766e, false).withIcon(icon)); + String css = getText(styled, themePath); + assertContains(css, "--brand: #0f766e;", "The chosen brand colour should be the theme's"); + assertFalse(css.indexOf("#" + brand) >= 0, "The template's own brand colour should be gone from the theme"); + assertFalse(css.indexOf("#edf2ff") >= 0 || css.indexOf("#1a2440") >= 0, + "The brand's tints should follow the chosen colour in both modes"); + assertContains(css, "--radius: 0.6mm;", "Square corners should set the radius"); + assertContains(css, "--radius-small: 0.4mm;", "Square corners should set the small radius"); + assertContains(css, "@media (prefers-color-scheme: dark)", "The theme keeps its dark mode"); + assertFalse(css.indexOf("Initializr Theme Overrides") >= 0, + "A scheme is set through the variables, never appended as overrides"); + assertTrue(css.length() > stock.length() - 64 && css.length() < stock.length() + 64, + "Choosing a scheme should rewrite declarations and add no rules"); + assertTrue(java.util.Arrays.equals(icon, styled.get("common/icon.png")), + "The chosen icon should replace the stock one"); + assertFalse(java.util.Arrays.equals(icon, mavenEntries(FULL_STACK_VERSION, Template.WAYLINE, IDE.INTELLIJ, + "StockApp", "com.acme.stock", base).get("common/icon.png")), + "Without a chosen icon the project keeps the stock one"); + + String missing = null; + try { + GeneratorModel.applyThemeVariables("Button { color: red; }", base.withScheme(0x0f766e, true)); + } catch (IOException expected) { + missing = expected.getMessage(); + } + assertNotNull(missing, "A stylesheet without the variables must fail the generation, not drop the choice"); + } + private void assertRefused(GeneratorModel model, String expectedReason, String message) { String reason = null; try { @@ -363,6 +510,8 @@ private void assertRefused(GeneratorModel model, String expectedReason, String m /// release the initializr generates against. private static final String LEGACY_VERSION = "7.0.274"; private static final String LAYOUTS_VERSION = "7.0.275"; + /// The version the full-stack combinations are generated for. + private static final String FULL_STACK_VERSION = GeneratorModel.FULL_STACK_SINCE; private static ProjectOptions mavenOptions(ProjectOptions.ProjectType type, boolean allModules) { return ProjectOptions.defaults().withBuild(ProjectOptions.BuildTool.MAVEN, type).withPlatformModules(allModules); @@ -388,6 +537,20 @@ private static byte[] mavenZip(String pluginVersion, Template template, IDE ide, return output.toByteArray(); } + private static boolean sameEntries(byte[] zip, byte[] other) throws Exception { + Map entries = readZipEntries(zip); + Map others = readZipEntries(other); + if (!entries.keySet().equals(others.keySet())) { + return false; + } + for (Map.Entry entry : entries.entrySet()) { + if (!java.util.Arrays.equals(entry.getValue(), others.get(entry.getKey()))) { + return false; + } + } + return true; + } + private void validateVersionGate() throws Exception { assertTrue(GeneratorModel.isVersionAtLeast("7.0.275", "7.0.275"), "the gate's own release is in"); assertFalse(GeneratorModel.isVersionAtLeast("7.0.274", "7.0.275"), "the release before it is not"); @@ -407,7 +570,10 @@ private void validateVersionGate() throws Exception { mavenOptions(ProjectOptions.ProjectType.APP, false)); byte[] fullAsked = mavenZip(LEGACY_VERSION, Template.BAREBONES, IDE.INTELLIJ, "GateApp", "com.acme.gate", mavenOptions(ProjectOptions.ProjectType.APP_WITH_BACKEND, true)); - assertTrue(java.util.Arrays.equals(defaults, minimalAsked) && java.util.Arrays.equals(defaults, fullAsked), + // Entry by entry, not byte by byte: an entry is stamped with the time it + // was written, to two seconds, so two archives of the same files differ + // whenever the clock crosses one of those between them. + assertTrue(sameEntries(defaults, minimalAsked) && sameEntries(defaults, fullAsked), "Before the gate the project type and modules choice must not change a Maven download"); Map legacy = readZipEntries(defaults); assertNotNull(legacy.get("javase/pom.xml"), "Before the gate a Maven app keeps its platform modules"); @@ -460,11 +626,12 @@ private void validateMavenLayoutCombination(Template template, ProjectOptions.Pr String mainClassName = "Mvn" + template.ordinal() + type.ordinal() + (allModules ? 1 : 0) + ide.ordinal() + "App"; String packageName = "com.acme.m" + template.ordinal() + ".t" + type.ordinal() + ".i" + ide.ordinal(); String label = template + "/" + type + "/" + (allModules ? "full" : "minimal") + "/" + ide + ": "; - byte[] zipData = mavenZip(LAYOUTS_VERSION, template, ide, mainClassName, packageName, + String version = template.isFullStack() ? FULL_STACK_VERSION : LAYOUTS_VERSION; + byte[] zipData = mavenZip(version, template, ide, mainClassName, packageName, mavenOptions(type, allModules)); Map entries = readZipEntries(zipData); String rootPom = getText(entries, "pom.xml"); - assertContains(rootPom, "7.0.276", + assertContains(rootPom, "" + version + "", label + "the root pom should name the plugin the download was generated against"); assertCodenameOneRepository(rootPom, label); assertNotNull(entries.get("mvnw"), label + "missing mvnw"); @@ -510,6 +677,13 @@ private void validateMavenLayoutCombination(Template template, ProjectOptions.Pr assertNotNull(entries.get("backend/pom.xml"), label + "App + backend has the backend module"); assertContains(getText(entries, "README.md"), "## Backend", label + "README should explain the backend"); } + if (template.isFullStack()) { + assertFullStackProject(entries, zipData, packageName, mainClassName, label); + } else { + assertNull(entries.get("shared/pom.xml"), label + "only a full-stack template has a shared module"); + assertFalse(rootPom.indexOf("shared") >= 0, + label + "the root pom names a shared module the project does not have"); + } String[] modules = {"javascript", "ios", "win", "linux", "backend", "android", "javase"}; for (int i = 0; i < modules.length; i++) { assertContains(removeWhitespace(rootPom), "${basedir}/" + modules[i] + "/pom.xml", @@ -567,17 +741,16 @@ private void validateMavenBackendOnly(Map entries, IDE ide, Stri } } - /// No template that has cn1libs generates for Gradle yet (see Template), so the - /// translation from the template's pom to build.gradle.kts is checked directly. + /// The translation from a template's pom to build.gradle.kts, checked directly + /// on the build script. private void validateGradleTemplateDependencies() { - String script = GeneratorModel.create(IDE.INTELLIJ, Template.TWEET, "LibsApp", "com.acme.libs", + String script = GeneratorModel.create(IDE.INTELLIJ, Template.KOTLIN, "LibsApp", "com.acme.libs", gradleOptions(ProjectOptions.ProjectType.APP)).gradleAppBuildScript("// c\ndependencies {\n}\n"); - assertContains(script, "dependencies {\n cn1lib(\"com.codenameone:coderad-lib:2.0.5\")", - "Template cn1libs should become cn1lib(...) lines inside dependencies {}"); - assertContains(script, "cn1lib(\"com.codenameone:tweet-app-ui-kit-lib:1.0-pre1\")", - "Every template cn1lib should be declared"); - assertContains(script, "annotationProcessor(\"com.codenameone:coderad-annotation-processor:2.0.5\")", - "The template's annotation processor should be declared"); + assertContains(script, "dependencies {\n implementation(\"org.jetbrains.kotlin:kotlin-stdlib:" + + GeneratorModel.KOTLIN_VERSION + "\")", + "Template libraries should become lines inside dependencies {}"); + assertContains(script, "implementation(\"org.jetbrains:annotations:13.0\")", + "Every template library should be declared"); String bare = GeneratorModel.create(IDE.INTELLIJ, Template.BAREBONES, "LibsApp", "com.acme.libs", gradleOptions(ProjectOptions.ProjectType.APP)).gradleAppBuildScript("dependencies {\n}\n"); assertEqual("dependencies {\n}\n", bare, "A template with no libraries leaves the build script alone"); @@ -1306,14 +1479,14 @@ private void assertCommonPom(Map entries, Template template, Str assertContains(pom, "1.8", "Common pom should use Java 8 source when legacy Java 8 is selected"); assertContains(pom, "1.8", "Common pom should use Java 8 target when legacy Java 8 is selected"); } - if (template == Template.GRUB) { - assertContains(pom, "" + GeneratorModel.toLowerCaseInvariant(mainClassName) + "-CodeRAD", "Grub common pom should include local CodeRAD cn1lib dependency"); - assertContains(pom, "1.0-SNAPSHOT", "Grub common pom should use local snapshot CodeRAD cn1lib"); - } - if (template == Template.TWEET) { - assertContains(pom, "tweet-app-ui-kit-lib", "Tweet common pom should include Tweet UI Kit dependency"); - assertContains(pom, "coderad-annotation-processor", "Tweet common pom should include CodeRAD annotation processor path"); - assertContains(pom, "", "Tweet common pom should configure annotation processors"); + if (template == Template.BAREBONES || template == Template.KOTLIN || template.isFullStack()) { + // Without the goal a @Route, a @RestClient interface or a @Mapped class + // compiles and then does nothing: no dispatcher, client or mapper is + // generated, and the app finds that out when it runs. + assertContains(pom, "process-annotations", + "Common pom should generate what the project's annotations ask for"); + assertTrue(pom.indexOf("css") < pom.indexOf("process-annotations"), + "process-annotations should run in the process-classes execution, after css"); } assertFalse(pom.indexOf("com.example.myapp") >= 0, "Common pom still contains placeholder package"); assertFalse(pom.indexOf("myappname") >= 0, "Common pom still contains placeholder app name"); @@ -1393,15 +1566,6 @@ private void assertMainSourceFile(Map entries, Template template assertFalse(mainSource.indexOf("setBundle") >= 0, "Barebones starter should not install localization bundle by default"); } } - if (template == Template.GRUB) { - String grubModel = getText(entries, "common/src/main/java/" + packagePath + "/models/AccountModel.java"); - assertContains(grubModel, "extends Entity", "Grub models should keep CodeRAD 1 Entity base class"); - assertFalse(grubModel.indexOf("extends BaseEntity") >= 0, "Grub models should not be rewritten to BaseEntity"); - assertNotNull(entries.get("cn1libs/pom.xml"), "Grub should include cn1libs parent module"); - assertNotNull(entries.get("cn1libs/CodeRAD/pom.xml"), "Grub should include bundled CodeRAD cn1lib pom"); - assertNotNull(entries.get("cn1libs/CodeRAD/jars/main.zip"), "Grub should include bundled CodeRAD common jar"); - assertNotNull(entries.get("cn1libs/CodeRAD/jars/css.zip"), "Grub should include bundled CodeRAD css artifact"); - } } @@ -1433,9 +1597,6 @@ private void assertLocalizationBundles(Map entries, Template tem private void assertNoTemplatePlaceholders(Map entries, Template template) { for (String path : entries.keySet()) { assertFalse(path.indexOf("com/example/myapp") >= 0, "Unrefactored placeholder path found: " + path); - if (template == Template.GRUB) { - assertFalse(path.indexOf("com/codename1/demos/grub") >= 0, "Unrefactored grub path found: " + path); - } } String javasePom = getText(entries, "javase/pom.xml"); // The top-level provided codenameone-core/codenameone-javase blocks are stripped by diff --git a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java index 817dc488cc1..a58a7aa415d 100644 --- a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java +++ b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java @@ -45,11 +45,18 @@ public final class GestureSuite { /// tap before it can type anything, which took three and a half seconds /// on a simulator busy serving XCUITest accessibility snapshots. private static final long DEFAULT_STEP_TIMEOUT_MS = 30000L; - // XCUITest can still be waiting for the preceding long-press action to idle - // when this step becomes ready. The iOS 18.5 CI log spent 28 seconds there, - // exhausting the old 30-second budget before the first browser focus tap. - // Allow that handoff plus the driver's bounded 25-second real-key retry loop. - private static final long IOS_BROWSER_STEP_TIMEOUT_MS = 90000L; + // Budget for every step on iOS. XCUITest waits for the app to go idle before + // it synthesizes anything, and that wait has run past 30 seconds ahead of + // more than one step: 28 seconds after the long-press before the first + // browser focus tap, and 31.6 seconds before the very first tap, which the + // driver only asked for once its own launch had taken 15 of the 30 seconds + // the step was already counting. That tap arrived 0.45 seconds after the + // step had timed out. The step is armed at READY, before the driver acts, so + // its budget has to cover the driver's idle wait as well as the gesture -- + // plus, for the browser step, the driver's bounded 25-second real-key retry + // loop. A step that succeeds ends at its event, so this only lengthens a + // failure. + private static final long IOS_STEP_TIMEOUT_MS = 90000L; /// Non-iOS drivers retain the automatic exit. On iOS, XCUITest owns /// termination so completion cannot race a key still being synthesized. private static final long SUITE_EXIT_DELAY_MS = 8000L; @@ -117,8 +124,7 @@ public void onDetected(String details) { }); this.targetArea.revalidate(); log("CN1IV:READY:" + step.name()); - long timeout = "ios".equals(CN.getPlatformName()) && "browserkeytype".equals(step.name()) - ? IOS_BROWSER_STEP_TIMEOUT_MS : DEFAULT_STEP_TIMEOUT_MS; + long timeout = "ios".equals(CN.getPlatformName()) ? IOS_STEP_TIMEOUT_MS : DEFAULT_STEP_TIMEOUT_MS; this.activeTimeout = UITimer.timer((int) timeout, false, this.form, () -> { if (stepToken != activeStepToken) { return; diff --git a/scripts/ios/screenshots-metal-27/RealOsmVector.png b/scripts/ios/screenshots-metal-27/RealOsmVector.png index bc522c60a12..3f03a1fac5c 100644 Binary files a/scripts/ios/screenshots-metal-27/RealOsmVector.png and b/scripts/ios/screenshots-metal-27/RealOsmVector.png differ diff --git a/scripts/ios/screenshots-metal-27/VectorMapMarkers.png b/scripts/ios/screenshots-metal-27/VectorMapMarkers.png index e77d114da6e..159092fa033 100644 Binary files a/scripts/ios/screenshots-metal-27/VectorMapMarkers.png and b/scripts/ios/screenshots-metal-27/VectorMapMarkers.png differ diff --git a/scripts/linux/screenshots-arm/NativeMapFallback.png b/scripts/linux/screenshots-arm/NativeMapFallback.png index aa39edac16e..ab31704b521 100644 Binary files a/scripts/linux/screenshots-arm/NativeMapFallback.png and b/scripts/linux/screenshots-arm/NativeMapFallback.png differ diff --git a/scripts/linux/screenshots-arm/RealOsmVector.png b/scripts/linux/screenshots-arm/RealOsmVector.png index ce6d427d333..58610d97f34 100644 Binary files a/scripts/linux/screenshots-arm/RealOsmVector.png and b/scripts/linux/screenshots-arm/RealOsmVector.png differ diff --git a/scripts/linux/screenshots-arm/VectorMapDarkStyle.png b/scripts/linux/screenshots-arm/VectorMapDarkStyle.png index f84a1ad0548..4d4f0a39949 100644 Binary files a/scripts/linux/screenshots-arm/VectorMapDarkStyle.png and b/scripts/linux/screenshots-arm/VectorMapDarkStyle.png differ diff --git a/scripts/linux/screenshots-arm/VectorMapMarkers.png b/scripts/linux/screenshots-arm/VectorMapMarkers.png index 1bb39f7013c..3039454f67e 100644 Binary files a/scripts/linux/screenshots-arm/VectorMapMarkers.png and b/scripts/linux/screenshots-arm/VectorMapMarkers.png differ diff --git a/scripts/linux/screenshots/NativeMapFallback.png b/scripts/linux/screenshots/NativeMapFallback.png index 8ce9517c6de..5530bf8e225 100644 Binary files a/scripts/linux/screenshots/NativeMapFallback.png and b/scripts/linux/screenshots/NativeMapFallback.png differ diff --git a/scripts/linux/screenshots/RealOsmVector.png b/scripts/linux/screenshots/RealOsmVector.png index 4d371096e59..d1c8f5cc3e8 100644 Binary files a/scripts/linux/screenshots/RealOsmVector.png and b/scripts/linux/screenshots/RealOsmVector.png differ diff --git a/scripts/linux/screenshots/VectorMapDarkStyle.png b/scripts/linux/screenshots/VectorMapDarkStyle.png index 588baf8a1c7..da629423bb0 100644 Binary files a/scripts/linux/screenshots/VectorMapDarkStyle.png and b/scripts/linux/screenshots/VectorMapDarkStyle.png differ diff --git a/scripts/linux/screenshots/VectorMapMarkers.png b/scripts/linux/screenshots/VectorMapMarkers.png index 1756b7bd1a8..259f0981575 100644 Binary files a/scripts/linux/screenshots/VectorMapMarkers.png and b/scripts/linux/screenshots/VectorMapMarkers.png differ diff --git a/scripts/mac-catalyst/screenshots/RealOsmVector.png b/scripts/mac-catalyst/screenshots/RealOsmVector.png index 6e410f1810d..d79bf5ab4d1 100644 Binary files a/scripts/mac-catalyst/screenshots/RealOsmVector.png and b/scripts/mac-catalyst/screenshots/RealOsmVector.png differ diff --git a/scripts/mac-catalyst/screenshots/VectorMapDarkStyle.png b/scripts/mac-catalyst/screenshots/VectorMapDarkStyle.png index 591741138a8..aa8fe8ece39 100644 Binary files a/scripts/mac-catalyst/screenshots/VectorMapDarkStyle.png and b/scripts/mac-catalyst/screenshots/VectorMapDarkStyle.png differ diff --git a/scripts/macos/screenshots/NativeMapFallback.png b/scripts/macos/screenshots/NativeMapFallback.png index d04752d4056..e4a1e49310d 100644 Binary files a/scripts/macos/screenshots/NativeMapFallback.png and b/scripts/macos/screenshots/NativeMapFallback.png differ diff --git a/scripts/macos/screenshots/RealOsmVector.png b/scripts/macos/screenshots/RealOsmVector.png index 8dfaa9c9c22..e1977ad141f 100644 Binary files a/scripts/macos/screenshots/RealOsmVector.png and b/scripts/macos/screenshots/RealOsmVector.png differ diff --git a/scripts/macos/screenshots/VectorMapDarkStyle.png b/scripts/macos/screenshots/VectorMapDarkStyle.png index 2d2107cf044..bca8f9fc44e 100644 Binary files a/scripts/macos/screenshots/VectorMapDarkStyle.png and b/scripts/macos/screenshots/VectorMapDarkStyle.png differ diff --git a/scripts/macos/screenshots/VectorMapMarkers.png b/scripts/macos/screenshots/VectorMapMarkers.png index b067b5c9a77..e0fa7b75fba 100644 Binary files a/scripts/macos/screenshots/VectorMapMarkers.png and b/scripts/macos/screenshots/VectorMapMarkers.png differ diff --git a/scripts/macos/screenshots/VectorMapShapes.png b/scripts/macos/screenshots/VectorMapShapes.png index a413c5dee1a..0c7b06d61d9 100644 Binary files a/scripts/macos/screenshots/VectorMapShapes.png and b/scripts/macos/screenshots/VectorMapShapes.png differ diff --git a/scripts/sync-initializr-wayline.py b/scripts/sync-initializr-wayline.py new file mode 100644 index 00000000000..2b0b464f43b --- /dev/null +++ b/scripts/sync-initializr-wayline.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Keep Initializr's Wayline template aligned with scripts/wayline (--write to update). + +scripts/wayline is the project CI builds and tests. The Initializr offers the same +project as a template, from five resources this script derives from it: + + wayline-src.zip the application's sources (common/src/main, less the css) + wayline-css.zip its stylesheet + wayline-pom.xml the common module's pom: the one every Initializr project + gets (barebones-pom.xml), plus the dependency on `shared` + wayline-modules.zip the two modules a plain project does not have: `shared`, + the contract, and `backend`, the server that implements it + wayline-settings.properties + the build hints the application needs and a plain project + does not have, from common/codenameone_settings.properties + +Nothing else is copied. The reactor pom, the platform modules, the launchers and +the Maven wrapper are the ones every Initializr project gets, from common.zip. + +Four things are changed on the way, all of them so that the generator can then +make the project the developer's own: + + * The Asciidoctor tag markers (`// tag::name[]`, `// end::name[]`) are taken + out. The developer guide includes regions of this project through them, so + they belong to the guide and not to a generated project. + + * `wayline` in lower case -- an artifact id, a configuration key -- is written + `myappname`, which is the token the generator replaces with the project's + name. The Java package is left alone: the generator replaces it as a package. + * The licence header of this repository is taken off: a generated project is the + developer's, and is not published under it. + * Line endings are LF. + +The tests of the application, their goldens and the CI harness stay behind. The +server's tests go along -- they are how the developer finds out what they broke. + +Without --write the script only compares, and fails on a difference: a change to +scripts/wayline that the template did not get. +""" +from pathlib import Path +import io +import re +import sys +import zipfile + +root = Path(__file__).resolve().parents[1] +sample = root / 'scripts/wayline' +resources = root / 'scripts/initializr/common/src/main/resources' + +# What the generator rewrites as text; the rest is copied as bytes. +# Exactly the files the generator rewrites too (GeneratorModel.isTextFile): a +# `myappname` written into any other would stay in the generated project. +TEXT = ('.java', '.xml', '.properties', '.css', '.sh', '.md', '.json') + +HEADER = re.compile(r'\A/\*\n \* Copyright \(c\) .*?\*/\n', re.S) +HASH_HEADER = re.compile(r'\A((?:#!.*\n)?)(?:#\n)?# Copyright \(c\) .*?(?=^[^#]|\Z)', re.S | re.M) +XML_HEADER = re.compile(r'\n', re.S) +# A line holding nothing but an Asciidoctor tag marker: `// tag::name[]`, +# `# end::name[]`, `/* tag::name[] */`. +TAG_MARKER = re.compile(r'^[ \t]*(?://|#|/\*)[ \t]*(?:tag|end)::[A-Za-z0-9_-]+\[\][ \t]*(?:\*/)?[ \t]*\n', + re.M) + + +def text(path): + """A text file as the template carries it.""" + data = path.read_bytes().replace(b'\r\n', b'\n').decode('utf-8') + if path.suffix in ('.java', '.css'): + data = HEADER.sub('', data, count=1) + elif path.suffix in ('.properties', '.sh'): + data = HASH_HEADER.sub(r'\1', data, count=1) + elif path.suffix == '.xml': + data = XML_HEADER.sub('', data, count=1) + # The developer guide includes regions of the sample by Asciidoctor tag. The + # markers are the guide's plumbing and mean nothing in a generated project. + data = TAG_MARKER.sub('', data) + # The application's name wherever it is written in lower case -- an artifact + # id, a configuration key, a demonstration account's address -- but not in the + # Java package, which the generator replaces as a package. + data = re.sub(r'(? + + ${project.groupId} + myappname-shared + ${project.version} + +""" + + +def common_pom(): + """The common module's pom for the template. + + Not the sample's own: that one belongs to the reactor the sample has, built + from the archetype in this tree, while a generated project gets the reactor in + common.zip, and a module pom is only right for the reactor it was written for. + The one thing the application adds to a plain project's pom is `shared`. + """ + if 'wayline-shared' not in (sample / 'common/pom.xml').read_text('utf-8'): + raise SystemExit('scripts/wayline/common/pom.xml no longer depends on wayline-shared; ' + 'the template pom is built on the assumption that it does.') + pom = (resources / 'barebones-pom.xml').read_bytes().replace(b'\r\n', b'\n').decode('utf-8') + marker = ' \n' + if pom.count(marker) < 1: + raise SystemExit('barebones-pom.xml has no to add the shared module to') + return pom.replace(marker, marker + SHARED_DEPENDENCY, 1).encode('utf-8') + + +def keys(properties): + return [line.split('=', 1)[0] for line in properties.splitlines() + if '=' in line and not line.lstrip().startswith('#')] + + +def settings(): + """The build hints of the application that a plain project lacks. + + Only the keys common.zip's settings do not have: the rest of that file is the + project's identity and the defaults every project gets, which the generator + writes itself. + """ + with zipfile.ZipFile(resources / 'common.zip') as common: + plain = set(keys(common.read('common/codenameone_settings.properties').decode('utf-8'))) + lines = (sample / 'common/codenameone_settings.properties').read_text('utf-8').splitlines() + kept = [] + for index, line in enumerate(lines): + if '=' not in line or line.lstrip().startswith('#') or line.split('=', 1)[0] in plain: + continue + # The comment written above a hint is about it, and goes with it. + start = index + while start > 0 and lines[start - 1].startswith('# '): + start -= 1 + kept.extend(lines[start:index + 1]) + return ('\n'.join(kept) + '\n').encode('utf-8') + + +def archive(entries): + """A zip whose bytes depend on its entries and on nothing else.""" + result = io.BytesIO() + with zipfile.ZipFile(result, 'w', zipfile.ZIP_STORED) as target: + for name in sorted(entries): + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.external_attr = (0o755 if name.endswith('.sh') else 0o644) << 16 + target.writestr(info, entries[name]) + return result.getvalue() + + +def current(path): + if not path.is_file(): + return None + if path.suffix != '.zip': + return path.read_bytes() + with zipfile.ZipFile(path) as source: + return {name: source.read(name) for name in source.namelist() if not name.endswith('/')} + + +def leftovers(entries): + """A SNAPSHOT version has no business in a template: the generator stamps the + version the Initializr was built for, and only into the reactor pom.""" + return sorted(name for name, data in entries.items() + if name.endswith('pom.xml') and b'-SNAPSHOT-.zip. An app, its server and + the contract module both compile against, all of it renamed to the project.""" + names = set(z.namelist()) + full = archive.name.split('-')[1] == 'FULL' + root_pom = ''.join(z.read('pom.xml').decode().split()) + assert 'sharedcommon' in root_pom, archive.name + for module in ('shared', 'common', 'backend'): + assert module + '/pom.xml' in names, (archive.name, module) + for module in ('common', 'backend'): + assert 'launcherprobe-shared' in z.read(module + '/pom.xml').decode(), \ + (archive.name, module) + assert ('javase/pom.xml' in names) == full, archive.name + for name in ['shared/src/main/java/com/example/probe/api/RiderApi.java', + 'backend/src/main/java/com/example/probe/SecurityConfig.java', + 'backend/src/main/resources/db/migration/V1__accounts.sql', + 'backend/server.sh', + 'common/src/main/java/com/example/probe/LauncherProbe.java', + 'common/src/main/java/com/example/probe/rider/RiderForm.java']: + assert name in names, (archive.name, name) + # The generic server every other project gets is replaced, not merged with. + assert 'backend/src/main/java/com/example/probe/Greeter.java' not in names, archive.name + css = z.read('common/src/main/css/theme.css').decode() + assert '--brand: #0f766e;' in css and '--radius: 0.6mm;' in css and 'Initializr Theme Overrides' not in css, \ + archive.name + assert z.read('common/icon.png') == b'\x89PNG', archive.name + settings = z.read('common/codenameone_settings.properties').decode() + assert 'codename1.arg.ios.NSLocationWhenInUseUsageDescription=' in settings, archive.name + assert '## The app and its server' in z.read('README.md').decode(), archive.name + for name in names: + assert 'wayline' not in name.lower(), (archive.name, name) + if name.endswith(('.png', '.jar', '.res', '.ttf')): + continue + text = z.read(name).decode('utf-8', 'replace') + assert 'wayline' not in text.lower() and 'myappname' not in text.lower(), (archive.name, name) + + with tempfile.TemporaryDirectory(prefix='cn1-generator-') as directory: work = Path(directory) deps = [] @@ -117,13 +154,16 @@ def check_maven_layout_fixture(archive, z): with zipfile.ZipFile(archive) as z: assert z.testzip() is None for entry in z.infolist(): - expected = 0o100755 if entry.filename in ('build.sh', 'run.sh', 'mvnw', 'gradlew') else 0o100644 + executable = ('build.sh', 'run.sh', 'mvnw', 'gradlew', 'backend/server.sh') + expected = 0o100755 if entry.filename in executable else 0o100644 assert entry.create_system == 3 and entry.external_attr >> 16 == expected, entry.filename if archive.name.startswith('GRADLE-'): check_gradle_fixture(archive, z, maven_settings) continue if archive.name.startswith('MAVEN-') and not check_maven_layout_fixture(archive, z): continue + if archive.name.startswith('WAYLINE-'): + check_wayline_fixture(archive, z) readme = z.read('README.md').decode() assert 'JDK ' + ('8' if 'JAVA_8' in archive.name else '17') + ' or newer' in readme assert '.\\build.bat javascript_cloud' in readme and './build.sh javascript_cloud' in readme @@ -134,4 +174,5 @@ def check_maven_layout_fixture(archive, z): reasons = ['--reasons'] if archive.name == 'INTELLIJ-JAVA_17.zip' else [] subprocess.run([sys.executable, str(root / 'scripts/test-starter-launchers.py'), str(archive)] + reasons, check=True) - print('PASS: real Initializr ZIPs across all IDEs, Java 8/17 Maven, every Maven layout and every Gradle project type') + print('PASS: real Initializr ZIPs across all IDEs, Java 8/17 Maven, every Maven layout, every Gradle project type ' + 'and the full-stack template') diff --git a/scripts/wayline/LICENSE b/scripts/wayline/LICENSE new file mode 100644 index 00000000000..b23ebeaf9a6 --- /dev/null +++ b/scripts/wayline/LICENSE @@ -0,0 +1,22 @@ +MIT No Attribution + +Copyright (c) 2026, Codename One and/or its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This applies to the Wayline sample and to everything Codename One Initializr +writes into a generated project: the application templates under +maven/cn1app-archetype/src/main/resources/archetype-resources, the agent skill +under scripts/initializr/common/src/main/resources/skill, and the Initializr +template archives built from them. diff --git a/scripts/wayline/README.adoc b/scripts/wayline/README.adoc new file mode 100644 index 00000000000..733744c5399 --- /dev/null +++ b/scripts/wayline/README.adoc @@ -0,0 +1,219 @@ += Wayline + +A ride-hailing application and its server, in one Maven project and one language. +A rider asks for a ride, the nearest suitable driver is offered it, both follow the +trip on a map, the ride is paid for when it ends, and an administrator runs the +service. Which of the three a person sees is decided by the roles the server gives +their account: a rider opens on the map, a driver on the driving screen, an +administrator on the operations console. + +It is a sample of how a Codename One application and a Codename One Backend fit +together, and the project the Initializr generates when you start from the +ride-hailing template. + +== Start here + +Two chapters of the developer guide cover this project: + +* https://www.codenameone.com/developer-guide/#wayline-users-guide[The user's guide] + (link:../../docs/developer-guide/Wayline-Users-Guide.asciidoc[source]) shows the + application as a rider, a driver and an administrator see it, screen by screen, + with the demo accounts and test cards. +* https://www.codenameone.com/developer-guide/#wayline-implementors-guide[The implementor's guide] + (link:../../docs/developer-guide/Wayline-Implementors-Guide.asciidoc[source]) + explains how the project is built and what to change to make it yours: a + ride-sharing service or the dispatch service of a small taxi station, real + billing, your own map data or native maps, sign-in through another identity + provider, real text messages, analytics, and the database: the engines it + runs on, changing the schema, and hosting it. + +To see it running first, the two commands under <> are all it takes. + +== The modules + +[cols="1,4"] +|=== +| `shared` +| The contract: the data classes both sides exchange and the `@RestClient` + interfaces that name every call. The app's build generates a client from them + and the server's build generates the interfaces it implements, so a change the + other side did not follow is a compile error. + +| `common` +| The application: sign-in, the rider, driver and administrator screens, the map, + the wallet, the settings and the live channel. Styled in + `src/main/css/theme.css`, whose first block of variables holds the colors and + the corner radius everything else is drawn with. The text is in + `src/main/l10n`, in English, Spanish, French, German and Hebrew. + What it reports to Codename One analytics about how it is used, once the user + has agreed, is all in `Telemetry`. + +| `backend` +| The server: accounts and roles, phone verification, driver applications and + their documents, moderation, fares and pricing, matching, the ride state + machine, payments and payouts, statistics, and a WebSocket that pushes changes + as they happen. + The data is entity classes in `domain`, read and written by the repository + beside each service. The schema is the migration scripts in + `src/main/resources/db/migration`, and the same server runs on SQLite, + PostgreSQL, MySQL and MariaDB. + +|=== + +There are no platform modules: `common` builds the application for the +simulator, the desktop, Android, iOS and the browser itself. + +== Running it + +Start the server on the development profile. It needs nothing installed: the +database is in memory, and text messages are printed on the console. + +[source,bash] +---- +CN1_PROFILE=dev backend/server.sh run +---- + +Then run the application in the simulator, from another terminal: + +[source,bash] +---- +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +---- + +The development profile creates these accounts, all with the password +`wayline-demo`, and a fortnight of rides for the charts to draw: + +[cols="2,3"] +|=== +| `rider@wayline.example`, `rider2@wayline.example` | request rides +| `rider3@wayline.example` | a rider an administrator has flagged +| `driver@wayline.example`, `driver2@wayline.example` | approved drivers +| `driver3@wayline.example`, `driver4@wayline.example` | approved drivers of the + larger kinds of car +| `applicant@wayline.example` | has applied to drive and is waiting for a decision +| `admin@wayline.example` | runs the service +|=== + +Sign in as the rider in one simulator and as a driver in another to follow a ride +from both ends. On a phone, set the server's address on the welcome screen: +`localhost` on a phone is the phone. + +=== In a browser, with nothing installed + +The server can host the application itself. Build the browser version once, and +again when the application changes; it takes about a minute: + +[source,bash] +---- +backend/server.sh web +CN1_PROFILE=dev backend/server.sh run +---- + +Then open `http://localhost:8080/`. `server.sh run --web` does both in one step. + +`server.sh web` runs `cn1:backend-webapp`, which builds the application for the +browser and stages it in `backend/target/webapp`; a server that finds it there +serves it at `/`, after its own routes. In the browser the application takes the +page's address as the server's, signs in through the same authorization-code +flow with the code sent back to `/signin/code` on that server, and opens its live +channel to the same origin. To deploy it, put `backend/target/webapp` beside the +server binary as `webapp`. + +A browser gives a page less than a phone gives an app, and the application says +so where it matters: calling and texting a driver are offered only where the +device can dial, and the map opens on a default position when the browser will +not say where it is. + +== What each role does + +A rider picks a destination, a kind of car and a way to pay, and may ask for a +quiet ride, an accessible vehicle, a car that takes pets or a woman driver. The +wallet holds their cards and receipts, and a tip can be added after the ride. + +Anyone can apply to drive. The application asks for the driver's details, the +vehicle, and five photographs: an identity document, the licence, a selfie, the +vehicle registration and the insurance. Nobody drives until an administrator +approves the application; a rejection carries a reason, and the applicant can +correct it and send it again. An approved driver goes online, is offered rides, +and sees what they earned and cashes it out. + +An administrator sees the service in charts, reviews applications with their +documents, flags or blocks an account with a reason that is kept in its history, +cancels and refunds rides, and sets the prices. + +== What is real and what is simulated + +Maps, search and routes are real and need no key: the map tiles come from +OpenFreeMap, routes from the public OSRM server, and place search from Photon, +which the server calls so the app never talks to a third party directly. + +Text messages go through the provider `wayline.sms.provider` names: `twilio`, +`vonage` or `messagebird`, each with its own credentials. On a development +profile they are printed on the console instead, and the app is handed the code +so a sign-up can finish. Any other profile needs a provider; one whose +credentials are missing is logged as a configuration error, naming the missing +settings, and the app is told the code could not be sent. + +Payments are simulated on a development profile. Simulated, the card +`4242 4242 4242 4242` is accepted, `4000 0000 0000 0002` is declined, and no +money moves. With `wayline.payments.provider=stripe` and +`WAYLINE_PAYMENTS_STRIPE_SECRET` set in the server's environment, a card is +added on Stripe's own page, so its number never reaches the app or this server, +and a ride is charged when it completes. The Stripe provider is an untested +stub: it is covered by tests that answer for Stripe, has never been run against +a live account, and has to be tried with a Stripe test key before it is trusted +with money. Paying a +driver out is a ledger entry in both cases; moving that money to a bank is left +to the deployment. + +`backend/application.properties` lists every setting a deployment supplies. The +database is the one named by `DATABASE_URL`: a `postgres://` or `mysql://` +address, or the path of a SQLite file. The SMS and payment settings are listed +there too, each also read from the environment in upper case with `_` for every +dot (`WAYLINE_SMS_TWILIO_TOKEN`). + +== Tests + +[source,bash] +---- +mvn -pl shared,backend -Dcodename1.platform=backend test # <1> +mvn -pl shared,backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true # <2> +./run-e2e.sh # <3> +./check-template.sh # <4> +./run-web-e2e.sh # <5> +---- +<1> The server's tests, on the JVM. +<2> The same tests again, translated to a native binary, which is how the server + ships. +<3> Starts the server on the `test` profile and runs the application's tests + against it in the simulator: they register, apply to drive and are approved, + request a ride, drive it, pay for it and administer it, and compare the + screens in `screenshots/`. Pass `--native` to run them against the compiled + server. On Linux they then run a second time with the simulator as a desktop + window, where the admin console is laid out side by side under the + platform's own theme. +<4> Generates a project from the Initializr template, under another name and + package, and builds it. +<5> Builds the browser version of the application, starts the server hosting + it, and drives it in a headless browser: the application loads from the + server, signs in as the demo rider, reaches the home screen and opens its + live channel, then signs out and signs in as the demo admin. Needs Node with Playwright and its Chromium (`npm ci` and + `npx playwright install chromium` in `scripts/`). Screenshots and logs go + to `target/web-e2e`. + +`.github/workflows/wayline.yml` runs all five, and builds the application for +Android and iOS through `scripts/build-android-app.sh` and +`scripts/build-ios-app.sh` with `CN1_APP_DIR=scripts/wayline`. + +== The Initializr template + +`scripts/sync-initializr-wayline.py --write` copies this project into the +Initializr's resources; without `--write` it fails when they differ, which is what +CI runs. Change the project here, never the copies. + +== Licence + +Wayline is released under MIT No Attribution (see `LICENSE`), not under the +GPLv2 with Classpath Exception that covers Codename One itself. Take any of it +into your own application, under any licence, with no notice to keep. The same +holds for everything Initializr writes into a generated project. diff --git a/scripts/wayline/backend/application-dev.properties b/scripts/wayline/backend/application-dev.properties new file mode 100644 index 00000000000..2523cd64b1a --- /dev/null +++ b/scripts/wayline/backend/application-dev.properties @@ -0,0 +1,30 @@ +# The development profile, chosen with CN1_PROFILE=dev. +# +# An in-memory SQLite database, so the server starts with nothing installed: the +# migrations create the tables at start-up, and the demo accounts are added to +# them. Point this at a file to keep the data between runs: +# +# cn1.datasource.url=./target/app.db +# +# Production points cn1.datasource.url at the real database instead -- a +# postgres:// or mysql:// URL -- and the same code runs against it. +cn1.datasource.url=:memory: + +# On this profile the server also serves, without a token: +# +# /manage/health, /manage/metrics, /manage/prometheus, /manage/jobs +# /mcp -- the development MCP tools, for an agent working on this backend +# +# Neither is served outside a development profile unless it is turned on and +# given a token (cn1.management.token, cn1.mcp.token). + +# Text messages and push notifications are printed on the console and cards are +# simulated. All three are the defaults of a development profile anyway, and are +# spelled out to show where the choice is made. The console senders are refused +# outside a development profile. See application.properties for the real +# providers. +# tag::wayline-properties-dev-providers[] +wayline.sms.provider=console +wayline.payments.provider=simulated +wayline.push.provider=console +# end::wayline-properties-dev-providers[] diff --git a/scripts/wayline/backend/application-test.properties b/scripts/wayline/backend/application-test.properties new file mode 100644 index 00000000000..48dfc7f3169 --- /dev/null +++ b/scripts/wayline/backend/application-test.properties @@ -0,0 +1,20 @@ +# The test profile, chosen with CN1_PROFILE=test. It is what run-e2e.sh starts the +# server on for the app's end-to-end tests, and what @BackendTest runs the +# server's own tests on. +# +# Like `dev` -- an in-memory database, the demo accounts, text messages that are +# printed and not sent -- and one thing more: places are looked up in a list of +# ten the server carries, so a test run asks no public geocoder for anything. +cn1.datasource.url=:memory: + +# The tests sign several accounts in from one address, one after another, which +# is exactly what the sign-in rate limit is there to stop. +wayline.signin.per-minute=1000 + +# Codes printed on the console (and handed back to the test as demoCode), cards +# simulated: no test reaches an SMS provider or a payment processor. +wayline.sms.provider=console +wayline.payments.provider=simulated + +# Push notifications are kept for the tests to read, not sent. +wayline.push.provider=recording diff --git a/scripts/wayline/backend/application.properties b/scripts/wayline/backend/application.properties new file mode 100644 index 00000000000..8f229365cb8 --- /dev/null +++ b/scripts/wayline/backend/application.properties @@ -0,0 +1,98 @@ +# The settings this server reads at start-up, and the layer with the lowest +# precedence. A system property of the same name wins over anything here, then +# the environment variable the key maps to (cn1.datasource.url is read from +# CN1_DATASOURCE_URL, and also from DATABASE_URL, which is what a platform sets +# for you), then application-.properties, then this file. +# +# It sits in the module root on purpose. This runtime has no classpath to load a +# resource from, so Config opens application*.properties as FILES relative to the +# working directory: the module directory under `cn1:backend`, and wherever the +# binary is started once it is packaged. +# +# Choose a profile with CN1_PROFILE. The one beside this file is `dev`: +# +# CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend +# +# A development profile (dev, test, local) needs nothing else: an in-memory +# database, a signing key made at start-up, text messages printed on the +# console, simulated cards, and a set of demo accounts with a fortnight of rides behind them. A real +# deployment sets, here or in its environment: +# +# cn1.datasource.url the database +# cn1.security.authorizationserver.issuer the public address of this server +# cn1.security.authorizationserver.jwk.keys the key tokens are signed with +# wayline.admin.email, wayline.admin.password the first admin, who grants the rest +# wayline.sms.provider and its settings below +# wayline.payments.provider and its settings below +# wayline.push.app, wayline.push.key push notifications, below +# +# and may change the fares (wayline.fare.*), the currency (wayline.currency) and +# the geocoder (wayline.geocoder.url). +# +# Text messages. wayline.sms.provider names the provider verification codes are +# sent through, and each needs its own settings: +# +# twilio wayline.sms.twilio.sid, wayline.sms.twilio.token, +# wayline.sms.twilio.from +# vonage wayline.sms.vonage.key, wayline.sms.vonage.secret, +# wayline.sms.vonage.from +# messagebird wayline.sms.messagebird.key, wayline.sms.messagebird.originator +# console prints the code; development profiles only +# +# Unset, it is console on a development profile and twilio on any other. A +# provider whose settings are missing is a configuration error: it is logged at +# start-up and on every send, naming the missing settings, and the app is told +# the code could not be sent. Nothing falls back to printing codes in +# production. +# +# Payments. wayline.payments.provider is simulated or stripe; unset, it is +# simulated on a development profile and stripe on any other. Simulated cards: +# 4242 4242 4242 4242 is accepted, 4000 0000 0000 0002 is declined, and nothing +# is charged. Stripe is an untested stub over Stripe's REST API, a starting +# point to try against a test-mode key: cards are added on Stripe's own page, so +# a card number never reaches the app or this server, and rides are charged +# when they complete. It needs the secret key, and without it every payment +# call fails with a configuration error in the log. Keep the key and the SMS +# credentials out of this file and out of the repository; set them in the +# environment of the deployment instead, where each setting is read in upper +# case with _ for every dot: +# +# WAYLINE_PAYMENTS_STRIPE_SECRET a restricted or secret key (sk_...) +# WAYLINE_SMS_TWILIO_TOKEN and so on +# +# Stripe's page sends the browser back to /pay/return on this server when it is +# done, so the server has to know the address it is reached at from a phone. It +# uses cn1.security.authorizationserver.issuer, which a deployment sets anyway; +# wayline.public.url overrides that when the two differ. +# +# Push notifications. wayline.push.provider is cloud, console or recording; +# unset, it is recording on the test profile, console on the other development +# profiles and cloud on any other. cloud sends through the Codename One push +# service, which delivers to Firebase, Apple, Huawei and the browsers for you, +# and needs two settings from the Push section of the Codename One console: +# +# wayline.push.app the app's push id +# wayline.push.key a server key with the push scope; put it in the +# environment as WAYLINE_PUSH_KEY, like the other secrets +# +# Without them the server still starts and serves rides; the missing settings +# are logged at start-up and again for every notification that is not sent. +# wayline.push.activity.seconds (45) is how often, at most, a moving car +# updates the rider's Live Activity. +# +# The prices are settings only until an admin changes them in the app. From +# then on they are in the database (wayline.fare.* is the starting point, and +# wayline.fare.serviceFeePercent and wayline.fare.commissionPercent join it). + +# ${DATABASE_URL} is resolved when the value is READ, so this line is fine in a +# checkout where that variable is unset: the dev profile overrides the key, and +# nothing reads this one. SQLite (a file path), postgres:// and mysql:// all work. +cn1.datasource.url=${DATABASE_URL} + +# The port. A platform that sets PORT wins over this, which is what lets the same +# build deploy anywhere. +cn1.server.port=8080 + +# Creates the tables the accounts, the registered app and the tokens are kept in. +# Read by the build, so it has to be here and not in a profile's file. +cn1.security.schema.enabled=true diff --git a/scripts/wayline/backend/pom.xml b/scripts/wayline/backend/pom.xml new file mode 100644 index 00000000000..129790359ae --- /dev/null +++ b/scripts/wayline/backend/pom.xml @@ -0,0 +1,160 @@ + + + 4.0.0 + + com.codenameone.examples.wayline + wayline + 1.0-SNAPSHOT + + com.codenameone.examples.wayline + wayline-backend + 1.0-SNAPSHOT + jar + + wayline-backend + + + + + com.codenameone + codenameone-backend + ${cn1.version} + + + + ${project.groupId} + wayline-shared + ${project.version} + + + + org.xerial + sqlite-jdbc + 3.46.1.0 + runtime + + + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + cn1-generate-annotation-stubs + generate-sources + + + generate-annotation-stubs + + + + cn1-process-annotations + process-classes + + + process-annotations + + + + cn1-process-test-annotations + process-test-classes + + + process-test-annotations + + + + cn1-compiled-tests + test + + + backend-test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + false + + + + + diff --git a/scripts/wayline/backend/server.sh b/scripts/wayline/backend/server.sh new file mode 100755 index 00000000000..ef5843bfd10 --- /dev/null +++ b/scripts/wayline/backend/server.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Builds and runs the Wayline server. +# +# server.sh build [--jvm|--native] [--web] +# server.sh run [--jvm|--native] [--web] [--prebuilt] [--port N] +# server.sh web +# +# --jvm (the default) runs the server on this JDK, which is how you work on it. +# --native compiles it to a native binary, which is how it ships. +# +# The profile comes from CN1_PROFILE. `test` and `dev` need nothing else: an +# in-memory database, four demo accounts, a geocoder that knows ten places +# (`test`) and text messages that are printed, not sent. Anything else is a +# deployment and reads its settings from the environment -- see +# application.properties. +# +# `web` builds the app for the browser and stages it in target/webapp, which +# takes about a minute; `--web` does that as part of a build or a run. A server +# that finds the app there serves it at /, so http://localhost:8080/ is Wayline +# with nothing installed. It stays staged until the next `mvn clean`: build it +# once, and again when the app changed. +# +# The server prints "listening on http port N" when it accepts connections. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP="$(cd "$HERE/.." && pwd)" +# The project's own Maven wrapper where it has one. +if [ -z "${MVN:-}" ]; then + if [ -x "$APP/mvnw" ]; then MVN="$APP/mvnw"; else MVN="mvn"; fi +fi + +log() { echo "[wayline-server] $*" >&2; } + +build_jvm() { + log "building the server for the JVM" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl shared,backend \ + -Dmaven.test.skip=true package dependency:copy-dependencies \ + -DincludeScope=runtime -DoutputDirectory="$APP/backend/target/server-lib" >&2 +} + +build_native() { + log "translating the server to a native binary" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl shared,backend \ + -Dmaven.test.skip=true package cn1:backend-package \ + -Dcn1.backend.output="$APP/backend/target/wayline-server" >&2 +} + +build_web() { + log "building the app for the browser" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl backend \ + cn1:backend-webapp >&2 +} + +# The server looks for the app in `webapp` beside its settings; here it is build +# output, so it is under target and the server is told. An address set by the +# caller is left alone. +serve_web() { + if [ -z "${CN1_WEBAPP_ROOT:-}" ] && [ -f "$APP/backend/target/webapp/index.html" ]; then + export CN1_WEBAPP_ROOT="target/webapp" + log "serving the web app in backend/target/webapp" + fi +} + +command="${1:-run}" +shift || true +mode="jvm" +port="${PORT:-8080}" +prebuilt=0 +web=0 +while [ $# -gt 0 ]; do + case "$1" in + --jvm) mode="jvm" ;; + --native) mode="native" ;; + --prebuilt) prebuilt=1 ;; + --web) web=1 ;; + --port) port="$2"; shift ;; + *) log "unknown argument $1"; exit 2 ;; + esac + shift +done + +case "$command" in + build) + if [ "$mode" = "native" ]; then build_native; else build_jvm; fi + [ "$web" = 0 ] || build_web + ;; + web) + build_web + ;; + run) + # application.properties is read from the working directory. + cd "$APP/backend" + if [ "$mode" = "native" ]; then + [ "$prebuilt" = 1 ] || build_native + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + PORT="$port" exec "$APP/backend/target/wayline-server" + fi + [ "$prebuilt" = 1 ] || build_jvm + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + main="$(tr -d '\r\n' < "$APP/backend/target/classes/META-INF/cn1-backend-main")" + log "running $main on port $port" + PORT="$port" exec java -cp "$APP/backend/target/classes:$APP/backend/target/server-lib/*" "$main" + ;; + *) + log "unknown command $command (build, run, web)" + exit 2 + ;; +esac diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Caller.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Caller.java new file mode 100644 index 00000000000..630e63f21db --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Caller.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.SecurityContextHolder; + +import java.util.Iterator; + +/// Who is making the request being served. +/// +/// The endpoints implement interfaces generated from the shared contract, whose +/// methods take what the app sends and nothing else, so the caller is read from +/// the security context of the request and not from a parameter. +public final class Caller { + private Caller() { + } + + /// The signed-in user's name, which is the e-mail address of the account. + /// Answers 401 when nobody is signed in; the filter chain has normally done + /// so already, and this is what stands behind it. + public static String name() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || !who.isAuthenticated() || who.getName() == null) { + throw new ResponseStatusException(401, "Sign in first"); + } + return who.getName(); + } + + /// Whether the caller holds `role`, named without its `ROLE_` prefix. + public static boolean hasRole(String role) { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || who.getAuthorities() == null) { + return false; + } + String wanted = "ROLE_" + role; + Iterator it = who.getAuthorities().iterator(); + while (it.hasNext()) { + if (wanted.equals(it.next().getAuthority())) { + return true; + } + } + return false; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Days.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Days.java new file mode 100644 index 00000000000..d53e6959ec0 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Days.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +/// Days, counted in UTC. +/// +/// The charts and the earnings are by day, and a day has to mean the same on +/// SQLite, PostgreSQL and MySQL, whose date functions agree on nothing. So the +/// tables hold milliseconds since the epoch, and the arithmetic is done here. +/// UTC throughout: a service in one city would shift by that city's offset. +public final class Days { + public static final long MILLIS = 86400000L; + + private Days() { + } + + /// The number of the day `millis` falls in: whole days since the epoch. + public static long number(long millis) { + return millis / MILLIS; + } + + /// The first millisecond of the day `millis` falls in. + public static long start(long millis) { + return millis - millis % MILLIS; + } + + /// The stretch of time from `from` to `to` cut at each midnight: the + /// number of each day it touches followed by how many of its milliseconds + /// fall in that day, day after day. Empty when `to` is not after `from`. + /// + /// Time on line is credited to the days it was spent in. Credited whole + /// to the day it ended in, a driver on line across midnight had the + /// minutes before it counted as the next day's. + public static long[] split(long from, long to) { + if (to <= from) { + return new long[0]; + } + long[] out = new long[(int) (number(to - 1) - number(from) + 1) * 2]; + long at = from; + for (int iter = 0; iter < out.length; iter += 2) { + long end = Math.min(to, start(at) + MILLIS); + out[iter] = number(at); + out[iter + 1] = end - at; + at = end; + } + return out; + } + + /// The day `millis` falls in, as `yyyy-MM-dd`. + public static String iso(long millis) { + // Days since the epoch to a civil date, by the usual era arithmetic: + // shift the year to start in March so the leap day is its last day. + long z = number(millis) + 719468L; + long era = z / 146097L; + long dayOfEra = z - era * 146097L; + long yearOfEra = (dayOfEra - dayOfEra / 1460L + dayOfEra / 36524L - dayOfEra / 146096L) + / 365L; + long dayOfYear = dayOfEra - (365L * yearOfEra + yearOfEra / 4L - yearOfEra / 100L); + long shifted = (5L * dayOfYear + 2L) / 153L; + long day = dayOfYear - (153L * shifted + 2L) / 5L + 1L; + long month = shifted < 10L ? shifted + 3L : shifted - 9L; + long year = yearOfEra + era * 400L + (month <= 2L ? 1L : 0L); + StringBuilder out = new StringBuilder(10); + out.append(year).append('-'); + if (month < 10L) { + out.append('0'); + } + out.append(month).append('-'); + if (day < 10L) { + out.append('0'); + } + return out.append(day).toString(); + } + + /// Whether `text` is a date written `yyyy-MM-dd`, with a month and a day + /// that could exist. + public static boolean isIso(String text) { + if (text == null || text.length() != 10 || text.charAt(4) != '-' || text.charAt(7) != '-') { + return false; + } + for (int iter = 0; iter < 10; iter++) { + char c = text.charAt(iter); + if (iter != 4 && iter != 7 && (c < '0' || c > '9')) { + return false; + } + } + int year = 0; + for (int iter = 0; iter < 4; iter++) { + year = year * 10 + (text.charAt(iter) - '0'); + } + int month = (text.charAt(5) - '0') * 10 + (text.charAt(6) - '0'); + int day = (text.charAt(8) - '0') * 10 + (text.charAt(9) - '0'); + // The day is held to its own month: these strings are compared as + // text and never parsed, so a 31st of February accepted here would be + // stored, and approved, as somebody's date of birth. + return month >= 1 && month <= 12 && day >= 1 && day <= length(year, month); + } + + // How many days a month has, by the Gregorian rule for leap years. + private static int length(int year, int month) { + if (month == 2) { + boolean leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + return leap ? 29 : 28; + } + return month == 4 || month == 6 || month == 9 || month == 11 ? 30 : 31; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Ids.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Ids.java new file mode 100644 index 00000000000..81f774c9719 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Ids.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.Crypto; + +import java.io.IOException; + +/// Identifiers and secrets made of random bytes. +public final class Ids { + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private Ids() { + } + + /// A new identifier: 32 hex digits from 16 random bytes. Random and not a + /// counter, so the id of one ride says nothing about the id of another, and + /// so no table needs a generated key, which every database spells its own way. + public static String next() throws IOException { + return hex(Crypto.randomBytes(16)); + } + + public static String hex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int iter = 0; iter < bytes.length; iter++) { + out[iter * 2] = HEX[(bytes[iter] >> 4) & 0xf]; + out[iter * 2 + 1] = HEX[bytes[iter] & 0xf]; + } + return new String(out); + } + + /// The SHA-256 of `text`, in hex. What is stored in place of a secret that + /// only ever needs to be compared. + public static String sha256(String text) throws IOException { + return hex(Crypto.sha256(text.getBytes("UTF-8"))); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Roles.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Roles.java new file mode 100644 index 00000000000..b8bcc77c948 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Roles.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +/// The roles an account can hold. Every account is a rider; driving and +/// administering are granted on top of that, and the app offers a mode for each +/// role the account holds. +public final class Roles { + public static final String RIDER = "RIDER"; + public static final String DRIVER = "DRIVER"; + public static final String ADMIN = "ADMIN"; + + private Roles() { + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Rows.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Rows.java new file mode 100644 index 00000000000..a2816782a2f --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Rows.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codenameone.examples.wayline.api.RideState; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Reading the rows a repository answers for a select of several values, +/// `select r.driver, count(r) ...`, which come back as `Object[]`. +/// +/// What a column holds is checked and never cast blindly: a count is a `Long` +/// on one database and an `Integer` on another, and a state is the enum or +/// its name depending on how the column was read. +public final class Rows { + private Rows() { + } + + /// A column as a whole number; 0 for null or anything that is not a + /// number. + public static long number(Object value) { + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + /// A column as text; empty for null. + public static String text(Object value) { + return value == null ? "" : String.valueOf(value); + } + + /// A column holding a ride's state, or null for one that names none. + public static RideState state(Object value) { + if (value instanceof RideState) { + return (RideState) value; + } + String name = text(value); + RideState[] all = RideState.values(); + for (int iter = 0; iter < all.length; iter++) { + if (all[iter].name().equals(name)) { + return all[iter]; + } + } + return null; + } + + /// Rows of `{name, count}` as a map from the name to the count. + public static Map counts(List rows) { + Map out = new HashMap(); + for (int iter = 0; iter < rows.size(); iter++) { + Object[] row = rows.get(iter); + out.put(text(row[0]), Long.valueOf(number(row[1]))); + } + return out; + } + + /// A single number a repository answers, which is null for a sum or a + /// maximum of no rows. + public static long number(Long value) { + return value == null ? 0L : value.longValue(); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/SecurityConfig.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/SecurityConfig.java new file mode 100644 index 00000000000..2431cb74f11 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/SecurityConfig.java @@ -0,0 +1,413 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Order; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.AntPathRequestMatcher; +import com.codename1.backend.security.DisabledException; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.HttpSecurity; +import com.codename1.backend.security.HttpStatusReturningLogoutSuccessHandler; +import com.codename1.backend.security.LogoutFilter; +import com.codename1.backend.security.SecurityExchange; +import com.codename1.backend.security.SecurityFilter; +import com.codename1.backend.security.SecurityFilterChain; +import com.codename1.backend.security.SessionCreationPolicy; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.JwkSource; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoderFactories; +import com.codename1.backend.security.oauth2.core.AuthorizationGrantType; +import com.codename1.backend.security.oauth2.core.ClientAuthenticationMethod; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.JwtAudienceValidator; +import com.codename1.backend.security.oauth2.jwt.JwtClaimValidator; +import com.codename1.backend.security.oauth2.jwt.JwtIssuerValidator; +import com.codename1.backend.security.oauth2.jwt.JwtValidators; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerKeys; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerSettings; +import com.codename1.backend.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.JdbcRegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClient; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationConverter; +import com.codename1.backend.security.ratelimit.InMemoryRateLimiter; +import com.codename1.backend.security.ratelimit.RateLimitKeys; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; + +/// Who may call what. +/// +/// This server is two things at once, and has a filter chain for each: +/// +/// - **An authorization server.** The app signs a user in here -- e-mail and +/// password, then the authorization-code flow with PKCE -- and gets a +/// short-lived access token and a refresh token. Nothing but this server ever +/// sees the password. +/// - **An API.** Everything under `/api` takes that access token as a bearer +/// token, keeps no session, and decides from the account's roles what the +/// caller may do. +/// +/// The roles are RIDER, DRIVER and ADMIN ([Roles]). `/api/driver` is for +/// drivers and `/api/admin` for admins; the rest is for anyone signed in, and +/// acts on the caller's own account, cards, receipts and driving application. +/// `/api/driving` is that last one, and is deliberately not under +/// `/api/driver`: applying to drive is what someone does before they may. +@Configuration +public class SecurityConfig { + /// The id the app signs in as. A public client: it holds no secret, since + /// anything shipped inside an app can be read out of it, and proves itself + /// with PKCE instead. + public static final String CLIENT_ID = "wayline-app"; + /// Where the authorization code is sent back to. The app makes the requests + /// of the flow itself and reads the code out of the redirect, so this is + /// never opened; it is the loopback address because that is the one address + /// nobody else can register a listener on. An app that signs in through the + /// system browser adds its own address scheme here with `redirectUri`. + public static final String LOOPBACK_REDIRECT = "http://127.0.0.1/callback"; + /// Where the code is sent back to when the app runs in a browser: a path on + /// this server, which answers with the code it was given. See + /// [#withWebRedirect]. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + /// Whom the access tokens are for. The API accepts no token issued for + /// anything else. + public static final String AUDIENCE = "urn:wayline:api"; + public static final String ISSUER_KEY = "cn1.security.authorizationserver.issuer"; + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + /// The accounts, in the tables `cn1.security.schema.enabled` creates. + @Bean + public JdbcUserDetailsManager users(DataSource db) { + return new JdbcUserDetailsManager(db); + } + + @Bean + public RegisteredClientRepository clients(DataSource db, + @Value("${" + ISSUER_KEY + ":}") final String issuer) throws IOException { + final JdbcRegisteredClientRepository clients = new JdbcRegisteredClientRepository(db); + if (clients.findByClientId(CLIENT_ID) == null) { + clients.save(RegisteredClient.withId("wayline").clientId(CLIENT_ID) + .clientName("Wayline") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(LOOPBACK_REDIRECT) + .scope("openid").scope("profile") + .build()); + } + // The app in a browser is the same client with one more address: this + // server's own. It is worked out and not stored, because on a + // development profile the server has no one address to store -- and a + // row written by an earlier version would not have it either. + return new RegisteredClientRepository() { + @Override + public void save(RegisteredClient client) { + clients.save(client); + } + + @Override + public RegisteredClient findByClientId(String clientId) { + return withWebRedirect(clients.findByClientId(clientId), issuer); + } + + @Override + public RegisteredClient findById(String id) { + return withWebRedirect(clients.findById(id), issuer); + } + }; + } + + /// The app's registration with [#WEB_REDIRECT_PATH] on this server added + /// to the addresses a code may be sent back to. + /// + /// A browser will not show a page the answer to a request that was + /// redirected to another origin, so the app running in one cannot read its + /// code off a redirect to the loopback address the way the installed app + /// does. It asks for the code to be sent to this server instead, which + /// hands it straight back ([SignInCodePage]). + /// + /// "This server" is the configured issuer where there is one, and is then + /// the only web address accepted. With none -- a development profile -- it + /// is the address the request arrived at, the same rule + /// [#issuedAtThisAddress] holds a token to: a code is only ever sent back + /// to the server the browser was already talking to. + static RegisteredClient withWebRedirect(RegisteredClient client, String issuer) { + if (client == null || !CLIENT_ID.equals(client.getClientId())) { + return client; + } + String here = here(issuer); + if (here == null) { + return client; + } + RegisteredClient.Builder copy = RegisteredClient.withId(client.getId()) + .clientId(client.getClientId()) + .clientName(client.getClientName()) + .clientSettings(client.getClientSettings()) + .tokenSettings(client.getTokenSettings()) + .redirectUri(here + WEB_REDIRECT_PATH); + for (ClientAuthenticationMethod method : client.getClientAuthenticationMethods()) { + copy.clientAuthenticationMethod(method); + } + for (AuthorizationGrantType grant : client.getAuthorizationGrantTypes()) { + copy.authorizationGrantType(grant); + } + for (String uri : client.getRedirectUris()) { + copy.redirectUri(uri); + } + for (String scope : client.getScopes()) { + copy.scope(scope); + } + for (String resource : client.getResources()) { + copy.resource(resource); + } + return copy.build(); + } + + /// Refuses a request that changes something and was sent by a page of + /// another site. + /// + /// A browser names the site a request came from in `Origin` on every + /// request that is not a plain read, and a page cannot change or drop it. + /// The installed app is not a browser and sends none, so a request without + /// one is let through: there is no cookie jar of somebody else's for it to + /// be riding on. + static SecurityFilter sameOrigin(final String issuer) { + return (request, chain) -> { + String method = request.getMethod(); + if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) { + return chain.doFilter(request); + } + String origin = request.getHeader("Origin"); + if (origin == null || origin.length() == 0) { + return chain.doFilter(request); + } + String here = here(issuer); + if (here != null && sameOrigin(origin, here)) { + return chain.doFilter(request); + } + return new HttpServer.Response(403, "application/json", + "{\"error\":\"cross_origin\"}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"); + }; + } + + /// This server's address: the configured issuer, and with none -- a + /// development profile -- the address the request in hand arrived at. Null + /// outside a request that says. + static String here(String issuer) { + if (issuer != null && issuer.length() > 0) { + return issuer; + } + SecurityExchange exchange = SecurityExchange.current(); + String host = exchange == null ? null : exchange.getRequest().getHeader("Host"); + if (host == null || host.length() == 0) { + return null; + } + return (exchange.isSecure() ? "https://" : "http://") + host; + } + + /// Whether `origin` is the origin of `address`: the same scheme, host and + /// port, whatever path the address goes on to name. Scheme and host are + /// compared without regard to case, and without folding it -- that would + /// depend on the language the server runs in. + static boolean sameOrigin(String origin, String address) { + int path = address.indexOf('/', address.indexOf("://") + 3); + String root = path < 0 ? address : address.substring(0, path); + return origin.equalsIgnoreCase(root); + } + + /// Kept in the database, so a refresh token outlives a restart and is good + /// on every instance of the server. + @Bean + public OAuth2AuthorizationService authorizations(DataSource db) { + return new JdbcOAuth2AuthorizationService(db); + } + + /// The keys the tokens are signed with: the files named by + /// `cn1.security.authorizationserver.jwk.keys`. On a development profile + /// with none configured, a key made at start-up -- which is why signing in + /// again is needed after restarting a development server. + @Bean + public JwkSource signingKeys(Config config) throws IOException { + return AuthorizationServerKeys.load(config); + } + + /// Signing in. A session exists here and only here, for the few requests + /// between the password being accepted and the code being handed over; + /// the app ends it with `POST /logout` once it has its tokens, and one it + /// never ends runs out after `cn1.session.timeout` seconds without a + /// request, half an hour unless set. + @Bean + @Order(1) + public SecurityFilterChain signIn(HttpSecurity http, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + @Value("${wayline.signin.per-minute:10}") int signInsPerMinute) throws IOException { + http.securityMatcher("/oauth2/**", "/.well-known/**", "/userinfo", "/login", "/login/**", + "/logout") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + // No CSRF token: the app posts the sign-in form itself, from its own + // screen, and would have to fetch a token first to send one. What a + // token is for still has to be done, because the app also runs in a + // browser, where the session is a cookie the browser keeps and would + // send along with a form another site submitted -- signing its + // visitor in as somebody else. Two things stand in for it. The + // session cookie is `SameSite=Lax`, the server's default, so it does + // not travel with another site's request; and [#sameOrigin] below + // refuses any request that changes something and says it came from + // another site, which is the one a forged sign-in is. A server that + // signs people in through a page it serves keeps CSRF on. + .csrf(csrf -> csrf.disable()) + .addFilterBefore(sameOrigin(issuer), LogoutFilter.class) + // Signing out is a POST and nothing else -- with CSRF off the + // default takes a GET too, which an image tag on any page can send + // -- and is answered with a status, for the reason signing in is. + .logout(logout -> logout + .logoutRequestMatcher(AntPathRequestMatcher.antMatcher("POST", "/logout")) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(204))) + // And it is answered with a status, not a redirect to a page: + // the app in a browser cannot see a redirect or the cookie, only + // how the request ended, so that has to be the answer. The session + // cookie is set either way; the installed app reads it, a browser + // keeps it. + .formLogin(form -> form + .successHandler((request, authentication) -> new HttpServer.Response(200, + "application/json", "{\"signedIn\":true}".getBytes("UTF-8")) + .header("Cache-Control", "no-store")) + .failureHandler((request, refused) -> new HttpServer.Response(401, + "application/json", "{\"signedIn\":false}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"))) + // Guessing passwords is the attack on this chain. Ten tries a minute + // from one address, unless `wayline.signin.per-minute` says otherwise: + // the end-to-end tests are many people signing in from one address, + // and their profile raises it. + .rateLimit("/login", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(signInsPerMinute, 60)) + .authorizationServer(server -> server + .settings(AuthorizationServerSettings.builder() + .defaultAudience(AUDIENCE).build())); + return http.build(); + } + + /// The API. Stateless: every request stands on its own bearer token. + @Bean + @Order(2) + public SecurityFilterChain api(HttpSecurity http, JwkSource signingKeys, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + JdbcUserDetailsManager users) throws IOException { + DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtAudienceValidator(AUDIENCE), + issuer.length() > 0 ? new JwtIssuerValidator(issuer) + : new JwtClaimValidator("iss", SecurityConfig::issuedAtThisAddress))); + + // The token says who the caller is and nothing about what they may do. + // The roles are read from the account on every request instead of being + // copied into the token, for one reason: an admin who withdraws a role + // or suspends an account means now, not when the token runs out. It + // costs a lookup by primary key per request. + JwtAuthenticationConverter roles = new JwtAuthenticationConverter(); + roles.setJwtGrantedAuthoritiesConverter(jwt -> { + String name = jwt.getSubject(); + if (name == null || !users.userExists(name)) { + throw new DisabledException("No such account"); + } + UserDetails account = users.loadUserByUsername(name); + if (!account.isEnabled() || !account.isAccountNonLocked()) { + throw new DisabledException("This account is suspended"); + } + Collection held = new ArrayList(); + held.addAll(account.getAuthorities()); + return held; + }); + + http.securityMatcher("/api/**") + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // Opening accounts and sending text messages are the two things + // here that cost something each time and that a stranger can ask + // for. One limiter per rule: a shared one would share its count. + .rateLimit("/api/account/register", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(20, 3600)) + .rateLimit("/api/account/phone/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 600)) + // Guessing the current password with a token in hand, and filling + // the database with pictures: an hour's allowance of each is more + // than anyone honest needs. + .rateLimit("/api/account/password", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 3600)) + // Every search and every dropped pin is a request this server + // makes of somebody else's geocoder, in the service's name. A + // person typing an address makes a few a second at most; a client + // in a loop would get the whole service refused by the provider. + .rateLimit("/api/geo/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(120, 60)) + .rateLimit("/api/driving/application/documents", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(60, 3600)) + // Every card setup is a session made at the payment processor. + // `Payments` caps how many an account has open; this caps how + // often it may start one, so abandoning setups in a loop is slow. + .rateLimit("/api/payments/methods/setup", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(30, 3600)) + // tag::wayline-api-rules[] + .authorizeHttpRequests(auth -> auth + .requestMatchers(AntPathRequestMatcher.antMatcher("POST", + "/api/account/register")).permitAll() + .requestMatchers("/api/driver/**").hasRole(Roles.DRIVER) + .requestMatchers("/api/admin/**").hasRole(Roles.ADMIN) + .anyRequest().authenticated()) + // end::wayline-api-rules[] + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(decoder) + .jwtAuthenticationConverter(roles))); + return http.build(); + } + + /// Whether a token's issuer is the address this request came to. + /// + /// Used only when no issuer is configured, which the server allows only on + /// a development profile: it then issues tokens in the name of whatever + /// address each request arrived at, so that is the name to hold a token to. + static boolean issuedAtThisAddress(Object issuer) { + SecurityExchange exchange = SecurityExchange.current(); + if (exchange == null || !(issuer instanceof String)) { + return false; + } + String host = exchange.getRequest().getHeader("Host"); + return host != null && ((exchange.isSecure() ? "https://" : "http://") + host) + .equals(issuer); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Services.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Services.java new file mode 100644 index 00000000000..1fa80fad789 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Services.java @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.push.CloudPush; +import com.codenameone.examples.wayline.geo.Geocoder; +import com.codenameone.examples.wayline.geo.PhotonGeocoder; +import com.codenameone.examples.wayline.geo.StubGeocoder; +import com.codenameone.examples.wayline.pay.MisconfiguredPayments; +import com.codenameone.examples.wayline.pay.PaymentProvider; +import com.codenameone.examples.wayline.pay.SimulatedPayments; +import com.codenameone.examples.wayline.pay.StripePayments; +import com.codenameone.examples.wayline.push.CloudPushSender; +import com.codenameone.examples.wayline.push.ConsolePushSender; +import com.codenameone.examples.wayline.push.MisconfiguredPushSender; +import com.codenameone.examples.wayline.push.PushSender; +import com.codenameone.examples.wayline.push.RecordingPushSender; +import com.codenameone.examples.wayline.sms.ConsoleSmsSender; +import com.codenameone.examples.wayline.sms.MessageBirdSmsSender; +import com.codenameone.examples.wayline.sms.MisconfiguredSmsSender; +import com.codenameone.examples.wayline.sms.SmsSender; +import com.codenameone.examples.wayline.sms.TwilioSmsSender; +import com.codenameone.examples.wayline.sms.VonageSmsSender; + +import java.io.IOException; + +/// The services this server reaches outside itself for, each behind an +/// interface and each chosen here from the settings. +/// +/// Each is named by one setting -- `wayline.sms.provider`, +/// `wayline.payments.provider` and `wayline.push.provider` -- and, when that is +/// not set, a development profile (dev, test, local) gets the one that needs no +/// account: codes and notifications printed on the console, cards simulated. Any other profile gets the real provider, +/// and when its settings are missing the server still starts, logs a +/// configuration error naming each missing setting, and fails every call to +/// that provider with the same message. It never quietly falls back to a +/// development stand-in. +/// +/// Every setting is also read from the environment, in upper case with `_` +/// for each `.` (`WAYLINE_SMS_TWILIO_TOKEN`), which is where secrets belong. +@Configuration +public class Services { + /// `wayline.sms.provider` is one of: + /// + /// - `twilio`: `wayline.sms.twilio.sid`, `.token` and `.from` + /// - `vonage`: `wayline.sms.vonage.key`, `.secret` and `.from` + /// - `messagebird`: `wayline.sms.messagebird.key` and `.originator` + /// - `console`: prints the message, on a development profile only + /// + /// Unset, it is `console` on a development profile and `twilio` otherwise. + @Bean + public SmsSender smsSender(Config config, + @Value("${wayline.sms.provider:}") String chosen, + @Value("${wayline.sms.twilio.sid:}") String twilioSid, + @Value("${wayline.sms.twilio.token:}") String twilioToken, + @Value("${wayline.sms.twilio.from:}") String twilioFrom, + @Value("${wayline.sms.vonage.key:}") String vonageKey, + @Value("${wayline.sms.vonage.secret:}") String vonageSecret, + @Value("${wayline.sms.vonage.from:}") String vonageFrom, + @Value("${wayline.sms.messagebird.key:}") String birdKey, + @Value("${wayline.sms.messagebird.originator:}") String birdOriginator) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : config.isDevelopmentProfile() ? "console" : "twilio"; + if ("console".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredSmsSender("wayline.sms.provider=console only prints codes " + + "and is refused on the '" + config.getProfile() + "' profile; set it to " + + "twilio, vonage or messagebird"); + } + return new ConsoleSmsSender(); + } + if ("twilio".equalsIgnoreCase(kind)) { + String missing = missing("wayline.sms.twilio.", new String[] {"sid", twilioSid, + "token", twilioToken, "from", twilioFrom}); + return missing.length() > 0 ? new MisconfiguredSmsSender(unset("SMS", "twilio", + missing)) : new TwilioSmsSender(twilioSid, twilioToken, twilioFrom); + } + // tag::wayline-sms-choice[] + if ("vonage".equalsIgnoreCase(kind)) { + String missing = missing("wayline.sms.vonage.", new String[] {"key", vonageKey, + "secret", vonageSecret, "from", vonageFrom}); + return missing.length() > 0 ? new MisconfiguredSmsSender(unset("SMS", "vonage", + missing)) : new VonageSmsSender(vonageKey, vonageSecret, vonageFrom); + } + // end::wayline-sms-choice[] + if ("messagebird".equalsIgnoreCase(kind)) { + String missing = missing("wayline.sms.messagebird.", new String[] {"key", birdKey, + "originator", birdOriginator}); + return missing.length() > 0 ? new MisconfiguredSmsSender(unset("SMS", "messagebird", + missing)) : new MessageBirdSmsSender(birdKey, birdOriginator); + } + return new MisconfiguredSmsSender("wayline.sms.provider is '" + kind + "', which is " + + "none of twilio, vonage, messagebird or console"); + } + + /// Photon, at `wayline.geocoder.url`. `wayline.geocoder=stub` selects the + /// one that knows ten places and asks nobody, and it is what the `test` + /// profile gets unless told otherwise. + @Bean + public Geocoder geocoder(Config config, @Value("${wayline.geocoder:}") String chosen, + @Value("${wayline.geocoder.url:https://photon.komoot.io}") String address) + throws IOException { + String kind = chosen.length() > 0 ? chosen + : "test".equals(config.getProfile()) ? "stub" : "photon"; + if ("stub".equals(kind)) { + return new StubGeocoder(); + } + return new PhotonGeocoder(address); + } + + // tag::wayline-payment-choice[] + /// `wayline.payments.provider` is one of: + /// + /// - `stripe`: [StripePayments], an untested stub over Stripe's REST API. + /// It needs `wayline.payments.stripe.secret`. + /// - `simulated`: test card numbers, and no money moves. + /// + /// Unset, it is `simulated` on a development profile and `stripe` otherwise. + /// + /// With Stripe a card is typed into Stripe's own page, which then sends the + /// browser back to `/pay/return` on this server. That address has to be the + /// one a phone can reach, so it is `wayline.public.url` when that is set + /// and the issuer -- the public address of this server, which a deployment + /// sets anyway -- when it is not. + @Bean + public PaymentProvider paymentProvider(Config config, + @Value("${wayline.payments.provider:}") String chosen, + @Value("${wayline.payments.stripe.secret:}") String secret, + @Value("${wayline.public.url:}") String publicUrl, + @Value("${" + SecurityConfig.ISSUER_KEY + ":}") String issuer) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : config.isDevelopmentProfile() ? "simulated" : "stripe"; + if ("simulated".equalsIgnoreCase(kind)) { + return new SimulatedPayments(); + } + if (!"stripe".equalsIgnoreCase(kind)) { + return new MisconfiguredPayments(kind, "wayline.payments.provider is '" + kind + + "', which is neither stripe nor simulated"); + } + String address = publicUrl.length() > 0 ? publicUrl : issuer; + String missing = missing("", new String[] {"wayline.payments.stripe.secret", secret, + "wayline.public.url (or " + SecurityConfig.ISSUER_KEY + ")", address}); + if (missing.length() > 0) { + return new MisconfiguredPayments("stripe", unset("payments", "stripe", missing)); + } + while (address.endsWith("/")) { + address = address.substring(0, address.length() - 1); + } + return new StripePayments(secret, address + "/pay/return"); + } + // end::wayline-payment-choice[] + + // tag::wayline-push-choice[] + /// `wayline.push.provider` is one of: + /// + /// - `cloud`: the Codename One push service. It needs `wayline.push.app`, + /// the app's push id, and `wayline.push.key`, a server key with the push + /// scope; both are in the Push section of the Codename One console. + /// - `console`: prints each notification, on a development profile only. + /// - `recording`: keeps each notification for the tests to read, on a + /// development profile only. + /// + /// Unset, it is `recording` on the `test` profile, `console` on the other + /// development profiles and `cloud` otherwise. A server whose push settings + /// are missing still starts and still serves rides: it logs a configuration + /// error at start-up and again for every notification it could not send. + @Bean + public PushSender pushSender(Config config, + @Value("${wayline.push.provider:}") String chosen, + @Value("${wayline.push.app:}") String app, + @Value("${wayline.push.key:}") String key, + @Value("${wayline.push.url:}") String url) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : "test".equals(config.getProfile()) ? "recording" + : config.isDevelopmentProfile() ? "console" : "cloud"; + if ("console".equalsIgnoreCase(kind) || "recording".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredPushSender("wayline.push.provider=" + kind + + " sends nothing and is refused on the '" + config.getProfile() + + "' profile; set it to cloud"); + } + return "console".equalsIgnoreCase(kind) ? new ConsolePushSender() + : new RecordingPushSender(); + } + if (!"cloud".equalsIgnoreCase(kind)) { + return new MisconfiguredPushSender("wayline.push.provider is '" + kind + + "', which is none of cloud, console or recording"); + } + String missing = missing("wayline.push.", new String[] {"app", app, "key", key}); + if (missing.length() > 0) { + return new MisconfiguredPushSender(unset("push", "cloud", missing)); + } + try { + CloudPush push = new CloudPush(app.trim(), key.trim()); + return new CloudPushSender(url.trim().length() > 0 ? push.withEndpoint(url.trim()) + : push); + } catch (IllegalArgumentException err) { + return new MisconfiguredPushSender("push provider 'cloud' is misconfigured: " + + err.getMessage()); + } + } + // end::wayline-push-choice[] + + /// The names, after `prefix`, of the settings in `namesAndValues` whose + /// value is empty, separated by commas. + private static String missing(String prefix, String[] namesAndValues) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < namesAndValues.length; iter += 2) { + if (namesAndValues[iter + 1] == null || namesAndValues[iter + 1].trim().length() == 0) { + if (out.length() > 0) { + out.append(", "); + } + out.append(prefix).append(namesAndValues[iter]); + } + } + return out.toString(); + } + + private static String unset(String service, String provider, String missing) { + return service + " provider '" + provider + "' is not configured; set " + missing; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/SignInCodePage.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/SignInCodePage.java new file mode 100644 index 00000000000..fa4b24e33b6 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/SignInCodePage.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// Where a sign-in from a browser ends: the authorization code, handed back. +/// +/// The installed app asks for its code to be sent to a loopback address and +/// reads it off the redirect without following it. A page in a browser cannot +/// do that -- the browser follows the redirect itself and shows the page only +/// what it found at the end -- so the app running in one asks for the code to +/// be sent here, and what is at the end is the code. +/// +/// Nothing is decided here and nothing is trusted: the code arrived in the +/// address, so repeating it tells the caller nothing it did not have, and it +/// is worth nothing without the PKCE verifier only the app that started the +/// sign-in holds. That is why this needs no sign-in of its own. +@RestController +public class SignInCodePage { + @GetMapping(SecurityConfig.WEB_REDIRECT_PATH) + public HttpServer.Response code( + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state, + @RequestParam(value = "error", required = false) String error) throws Exception { + Map answer = new LinkedHashMap(); + if (code != null) { + answer.put("code", code); + } + if (state != null) { + answer.put("state", state); + } + if (error != null) { + answer.put("error", error); + } + return new HttpServer.Response(code == null ? 400 : 200, "application/json", + Json.write(answer).getBytes("UTF-8")) + // A code is good once, for a few minutes; nothing should keep it. + .header("Cache-Control", "no-store") + // And nothing should read it as anything but data: the values + // come from the address, which anyone can write a link to. + .header("X-Content-Type-Options", "nosniff"); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Text.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Text.java new file mode 100644 index 00000000000..c8b33b5d9a6 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/Text.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.ResponseStatusException; + +/// What the app sends, made safe to store: trimmed, bounded, and one of the +/// words a field allows. +public final class Text { + private Text() { + } + + /// `value` trimmed, or empty for null; answers 400 when longer than `max`. + public static String optional(String value, int max, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() > max) { + throw new ResponseStatusException(400, "That is too long for " + what); + } + return text; + } + + /// `value` trimmed; answers 400 when it is empty or longer than `max`. + public static String required(String value, int max, String what) { + String text = optional(value, max, what); + if (text.length() == 0) { + throw new ResponseStatusException(400, "Tell us " + what); + } + return text; + } + + /// `value` when it is one of `allowed`, and `fallback` when it is empty; + /// answers 400 for anything else. The words are compared as written: they + /// are the app's own constants, not something a person types. + public static String oneOf(String value, String[] allowed, String fallback, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + return fallback; + } + for (int iter = 0; iter < allowed.length; iter++) { + if (allowed[iter].equals(text)) { + return text; + } + } + throw new ResponseStatusException(400, "That is not a value for " + what); + } + + /// The first letters of the first and last words of a name, in upper case + /// when they are ASCII letters: "Dana Driver" is "DD". + public static String initials(String name) { + String text = name == null ? "" : name.trim(); + if (text.length() == 0) { + return ""; + } + int last = text.lastIndexOf(' '); + StringBuilder out = new StringBuilder(2); + out.append(upper(text.charAt(0))); + if (last > 0 && last + 1 < text.length()) { + out.append(upper(text.charAt(last + 1))); + } + return out.toString(); + } + + private static char upper(char c) { + // By hand: toUpperCase() follows the server's locale. + return c >= 'a' && c <= 'z' ? (char) (c - ('a' - 'A')) : c; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/AccountCleanup.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/AccountCleanup.java new file mode 100644 index 00000000000..57a1425c09c --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/AccountCleanup.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.repository.DriverHoursRepository; +import com.codenameone.examples.wayline.repository.DriverStateRepository; +import com.codenameone.examples.wayline.repository.LiveTicketRepository; +import com.codenameone.examples.wayline.repository.PayoutAccountRepository; +import com.codenameone.examples.wayline.repository.PhoneCodeRepository; +import com.codenameone.examples.wayline.repository.PreferenceRepository; +import com.codenameone.examples.wayline.repository.PushDeviceRepository; +import com.codenameone.examples.wayline.repository.RideActivityRepository; +import com.codenameone.examples.wayline.repository.RideRepository; + +/// What closing or restricting an account does to the rows other parts of the +/// server keep under its name. +/// +/// These are questions and statements over whole sets of rows -- everything +/// of one account in a table -- and not the work of the services that own +/// those tables, which is why they are gathered here. Each deletion is a bulk +/// statement, so whatever the session had loaded before it is stale +/// afterwards and must be read again before it is used. It has no +/// transaction of its own: [Accounts] calls it inside one. +@Component +public class AccountCleanup { + /// The states of a ride that somebody is still waiting on. + private static final RideState[] UNDER_WAY = {RideState.REQUESTED, RideState.OFFERED, + RideState.ACCEPTED, RideState.ARRIVED, RideState.IN_PROGRESS}; + /// The states of a ride a driver has taken and has yet to finish. + private static final RideState[] TAKEN = {RideState.ACCEPTED, RideState.ARRIVED, + RideState.IN_PROGRESS}; + + private final RideRepository rides; + private final DriverStateRepository drivers; + private final DriverHoursRepository hours; + private final PreferenceRepository preferences; + private final PayoutAccountRepository payoutAccounts; + private final PhoneCodeRepository codes; + private final LiveTicketRepository tickets; + private final PushDeviceRepository pushDevices; + private final RideActivityRepository activities; + + public AccountCleanup(RideRepository rides, DriverStateRepository drivers, + DriverHoursRepository hours, PreferenceRepository preferences, + PayoutAccountRepository payoutAccounts, PhoneCodeRepository codes, + LiveTicketRepository tickets, PushDeviceRepository pushDevices, + RideActivityRepository activities) { + this.rides = rides; + this.drivers = drivers; + this.hours = hours; + this.preferences = preferences; + this.payoutAccounts = payoutAccounts; + this.codes = codes; + this.tickets = tickets; + this.pushDevices = pushDevices; + this.activities = activities; + } + + /// Whether the account is the rider or the driver of a ride that has not + /// ended. + public boolean hasRideUnderWay(String username) { + return rides.existsByRiderAndStateInOrDriverAndStateIn(username, UNDER_WAY, username, + UNDER_WAY); + } + + /// Whether the account is driving a ride it has taken and not finished. An + /// offer it has not answered is not one. + public boolean drivesRideUnderWay(String username) { + return rides.existsByDriverAndStateIn(username, TAKEN); + } + + /// Whether the account is in a ride that has not ended: as its rider, from + /// the moment it is asked for, or as its driver, once taken. An offer the + /// driver has not answered is not one; it passes to the next driver. + public boolean inRideUnderWay(String username) { + return rides.existsByRiderAndStateInOrDriverAndStateIn(username, UNDER_WAY, username, + TAKEN); + } + + /// Takes a driver off line, when the account stops being allowed to drive. + /// Does nothing for an account that never drove. + public void takeOffLine(String username) { + drivers.takeOffLine(username); + } + + /// Deletes what an account leaves in the tables that are keyed by it and + /// belong to nobody's `forget`: its preferences, where it is paid, the + /// hours it drove, its place on the map, a code it was texted, the + /// tickets it held for the live connection, and the phones it was sent + /// notifications on. + public void removeEverythingOf(String username) { + preferences.deleteByUsername(username); + payoutAccounts.deleteByUsername(username); + hours.deleteByUsername(username); + drivers.deleteByUsername(username); + codes.deleteByUsername(username); + tickets.deleteByUsername(username); + pushDevices.deleteByUsername(username); + activities.deleteByUsername(username); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/AccountEndpoint.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/AccountEndpoint.java new file mode 100644 index 00000000000..09ffff17a09 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/AccountEndpoint.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.wayline.Caller; +import com.codenameone.examples.wayline.api.AccountApiServer; +import com.codenameone.examples.wayline.api.AccountClosureDto; +import com.codenameone.examples.wayline.api.PasswordChangeDto; +import com.codenameone.examples.wayline.api.PhoneChallengeDto; +import com.codenameone.examples.wayline.api.PhoneCodeDto; +import com.codenameone.examples.wayline.api.PreferencesDto; +import com.codenameone.examples.wayline.api.ProfileDto; +import com.codenameone.examples.wayline.api.PushDeviceDto; +import com.codenameone.examples.wayline.api.RegisterDto; +import com.codenameone.examples.wayline.api.TicketDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.live.LiveTickets; +import com.codenameone.examples.wayline.push.PushDevices; + +/// The server's half of `AccountApi`. +/// +/// `AccountApiServer` is generated from the contract the app calls through, and +/// the build routes each of the contract's paths to this class. Change a path or +/// a type in the contract and both sides stop compiling until they agree. +@Component +public class AccountEndpoint implements AccountApiServer { + private final Accounts accounts; + private final PhoneVerification phones; + private final LiveTickets tickets; + private final Preferences preferences; + private final PushDevices devices; + + public AccountEndpoint(Accounts accounts, PhoneVerification phones, LiveTickets tickets, + Preferences preferences, PushDevices devices) { + this.accounts = accounts; + this.phones = phones; + this.tickets = tickets; + this.preferences = preferences; + this.devices = devices; + } + + @Override + public UserDto register(RegisterDto request) throws Exception { + return accounts.register(request); + } + + @Override + public UserDto me() throws Exception { + return accounts.describe(Caller.name()); + } + + @Override + public PhoneChallengeDto startPhoneVerification() throws Exception { + return phones.start(Caller.name()); + } + + @Override + public UserDto verifyPhone(PhoneCodeDto code) throws Exception { + return phones.verify(Caller.name(), code == null ? null : code.code()); + } + + @Override + public TicketDto liveTicket() throws Exception { + return tickets.issue(Caller.name()); + } + + @Override + public PreferencesDto preferences() throws Exception { + return preferences.get(Caller.name()); + } + + @Override + public PreferencesDto savePreferences(PreferencesDto wanted) throws Exception { + return preferences.save(Caller.name(), wanted); + } + + @Override + public ProfileDto profile() throws Exception { + return accounts.profile(Caller.name()); + } + + @Override + public UserDto saveProfile(ProfileDto profile) throws Exception { + return accounts.saveProfile(Caller.name(), profile); + } + + @Override + public UserDto changePassword(PasswordChangeDto change) throws Exception { + return accounts.changePassword(Caller.name(), change == null ? null : change.current(), + change == null ? null : change.replacement()); + } + + @Override + public UserDto deleteAccount(AccountClosureDto closure) throws Exception { + return accounts.close(Caller.name(), closure == null ? null : closure.password()); + } + + @Override + public PushDeviceDto registerPush(PushDeviceDto device) throws Exception { + return devices.register(Caller.name(), device); + } + + @Override + public PushDeviceDto unregisterPush(PushDeviceDto device) throws Exception { + return devices.unregister(Caller.name(), device); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Accounts.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Accounts.java new file mode 100644 index 00000000000..533ae91da46 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Accounts.java @@ -0,0 +1,650 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.User; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.core.userdetails.UsernameNotFoundException; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.Roles; +import com.codenameone.examples.wayline.Text; +import com.codenameone.examples.wayline.api.ProfileDto; +import com.codenameone.examples.wayline.api.RegisterDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.domain.ClosedAccount; +import com.codenameone.examples.wayline.domain.Profile; +import com.codenameone.examples.wayline.driver.Earnings; +import com.codenameone.examples.wayline.driving.Applications; +import com.codenameone.examples.wayline.live.LiveHub; +import com.codenameone.examples.wayline.pay.Payments; +import com.codenameone.examples.wayline.repository.ClosedAccountRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// Accounts: opening one, describing one, and what an admin may change. +/// +/// An account is two things kept under one name. Its sign-in -- password hash, +/// roles, whether it is enabled -- is the backend's own user store. Its profile +/// -- display name, phone, car -- is this application's table. The name is the +/// e-mail address, in lower case. +/// +/// Driving is not something an account is opened with. Asking to drive opens +/// an application ([Applications]), and the DRIVER role arrives when an admin +/// approves it. +@Component +public class Accounts { + public static final String[] GENDERS = {"unspecified", "female", "male", "nonbinary"}; + + private final ProfileRepository profiles; + private final AccountCleanup cleanup; + private final JdbcUserDetailsManager users; + private final PasswordEncoder encoder; + private final Applications applications; + private final Moderation moderation; + private final Payments payments; + private final LiveHub live; + private final ClosedAccountRepository closed; + private final Grants grants; + private final Earnings earnings; + + public Accounts(ProfileRepository profiles, AccountCleanup cleanup, + JdbcUserDetailsManager users, PasswordEncoder encoder, Applications applications, + Moderation moderation, Payments payments, LiveHub live, + ClosedAccountRepository closed, Grants grants, Earnings earnings) { + this.earnings = earnings; + this.closed = closed; + this.grants = grants; + this.profiles = profiles; + this.cleanup = cleanup; + this.users = users; + this.encoder = encoder; + this.applications = applications; + this.moderation = moderation; + this.payments = payments; + this.live = live; + } + + /// Opens an account. Answers 400 for a request that cannot be an account + /// and 409 when the e-mail already has one. + /// + /// Not a transaction itself, so that it can look again after one failed. + /// Two requests for the same e-mail can both find it free; the name's + /// unique key then refuses the second, and what the database says about + /// that differs with the database. So nothing here reads the error: if the + /// account exists once the transaction is over, that is why it failed, and + /// the answer is the same 409 the check gives. + public UserDto register(RegisterDto request) throws IOException { + try { + return open(request); + } catch (ResponseStatusException answered) { + throw answered; + } catch (IOException failed) { + throw taken(request, failed); + } catch (RuntimeException failed) { + throw taken(request, failed); + } + } + + /// The 409 for a registration that failed because the account was opened + /// meanwhile, or the failure itself when it was something else. + private T taken(RegisterDto request, T failed) { + if (request != null && request.email() != null + && users.userExists(email(request.email()))) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + return failed; + } + + @Transactional(rollbackFor = IOException.class) + private UserDto open(RegisterDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Nothing to register"); + } + String username = email(request.email()); + password(request.password()); + String gender = Text.oneOf(request.gender(), GENDERS, GENDERS[0], "gender"); + String displayName = required(request.displayName(), 120, "your name"); + String phone = phone(request.phone()); + String vehicle = ""; + String plate = ""; + if (request.driver()) { + vehicle = required(request.vehicle(), 120, "the car you drive"); + plate = required(request.plate(), 32, "its plate"); + } + if (users.userExists(username)) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + // An address whose account was closed is not given to anyone again: + // the rides that account took are still filed under it. See + // [#retire]. + if (closed.existsById(Ids.sha256(username))) { + throw new ResponseStatusException(409, + "That e-mail belonged to an account that was closed. Use another"); + } + // Every account opens as a rider. Asking to drive -- `driver` is how an + // older app asks -- begins an application, and the role waits for an + // admin to approve it. + create(username, request.password(), displayName, phone, false, vehicle, plate, false, + false, gender, System.currentTimeMillis()); + if (request.driver() || request.wantsToDrive()) { + applications.open(username, vehicle, plate); + } + return describe(username); + } + + /// Creates the sign-in and the profile. The caller has checked the name is + /// free. An account created as a driver is an approved one: this is how the + /// demo accounts are made, not how anyone signs up. + @Transactional(rollbackFor = IOException.class) + void create(String username, String password, String displayName, String phone, + boolean phoneVerified, String vehicle, String plate, boolean driver, boolean admin) + throws IOException { + create(username, password, displayName, phone, phoneVerified, vehicle, plate, driver, + admin, GENDERS[0], System.currentTimeMillis()); + } + + /// As the method above, for a caller that also knows the gender and when + /// the account was opened; the demo data is the one caller with a past to + /// write. The profile is stored whole, in one go, and not corrected + /// afterwards. + @Transactional(rollbackFor = IOException.class) + void create(String username, String password, String displayName, String phone, + boolean phoneVerified, String vehicle, String plate, boolean driver, boolean admin, + String gender, long openedAt) throws IOException { + users.createUser(User.withUsername(username).password(encoder.encode(password)) + .roles(roles(driver, admin)).build()); + Profile profile = new Profile(); + profile.setUsername(username); + profile.setDisplayName(displayName); + profile.setPhone(phone); + profile.setPhoneVerified(phoneVerified); + profile.setVehicle(vehicle); + profile.setPlate(plate); + profile.setCreatedAt(openedAt); + profile.setGender(gender); + profiles.insert(profile); + if (driver) { + applications.grant(username, username); + } + } + + public boolean exists(String username) { + return users.userExists(username); + } + + /// The account as the app sees it. Answers 404 for a name with no account. + @Transactional(readOnly = true) + public UserDto describe(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null || !users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + UserDetails signIn = users.loadUserByUsername(username); + return toDto(profile, authorities(signIn), signIn.isEnabled(), + applications.status(username)); + } + + /// Every account, newest first, for the admin. + @Transactional(readOnly = true) + public List all() throws IOException { + Map driving = applications.statuses(); + List out = new ArrayList(); + List everyone = profiles.newestFirst(); + for (int iter = 0; iter < everyone.size(); iter++) { + Profile profile = everyone.get(iter); + // One sign-in read per account. The user store answers for one name + // at a time and has no way to list its users or to load several at + // once, and its tables are its own, not this application's to + // query. That is two statements an account, which the admin's list + // can afford; a store that could load them together would be asked + // once. + List held = new ArrayList(); + boolean enabled = true; + try { + UserDetails signIn = users.loadUserByUsername(profile.getUsername()); + held = authorities(signIn); + enabled = signIn.isEnabled(); + } catch (UsernameNotFoundException gone) { + // A profile whose sign-in was deleted under it is listed with + // no roles rather than failing the whole list. + enabled = true; + } + String status = driving.get(profile.getUsername()); + out.add(toDto(profile, held, enabled, status == null ? Applications.NONE : status)); + } + return out; + } + + /// Grants or withdraws driving and administering. `by` is the admin doing + /// it, who cannot withdraw their own admin role: that is how a service ends + /// up with nobody able to administer it. + @Transactional(rollbackFor = IOException.class) + public UserDto setRoles(String by, String username, boolean driver, boolean admin) + throws IOException { + actingAdmin(by, username); + UserDetails signIn = load(username); + if (!admin && username.equals(by)) { + throw new ResponseStatusException(400, "You cannot withdraw your own admin role"); + } + // Not from under a rider. The role is read on every request, so a + // driver who lost it mid-ride could no longer arrive, start, complete + // or cancel, and the trip would sit there until somebody noticed. The + // admin ends the ride first, or waits for it to end. + if (!driver && cleanup.drivesRideUnderWay(username)) { + throw new ResponseStatusException(409, + "That driver is on a ride. Withdraw the role when it has ended"); + } + // A driver can lose the role with earnings not yet cashed out, and + // the payouts are driver routes. The money is still theirs in the + // ledger, and settling it is the admin's to arrange -- the role given + // back for the cash-out, or paid outside the app. A removal for cause + // must not wait on a payout, so it is not refused here. + users.updateUser(User.withUserDetails(signIn).roles(roles(driver, admin)).build()); + // The role and the approval go together, whichever way it was given: + // being approved is what lets a driver go on line. + if (driver) { + applications.grant(by, username); + } else { + applications.withdraw(by, username); + cleanup.takeOffLine(username); + } + moderation.record(username, by, Moderation.ROLES, + (driver ? "driver" : "rider") + (admin ? ", admin" : "")); + return describe(username); + } + + /// Blocks an account or lifts the block. A blocked account cannot sign in, + /// and a token it already holds stops working at once, because the API + /// reads the account on every request. + @Transactional(rollbackFor = IOException.class) + public UserDto suspend(String by, String username, boolean suspended, String reason) + throws IOException { + actingAdmin(by, username); + UserDetails signIn = load(username); + if (suspended && username.equals(by)) { + throw new ResponseStatusException(400, "You cannot suspend your own account"); + } + String why = Text.optional(reason, 255, "the reason"); + // Not in the middle of a ride, for the reason a driver's role is not + // withdrawn then: a blocked account's requests are all refused, so a + // driver could no longer arrive, start, finish or cancel, and a rider + // could not cancel, and the other person would be left with a ride + // nobody can end. The admin cancels the ride first, or waits. + if (suspended && signIn.isEnabled() && cleanup.inRideUnderWay(username)) { + throw new ResponseStatusException(409, + "That account is on a ride. Block it when the ride has ended"); + } + users.updateUser(User.withUserDetails(signIn).disabled(suspended).build()); + if (suspended) { + cleanup.takeOffLine(username); + } + if (suspended == signIn.isEnabled()) { + // Only a change is an event; saying the same thing twice is not. + moderation.record(username, by, suspended ? Moderation.BLOCK : Moderation.UNBLOCK, why); + } + return describe(username); + } + + /// Marks an account for the admins' attention, or clears the mark. A flag + /// changes nothing the account can do; blocking is what does that. + @Transactional(rollbackFor = IOException.class) + public UserDto flag(String by, String username, boolean flagged, String reason) + throws IOException { + // The profile row first, as the other admin changes hold it: a flag + // racing the account's closure either lands before erase() (which + // then forgets it with the rest) or finds no account. Otherwise the + // moderation row recorded below would outlive the closure's cleanup. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + load(username); + String why = Text.optional(reason, 255, "the reason"); + if (flagged && why.length() == 0) { + throw new ResponseStatusException(400, "Say why the account is flagged"); + } + Profile profile = profiles.findById(username); + if (profile != null) { + profile.setFlagged(flagged); + profile.setFlagReason(flagged ? why : ""); + } + moderation.record(username, by, flagged ? Moderation.FLAG : Moderation.UNFLAG, why); + return describe(username); + } + + // ------------------------------------------------------------ one's own + + @Transactional(readOnly = true) + public ProfileDto profile(String username) throws IOException { + Profile row = profiles.findById(username); + if (row == null) { + throw new ResponseStatusException(404, "No such account"); + } + return new ProfileDto(row.getDisplayName(), row.getGender(), row.getEmergencyName(), + row.getEmergencyPhone(), row.getHomeAddress(), row.getWorkAddress()); + } + + @Transactional(rollbackFor = IOException.class) + public UserDto saveProfile(String username, ProfileDto profile) throws IOException { + if (profile == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + String contact = profile.emergencyContactPhone() == null ? "" + : profile.emergencyContactPhone().trim(); + // Everything is checked before anything is assigned: the row is written + // from what it holds when the transaction commits. + String name = required(profile.name(), 120, "your name"); + String gender = Text.oneOf(profile.gender(), GENDERS, GENDERS[0], "gender"); + String emergencyName = Text.optional(profile.emergencyContactName(), 120, + "the contact's name"); + String emergencyPhone = contact.length() == 0 ? "" : phone(contact); + String home = Text.optional(profile.homeAddress(), 255, "an address"); + String work = Text.optional(profile.workAddress(), 255, "an address"); + Profile row = profiles.findById(username); + if (row == null) { + throw new ResponseStatusException(404, "No such account"); + } + row.setDisplayName(name); + row.setGender(gender); + row.setEmergencyName(emergencyName); + row.setEmergencyPhone(emergencyPhone); + row.setHomeAddress(home); + row.setWorkAddress(work); + return describe(username); + } + + /// Changes a password, for someone who knows the one it replaces. A token + /// in hand is not enough: it is what a borrowed phone has. The account's + /// other sign-ins end with the old password. + @Transactional(rollbackFor = IOException.class) + public UserDto changePassword(String username, String current, String replacement) + throws IOException { + // Before the password is read. Two changes at once would otherwise + // both check the same old password and both succeed, the last one + // winning: whoever else knows the old password could race the owner's + // change and keep the account. Behind the hold the second change + // checks against the password the first one set. + profiles.hold(username); + UserDetails signIn = load(username); + if (current == null || !encoder.matches(current, signIn.getPassword())) { + throw new ResponseStatusException(403, "That is not your current password"); + } + password(replacement); + users.updatePassword(signIn, encoder.encode(replacement)); + // A password is changed, as often as not, because someone else may + // have the old one or a device that was signed in with it. A refresh + // token asks for no password, so every sign-in but this one ends here. + grants.endOthers(username); + return describe(username); + } + + /// Closes an account, for good: the sign-in, the profile, the saved cards + /// and the driving application with its documents are deleted, not hidden. + /// The rides it took stay, as the other party's history and the service's + /// accounts, under a name that no longer has anyone behind it. + /// + /// Refused while a ride is under way, which has a driver or a rider waiting + /// on it, and for an admin, so that a service is not left with none. + /// + /// `password` is the account's own, asked for again as a change of + /// password asks for it. A token alone is not enough for something that + /// cannot be undone: a phone left unlocked, or a token that leaked, would + /// otherwise be all it takes to destroy an account. + public UserDto close(String username, String password) throws IOException { + UserDto was = erase(username, password); + // After the commit and not inside it: what is said over a live + // connection is acted on at once, and must not be said about a change + // that could still be rolled back. + live.drop(username); + // The account as it was, answered as what it now is: unable to sign in. + return new UserDto(was.username(), was.displayName(), was.phone(), was.phoneVerified(), + was.rider(), was.driver(), was.admin(), true, was.vehicle(), was.plate(), + was.gender(), was.driverStatus(), was.flagged(), was.flagReason(), + was.language()); + } + + /// Everything closing an account deletes, as one transaction: either the + /// account is gone from every table or it is in all of them still. The + /// user store works on the same database and so takes part in it. + @Transactional(rollbackFor = IOException.class) + UserDto erase(String username, String password) throws IOException { + // Before anything is read. Asking for a ride holds the same row, so + // of a ride and a closure sent together one waits for the other: the + // closure then sees the ride and is refused, or the ride finds no + // profile. Without it both could pass, leaving a ride under way that + // belongs to nobody who can cancel it. + profiles.hold(username); + UserDetails signIn = load(username); + if (password == null || !encoder.matches(password, signIn.getPassword())) { + throw new ResponseStatusException(403, "That is not your current password"); + } + UserDto was = describe(username); + if (was.admin()) { + throw new ResponseStatusException(409, "Hand the admin role on before closing"); + } + if (cleanup.hasRideUnderWay(username)) { + throw new ResponseStatusException(409, "Finish your ride before closing the account"); + } + // Money this driver is owed. The rides and the payout ledger outlive + // the account, but the sign-in and the payout account do not, and + // the name is retired: once closed, nobody could cash it out. So the + // driver cashes out first. No ride is under way, so nothing can add + // to the balance between this check and the commit. + if (earnings.balance(username) > 0L) { + throw new ResponseStatusException(409, "Cash out your earnings before closing the account"); + } + payments.forget(username); + applications.forget(username); + moderation.forget(username); + cleanup.removeEverythingOf(username); + // Read again: the statements above each cleared what the session had + // loaded, the profile that was described among it. + Profile profile = profiles.findById(username); + if (profile != null) { + profiles.deleteById(username); + } + // The address is retired with the account, in the same transaction: + // nobody can register it and inherit the rides kept under it. + retire(username); + // Last: the tokens in hand stop working when the sign-in is gone. + users.deleteUser(username); + grants.endAll(username); + return was; + } + + /// Retires an e-mail for good, as only a hash of it. + /// + /// A ride outlives the account that took it, under the e-mail that account + /// signed in with, and so does a token until it expires. Were the address + /// free to register again, whoever took it would be handed the rides, the + /// receipts and the ratings of the person who left, and that person's old + /// token would sign in to the new account. Only a hash is kept: enough to + /// refuse it, and not a list of who has left. + private void retire(String username) throws IOException { + String hash = Ids.sha256(username); + if (!closed.existsById(hash)) { + ClosedAccount row = new ClosedAccount(); + row.setUsernameHash(hash); + row.setClosedAt(System.currentTimeMillis()); + closed.insert(row); + } + } + + /// Makes the rest of the caller's transaction take its turn with any other + /// that holds the same account. See [ProfileRepository#hold]. Answers 404 + /// when the account is gone: it was closed while the request was under + /// way, its clean-up has run, and a row the caller went on to insert would + /// never be deleted. + public void hold(String username) { + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + } + + @Transactional(rollbackFor = IOException.class) + public void markPhoneVerified(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile != null) { + profile.setPhoneVerified(true); + } + } + + /// Makes a change one admin is making to an account take its turn with any + /// other touching either of the two, and then checks `by` still is an + /// admin. Call it before anything is read. + /// + /// Without it two admins could each take the other's role, or block each + /// other, at the same moment. Each request was authorized before the other + /// committed and neither touches its own account, so both would go through + /// and the service would be left with nobody to administer it. Both + /// profiles are held, in the order of their names so that the two requests + /// queue and cannot deadlock; whichever comes second then reads what the + /// first left, finds it is no longer an admin, and is refused. + private void actingAdmin(String by, String username) { + if (by == null || username == null) { + return; + } + String first = by.compareTo(username) <= 0 ? by : username; + String second = first.equals(by) ? username : by; + profiles.hold(first); + if (!second.equals(first)) { + profiles.hold(second); + } + if (!users.userExists(by)) { + throw new ResponseStatusException(403, "You are no longer an admin"); + } + UserDetails acting = users.loadUserByUsername(by); + if (!acting.isEnabled() || !authorities(acting).contains("ROLE_ADMIN")) { + throw new ResponseStatusException(403, "You are no longer an admin"); + } + } + + private UserDetails load(String username) { + if (username == null || !users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + return users.loadUserByUsername(username); + } + + private static String[] roles(boolean driver, boolean admin) { + List roles = new ArrayList(); + roles.add(Roles.RIDER); + if (driver) { + roles.add(Roles.DRIVER); + } + if (admin) { + roles.add(Roles.ADMIN); + } + return roles.toArray(new String[roles.size()]); + } + + private static void password(String value) { + if (value == null || value.length() < 8) { + throw new ResponseStatusException(400, "Choose a password of at least 8 characters"); + } + if (value.length() > 200) { + throw new ResponseStatusException(400, "That password is too long"); + } + } + + private static List authorities(UserDetails signIn) { + List held = new ArrayList(); + Iterator it = signIn.getAuthorities().iterator(); + while (it.hasNext()) { + held.add(it.next().getAuthority()); + } + return held; + } + + private static UserDto toDto(Profile profile, List authorities, boolean enabled, + String driverStatus) { + boolean rider = authorities.contains("ROLE_" + Roles.RIDER); + boolean driver = authorities.contains("ROLE_" + Roles.DRIVER); + boolean admin = authorities.contains("ROLE_" + Roles.ADMIN); + return new UserDto(profile.getUsername(), profile.getDisplayName(), profile.getPhone(), + profile.isPhoneVerified(), rider, driver, admin, !enabled, profile.getVehicle(), + profile.getPlate(), profile.getGender(), driverStatus, profile.isFlagged(), + profile.getFlagReason(), profile.getLanguage()); + } + + /// An e-mail address as an account name: trimmed, and its ASCII letters in + /// lower case. Folded by hand, because `toLowerCase()` follows the server's + /// locale and a key must not: under a Turkish locale the `I` of `INFO@` would + /// fold to a dotless i and name a different account. + public static String email(String value) { + String text = value == null ? "" : value.trim(); + int at = text.indexOf('@'); + if (at < 1 || at != text.lastIndexOf('@') || at > text.length() - 4 + || text.indexOf('.', at) < 0 || text.length() > 190) { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + char[] chars = text.toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + char c = chars[iter]; + if (c <= ' ' || c > '~') { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + /// A phone number in the form it is sent to and stored in: a `+` and its + /// digits, with the spaces, dashes and brackets people type taken out. + static String phone(String value) { + String text = value == null ? "" : value.trim(); + StringBuilder digits = new StringBuilder(); + for (int iter = 0; iter < text.length(); iter++) { + char c = text.charAt(iter); + if (c >= '0' && c <= '9') { + digits.append(c); + } else if (c != ' ' && c != '-' && c != '(' && c != ')' && c != '.' + && !(c == '+' && iter == 0)) { + throw new ResponseStatusException(400, "That is not a phone number"); + } + } + if (digits.length() < 7 || digits.length() > 15) { + throw new ResponseStatusException(400, "That is not a phone number"); + } + return "+" + digits; + } + + private static String required(String value, int max, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + throw new ResponseStatusException(400, "Tell us " + what); + } + if (text.length() > max) { + throw new ResponseStatusException(400, "That is too long for " + what); + } + return text; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/DemoAccounts.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/DemoAccounts.java new file mode 100644 index 00000000000..e2a2ea122e4 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/DemoAccounts.java @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.account; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.wayline.Days; +import com.codenameone.examples.wayline.api.CardDto; +import com.codenameone.examples.wayline.api.DocumentUploadDto; +import com.codenameone.examples.wayline.api.DriverApplicationDto; +import com.codenameone.examples.wayline.api.PaymentSetupDto; +import com.codenameone.examples.wayline.domain.DriverApplication; +import com.codenameone.examples.wayline.domain.DriverDocument; +import com.codenameone.examples.wayline.domain.Profile; +import com.codenameone.examples.wayline.driving.Applications; +import com.codenameone.examples.wayline.pay.Payments; +import com.codenameone.examples.wayline.repository.DriverApplicationRepository; +import com.codenameone.examples.wayline.repository.DriverDocumentRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; +import com.codenameone.examples.wayline.ride.DemoRides; +import com.codenameone.examples.wayline.ride.Fares; + +import java.io.IOException; + +/// The accounts a server starts with. +/// +/// On a development profile, enough of each kind that every screen of the app +/// has something to show straight after a checkout: riders, approved drivers of +/// each kind of car, an admin, someone whose application to drive is waiting +/// for an answer, an account the admins have flagged, and a fortnight of rides +/// between them ([DemoRides]). +/// +/// Anywhere else, at most one: the admin named by `wayline.admin.email` and +/// `wayline.admin.password` (or the same two names in the environment, in upper +/// case with `_` for each `.`), because a new deployment needs somebody able to +/// grant the first roles. It is created once; changing the setting later does +/// not change the password of an account that exists. +@Component +public class DemoAccounts { + public static final String RIDER = "rider@wayline.example"; + public static final String DRIVER = "driver@wayline.example"; + public static final String SECOND_DRIVER = "driver2@wayline.example"; + public static final String ADMIN = "admin@wayline.example"; + /// A driver of each of the larger kinds of car; the second takes a + /// wheelchair. That one and the first driver of all take pets. + public static final String COMFORT_DRIVER = "driver3@wayline.example"; + public static final String XL_DRIVER = "driver4@wayline.example"; + public static final String SECOND_RIDER = "rider2@wayline.example"; + /// A rider the admins have flagged. + public static final String FLAGGED_RIDER = "rider3@wayline.example"; + /// Someone whose application to drive is complete and waiting for an admin. + public static final String APPLICANT = "applicant@wayline.example"; + /// The password of every demo account. Not a secret: these accounts exist + /// only where the profile says the server is not a real one. + public static final String PASSWORD = "wayline-demo"; + + /// A small grey chequered PNG, standing in for every photographed document + /// of the demo accounts. + static final String PLACEHOLDER_PICTURE = "iVBORw0KGgoAAAANSUhEUgAAADAAAAAeCAAAAABPz8IdAAAAJUlE" + + "QVR42mN4AgUnoIAQn2FEaiBWIYw/MjWMpqXRtDSaluioAQAjjbP9bRBUyQAAAABJRU5ErkJggg=="; + + private final Accounts accounts; + private final Applications applications; + private final Moderation moderation; + private final Payments payments; + private final DemoRides rides; + private final ProfileRepository profiles; + private final DriverApplicationRepository forms; + private final DriverDocumentRepository documents; + private final Config config; + private final String adminEmail; + private final String adminPassword; + + public DemoAccounts(Accounts accounts, Applications applications, Moderation moderation, + Payments payments, DemoRides rides, ProfileRepository profiles, + DriverApplicationRepository forms, DriverDocumentRepository documents, Config config, + @Value("${wayline.admin.email:}") String adminEmail, + @Value("${wayline.admin.password:}") String adminPassword) { + this.accounts = accounts; + this.applications = applications; + this.moderation = moderation; + this.payments = payments; + this.rides = rides; + this.profiles = profiles; + this.forms = forms; + this.documents = documents; + this.config = config; + this.adminEmail = adminEmail; + this.adminPassword = adminPassword; + } + + /// Runs once, when the server starts. Not a transaction itself: each + /// account is made by a transactional method -- [Accounts]' or one of this + /// class's own -- so a server stopped half way through start-up is left + /// with whole accounts, and the next start adds the ones that are missing. + @PostConstruct + public void seed() throws IOException { + if (!config.isDevelopmentProfile()) { + if (adminEmail.length() > 0 && adminPassword.length() >= 8) { + add(Accounts.email(adminEmail), adminPassword, "Administrator", "+10000000", "", + ""); + } + return; + } + // The fortnight is written once, with the accounts it is shared among, + // and not again when a server with a database that persists restarts. + // The marker is an account made before the rides, so a start-up that + // failed between the two leaves a development database with the + // accounts and no fortnight. That is demonstration data on a + // developer's machine, where the answer is to drop the database and + // start again; a marker written with the rides would be one more + // table for the sample to carry. + boolean first = !accounts.exists(COMFORT_DRIVER); + // The tests sign in as the first three and expect to find them as new + // accounts are: no rides behind them, no card saved, cash by default. + // Under the `test` profile the fortnight is therefore the other + // accounts' alone. + boolean everyone = !"test".equals(config.getProfile()); + + rider(RIDER, "Riley Rider", "+15550100", "unspecified", 0, everyone); + driver(DRIVER, "Dana Driver", "+15550101", "female", Fares.STANDARD, "Blue", "Toyota", + "Prius", "WAY 101", 4, false, true, 0); + driver(SECOND_DRIVER, "Devon Driver", "+15550102", "male", Fares.STANDARD, "White", "Kia", + "Niro", "WAY 102", 4, false, false, 0); + add(ADMIN, PASSWORD, "Alex Admin", "+15550103", "", ""); + driver(COMFORT_DRIVER, "Carmen Driver", "+15550104", "female", Fares.COMFORT, "Black", + "Tesla", "Model 3", "WAY 103", 4, false, false, 12); + driver(XL_DRIVER, "Dmitri Driver", "+15550105", "male", Fares.XL, "Silver", "Toyota", + "Sienna", "WAY 104", 6, true, true, 9); + rider(SECOND_RIDER, "Robin Rider", "+15550106", "nonbinary", 11, true); + boolean flag = !accounts.exists(FLAGGED_RIDER); + rider(FLAGGED_RIDER, "Rae Rider", "+15550107", "female", 6, false); + if (flag) { + flag(FLAGGED_RIDER, "Three no-shows at the pickup in one week", 3); + } + applicant(); + if (first) { + rides.seed(everyone ? new String[] {RIDER, SECOND_RIDER, FLAGGED_RIDER} + : new String[] {SECOND_RIDER, FLAGGED_RIDER}, + everyone ? new String[] {DRIVER, SECOND_DRIVER, COMFORT_DRIVER, XL_DRIVER} + : new String[] {COMFORT_DRIVER, XL_DRIVER}, everyone); + } + } + + /// Marks an account as one the admins flagged some days ago, with the + /// event that says who did and why. + @Transactional(rollbackFor = IOException.class) + void flag(String username, String why, int daysAgo) throws IOException { + Profile profile = profiles.findById(username); + profile.setFlagged(true); + profile.setFlagReason(why); + moderation.record(username, ADMIN, Moderation.FLAG, why, + System.currentTimeMillis() - daysAgo * Days.MILLIS); + } + + /// An admin, or the plainest kind of account. + private void add(String username, String password, String name, String phone, String vehicle, + String plate) throws IOException { + if (accounts.exists(username)) { + return; + } + try { + accounts.create(username, password, name, phone, true, vehicle, plate, false, + ADMIN.equals(username) || !config.isDevelopmentProfile()); + } catch (IOException | RuntimeException failed) { + // Two servers started together against a new database both find + // no admin and both make one; the second insert fails on the name. + // create() is one transaction, so the loser left nothing behind, + // and the account the other server made is the one wanted. Any + // other failure leaves no account, and is the start-up's to report. + if (!accounts.exists(username)) { + throw failed; + } + } + } + + private void rider(String username, String name, String phone, String gender, int daysAgo, + boolean card) throws IOException { + if (accounts.exists(username)) { + return; + } + accounts.create(username, PASSWORD, name, phone, true, "", "", false, false, gender, + opened(daysAgo)); + if (card) { + PaymentSetupDto setup = payments.startSetup(username, name); + // Only a simulated card can be made up. With a real processor + // configured the demo riders pay cash until someone adds a card. + if (!setup.hosted()) { + CardDto visa = new CardDto("4242424242424242", 12, year() + 3, "123", name); + payments.completeSetup(username, setup.id(), visa); + } + } + } + + /// An approved driver, with the application an approval implies: filled + /// in, its documents accepted. + private void driver(String username, String name, String phone, String gender, String product, + String colour, String make, String model, String plate, int seats, + boolean wheelchair, boolean pets, int daysAgo) throws IOException { + if (accounts.exists(username)) { + return; + } + accounts.create(username, PASSWORD, name, phone, true, colour + " " + make + " " + model, + plate, true, false, gender, opened(daysAgo)); + approved(username, name, product, colour, make, model, plate, seats, wheelchair, pets, + daysAgo); + } + + /// Fills in the application an account created as a driver was given, and + /// adds its documents, already accepted. + @Transactional(rollbackFor = IOException.class) + void approved(String username, String name, String product, String colour, String make, + String model, String plate, int seats, boolean wheelchair, boolean pets, int daysAgo) + throws IOException { + long handedIn = System.currentTimeMillis() - (daysAgo + 1) * Days.MILLIS; + DriverApplication form = forms.findById(username); + form.setLegalName(name); + form.setDateOfBirth("1988-04-12"); + form.setLicenceNumber("D" + (4400000 + plate.charAt(plate.length() - 1) * 7919)); + form.setLicenceExpiry((year() + 2) + "-06-30"); + form.setVehicleMake(make); + form.setVehicleModel(model); + form.setVehicleYear(year() - 3); + form.setVehicleColor(colour); + form.setVehiclePlate(plate); + form.setVehicleSeats(seats); + form.setProduct(product); + form.setWheelchair(wheelchair); + form.setPets(pets); + form.setSubmittedAt(handedIn); + form.setReviewedBy(ADMIN); + for (int iter = 0; iter < Applications.KINDS.length; iter++) { + DriverDocument picture = new DriverDocument(); + picture.setUsername(username); + picture.setKind(Applications.KINDS[iter]); + picture.setContentType("image/png"); + picture.setStatus("accepted"); + picture.setUploadedAt(handedIn); + picture.setData(PLACEHOLDER_PICTURE); + documents.insert(picture); + } + } + + /// The applicant, taken through the same steps the app takes: a form, five + /// pictures, and handing it in. Which also means the demo application is + /// one the server itself accepts as complete. + private void applicant() throws IOException { + if (accounts.exists(APPLICANT)) { + return; + } + accounts.create(APPLICANT, PASSWORD, "Avery Applicant", "+15550108", true, "", "", false, + false, "male", opened(4)); + applications.open(APPLICANT, "", ""); + // What an applicant fills in. The rest (who, the status, the documents, + // the dates) is the server's to say and is not read from a form. + DriverApplicationDto form = new DriverApplicationDto(null, null, null, + "Avery Jordan Applicant", "1992-09-03", "D4471902", (year() + 3) + "-03-31", + "Honda", "Civic", year() - 2, "Grey", "WAY 207", 4, Fares.STANDARD, false, + false, null, null, 0L, 0L, null); + applications.save(APPLICANT, form); + for (int iter = 0; iter < Applications.KINDS.length; iter++) { + DocumentUploadDto picture = new DocumentUploadDto(Applications.KINDS[iter], + "image/png", PLACEHOLDER_PICTURE); + applications.upload(APPLICANT, picture); + } + applications.submit(APPLICANT); + handedIn(APPLICANT, 2); + } + + /// Dates an application that was just handed in to some days ago, so that + /// the admins' queue shows one that has been waiting. + @Transactional(rollbackFor = IOException.class) + void handedIn(String username, int daysAgo) throws IOException { + DriverApplication form = forms.findById(username); + form.setSubmittedAt(System.currentTimeMillis() - daysAgo * Days.MILLIS); + } + + /// When an account opened that many days ago was opened, so that the chart + /// of new accounts is not one bar. + private static long opened(int daysAgo) { + return System.currentTimeMillis() - daysAgo * Days.MILLIS; + } + + private static int year() { + return Integer.parseInt(Days.iso(System.currentTimeMillis()).substring(0, 4)); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Grants.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Grants.java new file mode 100644 index 00000000000..1d695d43752 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Grants.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.account; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.SecurityContextHolder; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationToken; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/// The sign-ins an account has open, and ending them. +/// +/// Signing in leaves a grant in the authorization server's own table, and a +/// refresh token is good for as long as its grant is there: refreshing asks +/// for no password. So a password changed because a phone was lost changes +/// nothing for whoever holds that phone, unless the grants go too. +/// +/// An access token already issued is not recalled by this. It is a signed +/// token that is checked without the table, and it runs out on its own within +/// its few minutes; what ends here is the means of getting another. +@Component +public class Grants { + private final DataSource db; + private final OAuth2AuthorizationService authorizations; + + public Grants(DataSource db, OAuth2AuthorizationService authorizations) { + this.db = db; + this.authorizations = authorizations; + } + + /// Ends every sign-in of an account but the one this request was made + /// with, so the device a password was changed on stays signed in and every + /// other has to give the new password. + public void endOthers(String username) throws IOException { + end(username, current()); + } + + /// Ends every sign-in of an account. + public void endAll(String username) throws IOException { + end(username, ""); + } + + private void end(String username, String kept) throws IOException { + // The table is the authorization server's, which offers a grant by its + // id and no list of an account's: this reads the ids and leaves the + // removing, which takes the grant's tokens with it, to the service. + List rows = db.query("SELECT id FROM cn1_oauth2_authorization WHERE principal_name = ?", + new Object[] {username}); + for (int iter = 0; rows != null && iter < rows.size(); iter++) { + Object row = rows.get(iter); + Object id = row instanceof Map ? ((Map) row).get("id") : null; + if (id != null && !kept.equals(String.valueOf(id))) { + authorizations.remove(String.valueOf(id)); + } + } + } + + /// The id of the grant the request's token was issued under, or empty for + /// a request that carried no such token. The server writes the grant's id + /// before the first dot of the token's own. + private static String current() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (!(who instanceof JwtAuthenticationToken)) { + return ""; + } + String id = ((JwtAuthenticationToken) who).getToken().getId(); + int dot = id == null ? -1 : id.indexOf('.'); + return dot > 0 ? id.substring(0, dot) : ""; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Moderation.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Moderation.java new file mode 100644 index 00000000000..08d0a5c220d --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Moderation.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.account; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.api.ModerationEventDto; +import com.codenameone.examples.wayline.domain.ModerationEvent; +import com.codenameone.examples.wayline.repository.ModerationEventRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The record of what the admins did to an account, and why. +/// +/// Flagging, blocking, approving and rejecting all change an account's state, +/// and the state alone does not say who decided it or on what grounds. This +/// does. A row is written with the change and never altered afterwards. +@Component +public class Moderation { + public static final String FLAG = "flag"; + public static final String UNFLAG = "unflag"; + public static final String BLOCK = "block"; + public static final String UNBLOCK = "unblock"; + public static final String APPROVE = "approve"; + public static final String REJECT = "reject"; + public static final String ROLES = "roles"; + + private final ModerationEventRepository events; + + public Moderation(ModerationEventRepository events) { + this.events = events; + } + + @Transactional + public void record(String username, String by, String action, String reason) + throws IOException { + // Two changes made in the same millisecond -- a block and the flag that + // came with it -- would otherwise be listed in either order. The later + // one is stamped after the one before it, so the list is the order in + // which things were done. + Long newest = events.newest(username); + long after = newest == null ? 0L : newest.longValue() + 1L; + record(username, by, action, reason, Math.max(System.currentTimeMillis(), after)); + } + + /// As [#record(String, String, String, String)], at a time of the caller's + /// choosing; the demo data is the one caller that has a past to write. + @Transactional + public void record(String username, String by, String action, String reason, long at) + throws IOException { + String why = reason == null ? "" : reason.trim(); + ModerationEvent event = new ModerationEvent(); + event.setId(Ids.next()); + event.setUsername(username); + event.setCreatedAt(at); + event.setActor(by); + event.setAction(action); + event.setReason(why.length() > 255 ? why.substring(0, 255) : why); + events.insert(event); + } + + /// An account's events, newest first. + @Transactional(readOnly = true) + public List events(String username) throws IOException { + List rows = events.findTop100ByUsernameOrderByCreatedAtDescIdAsc(username); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + ModerationEvent row = rows.get(iter); + out.add(new ModerationEventDto(row.getCreatedAt(), row.getActor(), row.getAction(), + row.getReason())); + } + return out; + } + + @Transactional + public void forget(String username) throws IOException { + events.deleteByUsername(username); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/PhoneVerification.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/PhoneVerification.java new file mode 100644 index 00000000000..10ed791a384 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/PhoneVerification.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.account; + +import com.codename1.backend.Config; +import com.codename1.backend.Crypto; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.api.PhoneChallengeDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.domain.PhoneCode; +import com.codenameone.examples.wayline.repository.PhoneCodeRepository; +import com.codenameone.examples.wayline.sms.SmsSender; + +import java.io.IOException; + +/// Proves an account's phone number by texting it a code. +/// +/// The server makes the code, keeps only its hash, and is the one that decides +/// whether what the user typed matches. The app never sees the code it is asking +/// the user for, so it cannot be talked into accepting one. +@Component +public class PhoneVerification { + /// How long a code stays good. + static final int CODE_SECONDS = 300; + /// Wrong guesses allowed before the code is void. Six digits and five + /// guesses, each against at most two live codes (see [#store]), is one + /// chance in 100,000 per code sent. + static final int MAX_ATTEMPTS = 5; + /// The least time between two codes for one account. Each one is a text + /// message somebody pays for and somebody receives. + static final int RESEND_SECONDS = 30; + + private final PhoneCodeRepository codes; + private final Accounts accounts; + private final SmsSender sms; + private final Config config; + + public PhoneVerification(PhoneCodeRepository codes, Accounts accounts, SmsSender sms, + Config config) { + this.codes = codes; + this.accounts = accounts; + this.sms = sms; + this.config = config; + } + + /// Sends a new code, replacing the one before it. + /// + /// Not one transaction, because a text message is sent in the middle and a + /// transaction must not be held open across a call that leaves the server. + /// The code is stored and committed, then sent, and taken back in a second + /// transaction when it could not be. + public PhoneChallengeDto start(String username) throws IOException { + UserDto user = accounts.describe(username); + String code = newCode(); + String codeHash = hash(username, code); + store(username, codeHash, System.currentTimeMillis()); + try { + sms.send(user.phone(), "Your Wayline code is " + code); + } catch (IOException failed) { + System.err.println("could not send a verification code: " + failed.getMessage()); + discard(username, codeHash); + throw new ResponseStatusException(503, "We could not send the code. Try again shortly"); + } + // Only a server that cannot send a text message, and only on a + // development profile, tells the app what the code was. Both halves + // matter: a production server whose SMS settings were forgotten must fail + // to verify anyone, not verify everyone. + String demoCode = !sms.delivers() && config.isDevelopmentProfile() ? code : null; + return new PhoneChallengeDto(CODE_SECONDS, demoCode); + } + + /// Keeps the hash of a new code in place of the one before it. Answers 429 + /// when the one before it was sent a moment ago. + /// + /// The code before it stays good until it would have expired anyway. A + /// second code is asked for when the first is slow to arrive, and text + /// messages are not delivered in order: the first may arrive last, and the + /// newest message on the phone must not be a code the server refuses. Only + /// one is kept, so at most two codes are live, and only one whose guesses + /// were not used up. + @Transactional + void store(String username, String codeHash, long now) throws IOException { + // Before the code is read: two requests for one account take turns, + // so the second finds the code the first just stored and is told to + // wait. Otherwise both pass the check below and both send a text. + accounts.hold(username); + PhoneCode pending = codes.findById(username); + if (pending != null && now - pending.getSentAt() < RESEND_SECONDS * 1000L) { + throw new ResponseStatusException(429, "Wait a moment before asking for another code"); + } + boolean first = pending == null; + if (first) { + pending = new PhoneCode(); + pending.setUsername(username); + } else if (pending.getExpiresAt() >= now && pending.getAttempts() < MAX_ATTEMPTS) { + // With the guesses it has used: asking for another code must not + // buy the old one a fresh set. + pending.setPreviousHash(pending.getCodeHash()); + pending.setPreviousExpiresAt(pending.getExpiresAt()); + pending.setPreviousAttempts(pending.getAttempts()); + } else { + pending.setPreviousHash(""); + pending.setPreviousExpiresAt(0L); + pending.setPreviousAttempts(0); + } + pending.setCodeHash(codeHash); + pending.setExpiresAt(now + CODE_SECONDS * 1000L); + pending.setAttempts(0); + pending.setSentAt(now); + if (first) { + codes.insert(pending); + } + } + + /// Takes back a code that could not be sent, if it is still the one the + /// account is waiting on. A send can take longer to fail than the wait + /// between two codes, and by then a later request may have stored a code + /// of its own and had it delivered: that one is the user's, and stays. + /// The code before it, when it is still good, is the one waited on again, + /// and another may be asked for at once. + @Transactional + void discard(String username, String codeHash) throws IOException { + // The hold store() takes, before the row is read: a resend storing its + // code meanwhile commits first, and this then finds that code and + // leaves it, where read before the wait it would overwrite or delete it. + accounts.hold(username); + PhoneCode pending = codes.findById(username); + if (pending == null || !codeHash.equals(pending.getCodeHash())) { + return; + } + if (pending.getPreviousHash().length() == 0 + || pending.getPreviousExpiresAt() < System.currentTimeMillis()) { + codes.deleteById(username); + return; + } + pending.setCodeHash(pending.getPreviousHash()); + pending.setExpiresAt(pending.getPreviousExpiresAt()); + pending.setAttempts(pending.getPreviousAttempts()); + pending.setPreviousHash(""); + pending.setPreviousExpiresAt(0L); + pending.setPreviousAttempts(0); + pending.setSentAt(0L); + } + + /// Checks the code the user typed. Answers 400 for a wrong or expired one + /// and 429 once the guesses are used up. + /// + /// The refusal of a wrong code is thrown here, outside the transaction that + /// counted the guess. Thrown inside it, the error would roll the count back + /// and every code could be guessed at for ever. + public UserDto verify(String username, String code) throws IOException { + if (!guess(username, code)) { + throw new ResponseStatusException(400, "That is not the code we sent"); + } + return accounts.describe(username); + } + + /// Takes one guess at an account's code and answers whether it was right. + /// A right one uses the code up and marks the phone verified; a wrong one + /// leaves the guess counted, which is why this returns false where it + /// could have thrown. + @Transactional + boolean guess(String username, String code) throws IOException { + // The hold store() and discard() take, and in the same order as a + // closure, which holds the profile and then deletes the code: taken + // the other way round, a guess and a closure could each wait on the + // row the other has. + accounts.hold(username); + PhoneCode pending = codes.findById(username); + if (pending == null || pending.getExpiresAt() < System.currentTimeMillis()) { + throw new ResponseStatusException(400, "That code has expired. Ask for a new one"); + } + if (pending.getAttempts() >= MAX_ATTEMPTS) { + throw new ResponseStatusException(429, "Too many wrong codes. Ask for a new one"); + } + String expected = pending.getCodeHash(); + // Taken before the comparison, and by the database, which also holds + // the limit: the count read above is what it was when this request + // arrived, and any number of requests arriving together all read the + // same one. Only a request that is given a guess has its code looked at. + // And only of the code read above: one sent meanwhile is not this one. + if (codes.takeGuess(username, expected, MAX_ATTEMPTS) != 1) { + throw new ResponseStatusException(429, "Too many wrong codes. Ask for a new one"); + } + // Read again after the guess was taken, which the update did under + // the row's lock: the count of the code before is then this guess's + // own, and within its limit only when this guess was one it had left. + PhoneCode counted = codes.findById(username); + String typed = code == null ? "" : code.trim(); + byte[] given = hash(username, typed).getBytes("UTF-8"); + // Both compared every time, so the answer takes as long either way. The + // code before is good only until it would have expired; it was sent + // first, so it never outlives the current one checked above. + // + // `counted.getPreviousAttempts()` already includes every guess made + // since the resend: takeGuess adds one to `previousAttempts` in the + // same statement that adds one to `attempts`, so it is the old code's + // whole count, not the snapshot store() copied. A code that used four + // guesses before the resend reads 5 on its first guess after it (still + // allowed: its fifth) and 6 on the next (refused), so its budget stays + // five. AccountTest.theCodeBeforeKeepsTheGuessesItHadLeft holds that. + boolean current = Crypto.equalsConstantTime(given, expected.getBytes("UTF-8")); + boolean previous = Crypto.equalsConstantTime(given, + pending.getPreviousHash().getBytes("UTF-8")) + && pending.getPreviousHash().length() > 0 + && pending.getPreviousExpiresAt() >= System.currentTimeMillis() + && counted != null && counted.getPreviousAttempts() <= MAX_ATTEMPTS; + if (!current && !previous) { + return false; + } + pending = codes.findById(username); + if (pending != null) { + codes.deleteById(username); + } + accounts.markPhoneVerified(username); + return true; + } + + /// Six digits, each value as likely as the next: a draw that would favour + /// the low numbers is thrown away and drawn again. + private static String newCode() throws IOException { + while (true) { + byte[] random = Crypto.randomBytes(4); + int value = ((random[0] & 0x7f) << 24) | ((random[1] & 0xff) << 16) + | ((random[2] & 0xff) << 8) | (random[3] & 0xff); + if (value < 2147000000) { + String digits = String.valueOf(1000000 + value % 1000000); + return digits.substring(1); + } + } + } + + /// An unkeyed digest, kept so on purpose. Somebody who can read the + /// `phone_code` table could try every code within the five minutes one + /// lasts, but somebody who can read the database already owns what a + /// phone check guards: it proves a number, and grants nothing on its own. + /// A keyed digest under a server secret is a hardening step for a + /// production deployment, listed in the implementor's guide among what the + /// sample leaves out. + private static String hash(String username, String code) throws IOException { + return Ids.sha256(username + ":" + code); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Preferences.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Preferences.java new file mode 100644 index 00000000000..d241c1b8172 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/account/Preferences.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.wayline.Text; +import com.codenameone.examples.wayline.api.PreferencesDto; +import com.codenameone.examples.wayline.domain.Preference; +import com.codenameone.examples.wayline.domain.Profile; +import com.codenameone.examples.wayline.pay.Payments; +import com.codenameone.examples.wayline.repository.PreferenceRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; + +import java.io.IOException; + +/// How one person wants the app and their rides: language, theme, units, what +/// they are told about, and what a ride is asked for with unless they say +/// otherwise. +/// +/// Kept on the server so that they follow the account from one phone to the +/// next. Two of them live elsewhere, because something else reads them: the +/// language is in the profile, where the admin sees it, and the default way to +/// pay is the mark on the saved card itself, so that removing a card cannot +/// leave a preference pointing at it. +@Component +public class Preferences { + private static final String[] LANGUAGES = {"en", "es", "fr", "de", "he"}; + private static final String[] THEMES = {"system", "light", "dark"}; + private static final String[] UNITS = {"km", "mi"}; + private static final String[] DRIVER_GENDERS = {"any", "women"}; + + private final ProfileRepository profiles; + private final PreferenceRepository preferences; + private final Payments payments; + + public Preferences(ProfileRepository profiles, PreferenceRepository preferences, + Payments payments) { + this.profiles = profiles; + this.preferences = preferences; + this.payments = payments; + } + + @Transactional(readOnly = true) + public PreferencesDto get(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + Preference row = preferences.findById(username); + String language = profile.getLanguage(); + String method = payments.defaultMethod(username); + if (row == null) { + // What someone who never opened the settings gets. + return new PreferencesDto(language, THEMES[0], UNITS[0], true, true, false, + DRIVER_GENDERS[0], false, false, false, method, 0, ""); + } + return new PreferencesDto(language, row.getTheme(), row.getUnits(), row.isNotifyRides(), + row.isNotifyReceipts(), row.isNotifyPromotions(), row.getDriverGender(), + row.isQuietRide(), row.isWheelchair(), row.isPetFriendly(), method, + row.getDefaultTip(), row.getShareTripsWith()); + } + + /// Saves all of them. Each is checked against the values it can take: these + /// are words the server later acts on, not text to show back. One + /// transaction, so a value that is refused leaves none of the others saved. + @Transactional + public PreferencesDto save(String username, PreferencesDto wanted) throws IOException { + if (wanted == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + // First, before anything is read: two saves sent together for an + // account with no row of settings yet would each find none and each + // insert one, and the second insert fails on the key. The profile is + // there from registration, so held, one save waits and finds the + // other's row. + profiles.hold(username); + int tip = wanted.defaultTipPercent(); + if (tip != 0 && tip != 10 && tip != 15 && tip != 20) { + throw new ResponseStatusException(400, "A default tip is 0, 10, 15 or 20 percent"); + } + String share = wanted.shareTripsWith() == null ? "" : wanted.shareTripsWith().trim(); + String theme = Text.oneOf(wanted.theme(), THEMES, THEMES[0], "the theme"); + String units = Text.oneOf(wanted.units(), UNITS, UNITS[0], "the units"); + String driverGender = Text.oneOf(wanted.driverGender(), DRIVER_GENDERS, DRIVER_GENDERS[0], + "the driver"); + String shareWith = share.length() == 0 ? "" : Accounts.phone(share); + String language = Text.oneOf(wanted.language(), LANGUAGES, LANGUAGES[0], "the language"); + String method = wanted.defaultPaymentMethodId() == null ? "" + : wanted.defaultPaymentMethodId().trim(); + if (method.length() > 0 && !method.equals(payments.defaultMethod(username))) { + // Answers 404 for a card that is not this user's, before anything + // else is saved. + payments.makeDefault(username, method); + } + // Read after the card was marked, not before: marking one may be a + // statement over every card of the account, and what was read before + // such a statement is no longer the session's to write. + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + profile.setLanguage(language); + Preference row = preferences.findById(username); + boolean unsaved = row == null; + if (unsaved) { + row = new Preference(); + row.setUsername(username); + } + row.setTheme(theme); + row.setUnits(units); + row.setNotifyRides(wanted.notifyRideUpdates()); + row.setNotifyReceipts(wanted.notifyReceipts()); + row.setNotifyPromotions(wanted.notifyPromotions()); + row.setDriverGender(driverGender); + row.setQuietRide(wanted.quietRide()); + row.setWheelchair(wanted.accessibleVehicle()); + row.setPetFriendly(wanted.petFriendly()); + row.setDefaultTip(tip); + row.setShareTripsWith(shareWith); + if (unsaved) { + preferences.insert(row); + } + return get(username); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/admin/AdminEndpoint.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/admin/AdminEndpoint.java new file mode 100644 index 00000000000..f0efb4fc6d7 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/admin/AdminEndpoint.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PreAuthorize; +import com.codenameone.examples.wayline.Caller; +import com.codenameone.examples.wayline.Text; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.api.AdminApiServer; +import com.codenameone.examples.wayline.api.AdminStatsDto; +import com.codenameone.examples.wayline.api.DocumentContentDto; +import com.codenameone.examples.wayline.api.DriverApplicationDto; +import com.codenameone.examples.wayline.api.DriverDto; +import com.codenameone.examples.wayline.api.FlagDto; +import com.codenameone.examples.wayline.api.PricingDto; +import com.codenameone.examples.wayline.api.ReasonDto; +import com.codenameone.examples.wayline.api.ReceiptDto; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.api.RoleChangeDto; +import com.codenameone.examples.wayline.api.StatsSeriesDto; +import com.codenameone.examples.wayline.api.SuspendDto; +import com.codenameone.examples.wayline.api.UserDetailDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.driver.Drivers; +import com.codenameone.examples.wayline.driving.Applications; +import com.codenameone.examples.wayline.live.LiveHub; +import com.codenameone.examples.wayline.pay.Payments; +import com.codenameone.examples.wayline.ride.Fares; +import com.codenameone.examples.wayline.ride.Rides; + +import java.util.List; + +/// The server's half of `AdminApi`. Guarded by path in the filter chain and by +/// role here, as `DriverEndpoint` is. +/// +/// This is the one place a name in the path chooses whose data is read: an +/// applicant's documents, a rider's receipts. Everywhere else the caller is +/// the subject, and only an admin gets to name somebody else. +@Component +@PreAuthorize("hasRole('ADMIN')") +public class AdminEndpoint implements AdminApiServer { + private final Accounts accounts; + private final Rides rides; + private final Drivers drivers; + private final Fares fares; + private final LiveHub live; + private final Applications applications; + private final Payments payments; + private final Statistics statistics; + + public AdminEndpoint(Accounts accounts, Rides rides, Drivers drivers, + Fares fares, LiveHub live, Applications applications, Payments payments, + Statistics statistics) { + this.applications = applications; + this.payments = payments; + this.statistics = statistics; + this.accounts = accounts; + this.rides = rides; + this.drivers = drivers; + this.fares = fares; + this.live = live; + } + + @Override + public AdminStatsDto stats() throws Exception { + return statistics.dashboard(); + } + + @Override + public List rides() throws Exception { + return rides.recent(); + } + + @Override + public List users() throws Exception { + return accounts.all(); + } + + @Override + public List drivers() throws Exception { + return drivers.all(); + } + + @Override + public UserDto setRoles(String username, RoleChangeDto roles) throws Exception { + UserDto user = accounts.setRoles(Caller.name(), username, roles != null && roles.driver(), + roles != null && roles.admin()); + // A live channel keeps the roles it was opened with: one opened by an + // admin goes on receiving where every driver is after the role is + // taken away, since it asks for nothing again. Closed here, the app + // opens another with a fresh ticket and gets the frames of the roles + // the account has now. + live.drop(username); + return user; + } + + @Override + public UserDto suspend(String username, SuspendDto suspend) throws Exception { + UserDto user = accounts.suspend(Caller.name(), username, + suspend != null && suspend.suspended(), suspend == null ? "" : suspend.reason()); + if (user.suspended()) { + // The API turns a suspended account away on its next request. A + // live channel already open makes no further request, so it is + // closed here. + live.drop(username); + } + return user; + } + + @Override + public StatsSeriesDto statsSeries(int days) throws Exception { + return statistics.series(days); + } + + @Override + public UserDetailDto user(String username) throws Exception { + return statistics.user(username); + } + + @Override + public UserDto flag(String username, FlagDto flag) throws Exception { + return accounts.flag(Caller.name(), username, flag != null && flag.flagged(), + flag == null ? "" : flag.reason()); + } + + @Override + public List applications(String status) throws Exception { + return applications.list(status); + } + + @Override + public DriverApplicationDto application(String username) throws Exception { + return applications.review(username); + } + + @Override + public DocumentContentDto document(String username, String kind) throws Exception { + return applications.document(username, kind); + } + + @Override + public DriverApplicationDto approve(String username) throws Exception { + return applications.approve(Caller.name(), username); + } + + @Override + public DriverApplicationDto reject(String username, ReasonDto reason) throws Exception { + return applications.reject(Caller.name(), username, reason == null ? "" : reason.reason()); + } + + @Override + public RideDto cancelRide(String id, ReasonDto reason) throws Exception { + return rides.cancelByAdmin(Caller.name(), id, reason == null ? "" : reason.reason()); + } + + @Override + public ReceiptDto refundRide(String id, ReasonDto reason) throws Exception { + return payments.refund(id, Text.required(reason == null ? "" : reason.reason(), 255, + "why it is refunded")); + } + + @Override + public List payments() throws Exception { + return payments.allReceipts(); + } + + @Override + public PricingDto pricing() throws Exception { + return fares.pricing(); + } + + @Override + public PricingDto savePricing(PricingDto pricing) throws Exception { + return fares.save(Caller.name(), pricing); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/admin/Statistics.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/admin/Statistics.java new file mode 100644 index 00000000000..c8a89ce9e44 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/admin/Statistics.java @@ -0,0 +1,312 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.wayline.Days; +import com.codenameone.examples.wayline.Rows; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.account.Moderation; +import com.codenameone.examples.wayline.api.AdminStatsDto; +import com.codenameone.examples.wayline.api.DayStatsDto; +import com.codenameone.examples.wayline.api.DriverDto; +import com.codenameone.examples.wayline.api.NameCountDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.api.StatsSeriesDto; +import com.codenameone.examples.wayline.api.UserDetailDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.domain.DriverState; +import com.codenameone.examples.wayline.driver.Drivers; +import com.codenameone.examples.wayline.driver.Earnings; +import com.codenameone.examples.wayline.driving.Applications; +import com.codenameone.examples.wayline.repository.DriverStateRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; +import com.codenameone.examples.wayline.repository.RideEventRepository; +import com.codenameone.examples.wayline.repository.RideRepository; +import com.codenameone.examples.wayline.ride.Fares; +import com.codenameone.examples.wayline.ride.Matcher; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The numbers behind the admin's dashboard and charts, and the account of one +/// user. +/// +/// Whatever can be counted by a column is counted by the database: rides by +/// state, by product and by driver, and every sum of money. The charts by day +/// and by hour of the day are counted here instead, from one row per ride of +/// the period. Times are stored as milliseconds and a day is a division +/// ([Days]); [RideRepository#timeline] says why that division is not left +/// to a query. A sample's ninety days fit in memory. A service whose do not +/// keeps a table of daily totals, and this class is where it would be read. +@Component +public class Statistics { + static final int DEFAULT_DAYS = 14; + static final int MAX_DAYS = 90; + private static final int TOP_DRIVERS = 5; + private static final RideState[] STATES = {RideState.REQUESTED, RideState.OFFERED, + RideState.ACCEPTED, RideState.ARRIVED, RideState.IN_PROGRESS, RideState.COMPLETED, + RideState.CANCELLED_BY_RIDER, RideState.CANCELLED_BY_DRIVER, + RideState.CANCELLED_BY_ADMIN, RideState.NO_DRIVERS}; + + private static final String PAID = "PAID"; + private static final RideState[] ACTIVE = {RideState.REQUESTED, RideState.OFFERED, + RideState.ACCEPTED, RideState.ARRIVED, RideState.IN_PROGRESS}; + + private final ProfileRepository profiles; + private final DriverStateRepository cars; + private final RideRepository rides; + private final RideEventRepository events; + private final Accounts accounts; + private final Drivers drivers; + private final Moderation moderation; + private final Fares fares; + private final Applications applications; + private final Earnings earnings; + + public Statistics(ProfileRepository profiles, DriverStateRepository cars, + RideRepository rides, RideEventRepository events, Accounts accounts, + Drivers drivers, Moderation moderation, Fares fares, Applications applications, + Earnings earnings) { + this.earnings = earnings; + this.profiles = profiles; + this.cars = cars; + this.rides = rides; + this.events = events; + this.applications = applications; + this.accounts = accounts; + this.drivers = drivers; + this.moderation = moderation; + this.fares = fares; + } + + /// The handful of totals at the top of the admin's dashboard. + @Transactional(readOnly = true) + public AdminStatsDto dashboard() throws IOException { + long now = System.currentTimeMillis(); + int users = (int) profiles.count(); + // Who holds a role is the sign-in store's to say and not a table of + // this application's, so the drivers are counted from the accounts as + // the accounts service describes them. + int driving = 0; + List everyone = accounts.all(); + for (int iter = 0; iter < everyone.size(); iter++) { + if (everyone.get(iter).driver()) { + driving++; + } + } + int online = (int) cars.countByOnlineTrueAndLastSeenGreaterThanEqual( + now - Matcher.FRESH_MILLIS); + int today = (int) rides.countByRequestedAtGreaterThanEqual(Days.start(now)); + int active = (int) rides.countByStateIn(ACTIVE); + int done = (int) rides.countByStateIn(new RideState[] {RideState.COMPLETED}); + long revenue = Rows.number(rides.fares(RideState.COMPLETED, PAID)); + return new AdminStatsDto(users, driving, online, today, active, done, revenue, + fares.currency()); + } + + /// The last `days` days, today included and last. Fewer than one is taken + /// as the default and more than [#MAX_DAYS] as that many. + @Transactional(readOnly = true) + public StatsSeriesDto series(int days) throws IOException { + int span = days < 1 ? DEFAULT_DAYS : Math.min(days, MAX_DAYS); + long today = Days.start(System.currentTimeMillis()); + long since = today - (span - 1) * Days.MILLIS; + // Each day's figures, added up here and made into a DayStatsDto at the end. + int[] dayRides = new int[span]; + int[] dayCompleted = new int[span]; + int[] dayCancelled = new int[span]; + long[] dayRevenue = new long[span]; + int[] dayNewUsers = new int[span]; + int[] byHour = new int[24]; + // By day and by hour, counted here from one row per ride. + Map requestedAt = new HashMap(); + List timeline = rides.timeline(since); + for (int iter = 0; iter < timeline.size(); iter++) { + Object[] ride = timeline.get(iter); + long at = whole(ride[1]); + RideState state = Rows.state(ride[2]); + requestedAt.put(Rows.text(ride[0]), Long.valueOf(at)); + int index = (int) ((at - since) / Days.MILLIS); + byHour[(int) (at % Days.MILLIS / 3600000L)]++; + if (index < 0 || index >= span) { + continue; + } + dayRides[index]++; + if (RideState.COMPLETED == state) { + dayCompleted[index]++; + if (PAID.equals(ride[4])) { + dayRevenue[index] += whole(ride[3]); + } + } else if (cancelled(state)) { + dayCancelled[index]++; + } + } + // By state, by product and by driver, counted by the database. + int[] byState = new int[STATES.length]; + long completed = 0L; + long finished = 0L; + List states = rides.countByState(since); + for (int iter = 0; iter < states.size(); iter++) { + RideState state = Rows.state(states.get(iter)[0]); + long count = whole(states.get(iter)[1]); + for (int s = 0; s < STATES.length; s++) { + if (STATES[s] == state) { + byState[s] += (int) count; + } + } + if (RideState.COMPLETED == state) { + completed += count; + finished += count; + } else if (cancelled(state) || RideState.NO_DRIVERS == state) { + finished += count; + } + } + int[] byProduct = new int[Fares.PRODUCTS.length]; + List products = rides.countByProduct(since); + for (int iter = 0; iter < products.size(); iter++) { + for (int p = 0; p < Fares.PRODUCTS.length; p++) { + if (Fares.PRODUCTS[p].equals(products.get(iter)[0])) { + byProduct[p] += (int) whole(products.get(iter)[1]); + } + } + } + Map completedBy = new HashMap(); + List perDriver = rides.countByDriver(since, RideState.COMPLETED); + for (int iter = 0; iter < perDriver.size(); iter++) { + completedBy.put(Rows.text(perDriver.get(iter)[0]), + new int[] {(int) whole(perDriver.get(iter)[1])}); + } + long fareSum = Rows.number(rides.faresSince(since, RideState.COMPLETED)); + List hours = new ArrayList(); + for (int iter = 0; iter < byHour.length; iter++) { + hours.add(Integer.valueOf(byHour[iter])); + } + List statesSeen = new ArrayList(); + for (int iter = 0; iter < STATES.length; iter++) { + // A state no ride is in is left out; a chart has no slice for it. + if (byState[iter] > 0) { + statesSeen.add(new NameCountDto(STATES[iter].name(), byState[iter])); + } + } + List productsSold = new ArrayList(); + for (int iter = 0; iter < Fares.PRODUCTS.length; iter++) { + productsSold.add(new NameCountDto(Fares.PRODUCTS[iter], byProduct[iter])); + } + // Tested before dividing, and not left to an exception: dividing a + // whole number by zero does not raise one on every runtime this runs on. + long averageFare = completed == 0L ? 0L : fareSum / completed; + double completionRate = finished == 0L ? 0d + : Math.round(completed * 100d / finished) / 100d; + + List signUps = profiles.signUps(since); + for (int iter = 0; iter < signUps.size(); iter++) { + int index = (int) ((signUps.get(iter).longValue() - since) / Days.MILLIS); + if (index >= 0 && index < span) { + dayNewUsers[index]++; + } + } + List daily = new ArrayList(); + for (int iter = 0; iter < span; iter++) { + daily.add(new DayStatsDto(Days.iso(since + iter * Days.MILLIS), dayRides[iter], + dayCompleted[iter], dayCancelled[iter], dayRevenue[iter], + dayNewUsers[iter])); + } + + // How long a rider waited for a driver to take the ride: each time one + // was accepted, set against when it was asked for. + List waits = events.reached(since, RideState.ACCEPTED); + long waited = 0L; + int answered = 0; + for (int iter = 0; iter < waits.size(); iter++) { + Long asked = requestedAt.get(Rows.text(waits.get(iter)[0])); + if (asked != null) { + waited += Math.max(0L, whole(waits.get(iter)[1]) - asked.longValue()); + answered++; + } + } + double averageWaitSeconds = answered == 0 ? 0d + : Math.round(waited / 100d / answered) / 10d; + + List topDrivers = new ArrayList(); + for (int place = 0; place < TOP_DRIVERS && !completedBy.isEmpty(); place++) { + String best = null; + for (Map.Entry entry : completedBy.entrySet()) { + int[] top = best == null ? null : completedBy.get(best); + // Ties go to the name that sorts first, so the order is the + // same every time the chart is drawn. + if (top == null || entry.getValue()[0] > top[0] || (entry.getValue()[0] == top[0] + && entry.getKey().compareTo(best) < 0)) { + best = entry.getKey(); + } + } + int rode = completedBy.remove(best)[0]; + if (accounts.exists(best)) { + // Within the period, which is what the chart is of. + topDrivers.add(drivers.describe(best, rode)); + } + } + return new StatsSeriesDto(daily, hours, statesSeen, productsSold, topDrivers, + averageFare, averageWaitSeconds, completionRate, applications.waiting(), + (int) profiles.countByFlaggedTrue(), fares.currency()); + } + + /// One account, with what it has done and what was done to it. + @Transactional(readOnly = true) + public UserDetailDto user(String username) throws IOException { + UserDto user = accounts.describe(username); + int taken = (int) rides.countByRiderOrDriver(username, username); + int cancellations = (int) rides.countByRiderAndStateOrDriverAndState(username, + RideState.CANCELLED_BY_RIDER, username, RideState.CANCELLED_BY_DRIVER); + DriverState car = username == null || username.length() == 0 + || username.length() > 190 ? null : cars.findById(username); + // A rating is a driver's; an account that has never driven has none. + double rating = car == null ? 0d : car.rating(); + long spent = sum(rides.spent(username, RideState.COMPLETED, PAID)); + // What the driver's own screens call earned, from the same sum: a + // card ride whose charge failed, is pending or was refunded, and a + // tip whose charge has not come back, are not money the driver has. + long earned = earnings.earned(username); + return new UserDetailDto(user, taken, cancellations, rating, spent, earned, + moderation.events(username)); + } + + /// Whether a ride in `state` was called off, by whoever did it. + private static boolean cancelled(RideState state) { + return RideState.CANCELLED_BY_RIDER == state || RideState.CANCELLED_BY_DRIVER == state + || RideState.CANCELLED_BY_ADMIN == state; + } + + /// A count or a sum as a query answers it. + private static long whole(Object value) { + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + private static long sum(Object[] values) { + long total = 0L; + for (int iter = 0; values != null && iter < values.length; iter++) { + total += whole(values[iter]); + } + return total; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/ClosedAccount.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/ClosedAccount.java new file mode 100644 index 00000000000..7db5435fe5e --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/ClosedAccount.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// What is left of an account that was closed: the hash of its e-mail, kept so +/// that the address cannot be registered again. +@Entity +public class ClosedAccount { + @Id(autoIncrement = false) + @Column(nullable = false) + private String usernameHash = ""; + + @Column(nullable = false) + private long closedAt; + + public ClosedAccount() { + } + + public String getUsernameHash() { + return usernameHash; + } + + public void setUsernameHash(String usernameHash) { + this.usernameHash = usernameHash; + } + + public long getClosedAt() { + return closedAt; + } + + public void setClosedAt(long closedAt) { + this.closedAt = closedAt; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverApplication.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverApplication.java new file mode 100644 index 00000000000..7db720aad96 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverApplication.java @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An account's application to drive, and what became of it. +@Entity +public class DriverApplication { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String status = ""; + + @Column(nullable = false) + private String legalName = ""; + + @Column(nullable = false) + private String dateOfBirth = ""; + + @Column(nullable = false) + private String licenceNumber = ""; + + @Column(nullable = false) + private String licenceExpiry = ""; + + @Column(nullable = false) + private String vehicleMake = ""; + + @Column(nullable = false) + private String vehicleModel = ""; + + @Column(nullable = false) + private int vehicleYear; + + @Column(nullable = false) + private String vehicleColor = ""; + + @Column(nullable = false) + private String vehiclePlate = ""; + + @Column(nullable = false) + private int vehicleSeats; + + @Column(nullable = false) + private String product = ""; + + @Column(nullable = false) + private boolean wheelchair; + + @Column(nullable = false) + private boolean pets; + + @Column(nullable = false) + private String rejectionReason = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private long submittedAt; + + @Column(nullable = false) + private long reviewedAt; + + @Column(nullable = false) + private String reviewedBy = ""; + + public DriverApplication() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getLegalName() { + return legalName; + } + + public void setLegalName(String legalName) { + this.legalName = legalName; + } + + public String getDateOfBirth() { + return dateOfBirth; + } + + public void setDateOfBirth(String dateOfBirth) { + this.dateOfBirth = dateOfBirth; + } + + public String getLicenceNumber() { + return licenceNumber; + } + + public void setLicenceNumber(String licenceNumber) { + this.licenceNumber = licenceNumber; + } + + public String getLicenceExpiry() { + return licenceExpiry; + } + + public void setLicenceExpiry(String licenceExpiry) { + this.licenceExpiry = licenceExpiry; + } + + public String getVehicleMake() { + return vehicleMake; + } + + public void setVehicleMake(String vehicleMake) { + this.vehicleMake = vehicleMake; + } + + public String getVehicleModel() { + return vehicleModel; + } + + public void setVehicleModel(String vehicleModel) { + this.vehicleModel = vehicleModel; + } + + public int getVehicleYear() { + return vehicleYear; + } + + public void setVehicleYear(int vehicleYear) { + this.vehicleYear = vehicleYear; + } + + public String getVehicleColor() { + return vehicleColor; + } + + public void setVehicleColor(String vehicleColor) { + this.vehicleColor = vehicleColor; + } + + public String getVehiclePlate() { + return vehiclePlate; + } + + public void setVehiclePlate(String vehiclePlate) { + this.vehiclePlate = vehiclePlate; + } + + public int getVehicleSeats() { + return vehicleSeats; + } + + public void setVehicleSeats(int vehicleSeats) { + this.vehicleSeats = vehicleSeats; + } + + public String getProduct() { + return product; + } + + public void setProduct(String product) { + this.product = product; + } + + public boolean isWheelchair() { + return wheelchair; + } + + public void setWheelchair(boolean wheelchair) { + this.wheelchair = wheelchair; + } + + public boolean isPets() { + return pets; + } + + public void setPets(boolean pets) { + this.pets = pets; + } + + public String getRejectionReason() { + return rejectionReason; + } + + public void setRejectionReason(String rejectionReason) { + this.rejectionReason = rejectionReason; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public long getSubmittedAt() { + return submittedAt; + } + + public void setSubmittedAt(long submittedAt) { + this.submittedAt = submittedAt; + } + + public long getReviewedAt() { + return reviewedAt; + } + + public void setReviewedAt(long reviewedAt) { + this.reviewedAt = reviewedAt; + } + + public String getReviewedBy() { + return reviewedBy; + } + + public void setReviewedBy(String reviewedBy) { + this.reviewedBy = reviewedBy; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverDocument.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverDocument.java new file mode 100644 index 00000000000..ab5dbcc68b7 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverDocument.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A document an applicant uploaded, one of each kind. `data` is the file in +/// Base64, which is text on every database. +@Entity +public class DriverDocument { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Id(autoIncrement = false) + @Column(nullable = false) + private String kind = ""; + + @Column(nullable = false) + private String contentType = ""; + + @Column(nullable = false) + private String status = ""; + + @Column(nullable = false) + private long uploadedAt; + + @Column(nullable = false) + private String data = ""; + + public DriverDocument() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public long getUploadedAt() { + return uploadedAt; + } + + public void setUploadedAt(long uploadedAt) { + this.uploadedAt = uploadedAt; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverHours.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverHours.java new file mode 100644 index 00000000000..88b5770dd71 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverHours.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// How long a driver was on line on one day, counted in UTC days. +@Entity +public class DriverHours { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Id(autoIncrement = false) + @Column(nullable = false) + private long dayNumber; + + @Column(nullable = false) + private long millis; + + public DriverHours() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public long getDayNumber() { + return dayNumber; + } + + public void setDayNumber(long dayNumber) { + this.dayNumber = dayNumber; + } + + public long getMillis() { + return millis; + } + + public void setMillis(long millis) { + this.millis = millis; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverState.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverState.java new file mode 100644 index 00000000000..28d6594277d --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/DriverState.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Where a driver's car is, whether they are on line, and what riders made of them. +@Entity +public class DriverState { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private boolean online; + + @Column(nullable = false) + private double lat; + + @Column(nullable = false) + private double lng; + + @Column(nullable = false) + private double heading; + + @Column(nullable = false) + private long lastSeen; + + @Column(nullable = false) + private long ratingSum; + + @Column(nullable = false) + private long ratingCount; + + public DriverState() { + } + + /// The mean of the stars given, or 5 before any were. + public double rating() { + return ratingCount == 0 ? 5d : Math.round(ratingSum * 10d / ratingCount) / 10d; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public boolean isOnline() { + return online; + } + + public void setOnline(boolean online) { + this.online = online; + } + + public double getLat() { + return lat; + } + + public void setLat(double lat) { + this.lat = lat; + } + + public double getLng() { + return lng; + } + + public void setLng(double lng) { + this.lng = lng; + } + + public double getHeading() { + return heading; + } + + public void setHeading(double heading) { + this.heading = heading; + } + + public long getLastSeen() { + return lastSeen; + } + + public void setLastSeen(long lastSeen) { + this.lastSeen = lastSeen; + } + + public long getRatingSum() { + return ratingSum; + } + + public void setRatingSum(long ratingSum) { + this.ratingSum = ratingSum; + } + + public long getRatingCount() { + return ratingCount; + } + + public void setRatingCount(long ratingCount) { + this.ratingCount = ratingCount; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/LiveTicket.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/LiveTicket.java new file mode 100644 index 00000000000..5e2ddc8ee54 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/LiveTicket.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A ticket for one connection to the live channel, as its hash. +@Entity +public class LiveTicket { + @Id(autoIncrement = false) + @Column(nullable = false) + private String ticketHash = ""; + + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private long expiresAt; + + public LiveTicket() { + } + + public String getTicketHash() { + return ticketHash; + } + + public void setTicketHash(String ticketHash) { + this.ticketHash = ticketHash; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(long expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/ModerationEvent.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/ModerationEvent.java new file mode 100644 index 00000000000..1548ad0bd3e --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/ModerationEvent.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One thing an admin did to an account, and why. Written once, never changed. +@Entity +public class ModerationEvent { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private String actor = ""; + + @Column(nullable = false) + private String action = ""; + + @Column(nullable = false) + private String reason = ""; + + public ModerationEvent() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getActor() { + return actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Payment.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Payment.java new file mode 100644 index 00000000000..c17e2652544 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Payment.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// What a completed ride was charged, and what was given back. +@Entity +public class Payment { + @Id(autoIncrement = false) + @Column(nullable = false) + private String rideId = ""; + + @Column(nullable = false) + private long amountCents; + + @Column(nullable = false) + private String currency = ""; + + @Column(nullable = false) + private String status = ""; + + @Column(nullable = false) + private String providerRef = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private String tipRef = ""; + + @Column(nullable = false) + private String refundRef = ""; + + @Column(nullable = false) + private String tipRefundRef = ""; + + @Column(nullable = false) + private String refundReason = ""; + + @Column(nullable = false) + private long refundedAt; + + public Payment() { + } + + public String getRideId() { + return rideId; + } + + public void setRideId(String rideId) { + this.rideId = rideId; + } + + public long getAmountCents() { + return amountCents; + } + + public void setAmountCents(long amountCents) { + this.amountCents = amountCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getProviderRef() { + return providerRef; + } + + public void setProviderRef(String providerRef) { + this.providerRef = providerRef; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getTipRef() { + return tipRef; + } + + public void setTipRef(String tipRef) { + this.tipRef = tipRef; + } + + public String getRefundRef() { + return refundRef; + } + + public void setRefundRef(String refundRef) { + this.refundRef = refundRef; + } + + public String getTipRefundRef() { + return tipRefundRef; + } + + public void setTipRefundRef(String tipRefundRef) { + this.tipRefundRef = tipRefundRef; + } + + public String getRefundReason() { + return refundReason; + } + + public void setRefundReason(String refundReason) { + this.refundReason = refundReason; + } + + public long getRefundedAt() { + return refundedAt; + } + + public void setRefundedAt(long refundedAt) { + this.refundedAt = refundedAt; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PaymentCustomer.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PaymentCustomer.java new file mode 100644 index 00000000000..8b80f5a4ddb --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PaymentCustomer.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The payment provider's name for an account. +@Entity +public class PaymentCustomer { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String reference = ""; + + public PaymentCustomer() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getReference() { + return reference; + } + + public void setReference(String reference) { + this.reference = reference; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PaymentMethod.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PaymentMethod.java new file mode 100644 index 00000000000..90435dc4ba6 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PaymentMethod.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A saved card: what the provider calls it and what to show for it. The +/// number itself is never here. +@Entity +public class PaymentMethod { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String brand = ""; + + @Column(nullable = false) + private String last4 = ""; + + @Column(nullable = false) + private int expMonth; + + @Column(nullable = false) + private int expYear; + + @Column(nullable = false) + private String token = ""; + + @Column(nullable = false) + private boolean preferred; + + @Column(nullable = false) + private long createdAt; + + public PaymentMethod() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + + public String getLast4() { + return last4; + } + + public void setLast4(String last4) { + this.last4 = last4; + } + + public int getExpMonth() { + return expMonth; + } + + public void setExpMonth(int expMonth) { + this.expMonth = expMonth; + } + + public int getExpYear() { + return expYear; + } + + public void setExpYear(int expYear) { + this.expYear = expYear; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public boolean isPreferred() { + return preferred; + } + + public void setPreferred(boolean preferred) { + this.preferred = preferred; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PaymentSetup.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PaymentSetup.java new file mode 100644 index 00000000000..916c95061c3 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PaymentSetup.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A card being added on the provider's page, until the browser comes back. +@Entity +public class PaymentSetup { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String reference = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private boolean completed; + + public PaymentSetup() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getReference() { + return reference; + } + + public void setReference(String reference) { + this.reference = reference; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public boolean isCompleted() { + return completed; + } + + public void setCompleted(boolean completed) { + this.completed = completed; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Payout.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Payout.java new file mode 100644 index 00000000000..a63b1c55c94 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Payout.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One payment of earnings to a driver. +@Entity +public class Payout { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String driver = ""; + + @Column(nullable = false) + private long amountCents; + + @Column(nullable = false) + private String status = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private String destination = ""; + + public Payout() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDriver() { + return driver; + } + + public void setDriver(String driver) { + this.driver = driver; + } + + public long getAmountCents() { + return amountCents; + } + + public void setAmountCents(long amountCents) { + this.amountCents = amountCents; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getDestination() { + return destination; + } + + public void setDestination(String destination) { + this.destination = destination; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PayoutAccount.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PayoutAccount.java new file mode 100644 index 00000000000..9cba370a6b4 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PayoutAccount.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Where a driver's earnings are paid out to. +@Entity +public class PayoutAccount { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String holder = ""; + + @Column(nullable = false) + private String bankName = ""; + + @Column(nullable = false) + private String last4 = ""; + + public PayoutAccount() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getHolder() { + return holder; + } + + public void setHolder(String holder) { + this.holder = holder; + } + + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getLast4() { + return last4; + } + + public void setLast4(String last4) { + this.last4 = last4; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PhoneCode.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PhoneCode.java new file mode 100644 index 00000000000..95bce342378 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PhoneCode.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The verification code an account was last sent, as its hash. +@Entity +public class PhoneCode { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String codeHash = ""; + + @Column(nullable = false) + private long expiresAt; + + @Column(nullable = false) + private int attempts; + + @Column(nullable = false) + private long sentAt; + + /// The code sent before this one, while it is still good, or empty. + @Column(nullable = false) + private String previousHash = ""; + + @Column(nullable = false) + private long previousExpiresAt; + + /// The guesses already taken at the code before, which keeps the budget + /// it had rather than starting a new one when another code is sent. + @Column(nullable = false) + private int previousAttempts; + + public PhoneCode() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getCodeHash() { + return codeHash; + } + + public void setCodeHash(String codeHash) { + this.codeHash = codeHash; + } + + public long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(long expiresAt) { + this.expiresAt = expiresAt; + } + + public int getAttempts() { + return attempts; + } + + public void setAttempts(int attempts) { + this.attempts = attempts; + } + + public long getSentAt() { + return sentAt; + } + + public void setSentAt(long sentAt) { + this.sentAt = sentAt; + } + + public String getPreviousHash() { + return previousHash; + } + + public void setPreviousHash(String previousHash) { + this.previousHash = previousHash; + } + + public long getPreviousExpiresAt() { + return previousExpiresAt; + } + + public void setPreviousExpiresAt(long previousExpiresAt) { + this.previousExpiresAt = previousExpiresAt; + } + + public int getPreviousAttempts() { + return previousAttempts; + } + + public void setPreviousAttempts(int previousAttempts) { + this.previousAttempts = previousAttempts; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Preference.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Preference.java new file mode 100644 index 00000000000..a8a34034c07 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Preference.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// How one person wants the app and their rides, when they have said. +@Entity +public class Preference { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String theme = ""; + + @Column(nullable = false) + private String units = ""; + + @Column(nullable = false) + private boolean notifyRides; + + @Column(nullable = false) + private boolean notifyReceipts; + + @Column(nullable = false) + private boolean notifyPromotions; + + @Column(nullable = false) + private String driverGender = ""; + + @Column(nullable = false) + private boolean quietRide; + + @Column(nullable = false) + private boolean wheelchair; + + @Column(nullable = false) + private boolean petFriendly; + + @Column(nullable = false) + private int defaultTip; + + @Column(nullable = false) + private String shareTripsWith = ""; + + public Preference() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getTheme() { + return theme; + } + + public void setTheme(String theme) { + this.theme = theme; + } + + public String getUnits() { + return units; + } + + public void setUnits(String units) { + this.units = units; + } + + public boolean isNotifyRides() { + return notifyRides; + } + + public void setNotifyRides(boolean notifyRides) { + this.notifyRides = notifyRides; + } + + public boolean isNotifyReceipts() { + return notifyReceipts; + } + + public void setNotifyReceipts(boolean notifyReceipts) { + this.notifyReceipts = notifyReceipts; + } + + public boolean isNotifyPromotions() { + return notifyPromotions; + } + + public void setNotifyPromotions(boolean notifyPromotions) { + this.notifyPromotions = notifyPromotions; + } + + public String getDriverGender() { + return driverGender; + } + + public void setDriverGender(String driverGender) { + this.driverGender = driverGender; + } + + public boolean isQuietRide() { + return quietRide; + } + + public void setQuietRide(boolean quietRide) { + this.quietRide = quietRide; + } + + public boolean isWheelchair() { + return wheelchair; + } + + public void setWheelchair(boolean wheelchair) { + this.wheelchair = wheelchair; + } + + public boolean isPetFriendly() { + return petFriendly; + } + + public void setPetFriendly(boolean petFriendly) { + this.petFriendly = petFriendly; + } + + public int getDefaultTip() { + return defaultTip; + } + + public void setDefaultTip(int defaultTip) { + this.defaultTip = defaultTip; + } + + public String getShareTripsWith() { + return shareTripsWith; + } + + public void setShareTripsWith(String shareTripsWith) { + this.shareTripsWith = shareTripsWith; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Pricing.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Pricing.java new file mode 100644 index 00000000000..2053797559a --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Pricing.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The prices an admin set. One row, with the id 1, once they have set any. +@Entity +public class Pricing { + @Id(autoIncrement = false) + @Column(nullable = false) + private int id; + + @Column(nullable = false) + private long baseCents; + + @Column(nullable = false) + private long perKmCents; + + @Column(nullable = false) + private long perMinuteCents; + + @Column(nullable = false) + private long minimumCents; + + @Column(nullable = false) + private double feePercent; + + @Column(nullable = false) + private double commissionPercent; + + @Column(nullable = false) + private double surge; + + @Column(nullable = false) + private double comfort; + + @Column(nullable = false) + private double xl; + + @Column(nullable = false) + private long updatedAt; + + @Column(nullable = false) + private String updatedBy = ""; + + public Pricing() { + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public long getBaseCents() { + return baseCents; + } + + public void setBaseCents(long baseCents) { + this.baseCents = baseCents; + } + + public long getPerKmCents() { + return perKmCents; + } + + public void setPerKmCents(long perKmCents) { + this.perKmCents = perKmCents; + } + + public long getPerMinuteCents() { + return perMinuteCents; + } + + public void setPerMinuteCents(long perMinuteCents) { + this.perMinuteCents = perMinuteCents; + } + + public long getMinimumCents() { + return minimumCents; + } + + public void setMinimumCents(long minimumCents) { + this.minimumCents = minimumCents; + } + + public double getFeePercent() { + return feePercent; + } + + public void setFeePercent(double feePercent) { + this.feePercent = feePercent; + } + + public double getCommissionPercent() { + return commissionPercent; + } + + public void setCommissionPercent(double commissionPercent) { + this.commissionPercent = commissionPercent; + } + + public double getSurge() { + return surge; + } + + public void setSurge(double surge) { + this.surge = surge; + } + + public double getComfort() { + return comfort; + } + + public void setComfort(double comfort) { + this.comfort = comfort; + } + + public double getXl() { + return xl; + } + + public void setXl(double xl) { + this.xl = xl; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public void setUpdatedBy(String updatedBy) { + this.updatedBy = updatedBy; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Profile.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Profile.java new file mode 100644 index 00000000000..cc59475a406 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Profile.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An account's profile: who it is to the people it rides with. +/// +/// The sign-in -- password hash, roles, whether it is enabled -- is the backend's +/// own user store, under the same name. +@Entity +public class Profile { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String displayName = ""; + + @Column(nullable = false) + private String phone = ""; + + @Column(nullable = false) + private boolean phoneVerified; + + @Column(nullable = false) + private String vehicle = ""; + + @Column(nullable = false) + private String plate = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private String gender = "unspecified"; + + @Column(nullable = false) + private String language = "en"; + + @Column(nullable = false) + private boolean flagged; + + @Column(nullable = false) + private String flagReason = ""; + + @Column(nullable = false) + private String emergencyName = ""; + + @Column(nullable = false) + private String emergencyPhone = ""; + + @Column(nullable = false) + private String homeAddress = ""; + + @Column(nullable = false) + private String workAddress = ""; + + public Profile() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public boolean isPhoneVerified() { + return phoneVerified; + } + + public void setPhoneVerified(boolean phoneVerified) { + this.phoneVerified = phoneVerified; + } + + public String getVehicle() { + return vehicle; + } + + public void setVehicle(String vehicle) { + this.vehicle = vehicle; + } + + public String getPlate() { + return plate; + } + + public void setPlate(String plate) { + this.plate = plate; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getGender() { + return gender; + } + + public void setGender(String gender) { + this.gender = gender; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public boolean isFlagged() { + return flagged; + } + + public void setFlagged(boolean flagged) { + this.flagged = flagged; + } + + public String getFlagReason() { + return flagReason; + } + + public void setFlagReason(String flagReason) { + this.flagReason = flagReason; + } + + public String getEmergencyName() { + return emergencyName; + } + + public void setEmergencyName(String emergencyName) { + this.emergencyName = emergencyName; + } + + public String getEmergencyPhone() { + return emergencyPhone; + } + + public void setEmergencyPhone(String emergencyPhone) { + this.emergencyPhone = emergencyPhone; + } + + public String getHomeAddress() { + return homeAddress; + } + + public void setHomeAddress(String homeAddress) { + this.homeAddress = homeAddress; + } + + public String getWorkAddress() { + return workAddress; + } + + public void setWorkAddress(String workAddress) { + this.workAddress = workAddress; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PushDevice.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PushDevice.java new file mode 100644 index 00000000000..a605159e027 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/PushDevice.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A device an account is sent push notifications on. +/// +/// Keyed by the hash of its provider and token, so a token is on one account at +/// a time: the phone belongs to whoever signed in on it last. +@Entity +public class PushDevice { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String username = ""; + + /// `fcm`, `apns`, `huawei`, `wns` or `web`. + @Column(nullable = false) + private String provider = ""; + + @Column(nullable = false) + private String token = ""; + + @Column(nullable = false) + private String platform = ""; + + @Column(nullable = false) + private long updatedAt; + + public PushDevice() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Ride.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Ride.java new file mode 100644 index 00000000000..9c98aef2e78 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/Ride.java @@ -0,0 +1,413 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import com.codenameone.examples.wayline.api.RideState; + +// tag::wayline-entity[] +/// A ride, from the request to its end. +/// +/// `rider` and `driver` are account names and not references: a ride outlives the +/// accounts that took it, and `driver` is empty until one is offered it. +@Entity +public class Ride { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private RideState state = RideState.REQUESTED; + + @Column(nullable = false) + private String rider = ""; + + @Column(nullable = false) + private String driver = ""; + // end::wayline-entity[] + + @Column(nullable = false) + private double pickupLat; + + @Column(nullable = false) + private double pickupLng; + + @Column(nullable = false) + private String pickupAddress = ""; + + @Column(nullable = false) + private double dropoffLat; + + @Column(nullable = false) + private double dropoffLng; + + @Column(nullable = false) + private String dropoffAddress = ""; + + @Column(nullable = false) + private long fareCents; + + @Column(nullable = false) + private String currency = ""; + + @Column(nullable = false) + private double distanceMeters; + + @Column(nullable = false) + private double durationSeconds; + + @Column(nullable = false) + private long requestedAt; + + @Column(nullable = false) + private long updatedAt; + + @Column(nullable = false) + private long offerExpiresAt; + + @Column(nullable = false) + private long searchUntil; + + @Column(nullable = false) + private int stars; + + @Column(nullable = false) + private String paymentStatus = ""; + + @Column(nullable = false) + private String product = "standard"; + + @Column(nullable = false) + private String methodId = "cash"; + + @Column(nullable = false) + private String methodLabel = "Cash"; + + @Column(nullable = false) + private long baseCents; + + @Column(nullable = false) + private long distanceCents; + + @Column(nullable = false) + private long timeCents; + + @Column(nullable = false) + private long feeCents; + + @Column(nullable = false) + private long tipCents; + + @Column(nullable = false) + private long driverCents; + + @Column(nullable = false) + private boolean womenOnly; + + @Column(nullable = false) + private boolean quiet; + + @Column(nullable = false) + private boolean wheelchair; + + // tag::wayline-column-entity[] + @Column(nullable = false) + private boolean pets; + // end::wayline-column-entity[] + + @Column(nullable = false) + private String note = ""; + + public Ride() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public RideState getState() { + return state; + } + + public void setState(RideState state) { + this.state = state; + } + + public String getRider() { + return rider; + } + + public void setRider(String rider) { + this.rider = rider; + } + + public String getDriver() { + return driver; + } + + public void setDriver(String driver) { + this.driver = driver; + } + + public double getPickupLat() { + return pickupLat; + } + + public void setPickupLat(double pickupLat) { + this.pickupLat = pickupLat; + } + + public double getPickupLng() { + return pickupLng; + } + + public void setPickupLng(double pickupLng) { + this.pickupLng = pickupLng; + } + + public String getPickupAddress() { + return pickupAddress; + } + + public void setPickupAddress(String pickupAddress) { + this.pickupAddress = pickupAddress; + } + + public double getDropoffLat() { + return dropoffLat; + } + + public void setDropoffLat(double dropoffLat) { + this.dropoffLat = dropoffLat; + } + + public double getDropoffLng() { + return dropoffLng; + } + + public void setDropoffLng(double dropoffLng) { + this.dropoffLng = dropoffLng; + } + + public String getDropoffAddress() { + return dropoffAddress; + } + + public void setDropoffAddress(String dropoffAddress) { + this.dropoffAddress = dropoffAddress; + } + + public long getFareCents() { + return fareCents; + } + + public void setFareCents(long fareCents) { + this.fareCents = fareCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public double getDistanceMeters() { + return distanceMeters; + } + + public void setDistanceMeters(double distanceMeters) { + this.distanceMeters = distanceMeters; + } + + public double getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(double durationSeconds) { + this.durationSeconds = durationSeconds; + } + + public long getRequestedAt() { + return requestedAt; + } + + public void setRequestedAt(long requestedAt) { + this.requestedAt = requestedAt; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + public long getOfferExpiresAt() { + return offerExpiresAt; + } + + public void setOfferExpiresAt(long offerExpiresAt) { + this.offerExpiresAt = offerExpiresAt; + } + + public long getSearchUntil() { + return searchUntil; + } + + public void setSearchUntil(long searchUntil) { + this.searchUntil = searchUntil; + } + + public int getStars() { + return stars; + } + + public void setStars(int stars) { + this.stars = stars; + } + + public String getPaymentStatus() { + return paymentStatus; + } + + public void setPaymentStatus(String paymentStatus) { + this.paymentStatus = paymentStatus; + } + + public String getProduct() { + return product; + } + + public void setProduct(String product) { + this.product = product; + } + + public String getMethodId() { + return methodId; + } + + public void setMethodId(String methodId) { + this.methodId = methodId; + } + + public String getMethodLabel() { + return methodLabel; + } + + public void setMethodLabel(String methodLabel) { + this.methodLabel = methodLabel; + } + + public long getBaseCents() { + return baseCents; + } + + public void setBaseCents(long baseCents) { + this.baseCents = baseCents; + } + + public long getDistanceCents() { + return distanceCents; + } + + public void setDistanceCents(long distanceCents) { + this.distanceCents = distanceCents; + } + + public long getTimeCents() { + return timeCents; + } + + public void setTimeCents(long timeCents) { + this.timeCents = timeCents; + } + + public long getFeeCents() { + return feeCents; + } + + public void setFeeCents(long feeCents) { + this.feeCents = feeCents; + } + + public long getTipCents() { + return tipCents; + } + + public void setTipCents(long tipCents) { + this.tipCents = tipCents; + } + + public long getDriverCents() { + return driverCents; + } + + public void setDriverCents(long driverCents) { + this.driverCents = driverCents; + } + + public boolean isWomenOnly() { + return womenOnly; + } + + public void setWomenOnly(boolean womenOnly) { + this.womenOnly = womenOnly; + } + + public boolean isQuiet() { + return quiet; + } + + public void setQuiet(boolean quiet) { + this.quiet = quiet; + } + + public boolean isWheelchair() { + return wheelchair; + } + + public void setWheelchair(boolean wheelchair) { + this.wheelchair = wheelchair; + } + + public boolean isPets() { + return pets; + } + + public void setPets(boolean pets) { + this.pets = pets; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/RideActivity.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/RideActivity.java new file mode 100644 index 00000000000..fb9243b9042 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/RideActivity.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The Live Activity a rider's phone shows a ride in, while the ride lasts. +@Entity +public class RideActivity { + @Id(autoIncrement = false) + @Column(nullable = false) + private String rideId = ""; + + @Column(nullable = false) + private String username = ""; + + /// The id the phone's platform gave the activity. + @Column(nullable = false) + private String activityId = ""; + + /// When the server last sent it a state, for spacing out the updates. + @Column(nullable = false) + private long sentAt; + + /// When its ride ended, 0 while the ride lasts. An ended activity is kept + /// until the phone has been told to end it. + @Column(nullable = false) + private long endedAt; + + public RideActivity() { + } + + public String getRideId() { + return rideId; + } + + public void setRideId(String rideId) { + this.rideId = rideId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getActivityId() { + return activityId; + } + + public void setActivityId(String activityId) { + this.activityId = activityId; + } + + public long getSentAt() { + return sentAt; + } + + public void setSentAt(long sentAt) { + this.sentAt = sentAt; + } + + public long getEndedAt() { + return endedAt; + } + + public void setEndedAt(long endedAt) { + this.endedAt = endedAt; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/RideEvent.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/RideEvent.java new file mode 100644 index 00000000000..7f4bd07cca2 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/RideEvent.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import com.codenameone.examples.wayline.api.RideState; + +/// One change of a ride's state: what it became, who made it so, and when. +@Entity +public class RideEvent { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String rideId = ""; + + @Column(nullable = false) + private RideState state; + + @Column(nullable = false) + private String actor = ""; + + @Column(nullable = false) + private long createdAt; + + public RideEvent() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRideId() { + return rideId; + } + + public void setRideId(String rideId) { + this.rideId = rideId; + } + + public RideState getState() { + return state; + } + + public void setState(RideState state) { + this.state = state; + } + + public String getActor() { + return actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/RidePassed.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/RidePassed.java new file mode 100644 index 00000000000..6f252ae7dac --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/domain/RidePassed.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A driver who passed on a ride, or let its offer lapse, and is not asked again. +@Entity +public class RidePassed { + @Id(autoIncrement = false) + @Column(nullable = false) + private String rideId = ""; + + @Id(autoIncrement = false) + @Column(nullable = false) + private String driver = ""; + + public RidePassed() { + } + + public String getRideId() { + return rideId; + } + + public void setRideId(String rideId) { + this.rideId = rideId; + } + + public String getDriver() { + return driver; + } + + public void setDriver(String driver) { + this.driver = driver; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driver/DriverEndpoint.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driver/DriverEndpoint.java new file mode 100644 index 00000000000..a6d421aab03 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driver/DriverEndpoint.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.driver; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PreAuthorize; +import com.codenameone.examples.wayline.Caller; +import com.codenameone.examples.wayline.api.DriverApiServer; +import com.codenameone.examples.wayline.api.DriverDto; +import com.codenameone.examples.wayline.api.DriverStatusDto; +import com.codenameone.examples.wayline.api.EarningsDto; +import com.codenameone.examples.wayline.api.EarningsSummaryDto; +import com.codenameone.examples.wayline.api.PayoutAccountDto; +import com.codenameone.examples.wayline.api.PayoutDto; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.ride.Rides; + +import java.util.List; + +/// The server's half of `DriverApi`. +/// +/// Guarded twice, on purpose. The filter chain refuses `/api/driver/**` to +/// anyone who is not a driver, by path; `@PreAuthorize` refuses these methods to +/// anyone who is not a driver, whatever path led to them. Either alone would do +/// today. The second is what still holds after someone moves a path. +@Component +@PreAuthorize("hasRole('DRIVER')") +public class DriverEndpoint implements DriverApiServer { + private final Drivers drivers; + private final Rides rides; + private final Earnings earnings; + + public DriverEndpoint(Drivers drivers, Rides rides, Earnings earnings) { + this.earnings = earnings; + this.drivers = drivers; + this.rides = rides; + } + + @Override + public DriverDto status(DriverStatusDto status) throws Exception { + return drivers.report(Caller.name(), status); + } + + @Override + public DriverDto me() throws Exception { + return drivers.describe(Caller.name()); + } + + @Override + public RideDto active() throws Exception { + return rides.activeForDriver(Caller.name()); + } + + @Override + public RideDto accept(String id) throws Exception { + return rides.accept(Caller.name(), id); + } + + @Override + public RideDto decline(String id) throws Exception { + return rides.decline(Caller.name(), id); + } + + @Override + public RideDto arrived(String id) throws Exception { + return rides.arrived(Caller.name(), id); + } + + @Override + public RideDto start(String id) throws Exception { + return rides.start(Caller.name(), id); + } + + @Override + public RideDto complete(String id) throws Exception { + return rides.complete(Caller.name(), id); + } + + @Override + public RideDto cancel(String id) throws Exception { + return rides.cancelByDriver(Caller.name(), id); + } + + @Override + public EarningsDto earnings() throws Exception { + return drivers.earnings(Caller.name()); + } + + @Override + public EarningsSummaryDto earningsSummary() throws Exception { + return earnings.summary(Caller.name()); + } + + @Override + public List payouts() throws Exception { + return earnings.payouts(Caller.name()); + } + + @Override + public PayoutDto cashOut() throws Exception { + return earnings.cashOut(Caller.name()); + } + + @Override + public PayoutAccountDto payoutAccount() throws Exception { + return earnings.account(Caller.name()); + } + + @Override + public PayoutAccountDto savePayoutAccount(PayoutAccountDto account) throws Exception { + return earnings.saveAccount(Caller.name(), account); + } + + @Override + public List history() throws Exception { + return rides.historyForDriver(Caller.name()); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driver/Drivers.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driver/Drivers.java new file mode 100644 index 00000000000..814a136912f --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driver/Drivers.java @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.driver; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.wayline.Days; +import com.codenameone.examples.wayline.Rows; +import com.codenameone.examples.wayline.api.DriverDto; +import com.codenameone.examples.wayline.api.DriverStatusDto; +import com.codenameone.examples.wayline.api.EarningsDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.domain.DriverApplication; +import com.codenameone.examples.wayline.domain.DriverHours; +import com.codenameone.examples.wayline.domain.DriverState; +import com.codenameone.examples.wayline.domain.Profile; +import com.codenameone.examples.wayline.domain.Ride; +import com.codenameone.examples.wayline.driving.Applications; +import com.codenameone.examples.wayline.geo.Geo; +import com.codenameone.examples.wayline.live.LiveHub; +import com.codenameone.examples.wayline.push.RidePushes; +import com.codenameone.examples.wayline.ride.Fares; +import com.codenameone.examples.wayline.repository.DriverApplicationRepository; +import com.codenameone.examples.wayline.repository.DriverHoursRepository; +import com.codenameone.examples.wayline.repository.DriverStateRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; +import com.codenameone.examples.wayline.repository.RideEventRepository; +import com.codenameone.examples.wayline.repository.RidePassedRepository; +import com.codenameone.examples.wayline.repository.RideRepository; +import com.codenameone.examples.wayline.ride.Matcher; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Drivers' live state and what they have earned. +@Component +public class Drivers { + private static final long DAY_MILLIS = 86400000L; + + /// The states of a ride a driver has taken and has yet to finish. + private static final RideState[] TAKEN = {RideState.ACCEPTED, RideState.ARRIVED, + RideState.IN_PROGRESS}; + + private final DriverStateRepository drivers; + private final DriverHoursRepository hours; + private final DriverApplicationRepository made; + private final RideRepository rides; + private final RideEventRepository events; + private final RidePassedRepository passes; + private final ProfileRepository profiles; + private final LiveHub live; + private final RidePushes pushes; + private final Fares fares; + private final Applications applications; + + public Drivers(DriverStateRepository drivers, DriverHoursRepository hours, + DriverApplicationRepository made, RideRepository rides, RideEventRepository events, + RidePassedRepository passes, ProfileRepository profiles, LiveHub live, RidePushes pushes, + Fares fares, Applications applications) { + this.drivers = drivers; + this.hours = hours; + this.made = made; + this.rides = rides; + this.events = events; + this.passes = passes; + this.profiles = profiles; + this.applications = applications; + this.live = live; + this.pushes = pushes; + this.fares = fares; + } + + /// Records whether a driver is on line and where the car is. The app sends + /// this every few seconds while on line; it is both the position riders + /// follow and the sign the driver is still there. + /// + /// The rider being driven and the admin's fleet view are told once the + /// report is stored, and not before: what they are told sends them here to + /// read it. + public DriverDto report(String driver, DriverStatusDto status) throws IOException { + if (status == null || (status.online() && !Geo.isPoint(status.lat(), status.lng()))) { + throw new ResponseStatusException(400, "Going on line needs the car's position"); + } + Report made = store(driver, status); + DriverDto dto = made.driver; + if (made.rideId != null) { + live.driverMoved(made.rider, made.rideId, dto.lat(), dto.lng(), dto.heading()); + pushes.driverMoved(made.rideId, dto.lat(), dto.lng()); + } + live.fleetChanged(dto.username(), dto.online(), dto.lat(), dto.lng(), dto.heading()); + return dto; + } + + /// A report as it was stored, and the ride the driver is on, if any. Not + /// private: the class the build writes to begin and commit the transaction + /// of [#store] has to be able to name what it answers. + static final class Report { + DriverDto driver; + String rideId; + String rider; + } + + @Transactional + private Report store(String driver, DriverStatusDto status) throws IOException { + // Two reports from one driver take turns, before anything is read: read together, + // both would count the same stretch since the last one as time on + // line. The clock is read after the wait, so reports are also stored + // in the order they were let through and none moves the time back. + // + // A driver's first report has no state row to hold, so two first + // reports would both find none and both insert one under the same + // name. They take turns on the profile instead, the order blocking an + // account takes them in, and the second then finds the first's row. + // No profile either means the account was closed meanwhile, after its + // clean-up deleted the state row; inserting one now would leave it + // behind for good. + if (drivers.hold(driver) == 0 && profiles.hold(driver) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + // The role lets a driver into this part of the API; the approval is + // what lets them take rides. They are granted together, but they are + // kept apart, and it is the approval that is asked for here. + if (status.online() && !applications.approved(driver)) { + throw new ResponseStatusException(403, "Your application to drive is not approved"); + } + Ride ride = rides.findFirstByDriverAndStateIn(driver, TAKEN); + if (!status.online() && ride != null) { + throw new ResponseStatusException(409, "Finish the ride before going off line"); + } + long now = System.currentTimeMillis(); + DriverState state = drivers.findById(driver); + // The time since the last report was time on line, unless it was so + // long ago that the app had plainly stopped reporting. It is counted + // here, from the row as it was, and added last, once the row has been + // changed. Adding it is a bulk update that leaves the session as it + // is, so the row read here stays managed and its change is written + // either way; a statement marked clearAutomatically would detach it, + // and a change made after that would be lost. + long since = state != null && state.isOnline() ? now - state.getLastSeen() : 0L; + boolean first = state == null; + if (first) { + state = new DriverState(); + state.setUsername(driver); + } + state.setOnline(status.online()); + state.setLastSeen(now); + // Off line keeps the last position: there is none to report. A first + // report stores whatever came with it. + if (status.online() || first) { + state.setLat(status.lat()); + state.setLng(status.lng()); + state.setHeading(status.heading()); + } + if (first) { + drivers.insert(state); + } + if (since > 0L && since < Matcher.FRESH_MILLIS) { + // Cut at midnight: a stretch on line across it belongs half to + // each day, not all to the new one. + long[] days = Days.split(now - since, now); + for (int iter = 0; iter < days.length; iter += 2) { + addHours(driver, days[iter], days[iter + 1]); + } + } + Report made = new Report(); + made.driver = view(driver, state); + if (ride != null) { + made.rideId = ride.getId(); + made.rider = ride.getRider(); + } + return made; + } + + @Transactional(readOnly = true) + public DriverDto describe(String driver) throws IOException { + return view(driver, drivers.findById(driver)); + } + + /// A driver with `rides` given rather than counted: the rides of a period + /// the caller is describing, not of all time. + @Transactional(readOnly = true) + public DriverDto describe(String driver, int rides) throws IOException { + return view(driver, drivers.findById(driver), rides); + } + + private DriverDto view(String driver, DriverState state) { + return view(driver, state, (int) rides.countByDriverAndState(driver, + RideState.COMPLETED)); + } + + private DriverDto view(String driver, DriverState state, int completed) { + Profile profile = profiles.findById(driver); + if (profile == null) { + throw new ResponseStatusException(404, "No such driver"); + } + return toDto(profile, made.findById(driver), state, completed, + acceptanceRate(events.countByActorAndState(driver, RideState.ACCEPTED), + passes.countByDriver(driver))); + } + + /// Adds time on line to a driver's day. The addition is the database's, + /// so reports that overlap are both counted. + private void addHours(String driver, long dayNumber, long millis) { + if (hours.addMillis(driver, dayNumber, millis) == 0) { + DriverHours day = new DriverHours(); + day.setUsername(driver); + day.setDayNumber(dayNumber); + day.setMillis(millis); + hours.insert(day); + } + } + + /// The share of the offers a driver answered that they took: 1 before they + /// have answered any. An offer left to lapse counts as passed on. + static double acceptanceRate(long accepted, long passed) { + long offers = accepted + passed; + return offers == 0L ? 1d : Math.round(accepted * 100d / offers) / 100d; + } + + /// Every account that has driven or gone on line, for the admin's fleet + /// view; those on line first. + @Transactional(readOnly = true) + public List all() throws IOException { + Map done = Rows.counts(rides.countByDriver(RideState.COMPLETED)); + Map accepted = Rows.counts(events.countByActor(RideState.ACCEPTED)); + Map passed = Rows.counts(passes.countByDriver()); + Map people = new HashMap(); + List profilesOfAll = profiles.drivers(); + for (int iter = 0; iter < profilesOfAll.size(); iter++) { + people.put(profilesOfAll.get(iter).getUsername(), profilesOfAll.get(iter)); + } + Map forms = new HashMap(); + List applicationsOfAll = made.ofDrivers(); + for (int iter = 0; iter < applicationsOfAll.size(); iter++) { + forms.put(applicationsOfAll.get(iter).getUsername(), applicationsOfAll.get(iter)); + } + List states = drivers.onLineFirst(); + List out = new ArrayList(); + for (int iter = 0; iter < states.size(); iter++) { + DriverState state = states.get(iter); + Profile profile = people.get(state.getUsername()); + if (profile == null) { + // A state left behind by an account that is gone. + continue; + } + Long rode = done.get(state.getUsername()); + Long took = accepted.get(state.getUsername()); + Long left = passed.get(state.getUsername()); + out.add(toDto(profile, forms.get(state.getUsername()), state, + rode == null ? 0 : (int) rode.longValue(), + acceptanceRate(took == null ? 0L : took.longValue(), + left == null ? 0L : left.longValue()))); + } + return out; + } + + /// What a driver's completed, paid rides earned them: their share of + /// each fare, without tips. "Today" is the day in UTC; a service in one + /// city would use that city's midnight. + @Transactional(readOnly = true) + public EarningsDto earnings(String driver) throws IOException { + long now = System.currentTimeMillis(); + // The day's trips and the day's money from one query, so the header + // that shows them together counts the rides that made the sum. + Object[] day = rides.paidTotalsSince(driver, now - now % DAY_MILLIS); + int todayCount = day == null ? 0 : (int) Rows.number(day[0]); + long today = day == null ? 0L : Rows.number(day[1]); + long week = Rows.number(rides.paidSince(driver, now - 7 * DAY_MILLIS)); + Object[] total = rides.paidTotals(driver); + int count = total == null ? 0 : (int) Rows.number(total[0]); + long all = total == null ? 0L : Rows.number(total[1]); + return new EarningsDto(today, week, all, count, todayCount, fares.currency()); + } + + /// @param application the account's application to drive, or null + /// @param car the account's state, or null when it has never gone on line + private static DriverDto toDto(Profile profile, DriverApplication application, + DriverState car, int rides, double acceptanceRate) { + boolean approved = application != null + && Applications.APPROVED.equals(application.getStatus()); + // An account with no application has no product; the app is told the + // standard one so that it always has a word it knows. + String product = application == null ? "" : application.getProduct(); + boolean accessible = application != null && application.isWheelchair(); + boolean online = false; + double lat = 0d; + double lng = 0d; + double heading = 0d; + double rating = 5d; + long lastSeen = 0L; + if (car != null) { + lastSeen = car.getLastSeen(); + // On line means said so and heard from lately; see Matcher. + online = car.isOnline() + && System.currentTimeMillis() - lastSeen < Matcher.FRESH_MILLIS; + lat = car.getLat(); + lng = car.getLng(); + heading = car.getHeading(); + rating = car.rating(); + } + return new DriverDto(profile.getUsername(), profile.getDisplayName(), + profile.getVehicle(), profile.getPlate(), online, lat, lng, heading, rating, + lastSeen, rides, approved, product.length() == 0 ? Fares.STANDARD : product, + profile.getGender(), accessible, acceptanceRate, profile.isFlagged()); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driver/Earnings.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driver/Earnings.java new file mode 100644 index 00000000000..500767060fe --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driver/Earnings.java @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.driver; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.wayline.Days; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.Rows; +import com.codenameone.examples.wayline.Text; +import com.codenameone.examples.wayline.api.DayAmountDto; +import com.codenameone.examples.wayline.api.EarningsSummaryDto; +import com.codenameone.examples.wayline.api.PayoutAccountDto; +import com.codenameone.examples.wayline.api.PayoutDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.domain.DriverHours; +import com.codenameone.examples.wayline.domain.DriverState; +import com.codenameone.examples.wayline.domain.Payout; +import com.codenameone.examples.wayline.domain.PayoutAccount; +import com.codenameone.examples.wayline.domain.Ride; +import com.codenameone.examples.wayline.pay.Payments; +import com.codenameone.examples.wayline.repository.DriverHoursRepository; +import com.codenameone.examples.wayline.repository.DriverStateRepository; +import com.codenameone.examples.wayline.repository.PayoutAccountRepository; +import com.codenameone.examples.wayline.repository.PayoutRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; +import com.codenameone.examples.wayline.repository.RideEventRepository; +import com.codenameone.examples.wayline.repository.RidePassedRepository; +import com.codenameone.examples.wayline.repository.RideRepository; +import com.codenameone.examples.wayline.ride.Fares; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// What a driver has earned, and what has been paid out to them. +/// +/// A completed ride earns its driver the fare less the service's commission, +/// fixed when the ride was asked for, plus the whole of any tip. The balance +/// is everything earned less everything paid out, and cashing out moves all +/// of it. +/// +/// Payouts are a ledger and no more: a row says the money was sent, and no +/// bank is asked to send it. For the same reason a cash ride is counted like +/// any other, although its driver was handed the money in the car. A service +/// that moves real money settles cash rides the other way, and that sum +/// belongs with the transfer this sample does not make. +@Component +public class Earnings { + private static final int DAYS = 14; + + private final PayoutRepository payouts; + private final PayoutAccountRepository accounts; + private final RideRepository rides; + private final RideEventRepository events; + private final RidePassedRepository passes; + private final DriverStateRepository drivers; + private final DriverHoursRepository hours; + private final ProfileRepository profiles; + private final Fares fares; + + public Earnings(PayoutRepository payouts, PayoutAccountRepository accounts, + RideRepository rides, RideEventRepository events, RidePassedRepository passes, + DriverStateRepository drivers, DriverHoursRepository hours, + ProfileRepository profiles, Fares fares) { + this.payouts = payouts; + this.accounts = accounts; + this.rides = rides; + this.events = events; + this.passes = passes; + this.drivers = drivers; + this.hours = hours; + this.profiles = profiles; + this.fares = fares; + } + + @Transactional(readOnly = true) + public EarningsSummaryDto summary(String driver) throws IOException { + long now = System.currentTimeMillis(); + long today = Days.start(now); + long month = 0L; + long week = 0L; + long earnedToday = 0L; + // Each day's money and trips, oldest first. + long[] dayAmount = new long[DAYS]; + int[] dayTrips = new int[DAYS]; + // Thirty days of rides, added up here: "group by day" is spelt + // differently by every database, and a driver's month is a few hundred + // rows. + long monthStart = today - 29 * Days.MILLIS; + long weekStart = today - 6 * Days.MILLIS; + List done = rides.findByDriverAndStateAndUpdatedAtGreaterThanEqual(driver, + RideState.COMPLETED, monthStart); + // A card tip is on its ride from the moment it is asked for, and is + // still there when the processor never answered. Only one whose charge + // is on record is earned, here as in the balance: otherwise a day's + // total would show money that cannot be cashed out. + Set charged = new HashSet( + rides.tipChargedSince(driver, monthStart)); + for (int iter = 0; iter < done.size(); iter++) { + Ride ride = done.get(iter); + long at = ride.getUpdatedAt(); + boolean tipped = "cash".equals(ride.getMethodId()) || charged.contains(ride.getId()); + // The fare likewise counts only once it is paid, as in the balance: + // a card charge that failed, was refunded or has not come back is + // no money of the driver's. The trip still counts as driven. + boolean paid = Payments.PAID.equals(ride.getPaymentStatus()); + long earned = (paid ? ride.getDriverCents() : 0L) + (tipped ? ride.getTipCents() : 0L); + month += earned; + if (at >= weekStart) { + week += earned; + } + if (at >= today) { + earnedToday += earned; + } + int index = DAYS - 1 - (int) (Days.number(today) - Days.number(at)); + if (index >= 0 && index < DAYS) { + dayAmount[index] += earned; + dayTrips[index]++; + } + } + List daily = new ArrayList(); + for (int iter = 0; iter < DAYS; iter++) { + daily.add(new DayAmountDto(Days.iso(today - (DAYS - 1 - iter) * Days.MILLIS), + dayAmount[iter], dayTrips[iter])); + } + int trips = (int) rides.countByDriverAndState(driver, RideState.COMPLETED); + DriverHours day = hours.findById(Identifier.of(driver, Long.valueOf(Days.number(now)))); + int onlineMinutes = day == null ? 0 : (int) (day.getMillis() / 60000L); + DriverState car = drivers.findById(driver); + double rating = car == null ? 5d : car.rating(); + double acceptanceRate = Drivers.acceptanceRate(events.countByActorAndState(driver, + RideState.ACCEPTED), passes.countByDriver(driver)); + return new EarningsSummaryDto(fares.currency(), earnedToday, week, month, + balance(driver), trips, tips(driver), onlineMinutes, rating, acceptanceRate, + daily); + } + + /// The payouts made to a driver, newest first. + @Transactional(readOnly = true) + public List payouts(String driver) throws IOException { + List rows = payouts.findTop100ByDriverOrderByCreatedAtDescIdAsc(driver); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter))); + } + return out; + } + + /// Pays out the whole balance to the driver's account. Answers 409 when + /// there is no account to pay it to, or nothing to pay. + /// + /// Two requests at once would both read the same balance and both pay + /// it. So the first thing the transaction does is hold the driver's + /// payout account, the one row every cash-out of theirs needs: the second + /// request waits there until the first has committed, then reads a + /// balance that already has the first payout taken off it, and is told + /// there is nothing left. The hold has to come before any read, or the + /// second request would have looked at the ledger before it waited. + @Transactional + public PayoutDto cashOut(String driver) throws IOException { + if (accounts.hold(driver) == 0) { + throw new ResponseStatusException(409, "Add the account to be paid into first"); + } + PayoutAccount account = accounts.findById(driver); + long amount = balance(driver); + if (amount <= 0L) { + throw new ResponseStatusException(409, "There is nothing to cash out"); + } + Payout row = new Payout(); + row.setId(Ids.next()); + row.setDriver(driver); + row.setAmountCents(amount); + row.setStatus("paid"); + row.setCreatedAt(System.currentTimeMillis()); + row.setDestination(account.getBankName() + " ****" + account.getLast4()); + payouts.insert(row); + return toDto(row); + } + + /// Where a driver is paid; every field empty until they have said. + @Transactional(readOnly = true) + public PayoutAccountDto account(String driver) throws IOException { + return toDto(accounts.findById(driver)); + } + + /// Saves where a driver is paid. Only the last four digits of the account + /// are taken, which is all a ledger with no bank behind it has a use for. + @Transactional + public PayoutAccountDto saveAccount(String driver, PayoutAccountDto wanted) + throws IOException { + if (wanted == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + String last4 = wanted.accountLast4() == null ? "" : wanted.accountLast4().trim(); + boolean digits = last4.length() == 4; + for (int iter = 0; digits && iter < 4; iter++) { + digits = last4.charAt(iter) >= '0' && last4.charAt(iter) <= '9'; + } + if (!digits) { + throw new ResponseStatusException(400, "Give the last four digits of the account"); + } + String holder = Text.required(wanted.holder(), 120, "the account holder's name"); + String bankName = Text.required(wanted.bankName(), 80, "the bank's name"); + // Before the row is looked for. Two first saves at once would both + // find none and both insert one under the same name, and one would + // fail; behind the profile's hold the second finds the first's row + // and updates it. The payout account itself cannot be held: until + // this inserts it there is no row. With no profile to hold the account + // was closed meanwhile and its clean-up has run: a row inserted now + // would keep the closed account's bank details with nobody able to + // remove them. + if (profiles.hold(driver) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + PayoutAccount row = accounts.findById(driver); + boolean added = row == null; + if (added) { + row = new PayoutAccount(); + row.setUsername(driver); + } + row.setHolder(holder); + row.setBankName(bankName); + row.setLast4(last4); + if (added) { + accounts.insert(row); + } + return toDto(row); + } + + /// Everything earned less everything paid out. + /// + /// The dashboard's totals count the same rides as [#earned]. Closing an + /// account asks it too, and is refused while it is above zero. + public long balance(String driver) { + return earned(driver) - Rows.number(payouts.paidOut(driver)); + } + + /// Everything a driver has earned, before anything was paid out: their + /// share of the fares of every ride they completed and that was paid for, + /// plus their tips as [#tips] counts them. + /// + /// The administrator's view of an account shows this figure, and takes it + /// from here rather than from a sum of its own, so the two can never + /// disagree about a charge that failed, a refund or a tip whose charge has + /// not come back. + /// + /// @param driver the driver's user name + /// @return the cents earned + public long earned(String driver) { + return Rows.number(rides.driverShare(driver)) + tips(driver); + } + + /// The tips a driver has been given: those handed over in cash, and those + /// put on a card whose charge has come back. + /// + /// A card tip sits on its ride from the moment it is asked for, and stays + /// there with no charge recorded when the processor did not answer. It is + /// not money yet -- the card may never have been charged -- so it is not + /// counted, and so cannot be cashed out, until the charge is on record. + private long tips(String driver) { + return Rows.number(rides.cashTips(driver)) + Rows.number(rides.chargedTips(driver)); + } + + private static PayoutAccountDto toDto(PayoutAccount row) { + if (row == null) { + return new PayoutAccountDto("", "", ""); + } + return new PayoutAccountDto(row.getHolder(), row.getBankName(), row.getLast4()); + } + + private static PayoutDto toDto(Payout row) { + return new PayoutDto(row.getId(), row.getAmountCents(), row.getStatus(), + row.getCreatedAt(), row.getDestination()); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driving/Applications.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driving/Applications.java new file mode 100644 index 00000000000..3a7e343e68a --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driving/Applications.java @@ -0,0 +1,660 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.driving; + +import com.codename1.backend.Base64; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.User; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.wayline.Days; +import com.codenameone.examples.wayline.Roles; +import com.codenameone.examples.wayline.Text; +import com.codenameone.examples.wayline.account.Moderation; +import com.codenameone.examples.wayline.api.DocumentContentDto; +import com.codenameone.examples.wayline.api.DocumentDto; +import com.codenameone.examples.wayline.api.DocumentUploadDto; +import com.codenameone.examples.wayline.api.DriverApplicationDto; +import com.codenameone.examples.wayline.domain.DriverApplication; +import com.codenameone.examples.wayline.domain.DriverDocument; +import com.codenameone.examples.wayline.domain.Profile; +import com.codenameone.examples.wayline.repository.DriverApplicationRepository; +import com.codenameone.examples.wayline.repository.DriverDocumentRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; +import com.codenameone.examples.wayline.ride.Fares; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// Applications to drive, from the first field filled in to an admin's answer. +/// +/// ``` +/// (none) -> draft -> pending -> approved +/// ^ | +/// +---------+-> rejected -> pending corrected and handed in again +/// ``` +/// +/// The applicant fills in a form and uploads five documents, in any order and +/// over as many visits as it takes; nothing is checked for completeness until +/// they hand it in. An admin then approves it, which is the one thing that +/// grants the DRIVER role, or rejects it with a reason the applicant reads +/// before correcting it and handing it in again. +/// +/// `approved` is what the rest of the server reads: going on line and being +/// offered rides both ask for it here, and not for the role alone. +@Component +public class Applications { + public static final String NONE = "none"; + public static final String DRAFT = "draft"; + public static final String PENDING = "pending"; + public static final String APPROVED = "approved"; + public static final String REJECTED = "rejected"; + /// The documents an application needs, in the order the app asks for them. + public static final String[] KINDS = {"photo_id", "licence", "selfie", "vehicle_registration", + "insurance"}; + /// The largest picture taken, decoded: 1.5 MB. The app shrinks a photograph + /// well below this before sending it; the cap is for whoever does not. + static final int MAX_DOCUMENT_BYTES = 1572864; + + /// The most pixels a document may have on a side, read from its header. + static final int MAX_DOCUMENT_SIDE = 4096; + + private static final String[] KIND_NAMES = {"a photo of your ID", "a photo of your licence", + "a selfie", "the vehicle's registration", "proof of insurance"}; + + private static final int LIST_LIMIT = 200; + + private final DriverApplicationRepository applications; + private final DriverDocumentRepository documents; + private final ProfileRepository profiles; + private final JdbcUserDetailsManager users; + private final Moderation moderation; + + public Applications(DriverApplicationRepository applications, + DriverDocumentRepository documents, ProfileRepository profiles, + JdbcUserDetailsManager users, Moderation moderation) { + this.applications = applications; + this.documents = documents; + this.profiles = profiles; + this.users = users; + this.moderation = moderation; + } + + // ------------------------------------------------------------- applicant + + /// Where an account stands: one of the five statuses. + @Transactional(readOnly = true) + public String status(String username) throws IOException { + DriverApplication application = find(username); + return application == null ? NONE : application.getStatus(); + } + + @Transactional(readOnly = true) + public boolean approved(String username) throws IOException { + return APPROVED.equals(status(username)); + } + + /// The status of every account that has an application, by name. + @Transactional(readOnly = true) + public Map statuses() throws IOException { + Map out = new HashMap(); + List rows = applications.statuses(); + for (int iter = 0; iter < rows.size(); iter++) { + Object[] row = rows.get(iter); + out.put((String) row[0], (String) row[1]); + } + return out; + } + + /// An account's application, or one whose status is `none`. + @Transactional(readOnly = true) + public DriverApplicationDto get(String username) throws IOException { + Profile profile = profiles.findById(username); + return toDto(username, profile == null ? "" : profile.getDisplayName(), + find(username), documents.summaries(username), 0); + } + + /// Begins an application as a draft, when the account has none. `vehicle` + /// and `plate` are what an older app collected at registration. + @Transactional + public void open(String username, String vehicle, String plate) throws IOException { + if (find(username) == null) { + applications.insert(blank(username, DRAFT, vehicle, plate)); + } + } + + /// Saves the form. Nothing has to be complete, but what is there has to be + /// well formed. An application being reviewed or already approved is not + /// the applicant's to change. + /// + /// A refusal does not undo the transaction: the draft a first visit begins + /// is kept whether or not what came with it was well formed. Nothing else + /// is written before the last check has passed. + @Transactional(noRollbackFor = ResponseStatusException.class) + public DriverApplicationDto save(String username, DriverApplicationDto form) + throws IOException { + if (form == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + // Held first, as an upload holds it: what is saved here must not land + // on an application that was handed in after it was read. + hold(username); + DriverApplication application = editable(username); + String birth = date(form.dateOfBirth(), "your date of birth"); + String expiry = date(form.licenceExpiry(), "the licence's expiry date"); + if (form.vehicleYear() < 0 || form.vehicleYear() > 2200 || form.vehicleSeats() < 0 + || form.vehicleSeats() > 20) { + throw new ResponseStatusException(400, "Check the vehicle's year and seats"); + } + // Every field is checked before the first is assigned: the application + // is a managed entity, and a field assigned ahead of a refusal would be + // written with the draft. + String legalName = Text.optional(form.legalName(), 120, "your name"); + String licenceNumber = Text.optional(form.licenceNumber(), 40, "the licence number"); + String make = Text.optional(form.vehicleMake(), 40, "the vehicle's make"); + String model = Text.optional(form.vehicleModel(), 40, "the vehicle's model"); + String color = Text.optional(form.vehicleColor(), 24, "the vehicle's colour"); + String plate = Text.optional(form.vehiclePlate(), 32, "the vehicle's plate"); + String product = Fares.product(form.product()); + application.setLegalName(legalName); + application.setDateOfBirth(birth); + application.setLicenceNumber(licenceNumber); + application.setLicenceExpiry(expiry); + application.setVehicleMake(make); + application.setVehicleModel(model); + application.setVehicleYear(form.vehicleYear()); + application.setVehicleColor(color); + application.setVehiclePlate(plate); + application.setVehicleSeats(form.vehicleSeats()); + application.setProduct(product); + application.setWheelchair(form.accessible()); + application.setPets(form.petFriendly()); + return get(username); + } + + /// Stores a document, in place of an earlier one of its kind. + /// + /// What arrives is checked to be what it says: base64 that decodes, to no + /// more than the cap, to bytes that begin as a JPEG or a PNG does. The + /// declared type alone is the caller's word, and this is later sent to an + /// admin's screen to be drawn. + @Transactional + public DriverApplicationDto upload(String username, DocumentUploadDto upload) + throws IOException { + if (upload == null) { + throw new ResponseStatusException(400, "No document to store"); + } + String kind = Text.oneOf(upload.kind(), KINDS, "", "the kind of document"); + if (kind.length() == 0) { + throw new ResponseStatusException(400, "Say which document this is"); + } + String data = upload.dataBase64() == null ? "" : upload.dataBase64().trim(); + // Refused by length first, so an oversized upload is not decoded at all. + if (data.length() == 0 || data.length() > (MAX_DOCUMENT_BYTES / 3 + 1) * 4) { + throw new ResponseStatusException(data.length() == 0 ? 400 : 413, + data.length() == 0 ? "The document is empty" : "That picture is too large"); + } + byte[] bytes = Base64.decode(data); + if (bytes == null) { + throw new ResponseStatusException(400, "The document is not base64"); + } + if (bytes.length > MAX_DOCUMENT_BYTES) { + throw new ResponseStatusException(413, "That picture is too large"); + } + String type; + if (bytes.length > 3 && (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8 + && (bytes[2] & 0xff) == 0xff) { + type = "image/jpeg"; + } else if (bytes.length > 8 && (bytes[0] & 0xff) == 0x89 && bytes[1] == 'P' + && bytes[2] == 'N' && bytes[3] == 'G') { + type = "image/png"; + } else { + throw new ResponseStatusException(400, "Send a JPEG or a PNG picture"); + } + // The size the picture says it has, before anyone decodes it. A few + // kilobytes can declare a picture of millions of pixels, and an admin's + // app decoding that runs out of memory: the app sends at most 1280 on + // a side, so anything far past that is refused. + int[] size = "image/png".equals(type) ? pngSize(bytes) : jpegSize(bytes); + if (size == null || size[0] <= 0 || size[1] <= 0) { + throw new ResponseStatusException(400, "Send a JPEG or a PNG picture"); + } + if (size[0] > MAX_DOCUMENT_SIDE || size[1] > MAX_DOCUMENT_SIDE) { + throw new ResponseStatusException(413, "That picture is too large"); + } + // Held before it is read. The check below says the application may + // still be changed, and the picture is stored after it: without the + // hold, an application handed in between the two had a document + // replaced while it was under review, perhaps after an admin had + // looked at the one it replaced. + hold(username); + editable(username); + long now = System.currentTimeMillis(); + // Replaced with a statement where there is one already, so the picture + // it held is not read only to be overwritten. + if (documents.replace(username, kind, type, "uploaded", now, data) == 0) { + DriverDocument document = new DriverDocument(); + document.setUsername(username); + document.setKind(kind); + document.setContentType(type); + document.setStatus("uploaded"); + document.setUploadedAt(now); + document.setData(data); + documents.insert(document); + } + return get(username); + } + + /// A stored document, picture included. The caller decides who may ask: + /// the applicant for their own, an admin for anyone's. + @Transactional(readOnly = true) + public DocumentContentDto document(String username, String kind) throws IOException { + DriverDocument stored = kind == null || kind.length() == 0 || kind.length() > 24 + || username == null || username.length() == 0 ? null + : documents.findById(Identifier.of(username, kind)); + if (stored == null) { + throw new ResponseStatusException(404, "No such document"); + } + return new DocumentContentDto(stored.getKind(), stored.getContentType(), + stored.getData()); + } + + /// Hands the application in. Answers 400, naming what is missing, until it + /// is complete. As in [#save], that refusal keeps the draft a first visit + /// began. + @Transactional(noRollbackFor = ResponseStatusException.class) + public DriverApplicationDto submit(String username) throws IOException { + // Held before the form is read and checked: a save that lands between + // the check and the move below would otherwise hand in an application + // emptied after it was found complete, where it can no longer be + // corrected. Behind the hold a save either went first, and is what is + // checked, or comes after and finds it handed in. + hold(username); + String was = editable(username).getStatus(); + DriverApplicationDto form = get(username); + List missing = new ArrayList(); + String today = Days.iso(System.currentTimeMillis()); + need(missing, form.legalName().length() > 0, "your legal name"); + // Dates in this form compare as text. Eighteen years ago is today with + // the year lowered. + String adult = (Integer.parseInt(today.substring(0, 4)) - 18) + today.substring(4); + need(missing, form.dateOfBirth().length() > 0 && form.dateOfBirth().compareTo(adult) <= 0, + "a date of birth that makes you 18"); + need(missing, form.licenceNumber().length() > 0, "your licence number"); + need(missing, form.licenceExpiry().compareTo(today) >= 0, "a licence that has not expired"); + need(missing, form.vehicleMake().length() > 0 && form.vehicleModel().length() > 0, + "the vehicle's make and model"); + need(missing, form.vehicleYear() >= 1990, "the vehicle's year"); + need(missing, form.vehicleColor().length() > 0, "the vehicle's colour"); + need(missing, form.vehiclePlate().length() > 0, "the vehicle's plate"); + need(missing, form.vehicleSeats() >= (Fares.XL.equals(form.product()) ? 6 : 4), + Fares.XL.equals(form.product()) ? "six seats for XL" : "the number of seats"); + for (int iter = 0; iter < form.documents().size(); iter++) { + need(missing, !"missing".equals(form.documents().get(iter).status()), KIND_NAMES[iter]); + } + if (!missing.isEmpty()) { + StringBuilder what = new StringBuilder("Still needed: "); + for (int iter = 0; iter < missing.size(); iter++) { + what.append(iter == 0 ? "" : ", ").append(missing.get(iter)); + } + throw new ResponseStatusException(400, what.toString()); + } + // Held to the status it was read in: handed in twice, it is handed in once. + applications.submit(username, was, PENDING, System.currentTimeMillis()); + return get(username); + } + + // ----------------------------------------------------------------- admin + + /// Applications, the longest waiting first among those waiting. `status` + /// narrows them to one status; empty lists all of them. + @Transactional(readOnly = true) + public List list(String status) throws IOException { + String wanted = Text.oneOf(status, new String[] {DRAFT, PENDING, APPROVED, REJECTED}, "", + "an application's status"); + PageRequest page = new PageRequest(0, LIST_LIMIT); + List rows = wanted.length() == 0 ? applications.newest(page) + : applications.inStatus(wanted, page); + List out = new ArrayList(); + if (rows.isEmpty()) { + return out; + } + List owners = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + owners.add(rows.get(iter).getUsername()); + } + // The applicants' names in one query, and the documents of all of them + // in another, without the pictures. + Map names = new HashMap(); + List named = profiles.displayNames(owners); + for (int iter = 0; iter < named.size(); iter++) { + Object[] row = named.get(iter); + names.put((String) row[0], (String) row[1]); + } + Map> byOwner = new HashMap>(); + List all = documents.summaries(owners); + for (int iter = 0; iter < all.size(); iter++) { + Object[] row = all.get(iter); + String owner = (String) row[0]; + List held = byOwner.get(owner); + if (held == null) { + held = new ArrayList(); + byOwner.put(owner, held); + } + held.add(row); + } + for (int iter = 0; iter < rows.size(); iter++) { + DriverApplication row = rows.get(iter); + String name = names.get(row.getUsername()); + List held = byOwner.get(row.getUsername()); + out.add(toDto(row.getUsername(), name == null ? "" : name, row, + held == null ? new ArrayList() : held, 1)); + } + return out; + } + + /// How many applications are waiting for an admin's answer. + @Transactional(readOnly = true) + public int waiting() throws IOException { + return (int) applications.countByStatus(PENDING); + } + + /// An application for an admin: 404 for an account that never began one. + @Transactional(readOnly = true) + public DriverApplicationDto review(String username) throws IOException { + if (find(username) == null) { + throw new ResponseStatusException(404, "No such application"); + } + return get(username); + } + + /// Approves an application that was handed in: the account becomes a + /// driver, with the car on the application as the car riders look for. + @Transactional + public DriverApplicationDto approve(String by, String username) throws IOException { + // The account first, the lock blocking an account takes too: role() + // rewrites the whole user row, its enabled flag with it, so an approval + // that read the account before a block and wrote it after would undo + // the block. Behind the hold it reads what the block left. + profiles.hold(username); + decide(by, username, APPROVED, ""); + documents.markAll(username, "accepted"); + // Read after markAll, which empties the session. + DriverApplication application = find(username); + Profile profile = profiles.findById(username); + if (application.getVehicleMake().length() > 0 && profile != null) { + String car = (application.getVehicleColor() + " " + application.getVehicleMake() + " " + + application.getVehicleModel()).trim(); + profile.setVehicle(car.length() > 120 ? car.substring(0, 120) : car); + profile.setPlate(application.getVehiclePlate()); + } + role(username, true); + moderation.record(username, by, Moderation.APPROVE, ""); + return get(username); + } + + /// Rejects an application that was handed in, with the reason the + /// applicant is shown. + @Transactional + public DriverApplicationDto reject(String by, String username, String reason) + throws IOException { + String why = Text.required(reason, 255, "why it is rejected"); + decide(by, username, REJECTED, why); + moderation.record(username, by, Moderation.REJECT, why); + return get(username); + } + + /// Makes an account an approved driver without an application having been + /// handed in: the admin's own switch, and the demo accounts. Whatever + /// application the account has is marked approved, and one is made if it + /// has none, so that "approved" has one meaning everywhere. + @Transactional + public void grant(String by, String username) throws IOException { + if (find(username) == null) { + applications.insert(blank(username, APPROVED, "", "")); + return; + } + applications.approveUnlessApproved(username, APPROVED, System.currentTimeMillis(), by); + } + + /// Takes the approval back, when an admin withdraws the DRIVER role. The + /// application is left rejected, so it can be corrected and handed in again. + @Transactional + public void withdraw(String by, String username) throws IOException { + applications.move(username, APPROVED, REJECTED, "Driving was withdrawn by an admin", + System.currentTimeMillis(), by); + } + + /// Everything kept about an account's application, for an account being + /// closed. + @Transactional + public void forget(String username) throws IOException { + documents.deleteByUsername(username); + applications.deleteByUsername(username); + } + + // ------------------------------------------------------------- internals + + /// The one conditional update behind both answers: it changes an + /// application only while it is waiting, so of two admins answering at + /// once one decides and the other is told it has been decided. + private void decide(String by, String username, String to, String reason) + throws IOException { + if (find(username) == null) { + throw new ResponseStatusException(404, "No such application"); + } + if (applications.move(username, PENDING, to, reason, System.currentTimeMillis(), by) + != 1) { + throw new ResponseStatusException(409, "That application is not waiting for an answer"); + } + } + + /// Grants or withdraws the DRIVER role, leaving the account's other roles + /// as they are. + private void role(String username, boolean driver) { + if (!users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + UserDetails signIn = users.loadUserByUsername(username); + String wanted = "ROLE_" + Roles.DRIVER; + List held = new ArrayList(); + Iterator it = signIn.getAuthorities().iterator(); + while (it.hasNext()) { + String authority = it.next().getAuthority(); + if (!wanted.equals(authority)) { + held.add(authority); + } + } + if (driver) { + held.add(wanted); + } + users.updateUser(User.withUserDetails(signIn) + .authorities(held.toArray(new String[held.size()])).build()); + } + + /// The application, made as a draft if there is none, when the applicant + /// may still change it; 409 otherwise. + /// Takes turns with every other change to an account's application. One + /// that has none yet has no row to hold: the first save, upload or + /// hand-in makes it, and two at once would both insert one under the same + /// name, so they take turns on the profile instead. + /// + /// No profile to hold means Accounts.erase committed first, after + /// forget() had already deleted the application and its documents: going + /// on would make a new draft and store identity documents for an account + /// that no longer exists, which nothing would ever delete. 404 instead, as + /// PushDevices.register does. + private void hold(String username) { + if (applications.hold(username) == 0 && profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + } + + /// An account's application, or null when it has none. + private DriverApplication find(String username) { + if (username == null || username.length() == 0 || username.length() > 190) { + return null; + } + return applications.findById(username); + } + + private DriverApplication editable(String username) { + DriverApplication application = find(username); + if (application == null) { + application = blank(username, DRAFT, "", ""); + applications.insert(application); + } + if (PENDING.equals(application.getStatus())) { + throw new ResponseStatusException(409, "Your application is being reviewed"); + } + if (APPROVED.equals(application.getStatus())) { + throw new ResponseStatusException(409, "Your application was approved"); + } + return application; + } + + /// A new application with nothing filled in but what an older app + /// collected at registration. One made already approved was reviewed the + /// moment it was made. + private static DriverApplication blank(String username, String status, String vehicle, + String plate) { + long now = System.currentTimeMillis(); + DriverApplication application = new DriverApplication(); + application.setUsername(username); + application.setStatus(status); + application.setVehicleModel(vehicle.length() > 40 ? vehicle.substring(0, 40) : vehicle); + application.setVehiclePlate(plate); + application.setVehicleSeats(4); + application.setProduct(Fares.STANDARD); + application.setCreatedAt(now); + application.setReviewedAt(APPROVED.equals(status) ? now : 0L); + return application; + } + + private static void need(List missing, boolean present, String what) { + if (!present) { + missing.add(what); + } + } + + /// A date field: empty, or `yyyy-MM-dd`. + private static String date(String value, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() > 0 && !Days.isIso(text)) { + throw new ResponseStatusException(400, "Write " + what + " as yyyy-MM-dd"); + } + return text; + } + + /// `documents` holds `{kind, status, uploadedAt}` from the index `from` on: + /// a list read for several accounts has the owner's name in front. + private static DriverApplicationDto toDto(String username, String name, + DriverApplication row, List documents, int from) { + List listed = new ArrayList(); + for (int kind = 0; kind < KINDS.length; kind++) { + String status = "missing"; + long uploadedAt = 0L; + for (int iter = 0; iter < documents.size(); iter++) { + Object[] stored = documents.get(iter); + if (KINDS[kind].equals(stored[from])) { + status = (String) stored[from + 1]; + Object at = stored[from + 2]; + uploadedAt = at instanceof Number ? ((Number) at).longValue() : 0L; + } + } + listed.add(new DocumentDto(KINDS[kind], status, uploadedAt)); + } + if (row == null) { + // Nobody has applied: an empty form, on the defaults it opens with. + return new DriverApplicationDto(username, name, NONE, "", "", "", "", "", "", 0, "", + "", 4, Fares.STANDARD, false, false, listed, "", 0L, 0L, ""); + } + return new DriverApplicationDto(username, name, row.getStatus(), row.getLegalName(), + row.getDateOfBirth(), row.getLicenceNumber(), row.getLicenceExpiry(), + row.getVehicleMake(), row.getVehicleModel(), row.getVehicleYear(), + row.getVehicleColor(), row.getVehiclePlate(), row.getVehicleSeats(), + row.getProduct(), row.isWheelchair(), row.isPets(), listed, + row.getRejectionReason(), row.getSubmittedAt(), row.getReviewedAt(), + row.getReviewedBy()); + } + + /// Width and height from a PNG's header chunk, or null without one. + static int[] pngSize(byte[] bytes) { + if (bytes.length < 24 || bytes[12] != 'I' || bytes[13] != 'H' || bytes[14] != 'D' + || bytes[15] != 'R') { + return null; + } + return new int[] {int32(bytes, 16), int32(bytes, 20)}; + } + + /// Width and height from a JPEG's frame header, or null when the scan + /// starts, or the bytes run out, before one. + static int[] jpegSize(byte[] bytes) { + int at = 2; + while (at + 3 < bytes.length) { + if ((bytes[at] & 0xff) != 0xff) { + return null; + } + int marker = bytes[at + 1] & 0xff; + if (marker == 0xff) { + // Fill before a marker. + at++; + continue; + } + if (marker == 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { + // Markers that carry no length. + at += 2; + continue; + } + if (marker == 0xda || marker == 0xd9) { + return null; + } + int length = ((bytes[at + 2] & 0xff) << 8) | (bytes[at + 3] & 0xff); + boolean frame = marker >= 0xc0 && marker <= 0xcf && marker != 0xc4 + && marker != 0xc8 && marker != 0xcc; + if (frame) { + if (at + 8 >= bytes.length) { + return null; + } + int height = ((bytes[at + 5] & 0xff) << 8) | (bytes[at + 6] & 0xff); + int width = ((bytes[at + 7] & 0xff) << 8) | (bytes[at + 8] & 0xff); + return new int[] {width, height}; + } + if (length < 2) { + return null; + } + at += 2 + length; + } + return null; + } + + private static int int32(byte[] bytes, int at) { + return ((bytes[at] & 0xff) << 24) | ((bytes[at + 1] & 0xff) << 16) + | ((bytes[at + 2] & 0xff) << 8) | (bytes[at + 3] & 0xff); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driving/DrivingEndpoint.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driving/DrivingEndpoint.java new file mode 100644 index 00000000000..9da78e5d4c9 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/driving/DrivingEndpoint.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.driving; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.wayline.Caller; +import com.codenameone.examples.wayline.api.DocumentContentDto; +import com.codenameone.examples.wayline.api.DocumentUploadDto; +import com.codenameone.examples.wayline.api.DriverApplicationDto; +import com.codenameone.examples.wayline.api.DrivingApiServer; + +/// The server's half of `DrivingApi`. +/// +/// Open to anyone signed in, which is why its paths are not under +/// `/api/driver`: applying is what someone does before they hold the DRIVER +/// role. What keeps one applicant out of another's application -- and out of +/// the identity documents in it -- is that no method here takes a name. Each +/// acts on the application of whoever is signed in, and there is no parameter +/// to change. +@Component +public class DrivingEndpoint implements DrivingApiServer { + private final Applications applications; + + public DrivingEndpoint(Applications applications) { + this.applications = applications; + } + + @Override + public DriverApplicationDto application() throws Exception { + return applications.get(Caller.name()); + } + + @Override + public DriverApplicationDto save(DriverApplicationDto application) throws Exception { + return applications.save(Caller.name(), application); + } + + @Override + public DriverApplicationDto upload(DocumentUploadDto document) throws Exception { + return applications.upload(Caller.name(), document); + } + + @Override + public DocumentContentDto document(String kind) throws Exception { + return applications.document(Caller.name(), kind); + } + + @Override + public DriverApplicationDto submit() throws Exception { + return applications.submit(Caller.name()); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/Geo.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/Geo.java new file mode 100644 index 00000000000..8c2e38317ee --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/Geo.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.geo; + +/// Distances on the ground. +public final class Geo { + private static final double EARTH_RADIUS_METERS = 6371000d; + /// Metres in a degree of latitude, anywhere. A degree of longitude is this + /// times the cosine of the latitude. + public static final double METERS_PER_DEGREE = 111320d; + + private Geo() { + } + + /// The great-circle distance between two points, in metres. + public static double meters(double lat1, double lng1, double lat2, double lng2) { + double dLat = Math.toRadians(lat2 - lat1); + double dLng = Math.toRadians(lng2 - lng1); + double sinLat = Math.sin(dLat / 2); + double sinLng = Math.sin(dLng / 2); + double a = sinLat * sinLat + Math.cos(Math.toRadians(lat1)) + * Math.cos(Math.toRadians(lat2)) * sinLng * sinLng; + return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0d, 1 - a))); + } + + public static boolean isPoint(double lat, double lng) { + return lat >= -90d && lat <= 90d && lng >= -180d && lng <= 180d + && !(lat == 0d && lng == 0d); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/GeoEndpoint.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/GeoEndpoint.java new file mode 100644 index 00000000000..2f46577c384 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/GeoEndpoint.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.geo; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.wayline.api.GeoApiServer; +import com.codenameone.examples.wayline.api.PlaceDto; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The server's half of `GeoApi`. +@Component +public class GeoEndpoint implements GeoApiServer { + private final Geocoder geocoder; + + public GeoEndpoint(Geocoder geocoder) { + this.geocoder = geocoder; + } + + @Override + public List search(String q, double lat, double lng) throws Exception { + String text = q == null ? "" : q.trim(); + // Two letters match half the map; nothing useful comes back, and each + // keystroke would be a request to the service behind this. + if (text.length() < 3 || text.length() > 120) { + return new ArrayList(); + } + try { + return geocoder.search(text, lat, lng); + } catch (IOException failed) { + System.err.println("geocoder search failed: " + failed.getMessage()); + throw new ResponseStatusException(502, "Search is unavailable right now"); + } + } + + @Override + public PlaceDto reverse(double lat, double lng) throws Exception { + if (!Geo.isPoint(lat, lng)) { + throw new ResponseStatusException(400, "That is not a point on the map"); + } + PlaceDto found; + try { + found = geocoder.reverse(lat, lng); + } catch (IOException failed) { + System.err.println("geocoder reverse failed: " + failed.getMessage()); + found = null; + } + // The point asked about, not the centre of whatever was found near it. + if (found == null) { + // A pin can be dropped anywhere, and a ride can start there: an + // address the service does not know is a point without a name. + return new PlaceDto("Dropped pin", "", lat, lng); + } + return new PlaceDto(found.name(), found.address(), lat, lng); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/Geocoder.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/Geocoder.java new file mode 100644 index 00000000000..e4b67cd6a4f --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/Geocoder.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.geo; + +import com.codenameone.examples.wayline.api.PlaceDto; + +import java.io.IOException; +import java.util.List; + +/// Turns text into places and a point into an address. +/// +/// The app never talks to a geocoding service itself. It asks this server, +/// which asks whichever service is configured: no key ships inside the app, the +/// service can be replaced without a new release, and the server is where its +/// use can be limited. +public interface Geocoder { + /// Places matching `text`, best first, favouring those near the point. + List search(String text, double lat, double lng) throws IOException; + + /// The address at a point, or null when the service knows of none. + PlaceDto reverse(double lat, double lng) throws IOException; +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/PhotonGeocoder.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/PhotonGeocoder.java new file mode 100644 index 00000000000..d377457e732 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/PhotonGeocoder.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.geo; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import com.codenameone.examples.wayline.api.PlaceDto; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Geocoding by Photon, the open-source geocoder over OpenStreetMap data. +/// +/// The address defaults to the public instance, which needs no key and asks in +/// return for moderate use. Run your own, or point `wayline.geocoder.url` at +/// another Photon, before sending it a real service's traffic. +public class PhotonGeocoder implements Geocoder { + private final String base; + + public PhotonGeocoder(String base) { + this.base = base.endsWith("/") ? base.substring(0, base.length() - 1) : base; + } + + @Override + public List search(String text, double lat, double lng) throws IOException { + String url = base + "/api?limit=8&q=" + URLEncoder.encode(text, "UTF-8"); + if (Geo.isPoint(lat, lng)) { + url += "&lat=" + lat + "&lon=" + lng; + } + return places(url); + } + + @Override + public PlaceDto reverse(double lat, double lng) throws IOException { + List found = places(base + "/reverse?lat=" + lat + "&lon=" + lng); + return found.isEmpty() ? null : found.get(0); + } + + private static List places(String url) throws IOException { + Web.Result answer = Web.get(url); + if (!answer.isSuccess()) { + throw new IOException("The geocoder answered " + answer.getStatus()); + } + List out = new ArrayList(); + Object features = Json.parseObject(answer.getBodyAsString()).get("features"); + if (!(features instanceof List)) { + return out; + } + List list = (List) features; + for (int iter = 0; iter < list.size(); iter++) { + PlaceDto place = place(list.get(iter)); + if (place != null) { + out.add(place); + } + } + return out; + } + + /// One GeoJSON feature as a place, or null when it has no usable point. + private static PlaceDto place(Object feature) { + if (!(feature instanceof Map)) { + return null; + } + Object geometry = ((Map) feature).get("geometry"); + Object properties = ((Map) feature).get("properties"); + if (!(geometry instanceof Map) || !(properties instanceof Map)) { + return null; + } + Object coordinates = ((Map) geometry).get("coordinates"); + if (!(coordinates instanceof List) || ((List) coordinates).size() < 2) { + return null; + } + Object lng = ((List) coordinates).get(0); + Object lat = ((List) coordinates).get(1); + if (!(lng instanceof Number) || !(lat instanceof Number)) { + return null; + } + Map p = (Map) properties; + String street = text(p, "street"); + if (street.length() > 0 && text(p, "housenumber").length() > 0) { + street = text(p, "housenumber") + " " + street; + } + String name = text(p, "name"); + if (name.length() == 0) { + name = street.length() > 0 ? street : text(p, "city"); + } + if (name.length() == 0) { + return null; + } + StringBuilder address = new StringBuilder(); + append(address, name.equals(street) ? "" : street); + append(address, text(p, "city")); + append(address, text(p, "country")); + return new PlaceDto(name, address.toString(), ((Number) lat).doubleValue(), + ((Number) lng).doubleValue()); + } + + private static String text(Map properties, String key) { + Object value = properties.get(key); + return value instanceof String ? (String) value : ""; + } + + private static void append(StringBuilder to, String part) { + if (part.length() > 0) { + if (to.length() > 0) { + to.append(", "); + } + to.append(part); + } + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/StubGeocoder.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/StubGeocoder.java new file mode 100644 index 00000000000..a5c0af3d496 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/geo/StubGeocoder.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.geo; + +import com.codenameone.examples.wayline.api.PlaceDto; + +import java.util.ArrayList; +import java.util.List; + +/// A geocoder that knows ten places and asks nobody. What the tests run +/// against, so they neither depend on a public service nor lean on one. +public class StubGeocoder implements Geocoder { + private static final Object[][] PLACES = { + {"Ferry Building", "1 Ferry Building, San Francisco", 37.7955, -122.3937}, + {"Pier 39", "The Embarcadero, San Francisco", 37.8087, -122.4098}, + {"Coit Tower", "1 Telegraph Hill Blvd, San Francisco", 37.8024, -122.4058}, + {"Ghirardelli Square", "900 North Point St, San Francisco", 37.8059, -122.4229}, + {"Lombard Street", "Lombard St, San Francisco", 37.8021, -122.4187}, + {"Palace of Fine Arts", "3601 Lyon St, San Francisco", 37.8029, -122.4484}, + {"Union Square", "333 Post St, San Francisco", 37.7880, -122.4075}, + {"Oracle Park", "24 Willie Mays Plaza, San Francisco", 37.7786, -122.3893}, + {"Fort Mason", "2 Marina Blvd, San Francisco", 37.8066, -122.4310}, + {"Transamerica Pyramid", "600 Montgomery St, San Francisco", 37.7952, -122.4028}, + }; + + @Override + public List search(String text, double lat, double lng) { + List out = new ArrayList(); + String wanted = text == null ? "" : text.trim(); + for (int iter = 0; iter < PLACES.length; iter++) { + if (contains((String) PLACES[iter][0], wanted)) { + out.add(place(iter)); + } + } + return out; + } + + @Override + public PlaceDto reverse(double lat, double lng) { + int nearest = 0; + double best = Double.MAX_VALUE; + for (int iter = 0; iter < PLACES.length; iter++) { + double meters = Geo.meters(lat, lng, ((Double) PLACES[iter][2]).doubleValue(), + ((Double) PLACES[iter][3]).doubleValue()); + if (meters < best) { + best = meters; + nearest = iter; + } + } + return place(nearest); + } + + private static PlaceDto place(int index) { + return new PlaceDto((String) PLACES[index][0], (String) PLACES[index][1], + ((Double) PLACES[index][2]).doubleValue(), + ((Double) PLACES[index][3]).doubleValue()); + } + + /// Whether `text` contains `part`, whatever the case of either. Compared a + /// character at a time and not by folding both, which would follow the + /// server's locale. + private static boolean contains(String text, String part) { + for (int at = 0; at + part.length() <= text.length(); at++) { + if (text.regionMatches(true, at, part, 0, part.length())) { + return true; + } + } + return false; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/live/LiveEndpoint.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/live/LiveEndpoint.java new file mode 100644 index 00000000000..190940e105c --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/live/LiveEndpoint.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.live; + +import com.codename1.backend.WebSocket; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.WebSocketMapping; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.api.UserDto; + +/// The live channel: one WebSocket per signed-in app, down which the server +/// says what changed ([LiveHub]). +/// +/// No filter chain covers this path, deliberately: the handshake cannot carry +/// the bearer token on every platform. The connection is let in by the ticket +/// in its address instead ([LiveTickets]), and one that presents none, or one +/// already used, is closed before it is told anything. +@WebSocketMapping("/ws/live") +public class LiveEndpoint implements WebSocket { + private final LiveTickets tickets; + private final Accounts accounts; + private final LiveHub hub; + + public LiveEndpoint(LiveTickets tickets, Accounts accounts, LiveHub hub) { + this.tickets = tickets; + this.accounts = accounts; + this.hub = hub; + } + + @Override + public void onOpen(WebSocketSession session) throws Exception { + String username = tickets.redeem(ticket(session.getQuery())); + UserDto user = username == null ? null : accounts.describe(username); + if (user == null || user.suspended()) { + session.close(4401, "ticket"); + return; + } + // Who this connection is, kept on the connection: the one piece of + // state a WebSocket has that a request does not. + session.setAttachment(username); + hub.add(username, user.admin(), session); + // Read again now that the connection is in the hub. A block or a + // withdrawn admin role commits and then drops the account's + // connections; one that committed after the read above and dropped + // before the add found nothing to drop, and this connection would + // carry what was read before -- for an admin, every driver's position. + // Either the change is seen here, or its drop comes after the add and + // closes this connection. + UserDto now = accounts.describe(username); + if (now == null || now.suspended() || now.admin() != user.admin()) { + hub.remove(username, session); + session.close(4403, "changed"); + return; + } + session.sendText("{\"type\":\"ready\"}"); + } + + @Override + public void onText(WebSocketSession session, String message) throws Exception { + // The app says one thing, to learn the connection is still alive on a + // network that drops idle ones without telling either end. + if (session.getAttachment() != null && message != null + && message.indexOf("\"ping\"") >= 0) { + session.sendText("{\"type\":\"pong\"}"); + } + } + + @Override + public void onBinary(WebSocketSession session, byte[] message, int offset, int length) + throws Exception { + session.close(1003, "text only"); + } + + @Override + public void onClose(WebSocketSession session, int code, String reason) { + Object username = session.getAttachment(); + if (username instanceof String) { + hub.remove((String) username, session); + } + } + + /// The `ticket` parameter of a query string, or null. A ticket is hex, so + /// there is nothing in it to decode. + static String ticket(String query) { + if (query == null) { + return null; + } + int at = 0; + while (at < query.length()) { + int end = query.indexOf('&', at); + if (end < 0) { + end = query.length(); + } + if (query.startsWith("ticket=", at)) { + return query.substring(at + 7, end); + } + at = end + 1; + } + return null; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/live/LiveHub.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/live/LiveHub.java new file mode 100644 index 00000000000..143a9e77361 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/live/LiveHub.java @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.live; + +import com.codename1.backend.Json; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.wayline.api.RideState; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The open live connections, and what is pushed down them. +/// +/// Three frames, each a small JSON object: +/// +/// - `{"type":"ride","id":...,"state":...}` -- a ride changed. Sent to its +/// rider, its driver and the admins, who then read the ride from the API. +/// The frame carries no more than that on purpose: the API is the one account +/// of a ride, and a client that missed a frame is put right by its next read. +/// - `{"type":"driver","rideId":...,"lat":...,"lng":...,"heading":...}` -- the +/// driver of a ride moved. Sent to its rider. +/// - `{"type":"fleet","username":...,"online":...,"lat":...}` -- a driver's +/// status or position changed. Sent to the admins. +/// +/// The connections are those of this process. Two instances of the server +/// would each know only their own, and a change made on one would not reach a +/// client connected to the other: running more than one needs a channel +/// between them -- a database LISTEN/NOTIFY, a message broker -- feeding the +/// same three methods. +@Component +public class LiveHub { + private final Map> byUser = + new HashMap>(); + private final List admins = new ArrayList(); + + synchronized void add(String username, boolean admin, WebSocketSession session) { + List open = byUser.get(username); + if (open == null) { + open = new ArrayList(); + byUser.put(username, open); + } + open.add(session); + if (admin) { + admins.add(session); + } + } + + synchronized void remove(String username, WebSocketSession session) { + List open = byUser.get(username); + if (open != null) { + open.remove(session); + if (open.isEmpty()) { + byUser.remove(username); + } + } + admins.remove(session); + } + + /// How many connections are open, for the tests. + public synchronized int connections() { + int total = 0; + for (List open : byUser.values()) { + total += open.size(); + } + return total; + } + + /// Closes every connection an account has open. + public void drop(String username) { + List open; + synchronized (this) { + open = byUser.remove(username); + if (open != null) { + admins.removeAll(open); + } + } + for (int iter = 0; open != null && iter < open.size(); iter++) { + open.get(iter).close(4403, "suspended"); + } + } + + /// Tells a ride's rider and driver, and the admins, that it changed. + /// `also` is one more account to tell, or empty: the driver an offer was + /// taken back from is no longer the ride's driver, and still has to hear. + public void rideChanged(String id, RideState state, String rider, String driver, String also) { + Map frame = new LinkedHashMap(); + frame.put("type", "ride"); + frame.put("id", id); + frame.put("state", state.name()); + String text = Json.write(frame); + List to = new ArrayList(); + synchronized (this) { + collect(to, rider); + collect(to, driver); + collect(to, also); + for (int iter = 0; iter < admins.size(); iter++) { + if (!to.contains(admins.get(iter))) { + to.add(admins.get(iter)); + } + } + } + send(to, text); + } + + public void driverMoved(String rider, String rideId, double lat, double lng, double heading) { + Map frame = new LinkedHashMap(); + frame.put("type", "driver"); + frame.put("rideId", rideId); + frame.put("lat", Double.valueOf(lat)); + frame.put("lng", Double.valueOf(lng)); + frame.put("heading", Double.valueOf(heading)); + List to = new ArrayList(); + synchronized (this) { + collect(to, rider); + } + send(to, Json.write(frame)); + } + + public void fleetChanged(String username, boolean online, double lat, double lng, + double heading) { + List to; + synchronized (this) { + if (admins.isEmpty()) { + return; + } + to = new ArrayList(admins); + } + Map frame = new LinkedHashMap(); + frame.put("type", "fleet"); + frame.put("username", username); + frame.put("online", Boolean.valueOf(online)); + frame.put("lat", Double.valueOf(lat)); + frame.put("lng", Double.valueOf(lng)); + frame.put("heading", Double.valueOf(heading)); + send(to, Json.write(frame)); + } + + /// Must be called holding the lock. + private void collect(List to, String username) { + List open = username == null ? null : byUser.get(username); + for (int iter = 0; open != null && iter < open.size(); iter++) { + if (!to.contains(open.get(iter))) { + to.add(open.get(iter)); + } + } + } + + /// Sends outside the lock: a slow client holds up its own frame, not + /// everyone's. One that cannot be written to is dropped; its close callback + /// takes it out of the maps. + private static void send(List to, String text) { + for (int iter = 0; iter < to.size(); iter++) { + WebSocketSession session = to.get(iter); + try { + session.sendText(text); + } catch (IOException gone) { + session.abort(); + } + } + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/live/LiveTickets.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/live/LiveTickets.java new file mode 100644 index 00000000000..e902411ff0d --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/live/LiveTickets.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.live; + +import com.codename1.backend.Crypto; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.api.TicketDto; +import com.codenameone.examples.wayline.domain.LiveTicket; +import com.codenameone.examples.wayline.repository.LiveTicketRepository; + +import java.io.IOException; + +/// Tickets for the live channel. +/// +/// A WebSocket handshake is one request the app cannot attach its bearer token +/// to on every platform -- a browser's WebSocket takes no headers at all -- and a +/// token in the address would end up in every access log on the way. So the app +/// asks for a ticket over the authenticated API and presents that instead: it +/// is random, it names nothing, it is good for one connection, and it is stale +/// in half a minute. +@Component +public class LiveTickets { + static final int TICKET_SECONDS = 30; + + private final LiveTicketRepository tickets; + + public LiveTickets(LiveTicketRepository tickets) { + this.tickets = tickets; + } + + /// Not held against the account being closed, as the rows that last are. + /// A ticket issued in the instant an account is erased outlives it by at + /// most [#TICKET_SECONDS] plus one [#sweep], and opens nothing meanwhile: + /// `LiveEndpoint` checks the account again when the ticket is redeemed. + /// A row lock on every socket connect is not worth that minute. + @Transactional + public TicketDto issue(String username) throws IOException { + String ticket = Ids.hex(Crypto.randomBytes(24)); + LiveTicket row = new LiveTicket(); + row.setTicketHash(Ids.sha256(ticket)); + row.setUsername(username); + row.setExpiresAt(System.currentTimeMillis() + TICKET_SECONDS * 1000L); + tickets.insert(row); + return new TicketDto(ticket, TICKET_SECONDS); + } + + /// The account a ticket was issued to, or null when it is unknown, used or + /// stale. Redeeming it uses it up: the row is deleted, and only the + /// connection whose delete removed it is let in, so a ticket presented twice + /// at the same moment still opens one connection. + @Transactional + public String redeem(String ticket) throws IOException { + if (ticket == null || ticket.length() == 0 || ticket.length() > 128) { + return null; + } + String hash = Ids.sha256(ticket); + LiveTicket row = tickets.findById(hash); + if (row == null) { + return null; + } + String username = row.getUsername(); + long expiresAt = row.getExpiresAt(); + // One delete statement: the count it answers is what says this + // connection, and not another presenting the same ticket, used it. + int removed = tickets.deleteByTicketHash(hash); + if (removed != 1 || expiresAt < System.currentTimeMillis()) { + return null; + } + return username; + } + + /// Clears the tickets nobody came back for. + @Scheduled(fixedDelay = 60000, initialDelay = 60000) + @Transactional + public void sweep() throws IOException { + tickets.deleteByExpiresAtLessThan(System.currentTimeMillis()); + } + + /// Forgets the tickets of an account that is being closed. + @Transactional + public void forget(String username) { + tickets.deleteByUsername(username); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/MisconfiguredPayments.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/MisconfiguredPayments.java new file mode 100644 index 00000000000..4481ece741d --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/MisconfiguredPayments.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import com.codenameone.examples.wayline.api.CardDto; + +import java.io.IOException; + +/// The payment provider a server gets when the one it was told to use cannot +/// be set up: every call fails, and says why. +/// +/// The problem is logged when the server starts and again on every call, +/// naming the settings to fix. Each call throws an `IOException`, as an outage +/// of the processor would, so a rider is told payments are unavailable and no +/// ride is charged to a card nobody can reach. +public class MisconfiguredPayments implements PaymentProvider { + private final String provider; + private final String problem; + + /// @param provider the provider that was asked for, which is what [#name] + /// answers so that stored references keep naming it + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredPayments(String provider, String problem) { + this.provider = provider; + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public String name() { + return provider; + } + + @Override + public String createCustomer(String username, String displayName) throws IOException { + throw fail(); + } + + @Override + public void forgetCustomer(String customer) throws IOException { + throw fail(); + } + + @Override + public Setup startCardSetup(String customer, String setupId) throws IOException { + throw fail(); + } + + @Override + public Card finishCardSetup(String customer, String reference, CardDto typed) + throws IOException { + throw fail(); + } + + @Override + public void detachCard(String customer, String token) throws IOException { + throw fail(); + } + + @Override + public String charge(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + throw fail(); + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + throw fail(); + } + + @Override + public String refundStatus(String refundReference) throws IOException { + throw fail(); + } + + private IOException fail() { + System.err.println("[config] a payment call was refused: " + problem); + return new IOException(problem); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PayReturnPage.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PayReturnPage.java new file mode 100644 index 00000000000..2ba8e47ab64 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PayReturnPage.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +/// The page a hosted card setup ends on. +/// +/// The payment processor's page sends the browser here when the user has added +/// a card or given up. There is nothing to do here but say so: the app is what +/// finishes the setup, by asking the server to read it back, once the user +/// returns to it. That is also why the page needs no sign-in and takes nothing +/// from its address but which of two sentences to show -- being sent here +/// proves nothing and changes nothing. +@RestController +public class PayReturnPage { + @GetMapping("/pay/return") + public HttpServer.Response page( + @RequestParam(value = "result", required = false) String result) throws Exception { + boolean done = "done".equals(result); + String html = "" + + "" + + "Wayline" + + "

" + (done ? "Card added" : "No card was added") + "

" + + (done ? "You can close this page and go back to Wayline to finish." + : "You can close this page and go back to Wayline to try again.") + + "

"; + return new HttpServer.Response(200, "text/html; charset=utf-8", html.getBytes("UTF-8")); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PaymentDeclinedException.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PaymentDeclinedException.java new file mode 100644 index 00000000000..870d19e0818 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PaymentDeclinedException.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import java.io.IOException; + +/// A card was refused, or a setup was not finished: the processor was asked +/// and said no. The message is written for the person paying and is safe to +/// show them -- unlike that of a plain `IOException` from a provider, which +/// describes a fault and goes to the log. +public class PaymentDeclinedException extends IOException { + public PaymentDeclinedException(String message) { + super(message); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PaymentEndpoint.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PaymentEndpoint.java new file mode 100644 index 00000000000..6c63bdfbe23 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PaymentEndpoint.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.wayline.Caller; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.api.CardDto; +import com.codenameone.examples.wayline.api.PaymentApiServer; +import com.codenameone.examples.wayline.api.PaymentConfigDto; +import com.codenameone.examples.wayline.api.PaymentMethodDto; +import com.codenameone.examples.wayline.api.PaymentSetupDto; +import com.codenameone.examples.wayline.api.ReceiptDto; +import com.codenameone.examples.wayline.api.TipDto; +import com.codenameone.examples.wayline.ride.Fares; + +import java.util.List; + +/// The server's half of `PaymentApi`. Every method acts as the signed-in user +/// and on that user's own cards and receipts: whose they are is never a +/// parameter, so there is no id to change to reach someone else's. +@Component +public class PaymentEndpoint implements PaymentApiServer { + private final Payments payments; + private final Accounts accounts; + private final Fares fares; + + public PaymentEndpoint(Payments payments, Accounts accounts, Fares fares) { + this.payments = payments; + this.accounts = accounts; + this.fares = fares; + } + + @Override + public PaymentConfigDto config() throws Exception { + Caller.name(); + return payments.config(fares.currency()); + } + + @Override + public List methods() throws Exception { + return payments.methods(Caller.name()); + } + + @Override + public PaymentSetupDto startSetup() throws Exception { + String who = Caller.name(); + return payments.startSetup(who, accounts.describe(who).displayName()); + } + + @Override + public PaymentMethodDto completeSetup(String id, CardDto card) throws Exception { + return payments.completeSetup(Caller.name(), id, card); + } + + @Override + public PaymentMethodDto makeDefault(String id) throws Exception { + return payments.makeDefault(Caller.name(), id); + } + + @Override + public PaymentMethodDto remove(String id) throws Exception { + return payments.remove(Caller.name(), id); + } + + @Override + public List receipts() throws Exception { + return payments.receipts(Caller.name()); + } + + @Override + public ReceiptDto receipt(String rideId) throws Exception { + return payments.receipt(Caller.name(), false, rideId); + } + + @Override + public ReceiptDto tip(String id, TipDto tip) throws Exception { + return payments.tip(Caller.name(), id, tip == null ? 0L : tip.amount()); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PaymentProvider.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PaymentProvider.java new file mode 100644 index 00000000000..2d4a510df50 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/PaymentProvider.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import com.codenameone.examples.wayline.api.CardDto; + +import java.io.IOException; + +/// A payment processor. The one place the server touches one. +/// +/// The shape is the one real processors share. A rider is a *customer* of the +/// processor; a card is saved to that customer once, in a *setup*, and comes +/// back as a token; from then on the server charges the token with nobody +/// present, which is what lets a ride be paid for as it ends. The server keeps +/// the token, a brand and four digits. It never keeps a card number, and with +/// a hosted setup it never sees one. +// tag::wayline-payment-provider[] +public interface PaymentProvider { + /// `simulated` or `stripe`: what the app is told, so it knows how to add a + /// card. + String name(); + + /// Opens the processor's record of a rider and returns its reference. + String createCustomer(String username, String displayName) throws IOException; + + /// Deletes the processor's record of a rider, and the cards saved to it + /// with it. A record that is already gone is deleted: this is asked again + /// when the first answer was lost. + void forgetCustomer(String customer) throws IOException; + + /// Begins saving a card to `customer`. `setupId` is the server's own id + /// for the attempt, for the processor to hand back. + Setup startCardSetup(String customer, String setupId) throws IOException; + + /// Finishes a setup and returns the card it saved. + /// + /// @param reference what [#startCardSetup] returned as the setup's reference + /// @param typed the card as typed into the app, for a processor whose setup + /// is not hosted; ignored by one whose setup is + /// - `PaymentDeclinedException`: when the card was refused or the setup was + /// not finished + Card finishCardSetup(String customer, String reference, CardDto typed) throws IOException; + + /// Takes a saved card off `customer`, so that it can't be charged again. + /// A card that is already off, or gone, is detached: this is asked again + /// when the first answer was lost. + /// + /// @param token what [#finishCardSetup] returned as the card's token + void detachCard(String customer, String token) throws IOException; + + /// Charges a saved card with its owner absent. + /// + /// @param key names this charge and no other. A processor asked twice with + /// the same key charges once, so a request repeated after a timeout + /// cannot take the money twice. + /// @return the processor's reference for the charge + /// - `PaymentDeclinedException`: when the card was refused + /// - `IOException`: when the processor could not be asked + String charge(String key, String customer, String token, long amountCents, String currency, + String description) throws IOException; + + /// Returns `amountCents` of a charge to the card it came from. + /// + /// A processor may take the refund and only later say whether the bank + /// returned the money: such a refund comes back [Refund#pending], and is + /// asked about with [#refundStatus] until it has an answer. + /// + /// @return the refund as the processor took it + /// - `IOException`: when the processor could not be asked, or refused the + /// refund outright + Refund refund(String chargeReference, long amountCents) throws IOException; + + /// What became of a refund that was [Refund#pending]: [#REFUND_SUCCEEDED], + /// [#REFUND_PENDING] while the processor still does not know, or + /// [#REFUND_FAILED] when the money did not go back. + String refundStatus(String refundReference) throws IOException; + // end::wayline-payment-provider[] + + String REFUND_SUCCEEDED = "succeeded"; + String REFUND_PENDING = "pending"; + String REFUND_FAILED = "failed"; + + /// A refund the processor took. + final class Refund { + public String reference = ""; + /// Whether the money is not yet known to be back on the card. + public boolean pending; + } + + /// A setup under way. + final class Setup { + /// Whether the card is typed into the processor's own page, at `url`. + public boolean hosted; + public String url = ""; + /// What to hand to [PaymentProvider#finishCardSetup]. + public String reference = ""; + } + + /// A saved card: what may be shown of it, and the token it is charged by. + final class Card { + public String brand = ""; + public String last4 = ""; + public int expMonth; + public int expYear; + public String token = ""; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/Payments.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/Payments.java new file mode 100644 index 00000000000..04a4b247ab4 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/Payments.java @@ -0,0 +1,1116 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.api.CardDto; +import com.codenameone.examples.wayline.api.PaymentConfigDto; +import com.codenameone.examples.wayline.api.PaymentMethodDto; +import com.codenameone.examples.wayline.api.PaymentSetupDto; +import com.codenameone.examples.wayline.api.ReceiptDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.domain.Payment; +import com.codenameone.examples.wayline.domain.PaymentCustomer; +import com.codenameone.examples.wayline.domain.PaymentMethod; +import com.codenameone.examples.wayline.domain.PaymentSetup; +import com.codenameone.examples.wayline.domain.Profile; +import com.codenameone.examples.wayline.domain.Ride; +import com.codenameone.examples.wayline.live.LiveHub; +import com.codenameone.examples.wayline.repository.PaymentCustomerRepository; +import com.codenameone.examples.wayline.repository.PaymentMethodRepository; +import com.codenameone.examples.wayline.repository.PaymentRepository; +import com.codenameone.examples.wayline.repository.PaymentSetupRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; +import com.codenameone.examples.wayline.repository.RideRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The ways a rider can pay, and what each ride came to. +/// +/// A ride is paid for when it completes: the fare and the service fee, charged +/// to the method the ride was asked for with. A tip is a second charge, made +/// when the rider leaves one. A ride paid in cash is recorded and nothing is +/// charged. What the driver earns from all this is `driver/Earnings`' business. +/// +/// Everything here that takes a `username` acts on that user's own methods and +/// rides and no one else's; the endpoints pass the signed-in user. +/// +/// The processor is somewhere else, and a call to it can take seconds, so none +/// is made with a transaction open. Whatever talks to it is three steps: a +/// transaction that reads, or claims, what the call needs and commits; the +/// call; and a transaction that records what came of it, or gives the claim +/// back. +@Component +public class Payments { + public static final String PENDING = "PENDING"; + /// How long a completed ride's charge goes on being asked for. Short of + /// the day a processor remembers the key a charge was first sent under. + public static final long RETRY_MILLIS = 23L * 60L * 60L * 1000L; + public static final String PAID = "PAID"; + public static final String FAILED = "FAILED"; + public static final String REFUNDED = "REFUNDED"; + /// Refunded at the processor, which has not yet said that the money is + /// back on the card. See [#settleRefunds]. + public static final String REFUND_PENDING = "REFUND_PENDING"; + /// A refund the processor took and then failed or canceled: the rider has + /// not had the money back. + public static final String REFUND_FAILED = "REFUND_FAILED"; + /// The id and the label of the method every account has. + public static final String CASH = "cash"; + public static final String CASH_LABEL = "Cash"; + private static final int MAX_CARDS = 8; + /// A setup not finished within this long is abandoned. + private static final long SETUP_MILLIS = 3600000L; + /// How many setups an account may have open at once. Each is a session + /// made at the processor and a row here; someone adding a card has one, + /// or two after giving up on the first. + private static final int MAX_OPEN_SETUPS = 5; + private static final long MAX_TIP_CENTS = 20000L; + /// The states of a ride that has not ended. + private static final RideState[] UNDER_WAY = {RideState.REQUESTED, RideState.OFFERED, + RideState.ACCEPTED, RideState.ARRIVED, RideState.IN_PROGRESS}; + + private final PaymentMethodRepository cards; + private final PaymentSetupRepository setups; + private final PaymentCustomerRepository customers; + private final PaymentRepository payments; + private final RideRepository rides; + private final ProfileRepository profiles; + private final PaymentProvider provider; + private final LiveHub live; + + public Payments(PaymentMethodRepository cards, PaymentSetupRepository setups, + PaymentCustomerRepository customers, PaymentRepository payments, + RideRepository rides, ProfileRepository profiles, PaymentProvider provider, + LiveHub live) { + this.cards = cards; + this.setups = setups; + this.customers = customers; + this.payments = payments; + this.rides = rides; + this.profiles = profiles; + this.provider = provider; + this.live = live; + } + + public PaymentConfigDto config(String currency) { + return new PaymentConfigDto(provider.name(), currency); + } + + // --------------------------------------------------------------- methods + + /// The saved cards, oldest first, then cash. Exactly one of them is the + /// default: a card when one is marked, cash otherwise. + @Transactional(readOnly = true) + public List methods(String username) throws IOException { + List rows = cards.findByUsernameOrderByCreatedAtAscIdAsc(username); + List out = new ArrayList(); + boolean marked = false; + for (int iter = 0; iter < rows.size(); iter++) { + PaymentMethodDto dto = card(rows.get(iter)); + marked |= dto.isDefault(); + out.add(dto); + } + out.add(cash(!marked)); + return out; + } + + /// The id of the user's default method. + @Transactional(readOnly = true) + public String defaultMethod(String username) throws IOException { + PaymentMethod row = cards.findFirstByUsernameAndPreferredTrue(username); + return row == null ? CASH : row.getId(); + } + + /// Begins adding a card. + public PaymentSetupDto startSetup(String username, String displayName) throws IOException { + String customer = customerForSetup(username); + String id = Ids.next(); + PaymentProvider.Setup setup; + try { + if (customer == null) { + String created = provider.createCustomer(username, displayName); + customer = keepCustomer(username, created); + if (!created.equals(customer)) { + // Not kept: the account was closed while the processor was + // asked (its clean-up found no customer to delete, so this + // one is ours to delete), or another request kept its own + // first. Either way the record made here would otherwise + // stay at the processor with the rider's name on it. + forgetQuietly(created); + } + if (customer == null) { + throw new ResponseStatusException(404, "No such account"); + } + } + setup = provider.startCardSetup(customer, id); + } catch (IOException failed) { + throw unavailable("card setup", failed); + } + keepSetup(username, id, setup.reference); + return new PaymentSetupDto(id, setup.hosted, setup.url); + } + + /// The rider's reference at the processor, or null when they have none + /// yet. Answers 409 for an account that has all the cards it may, and + /// 429 for one with as many setups open as it may have. + @Transactional(readOnly = true) + private String customerForSetup(String username) { + if (cards.countByUsername(username) >= MAX_CARDS) { + throw new ResponseStatusException(409, "Remove a card before adding another"); + } + // Refused before the processor is asked: every setup is a session + // made there, so a loop of these would otherwise make sessions + // without end in the service's name. + refuseTooManySetups(username); + PaymentCustomer row = customers.findById(username); + return row == null ? null : row.getReference(); + } + + /// Answers 429 when the account already has [#MAX_OPEN_SETUPS] setups + /// that are neither finished nor abandoned. + private void refuseTooManySetups(String username) { + if (setups.countByUsernameAndCompletedFalseAndCreatedAtGreaterThan(username, + System.currentTimeMillis() - SETUP_MILLIS) >= MAX_OPEN_SETUPS) { + throw new ResponseStatusException(429, + "Too many cards are being added. Finish one, or try again later"); + } + } + + /// Deletes the setups nobody can use any more: the finished ones, whose + /// card is saved, and the abandoned ones. Without it every setup ever + /// started would stay, one row for each tap of "Add card". + /// + /// An abandoned setup is kept for twice the time it can be finished in. + /// One finished just inside the hour is still at the processor when the + /// hour passes, and its row is what saves the card when the answer comes. + @Scheduled(fixedDelay = 600000, initialDelay = 60000, lock = "wayline-card-setups") + @Transactional + public void sweepSetups() throws IOException { + setups.deleteByCompletedTrue(); + setups.deleteByCreatedAtLessThan(System.currentTimeMillis() - 2L * SETUP_MILLIS); + } + + /// Keeps the reference the processor made for a rider on first use, and + /// returns the one the account has: another request may have kept its own + /// while this one was asking for it. Null when the account is gone. + @Transactional + private String keepCustomer(String username, String reference) { + // Two first cards started together both find no row. Taking turns on + // the account lets the second see the first's row and use it, where + // it would otherwise insert a second and fail on the key. With no + // profile to hold the account was closed meanwhile, and its clean-up + // has already run: a row kept now would never be deleted. + if (profiles.hold(username) == 0) { + return null; + } + PaymentCustomer row = customers.findById(username); + if (row == null) { + row = new PaymentCustomer(); + row.setUsername(username); + row.setReference(reference); + customers.insert(row); + } + return row.getReference(); + } + + @Transactional + private void keepSetup(String username, String id, String reference) { + // As in keepCustomer: an account closed while the processor was asked + // gets no row its clean-up has already missed. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + // Counted again with the account held: several requests sent together + // all pass the count made before the processor was asked. + // + // The session a refusal here leaves at the processor is not cleaned + // up, on purpose. Its address is never answered, so nobody can open + // it; a card entered there anyway could not be saved, because saving + // claims this row and there is none; and the processor expires an + // unfinished session by itself. Only the requests already past the + // first count when the limit was reached can leave one, so a loop + // of them still makes no more than a handful. + refuseTooManySetups(username); + PaymentSetup row = new PaymentSetup(); + row.setId(id); + row.setUsername(username); + row.setReference(reference); + row.setCreatedAt(System.currentTimeMillis()); + row.setCompleted(false); + setups.insert(row); + } + + /// Finishes adding a card and returns it. The first card an account saves + /// becomes its default: someone who adds a card means to pay with it. + /// Answers 402 when the card was refused, with the reason. + public PaymentMethodDto completeSetup(String username, String setupId, CardDto typed) + throws IOException { + String[] references = openSetup(username, setupId); + PaymentProvider.Card card; + try { + card = provider.finishCardSetup(references[0], references[1], typed); + } catch (PaymentDeclinedException declined) { + throw new ResponseStatusException(402, declined.getMessage()); + } catch (IOException failed) { + throw unavailable("card setup", failed); + } + try { + return keepCard(username, setupId, Ids.next(), card); + } catch (ResponseStatusException refused) { + // The processor attached the card when the setup finished. Refused + // here by the card limit -- two setups finishing together for the + // last slot, or a slot filled meanwhile -- it has no row, so the + // rider has nothing to remove it through: it is detached here + // instead. keepCard has rolled back by now, so this runs outside + // its transaction and nothing undoes it. + // + // Undone after the fact rather than a slot reserved before the + // processor is asked: a reservation is one more row with a life of + // its own, given back when the processor declines, times out or + // the server stops between the two, and swept when nothing gives + // it back. A refusal is the rare case, and this costs only it. + // + // Only the limit's 409. The 404 is a setup claimed by a request + // sent twice, whose other copy may have saved this very card. + if (refused.getStatus() == 409) { + discard(username, references[0], card); + } + throw refused; + } + } + + /// Detaches a card the processor attached that this server did not keep. + /// A failure is logged and not answered: the rider's answer is the refusal + /// that brought us here, and the card is no worse off than before. + private void discard(String username, String customer, PaymentProvider.Card card) { + try { + provider.detachCard(customer, card.token); + } catch (IOException failed) { + System.err.println("a card refused for " + username + " is still attached at the " + + "processor, as " + card.token + ": " + failed.getMessage()); + } + } + + /// What the processor calls the rider and a setup of theirs that is still + /// open, in that order. Answers 404 for a setup that is not. + @Transactional(readOnly = true) + private String[] openSetup(String username, String setupId) { + PaymentSetup setup = setupId == null || setupId.length() == 0 || setupId.length() > 32 + ? null : setups.findFirstByIdAndUsername(setupId, username); + if (setup == null || setup.isCompleted() + || setup.getCreatedAt() < System.currentTimeMillis() - SETUP_MILLIS) { + throw new ResponseStatusException(404, "No such card setup"); + } + PaymentCustomer customer = customers.findById(username); + return new String[] {customer == null ? "" : customer.getReference(), setup.getReference()}; + } + + /// Closes the setup and saves its card, together or not at all. + @Transactional + private PaymentMethodDto keepCard(String username, String setupId, String id, + PaymentProvider.Card card) { + // Claimed with a conditional update, so a request sent twice saves the + // card once. + if (setups.claim(setupId) != 1) { + throw new ResponseStatusException(404, "No such card setup"); + } + // Counted again here, where the card is saved: starting a setup checks + // the limit too, but several can be started while there is room for + // one. The account's row at the processor is written first, so two + // setups finishing together take turns and the second counts the + // first one's card. Refusing rolls the claim back with everything else. + customers.hold(username); + if (cards.countByUsername(username) >= MAX_CARDS) { + throw new ResponseStatusException(409, "Remove a card before adding another"); + } + boolean first = cards.findFirstByUsernameAndPreferredTrue(username) == null; + // Cards are listed in the order they were added, and two added in the + // same millisecond would have no order: the later is stamped after + // the one before it. + Long newest = cards.newestAt(username); + long after = newest == null ? 0L : newest.longValue() + 1L; + PaymentMethod row = new PaymentMethod(); + row.setId(id); + row.setUsername(username); + row.setBrand(cut(card.brand, 24)); + row.setLast4(cut(card.last4, 4)); + row.setExpMonth(card.expMonth); + row.setExpYear(card.expYear); + row.setToken(card.token); + row.setPreferred(first); + row.setCreatedAt(Math.max(System.currentTimeMillis(), after)); + cards.insert(row); + return card(row); + } + + /// Makes a method the one rides are paid with unless they say otherwise. + @Transactional + public PaymentMethodDto makeDefault(String username, String id) throws IOException { + // One at a time for an account: two choices made together would each + // clear the cards it had read and mark its own, and leave two marked. + profiles.hold(username); + PaymentMethod chosen = CASH.equals(id) ? null : owned(username, id); + // The cards are few, and each is written only if its mark changed. + List rows = cards.findByUsernameOrderByCreatedAtAscIdAsc(username); + for (int iter = 0; iter < rows.size(); iter++) { + PaymentMethod row = rows.get(iter); + row.setPreferred(chosen != null && row.getId().equals(chosen.getId())); + } + if (chosen == null) { + return cash(true); + } + chosen.setPreferred(true); + return card(chosen); + } + + /// Forgets a card, here and at the processor. Cash cannot be removed, and + /// neither can the card a ride under way is to be paid with. + /// + /// The card is detached at the processor before its row is deleted, and + /// inside the transaction that deletes it, as [#forget] deletes a + /// customer: the token is the only way back to the card, so a processor + /// that cannot be reached fails the removal and the card stays, to be + /// removed again. Detaching a card that is already detached succeeds, so + /// a removal whose commit failed after the processor answered finishes + /// the second time. The account's row is held across the request, which + /// holds back only that rider's own ride requests, and removing a card is + /// rare enough for that. + @Transactional + public PaymentMethodDto remove(String username, String id) throws IOException { + if (CASH.equals(id)) { + throw new ResponseStatusException(400, "Cash is always available"); + } + // Asking for a ride holds the same row before it picks its card. So a + // ride and a removal sent together take turns: either the ride is + // there to be seen below, or it no longer finds the card. + profiles.hold(username); + PaymentMethod row = owned(username, id); + // What is answered is the card as it was, no longer anyone's default. + PaymentMethodDto dto = card(row, false); + if (rides.existsByRiderAndMethodIdAndStateIn(username, id, UNDER_WAY)) { + throw new ResponseStatusException(409, "That card is paying for a ride under way"); + } + // A ride that ended with nobody able to say whether its card was + // charged is asked about again, by the card's token. With the card + // gone the question could not be put: the ride would be written down + // as unpaid, whether or not the first attempt took the money. + if (awaitsCharge(username, id, retriedSince())) { + throw new ResponseStatusException(409, + "That card is still paying for a ride. Try again in a few minutes"); + } + PaymentCustomer customer = customers.findById(username); + if (customer != null && customer.getReference().length() > 0) { + try { + provider.detachCard(customer.getReference(), row.getToken()); + } catch (IOException failed) { + // A runtime exception, so the transaction rolls back with it. + throw unavailable("card removal", failed); + } + } + cards.deleteById(row.getId()); + return dto; + } + + /// The method a ride is asked for with: the one named, or the default when + /// none is. Answers 400 for a method that is not the rider's. + /// + /// @return the method's id and its label, in that order + @Transactional(readOnly = true) + public String[] choose(String username, String wanted) throws IOException { + String id = wanted == null || wanted.trim().length() == 0 ? defaultMethod(username) + : wanted.trim(); + if (CASH.equals(id)) { + return new String[] {CASH, CASH_LABEL}; + } + PaymentMethod row = card(username, id); + if (row == null) { + throw new ResponseStatusException(400, "Choose a way to pay"); + } + return new String[] {id, label(row)}; + } + + /// Everything of a user's that this class keeps, for an account being + /// closed, here and at the processor. Answers 409 while a ride of theirs + /// is still to be charged; see [#remove]. + /// + /// The processor is asked last, and inside the transaction that deletes + /// the rest. It holds the rider's address, their name and their cards, and + /// the reference deleted here is the only way back to them: forgotten + /// first, a request that then failed would leave that record with nothing + /// pointing at it. So a processor that cannot be reached fails the closure + /// whole and the account stays as it was, to be closed again. The other + /// way round -- the processor has deleted and the commit then fails -- is + /// mended by the same second attempt, since a customer already gone counts + /// as deleted. A closure is rare enough to wait on a request with a row + /// held, which nothing else in this class does. + @Transactional(rollbackFor = IOException.class) + public void forget(String username) throws IOException { + // Only charges still being retried hold a closure up. One left + // pending past the retry window has already been handed to a person + // to settle against the processor's own records, which keep the + // charge and the card used after the customer is deleted here. + // Counting it would refuse the closure for good over something the + // rider can do nothing about. + if (awaitsCharge(username, null, retriedSince())) { + throw new ResponseStatusException(409, + "A ride is still being paid for. Try again in a few minutes"); + } + PaymentCustomer customer = customers.findById(username); + String reference = customer == null ? "" : customer.getReference(); + cards.deleteByUsername(username); + setups.deleteByUsername(username); + customers.deleteByUsername(username); + if (reference != null && reference.length() > 0) { + provider.forgetCustomer(reference); + } + } + + private static long retriedSince() { + return System.currentTimeMillis() - RETRY_MILLIS; + } + + // --------------------------------------------------------------- charges + + /// Settles a ride that has just completed and returns [#PAID], [#FAILED], + /// or [#PENDING] when nobody can say yet. + /// A ride is settled once: its payment is keyed by the ride, and a ride + /// completes once. Called with no transaction open, since the card is + /// charged here; what came of the charge is then written in one. + /// + /// Safe to ask for again, which is how a ride whose settlement was cut + /// short is finished later: the charge carries the ride as its key, so + /// the processor answers a second request with the first one's charge, and + /// a payment already written is the answer without asking anyone. + /// + /// Only a card the processor refused is a payment that failed. Any other + /// failure -- the processor not reached, its answer lost on the way back -- + /// says nothing about whether the card was charged, so nothing is written + /// and the ride stays pending for `Rides.settleLeftBehind` to ask again + /// under the same key. Written down as failed, a charge that did go through + /// would never be looked for, and the rider would have paid for a ride + /// that says they did not. + public String settle(Ride ride) throws IOException { + Payment kept = payment(ride.getId()); + if (kept != null) { + return kept.getStatus(); + } + long amount = ride.getFareCents() + ride.getFeeCents(); + String status = PAID; + String reference = CASH; + if (!CASH.equals(ride.getMethodId())) { + try { + reference = charge("ride-" + ride.getId(), ride.getRider(), ride.getMethodId(), amount, + ride.getCurrency(), "Wayline ride"); + } catch (PaymentDeclinedException declined) { + // The ride and the amount, never the card. + System.err.println("payment for ride " + ride.getId() + " failed: " + + declined.getMessage()); + status = FAILED; + reference = ""; + } catch (IOException unknown) { + System.err.println("payment for ride " + ride.getId() + " is not known yet: " + + unknown.getMessage()); + return PENDING; + } + } + keepPayment(ride.getId(), amount, ride.getCurrency(), status, reference); + return status; + } + + @Transactional + private void keepPayment(String rideId, long amount, String currency, String status, + String reference) { + if (payments.findById(rideId) != null) { + // Written while this request was at the processor. + return; + } + Payment row = new Payment(); + row.setRideId(rideId); + row.setAmountCents(amount); + row.setCurrency(currency); + row.setStatus(status); + row.setProviderRef(reference); + row.setCreatedAt(System.currentTimeMillis()); + payments.insert(row); + } + + /// Tips the driver of a completed ride, once. Charged to the method the + /// ride was paid with; a cash ride's tip is recorded as given in cash. + /// + /// The tip is taken off the ride again only when the card was refused. A + /// charge that failed any other way may have gone through, so the tip + /// stays on the ride with no charge recorded for it: the rider is told to + /// try again, and trying again with the same amount asks the processor + /// under the same key, which answers with the first charge if there was + /// one. Until then the ride cannot be refunded -- see [#claimRefund]. + public ReceiptDto tip(String rider, String rideId, long amountCents) throws IOException { + Ride ride = claimTip(rider, rideId, amountCents); + if (!CASH.equals(ride.getMethodId())) { + String reference; + try { + reference = charge("tip-" + rideId, rider, ride.getMethodId(), amountCents, + ride.getCurrency(), "Wayline tip"); + } catch (PaymentDeclinedException declined) { + releaseTip(rideId); + throw new ResponseStatusException(402, declined.getMessage()); + } catch (IOException unknown) { + throw unavailable("tip", unknown); + } + keepTip(rideId, reference); + } + // The driver's earnings changed; their app reads them again. + live.rideChanged(rideId, ride.getState(), rider, ride.getDriver(), ""); + return receipt(rider, false, rideId); + } + + /// Sets the tip on the rider's ride and returns the ride as it was just + /// before. Committed before the card is charged. + @Transactional + private Ride claimTip(String rider, String rideId, long amountCents) { + Ride ride = ride(rideId); + if (ride == null || !rider.equals(ride.getRider())) { + throw new ResponseStatusException(404, "No such ride"); + } + if (amountCents < 1L || amountCents > MAX_TIP_CENTS) { + throw new ResponseStatusException(400, "That is not an amount for a tip"); + } + // Not for a driver who has closed the account: the tip would be + // credited to a name nobody can sign in as or cash out. A closure can + // still land while this tip's charge is at the processor, since the + // balance counts a tip only once its charge is recorded; the driver + // would have to close in that second or two, just after cashing out. + // Holding closures until every tip in flight settles would need a + // state of its own, and one that never settles would hold the + // account open for good. + String driver = ride.getDriver(); + if (driver == null || driver.length() == 0 || profiles.findById(driver) == null) { + throw new ResponseStatusException(409, "That driver can no longer be tipped"); + } + // Claimed before the charge: of two requests, one changes the row and + // only that one charges. + if (rides.claimTip(rideId, rider, amountCents) != 1 && !tipLeftUnknown(ride, amountCents)) { + throw new ResponseStatusException(409, "That ride cannot be tipped now"); + } + return ride; + } + + /// Whether the ride already carries this very tip with no charge recorded + /// for it: one whose charge was asked for and never answered. Asking again + /// is safe only for the same amount, the key being the ride's. + private boolean tipLeftUnknown(Ride ride, long amountCents) { + if (ride.getTipCents() != amountCents || CASH.equals(ride.getMethodId()) + || !PAID.equals(ride.getPaymentStatus())) { + return false; + } + Payment payment = payments.findById(ride.getId()); + return payment != null && (payment.getTipRef() == null || payment.getTipRef().length() == 0); + } + + @Transactional + private void releaseTip(String rideId) { + rides.releaseTip(rideId); + } + + @Transactional + private void keepTip(String rideId, String reference) { + Payment payment = payments.findById(rideId); + if (payment != null) { + payment.setTipRef(reference); + } + } + + /// Returns what a ride cost to the card that paid for it, tip included. + /// For an admin. A cash ride has nothing to return, and a ride is refunded + /// once. + public ReceiptDto refund(String rideId, String reason) throws IOException { + Ride ride = claimRefund(rideId); + Payment payment = payment(rideId); + String why = cut(reason == null ? "" : reason.trim(), 255); + PaymentProvider.Refund fare; + PaymentProvider.Refund tip = null; + try { + fare = provider.refund(payment.getProviderRef(), payment.getAmountCents()); + } catch (IOException failed) { + releaseRefund(rideId); + throw unavailable("refund", failed); + } + if (payment.getTipRef().length() > 0) { + try { + tip = provider.refund(payment.getTipRef(), ride.getTipCents()); + } catch (IOException failed) { + // The fare is back with the rider already. Paid again, the + // ride would show that money as paid and its driver could cash + // it out; so the fare's refund is kept and the payment marked + // failed, which is what it is until a person with the + // processor's records settles the tip -- see settleRefunds. + keepPartialRefund(rideId, fare, why); + throw unavailable("tip refund", failed); + } + } + keepRefund(rideId, fare, tip, why); + live.rideChanged(rideId, ride.getState(), ride.getRider(), ride.getDriver(), ""); + return receipt("", true, rideId); + } + + /// Asks the processor how each refund it took and had not finished came + /// out, and records the ones that have an answer. + /// + /// A processor can take a refund and only say later whether the bank + /// returned the money; Stripe answers such a refund `pending`, and it can + /// still end `failed` or `canceled`. Recorded as refunded at once, a + /// refund that then failed would show the rider and the admin money that + /// never came back, and nothing would look at it again. So it is kept as + /// [#REFUND_PENDING] and asked about here until it ends: [#REFUNDED] once + /// the fare's refund and the tip's have both succeeded, [#REFUND_FAILED] + /// as soon as either has not. A failed refund is not tried again from + /// here. Part of the money may have gone back, and the processor may + /// answer a repeat with the refund that failed, so what to do about it is + /// for a person with the processor's records. + /// + /// Not a transaction itself, as `Rides.settleLeftBehind` is not: the + /// processor is asked with none open, each refund is recorded in one of + /// its own, and what went wrong with the first refund that could not be + /// asked about is reported once the rest have been. + @Scheduled(fixedDelay = 60000, initialDelay = 45000, lock = "wayline-refunds") + public void settleRefunds() throws IOException { + List pending = refundsPending(); + Exception failed = null; + for (int iter = 0; iter < pending.size(); iter++) { + Payment payment = pending.get(iter); + try { + String outcome = refundOutcome(payment); + Ride ride = outcome == null ? null : endRefund(payment.getRideId(), outcome); + if (ride != null) { + live.rideChanged(ride.getId(), ride.getState(), ride.getRider(), ride.getDriver(), ""); + } + } catch (IOException err) { + failed = failed == null ? err : failed; + } catch (RuntimeException err) { + failed = failed == null ? err : failed; + } + } + if (failed instanceof IOException) { + throw (IOException) failed; + } + if (failed instanceof RuntimeException) { + throw (RuntimeException) failed; + } + } + + /// How a pending refund came out: [#REFUNDED], [#REFUND_FAILED], or null + /// while the processor still does not know. + private String refundOutcome(Payment payment) throws IOException { + String fare = provider.refundStatus(payment.getRefundRef()); + String tip = payment.getTipRefundRef().length() == 0 ? PaymentProvider.REFUND_SUCCEEDED + : provider.refundStatus(payment.getTipRefundRef()); + if (PaymentProvider.REFUND_FAILED.equals(fare) + || PaymentProvider.REFUND_FAILED.equals(tip)) { + return REFUND_FAILED; + } + if (PaymentProvider.REFUND_SUCCEEDED.equals(fare) + && PaymentProvider.REFUND_SUCCEEDED.equals(tip)) { + return REFUNDED; + } + return null; + } + + @Transactional(readOnly = true) + private List refundsPending() { + return payments.findByStatusOrderByCreatedAtAsc(REFUND_PENDING, new PageRequest(0, 50)); + } + + /// Records how a refund ended and returns its ride, or null when another + /// pass recorded it first. + @Transactional + private Ride endRefund(String rideId, String outcome) { + if (payments.endRefund(rideId, outcome, + REFUNDED.equals(outcome) ? System.currentTimeMillis() : 0L) != 1) { + return null; + } + rides.paymentStatus(rideId, outcome); + return ride(rideId); + } + + /// Marks the ride refunded and returns it as it was just before. Committed + /// before the processor is asked for the money: of two requests, one + /// changes the row and only that one refunds. + /// + /// The update comes first and the ride is read after it. A tip is claimed + /// by an update of the same row, so the two take turns, and the ride read + /// here says whether a tip got in before this did. One that did and has no + /// charge recorded yet is still at the processor: refunding now would + /// return the fare, miss the tip, and leave a tip charged to a refunded + /// ride. The refund is refused until the tip has settled either way, and + /// a tip asked for after this commits finds the ride no longer paid. + /// + /// A tip whose charge never reports back keeps its ride from being + /// refunded here. That is deliberate: nobody knows whether that card was + /// charged, and it takes a person and the processor's records to say. + @Transactional + private Ride claimRefund(String rideId) { + boolean claimed = rides.claimRefund(rideId) == 1; + Ride ride = ride(rideId); + if (ride == null) { + throw new ResponseStatusException(404, "No such ride"); + } + if (CASH.equals(ride.getMethodId())) { + throw new ResponseStatusException(409, "A ride paid in cash has nothing to refund"); + } + if (REFUND_PENDING.equals(ride.getPaymentStatus())) { + throw new ResponseStatusException(409, + "That ride's refund is still with the payment processor"); + } + if (REFUND_FAILED.equals(ride.getPaymentStatus())) { + throw new ResponseStatusException(409, + "That ride's refund failed. Check it with the payment processor"); + } + if (!claimed && !refundUnfinished(ride)) { + throw new ResponseStatusException(409, "That ride has no payment to refund"); + } + if (ride.getTipCents() > 0L) { + Payment payment = payments.findById(rideId); + if (payment == null || payment.getTipRef() == null || payment.getTipRef().length() == 0) { + throw new ResponseStatusException(409, + "A tip on that ride is still being charged. Try again in a moment"); + } + } + return ride; + } + + /// Whether `ride` was marked refunded by a request that never recorded + /// the refund: the server stopped between asking the processor and + /// writing the answer down, so neither [#keepRefund] nor [#releaseRefund] + /// ran. Such a ride would otherwise be refunded in name only and refused + /// for good. It is refunded again instead, which is safe because a refund + /// is keyed by the charge it returns: the processor answers a repeat with + /// the refund it already made, and makes one if it never did. + /// + /// It cannot tell such a ride from one whose refund is still at the + /// processor, so it also lets a second request through while the first + /// is under way. That is safe for the same reason, and [#releaseRefund] + /// and [#keepRefund] keep the ride agreeing with the payment however the + /// two end. + private boolean refundUnfinished(Ride ride) { + if (!REFUNDED.equals(ride.getPaymentStatus())) { + return false; + } + Payment payment = payments.findById(ride.getId()); + return payment != null && !REFUNDED.equals(payment.getStatus()); + } + + @Transactional(readOnly = true) + private Payment payment(String rideId) { + return payments.findById(rideId); + } + + /// Gives back the claim of a refund the processor did not take: the ride + /// is paid again -- unless another request has recorded a refund of it + /// meanwhile. + /// + /// Two refunds can overlap: the second is let through by + /// [#refundUnfinished] while the first is at the processor, which is + /// what lets a refund cut short be asked for again. Should one of them + /// record a refund and the other then fail, putting the ride back to paid + /// would leave a refunded payment on a ride its driver is paid for, and + /// the fare would be cashed out after it went back to the rider. So the + /// payment decides: the ride is paid again only while its payment is. + /// + /// The ride's row is written first, as in [#keepRefund], so the two take + /// turns on it: whichever comes second sees what the first wrote. + @Transactional + private void releaseRefund(String rideId) { + if (rides.releaseRefund(rideId) != 1) { + // No longer marked refunded: a refund was recorded as pending. + return; + } + Payment payment = payments.findById(rideId); + if (payment != null && !PAID.equals(payment.getStatus())) { + rides.paymentStatus(rideId, payment.getStatus()); + } + } + + /// Records the refunds the processor took. Refunded when both are known + /// to have returned the money; otherwise the payment and its ride are + /// [#REFUND_PENDING], for [#settleRefunds] to finish. + /// + /// The ride is written as well, and first, although its claim already + /// marked it refunded: an overlapping request that failed may have put it + /// back to paid in the meantime. See [#releaseRefund]. + @Transactional + private void keepRefund(String rideId, PaymentProvider.Refund fare, + PaymentProvider.Refund tip, String reason) { + boolean pending = fare.pending || (tip != null && tip.pending); + rides.paymentStatus(rideId, pending ? REFUND_PENDING : REFUNDED); + Payment payment = payments.findById(rideId); + payment.setStatus(pending ? REFUND_PENDING : REFUNDED); + payment.setRefundRef(cut(fare.reference, 64)); + payment.setTipRefundRef(tip == null ? "" : cut(tip.reference, 64)); + payment.setRefundReason(reason); + payment.setRefundedAt(pending ? 0L : System.currentTimeMillis()); + } + + /// Records a refund of the fare whose tip could not be refunded: the ride + /// and its payment are [#REFUND_FAILED], with the fare's refund kept. + @Transactional + private void keepPartialRefund(String rideId, PaymentProvider.Refund fare, String reason) { + rides.paymentStatus(rideId, REFUND_FAILED); + Payment payment = payments.findById(rideId); + payment.setStatus(REFUND_FAILED); + payment.setRefundRef(cut(fare.reference, 64)); + payment.setTipRefundRef(""); + payment.setRefundReason(reason); + payment.setRefundedAt(0L); + } + + // -------------------------------------------------------------- receipts + + /// A rider's receipts, newest first: one for every ride that completed. + @Transactional(readOnly = true) + public List receipts(String rider) throws IOException { + return receipts(rides.findByRiderAndStateOrderByRequestedAtDesc(rider, RideState.COMPLETED, + new PageRequest(0, 50)), false); + } + + /// Every receipt, newest first, for the admin. + @Transactional(readOnly = true) + public List allReceipts() throws IOException { + return receipts(rides.findByStateOrderByRequestedAtDesc(RideState.COMPLETED, + new PageRequest(0, 200)), true); + } + + /// The receipt of one ride: what it will cost while it is under way, what + /// it cost once it completed. Answers 404 for a ride that is not the + /// viewer's, and for one that ended without completing, which cost nothing. + @Transactional(readOnly = true) + public ReceiptDto receipt(String viewer, boolean admin, String rideId) throws IOException { + Ride ride = ride(rideId); + RideState state = ride == null ? RideState.NONE : ride.getState(); + if (ride == null || !(admin || viewer.equals(ride.getRider())) + || RideState.CANCELLED_BY_RIDER == state || RideState.CANCELLED_BY_DRIVER == state + || RideState.CANCELLED_BY_ADMIN == state || RideState.NO_DRIVERS == state) { + throw new ResponseStatusException(404, "No such receipt"); + } + Payment payment = payments.findById(rideId); + return receipt(ride, payment == null ? 0L : payment.getCreatedAt(), admin, + new HashMap()); + } + + /// The rides' receipts. When each was paid is on its payment and not on + /// the ride, so the payments of all of them are read at once and matched + /// to their rides here. + private List receipts(List completed, boolean admin) { + Map paidAt = new HashMap(); + if (completed.size() > 0) { + List ids = new ArrayList(); + for (int iter = 0; iter < completed.size(); iter++) { + ids.add(completed.get(iter).getId()); + } + List rows = payments.findByRideIdIn(ids); + for (int iter = 0; iter < rows.size(); iter++) { + Payment payment = rows.get(iter); + paidAt.put(payment.getRideId(), Long.valueOf(payment.getCreatedAt())); + } + } + Map drivers = new HashMap(); + List out = new ArrayList(); + for (int iter = 0; iter < completed.size(); iter++) { + Ride ride = completed.get(iter); + Long at = paidAt.get(ride.getId()); + out.add(receipt(ride, at == null ? 0L : at.longValue(), admin, drivers)); + } + return out; + } + + private ReceiptDto receipt(Ride ride, long paidAt, boolean admin, + Map drivers) { + long fare = ride.getFareCents(); + long distanceFare = ride.getDistanceCents(); + long timeFare = ride.getTimeCents(); + // Whatever of the fare the other two parts do not account for, which + // for a ride asked for before the parts were kept is all of it. + long baseFare = fare - distanceFare - timeFare; + long serviceFee = ride.getFeeCents(); + long tip = ride.getTipCents(); + long discount = 0L; + String paid = ride.getPaymentStatus(); + boolean cash = CASH.equals(ride.getMethodId()); + String status; + if (PAID.equals(paid)) { + status = cash ? "cash" : "paid"; + } else if (FAILED.equals(paid)) { + status = "failed"; + } else if (REFUNDED.equals(paid)) { + status = "refunded"; + } else if (REFUND_PENDING.equals(paid)) { + status = "refund_pending"; + } else if (REFUND_FAILED.equals(paid)) { + status = "refund_failed"; + } else { + status = "pending"; + } + // Every refund state follows a payment that was made. + long paidOn = PAID.equals(paid) || (paid != null && paid.startsWith("REFUND")) + ? paidAt : 0L; + String driverName = ""; + String vehicle = ""; + String driver = ride.getDriver(); + if (driver.length() > 0 && RideState.OFFERED != ride.getState()) { + Profile who = drivers.get(driver); + if (who == null && !drivers.containsKey(driver)) { + who = profiles.findById(driver); + drivers.put(driver, who); + } + if (who != null) { + driverName = who.getDisplayName(); + vehicle = who.getVehicle(); + } + } + return new ReceiptDto(ride.getId(), ride.getCurrency(), baseFare, distanceFare, + timeFare, serviceFee, tip, discount, fare + serviceFee + tip - discount, + ride.getMethodLabel(), status, paidOn, ride.getPickupAddress(), + ride.getDropoffAddress(), ride.getDistanceMeters(), ride.getDurationSeconds(), + driverName, vehicle, admin ? ride.getRider() : ""); + } + + // ------------------------------------------------------------- internals + + /// Charges one of a rider's saved cards. What the charge needs is read in + /// a transaction that has ended by the time the processor is called. + private String charge(String key, String rider, String methodId, long amountCents, + String currency, String description) throws IOException { + String[] references = chargeable(rider, methodId); + if (references == null) { + throw new PaymentDeclinedException("The card is no longer on the account"); + } + return cut(provider.charge(key, references[0], references[1], amountCents, currency, + description), 64); + } + + /// What the processor calls the rider and one of their cards, in that + /// order, or null when either is gone. + @Transactional(readOnly = true) + private String[] chargeable(String rider, String methodId) { + PaymentMethod method = card(rider, methodId); + PaymentCustomer customer = customers.findById(rider); + if (method == null || customer == null) { + return null; + } + return new String[] {customer.getReference(), method.getToken()}; + } + + private PaymentMethod owned(String username, String id) { + PaymentMethod row = card(username, id); + if (row == null) { + // Someone else's card is told apart from no card by nobody. + throw new ResponseStatusException(404, "No such payment method"); + } + return row; + } + + private static PaymentMethodDto card(PaymentMethod row) { + return card(row, row.isPreferred()); + } + + private static PaymentMethodDto card(PaymentMethod row, boolean isDefault) { + return new PaymentMethodDto(row.getId(), "card", row.getBrand(), row.getLast4(), + row.getExpMonth(), row.getExpYear(), isDefault, label(row)); + } + + private static PaymentMethodDto cash(boolean isDefault) { + return new PaymentMethodDto(CASH, "cash", "", "", 0, 0, isDefault, CASH_LABEL); + } + + private static String label(PaymentMethod method) { + return method.getBrand() + " " + method.getLast4(); + } + + private static String cut(String text, int max) { + return text.length() > max ? text.substring(0, max) : text; + } + + /// Deletes a customer record this server made and did not keep. A failure + /// is logged rather than answered: the request has its own answer, and + /// the record holds no card. + private void forgetQuietly(String reference) { + try { + provider.forgetCustomer(reference); + } catch (IOException failed) { + System.err.println("could not delete an unused payment customer: " + + failed.getMessage()); + } + } + + /// A fault at the processor, as the app is told of it. What went wrong goes + /// to the log; it describes the request, never a card or a key. + private static ResponseStatusException unavailable(String what, IOException failed) { + System.err.println("payment provider failed during " + what + ": " + failed.getMessage()); + return new ResponseStatusException(502, "Payments are unavailable right now"); + } + + /// A ride, or null when there is none. An id that cannot be one is not + /// looked for. + private Ride ride(String id) { + if (id == null || id.length() == 0 || id.length() > 32) { + return null; + } + return rides.findById(id); + } + + /// One of an account's cards, or null when the id is not a card of theirs. + private PaymentMethod card(String username, String id) { + if (id == null || id.length() == 0 || id.length() > 32) { + return null; + } + return cards.findFirstByIdAndUsername(id, username); + } + + /// Whether a completed ride of the rider's is still to be charged to this + /// method -- or to any card, for a null `methodId` -- and will be asked + /// about again: its payment is pending and it ended after `since`. + /// + /// A card tip left unknown counts too, with no time limit: see [#tip]. + /// Its retry charges the ride's card under the same key, so with the card + /// gone a tip that may have been charged would be cleared as refused. + private boolean awaitsCharge(String rider, String methodId, long since) { + List tipped = rides.findByRiderAndPaymentStatusAndTipCentsGreaterThan(rider, PAID, + 0L); + for (Ride ride : tipped) { + if ((methodId == null || methodId.equals(ride.getMethodId())) + && tipLeftUnknown(ride, ride.getTipCents())) { + return true; + } + } + if (methodId == null) { + return rides.existsByRiderAndStateAndPaymentStatusAndUpdatedAtGreaterThanAndMethodIdNot( + rider, RideState.COMPLETED, PENDING, since, CASH); + } + return rides.existsByRiderAndStateAndPaymentStatusAndUpdatedAtGreaterThanAndMethodId( + rider, RideState.COMPLETED, PENDING, since, methodId); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/SimulatedPayments.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/SimulatedPayments.java new file mode 100644 index 00000000000..c6e102cce8d --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/SimulatedPayments.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import com.codenameone.examples.wayline.Days; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.api.CardDto; + +import java.io.IOException; + +/// A processor that moves no money. What the sample runs on until it is given +/// a real one's key, and what every test but that provider's own runs against. +/// +/// It behaves like the real thing where the app can tell the difference: a +/// card has to look like a card, and the well-known test numbers do what they +/// do at a real processor's test mode. +/// +/// - `4242 4242 4242 4242` is accepted, as is any other number that passes the +/// check digit. +/// - `4000 0000 0000 0002` is declined when it is added. +/// - `4000 0000 0000 0341` is accepted and then declined when it is charged, +/// which is how a payment that fails at the end of a ride is tried out. +/// - `4000 0000 0000 0119` is accepted and then cannot be charged at all: the +/// processor fails without saying whether it took the money, which is how +/// a payment nobody can vouch for is tried out. +/// - `4000 0000 0000 7726` is charged like any other, and a refund of it is +/// pending when it is made and has succeeded when it is next asked about, +/// which is how a refund the bank has not answered yet is tried out. +/// +/// A refund that is pending and then fails has no card number: a card whose +/// token starts `sim_pm_lost_` does that, and only a test makes one. +/// +/// The card number is read here, in this method, and goes no further: the +/// brand and the last four digits are all that is returned or stored. +public class SimulatedPayments implements PaymentProvider { + static final String DECLINED_NUMBER = "4000000000000002"; + static final String FAILS_LATER_NUMBER = "4000000000000341"; + static final String FAULTY_NUMBER = "4000000000000119"; + static final String SLOW_REFUND_NUMBER = "4000000000007726"; + private static final String FAILING_TOKEN = "sim_pm_fail_"; + private static final String FAULTY_TOKEN = "sim_pm_fault_"; + private static final String SLOW_REFUND_TOKEN = "sim_pm_slow_"; + private static final String LOST_REFUND_TOKEN = "sim_pm_lost_"; + private static final String SLOW_REFUND_CHARGE = "sim_ch_slow_"; + private static final String LOST_REFUND_CHARGE = "sim_ch_lost_"; + private static final String REFUND = "sim_re_"; + + @Override + public String name() { + return "simulated"; + } + + @Override + public String createCustomer(String username, String displayName) throws IOException { + return "sim_cus_" + Ids.next(); + } + + @Override + public Setup startCardSetup(String customer, String setupId) { + Setup setup = new Setup(); + setup.reference = "sim_seti_" + setupId; + return setup; + } + + @Override + public Card finishCardSetup(String customer, String reference, CardDto typed) + throws IOException { + if (typed == null) { + throw new PaymentDeclinedException("Enter the card's details"); + } + String number = digits(typed.number()); + if (number.length() < 12 || number.length() > 19 || !luhn(number)) { + throw new PaymentDeclinedException("That is not a card number"); + } + int year = typed.expYear() < 100 ? typed.expYear() + 2000 : typed.expYear(); + String today = Days.iso(System.currentTimeMillis()); + int thisYear = Integer.parseInt(today.substring(0, 4)); + int thisMonth = Integer.parseInt(today.substring(5, 7)); + if (typed.expMonth() < 1 || typed.expMonth() > 12 || year > thisYear + 25 + || year < thisYear || (year == thisYear && typed.expMonth() < thisMonth)) { + throw new PaymentDeclinedException("That card has expired"); + } + String cvc = typed.cvc() == null ? "" : typed.cvc().trim(); + if (cvc.length() < 3 || cvc.length() > 4 || digits(cvc).length() != cvc.length()) { + throw new PaymentDeclinedException("Check the card's security code"); + } + if (DECLINED_NUMBER.equals(number)) { + throw new PaymentDeclinedException("Your card was declined"); + } + Card card = new Card(); + card.brand = brand(number); + card.last4 = number.substring(number.length() - 4); + card.expMonth = typed.expMonth(); + card.expYear = year; + card.token = (FAILS_LATER_NUMBER.equals(number) ? FAILING_TOKEN + : FAULTY_NUMBER.equals(number) ? FAULTY_TOKEN + : SLOW_REFUND_NUMBER.equals(number) ? SLOW_REFUND_TOKEN : "sim_pm_") + Ids.next(); + return card; + } + + @Override + public void detachCard(String customer, String token) throws IOException { + // Nothing is kept of a card here; its token is all there is, and the + // server deletes that. + } + + @Override + public String charge(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + if (token.startsWith(FAILING_TOKEN)) { + throw new PaymentDeclinedException("Your card was declined"); + } + if (token.startsWith(FAULTY_TOKEN)) { + throw new IOException("The simulated processor did not answer"); + } + // A charge says how its refund is to go, as the card did: the refund + // has only the charge to go by. + return (token.startsWith(SLOW_REFUND_TOKEN) ? SLOW_REFUND_CHARGE + : token.startsWith(LOST_REFUND_TOKEN) ? LOST_REFUND_CHARGE : "sim_ch_") + key; + } + + @Override + public void forgetCustomer(String customer) { + // Nothing is kept of one here. + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + // Named after the charge, as a real processor's refund is keyed by + // it: asking twice is one refund, which is what lets a refund whose + // answer was lost be asked for again. + Refund refund = new Refund(); + refund.reference = REFUND + chargeReference; + refund.pending = chargeReference.startsWith(SLOW_REFUND_CHARGE) + || chargeReference.startsWith(LOST_REFUND_CHARGE); + return refund; + } + + @Override + public String refundStatus(String refundReference) throws IOException { + return refundReference.startsWith(REFUND + LOST_REFUND_CHARGE) ? REFUND_FAILED + : REFUND_SUCCEEDED; + } + + /// The digits of what was typed, with the spaces and dashes people put in a + /// card number taken out. Anything else in it leaves it too short to pass. + private static String digits(String value) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; value != null && iter < value.length(); iter++) { + char c = value.charAt(iter); + if (c >= '0' && c <= '9') { + out.append(c); + } else if (c != ' ' && c != '-') { + return ""; + } + } + return out.toString(); + } + + /// The check digit every card number carries, which catches a mistyped one. + private static boolean luhn(String number) { + int sum = 0; + boolean doubled = false; + for (int iter = number.length() - 1; iter >= 0; iter--) { + int digit = number.charAt(iter) - '0'; + if (doubled) { + digit *= 2; + if (digit > 9) { + digit -= 9; + } + } + sum += digit; + doubled = !doubled; + } + return sum % 10 == 0; + } + + private static String brand(String number) { + if (number.startsWith("4")) { + return "Visa"; + } + if (number.startsWith("34") || number.startsWith("37")) { + return "Amex"; + } + if (number.startsWith("6011") || number.startsWith("65")) { + return "Discover"; + } + int two = Integer.parseInt(number.substring(0, 2)); + if ((two >= 51 && two <= 55) || (two >= 22 && two <= 27)) { + return "Mastercard"; + } + return "Card"; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/StripePayments.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/StripePayments.java new file mode 100644 index 00000000000..75d698aaba6 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/pay/StripePayments.java @@ -0,0 +1,390 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.api.CardDto; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Payments by Stripe, over its REST API. No SDK: a few requests, form encoded, +/// with the account's secret key as a bearer token. +/// +/// **This is an untested stub.** It has never been run against a live Stripe +/// account: the requests follow Stripe's published API, and the tests hold it +/// to canned answers through [Transport], but nothing here has seen a real +/// card, a bank asking its cardholder to approve a charge, or a refund settle. +/// Treat it as a starting point, and run it against a Stripe test-mode key +/// before any money depends on it. +/// +/// Selected with `wayline.payments.provider=stripe`. +/// +/// - **A customer** is made the first time a rider adds a card. +/// - **A card is added on Stripe's own page.** [#startCardSetup] opens a +/// Checkout Session in `setup` mode and hands back its address; the app +/// opens it in the browser. The card is typed there, so its number never +/// reaches the app or this server, and neither falls under the card +/// industry's rules for systems that handle one. +/// - **The setup is read back** when the app says the user returned: the +/// session's SetupIntent names the PaymentMethod Stripe saved, and its brand +/// and last four digits are all this server keeps beside its id. +/// - **A ride is charged** with a PaymentIntent that is `off_session` and +/// confirmed in the same request, because the rider is in a car and not at a +/// payment form. +/// - **A refund** returns a charge to the card. +/// - **A card removed** in the app is detached from the customer, so Stripe +/// no longer offers it for a charge. +/// +/// The secret key is a setting of the server (`wayline.payments.stripe.secret`) +/// and is used for the `Authorization` header and nothing else: it is never +/// logged and never part of an error. +/// +/// Everything Stripe-specific is in [#form] strings and the reading of the +/// answers. The network is behind [Transport], which is what lets the tests +/// hold both to canned answers without a key. +public class StripePayments implements PaymentProvider { + private static final String API = "https://api.stripe.com"; + + private final Transport transport; + private final String returnUrl; + + /// @param secret the account's secret key + /// @param returnUrl the address of this server's `/pay/return` page, which + /// Stripe sends the browser to when the user is done + public StripePayments(String secret, String returnUrl) { + this(new WebTransport(secret), returnUrl); + } + + public StripePayments(Transport transport, String returnUrl) { + this.transport = transport; + this.returnUrl = returnUrl; + } + + @Override + public String name() { + return "stripe"; + } + + @Override + public String createCustomer(String username, String displayName) throws IOException { + // Two first cards started together both find the account without a + // customer and both ask for one. Only one reference is kept, and the + // other would be left at the processor with nothing pointing at it. + // With the same key the second request is answered with the first + // one's customer. The key is a digest, since the header is logged in + // places an address should not be, and it covers the name too: the + // processor refuses a key it has seen with other fields, and a rider + // who renamed themselves between two attempts must not be refused. + // The price is one orphan in a case that takes two mishaps: the + // answer to the first attempt is lost, and the rider renames + // themselves before trying again. The second customer is the one + // kept; the first holds no card and no money, and is left at the + // processor rather than looked up by address on every first card. + String key = "customer-" + Ids.sha256(username + "\n" + displayName); + Map made = ask("POST", "/v1/customers", form("email", username, "name", displayName, + "metadata[wayline_user]", username), key); + return id(made); + } + + @Override + public void forgetCustomer(String customer) throws IOException { + // Deleting a customer detaches its payment methods too. 404 is the + // answer for one deleted by a request whose answer never arrived. + Reply reply = transport.send("DELETE", "/v1/customers/" + + URLEncoder.encode(customer, "UTF-8"), null, ""); + if (reply.status != 404 && (reply.status < 200 || reply.status >= 300)) { + throw new IOException("The payment processor answered " + reply.status + + " to deleting a customer"); + } + } + + // tag::wayline-payment-hosted[] + @Override + public Setup startCardSetup(String customer, String setupId) throws IOException { + Map session = ask("POST", "/v1/checkout/sessions", form("mode", "setup", + "customer", customer, "payment_method_types[0]", "card", + "client_reference_id", setupId, + "success_url", returnUrl + "?result=done", + "cancel_url", returnUrl + "?result=cancelled"), ""); + Setup setup = new Setup(); + setup.hosted = true; + setup.reference = id(session); + setup.url = text(session, "url"); + if (setup.url.length() == 0) { + throw new IOException("The payment processor returned no page to open"); + } + return setup; + } + // end::wayline-payment-hosted[] + + @Override + public Card finishCardSetup(String customer, String reference, CardDto typed) + throws IOException { + Map session = ask("GET", "/v1/checkout/sessions/" + URLEncoder.encode(reference, "UTF-8") + + "?expand%5B%5D=setup_intent.payment_method", null, ""); + Object intent = session.get("setup_intent"); + Object method = intent instanceof Map ? ((Map) intent).get("payment_method") : null; + if (!"complete".equals(text(session, "status")) || !(intent instanceof Map) + || !"succeeded".equals(text((Map) intent, "status")) || !(method instanceof Map)) { + throw new PaymentDeclinedException("The card was not added. Try again."); + } + // The session is looked up by a reference this server stored for this + // rider, so this cannot differ -- unless something is very wrong, in + // which case the card is not attached to anybody. + if (!customer.equals(text(session, "customer"))) { + throw new IOException("The payment processor's session is for another customer"); + } + Card card = new Card(); + card.token = id((Map) method); + Object details = ((Map) method).get("card"); + if (details instanceof Map) { + card.brand = brand(text((Map) details, "brand")); + card.last4 = text((Map) details, "last4"); + card.expMonth = (int) number((Map) details, "exp_month"); + card.expYear = (int) number((Map) details, "exp_year"); + } + return card; + } + + @Override + public void detachCard(String customer, String token) throws IOException { + String path = "/v1/payment_methods/" + URLEncoder.encode(token, "UTF-8"); + Reply reply = transport.send("POST", path + "/detach", "", ""); + if ((reply.status >= 200 && reply.status < 300) || reply.status == 404) { + // Detached, or no such card: 404 is the answer for one deleted + // with its customer, or never there. + return; + } + // Stripe refuses to detach a card that is attached to nobody, which + // is what a second request finds after the first one's answer was + // lost. Rather than read that from an error's wording, the card is + // looked at: no longer this customer's, it is detached. + Reply now = transport.send("GET", path, null, ""); + if (now.status == 404) { + return; + } + if (now.status >= 200 && now.status < 300) { + Map method; + try { + method = Json.parseObject(now.body == null ? "" : now.body); + } catch (IOException unreadable) { + method = null; + } + if (method != null && !customer.equals(text(method, "customer"))) { + return; + } + } + throw new IOException("The payment processor answered " + reply.status + + " to detaching a card"); + } + + @Override + public String charge(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + Map intent = ask("POST", "/v1/payment_intents", form("amount", String.valueOf(amountCents), + "currency", asciiLower(currency), "customer", customer, "payment_method", token, + "off_session", "true", "confirm", "true", "description", description, + "metadata[wayline_charge]", key), key); + if (!"succeeded".equals(text(intent, "status"))) { + // Most often `requires_action`: the bank wants the cardholder to + // approve it, and there is nobody at a screen to do so. + throw new PaymentDeclinedException("The card needs its owner's approval"); + } + return id(intent); + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + Map answer = ask("POST", "/v1/refunds", form("payment_intent", chargeReference, + "amount", String.valueOf(amountCents)), "refund-" + chargeReference); + String status = refundState(text(answer, "status")); + if (REFUND_FAILED.equals(status)) { + throw new IOException("The refund was not accepted: " + text(answer, "status")); + } + Refund refund = new Refund(); + refund.reference = id(answer); + // A pending refund can still fail or be canceled, so it is not yet a + // refund to record as made: the caller keeps asking. + refund.pending = REFUND_PENDING.equals(status); + return refund; + } + + @Override + public String refundStatus(String refundReference) throws IOException { + return refundState(text(ask("GET", "/v1/refunds/" + + URLEncoder.encode(refundReference, "UTF-8"), null, null), "status")); + } + + /// Stripe's five refund states as three. `requires_action` waits on the + /// cardholder like `pending` waits on the bank, and `canceled` returned no + /// more money than `failed` did. A state this does not know is taken as + /// still pending: asked about again, it is never recorded as money back. + private static String refundState(String stripe) { + if ("succeeded".equals(stripe)) { + return REFUND_SUCCEEDED; + } + if ("failed".equals(stripe) || "canceled".equals(stripe)) { + return REFUND_FAILED; + } + return REFUND_PENDING; + } + + /// Makes a request and returns the object in the answer. + /// + /// A card Stripe refuses is answered with status 402 and an error of type + /// `card_error`, whose message is written for the cardholder; that becomes + /// a [PaymentDeclinedException]. Any other failure is a fault of ours or + /// theirs and is reported by status and code only. + private Map ask(String method, String path, String form, String key) throws IOException { + Reply reply = transport.send(method, path, form, key); + Map body; + try { + body = Json.parseObject(reply.body == null ? "" : reply.body); + } catch (IOException unreadable) { + throw new IOException("The payment processor answered " + reply.status + + " with something that is not JSON"); + } + if (reply.status >= 200 && reply.status < 300) { + return body; + } + Object error = body.get("error"); + if (error instanceof Map) { + Map details = (Map) error; + if ("card_error".equals(text(details, "type"))) { + String message = text(details, "message"); + throw new PaymentDeclinedException(message.length() > 0 ? message + : "Your card was declined"); + } + throw new IOException("The payment processor answered " + reply.status + " " + + text(details, "type") + " " + text(details, "code")); + } + throw new IOException("The payment processor answered " + reply.status); + } + + /// A form body from names and values in turn. Both are escaped: a name + /// such as `metadata[wayline_user]` has brackets in it. A space is written + /// as `+`, which a form body allows. + static String form(String... pairs) throws IOException { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < pairs.length; iter += 2) { + if (out.length() > 0) { + out.append('&'); + } + out.append(URLEncoder.encode(pairs[iter], "UTF-8")).append('=') + .append(URLEncoder.encode(pairs[iter + 1] == null ? "" : pairs[iter + 1], + "UTF-8")); + } + return out.toString(); + } + + private static String id(Map object) throws IOException { + String id = text(object, "id"); + if (id.length() == 0) { + throw new IOException("The payment processor's answer has no id"); + } + return id; + } + + private static String text(Map object, String key) { + Object value = object.get(key); + return value instanceof String ? (String) value : ""; + } + + private static long number(Map object, String key) { + Object value = object.get(key); + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + /// Stripe names brands in lower case (`visa`, `mastercard`, `amex`); this is + /// the name with its first letter raised, which is how a person writes it. + private static String brand(String name) { + if (name.length() == 0) { + return "Card"; + } + char first = name.charAt(0); + if (first >= 'a' && first <= 'z') { + first = (char) (first - ('a' - 'A')); + } + return first + name.substring(1); + } + + /// Stripe wants a currency code in lower case. Folded by hand, because + /// `toLowerCase()` follows the server's locale and a currency code must not. + private static String asciiLower(String text) { + char[] chars = text.toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + if (chars[iter] >= 'A' && chars[iter] <= 'Z') { + chars[iter] = (char) (chars[iter] + ('a' - 'A')); + } + } + return new String(chars); + } + + /// The network, as this class uses it: one request to the API, one answer. + public interface Transport { + /// @param path the path and query, from `/v1` + /// @param form the form-encoded body, or null for a request without one + /// @param idempotencyKey sent as `Idempotency-Key` when not empty, so a + /// request repeated with the same key is carried out once + Reply send(String method, String path, String form, String idempotencyKey) + throws IOException; + } + + /// An answer: its status and its body, whatever the status. + public static final class Reply { + public final int status; + public final String body; + + public Reply(int status, String body) { + this.status = status; + this.body = body; + } + } + + /// The real network: HTTPS to `api.stripe.com`, as [Web] makes it. + static final class WebTransport implements Transport { + private final String secret; + + WebTransport(String secret) { + this.secret = secret; + } + + @Override + public Reply send(String method, String path, String form, String idempotencyKey) + throws IOException { + List headers = new ArrayList(); + headers.add("Authorization: Bearer " + secret); + if (form != null) { + headers.add("Content-Type: application/x-www-form-urlencoded"); + } + if (idempotencyKey != null && idempotencyKey.length() > 0) { + headers.add("Idempotency-Key: " + idempotencyKey); + } + Web.Result answer = Web.request(method, API + path, headers, + form == null ? null : form.getBytes("UTF-8")); + return new Reply(answer.getStatus(), answer.getBodyAsString()); + } + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/CloudPushSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/CloudPushSender.java new file mode 100644 index 00000000000..2967fcec2f1 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/CloudPushSender.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.push; + +import com.codename1.backend.Tasks; +import com.codename1.backend.push.CloudPush; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushResult; +import com.codename1.backend.push.PushTarget; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +// tag::wayline-push-cloud[] +/// Sends through the Codename One push service, which delivers to Firebase, +/// Apple, Huawei and the browsers on the app's behalf. +/// +/// The call to the service is made on a thread of its own, so the request that +/// changed a ride answers without waiting for it, and a refusal is logged and +/// not thrown: the rider is still told over the live channel and on their next +/// look at the ride. +public class CloudPushSender implements PushSender { + private final CloudPush push; + + /// @param push the client for the service, holding the app's id and the + /// server key + public CloudPushSender(CloudPush push) { + this.push = push; + } + + @Override + public void send(List targets, final PushNotification message, + final Runnable delivered) { + if (targets == null || targets.isEmpty()) { + return; + } + final List to = new ArrayList(targets); + Tasks.platform(() -> deliver(to, message, delivered)); + } + + private void deliver(List to, PushNotification message, Runnable delivered) { + boolean accepted = false; + try { + PushResult result = push.send(message, to); + accepted = result.isAccepted(); + if (!accepted) { + System.err.println("[push] the push service refused a notification: " + + result.getStatus() + " " + result.getError()); + } + } catch (IOException | RuntimeException err) { + System.err.println("[push] a notification was not sent: " + err); + } + if (accepted && delivered != null) { + try { + delivered.run(); + } catch (RuntimeException err) { + System.err.println("[push] a notification was sent, and what follows it failed: " + + err); + } + } + } +} +// end::wayline-push-cloud[] diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/ConsolePushSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/ConsolePushSender.java new file mode 100644 index 00000000000..fc2f8ac5bf0 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/ConsolePushSender.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +/// Prints each notification on the server's console instead of sending it. +/// +/// For development only: [com.codenameone.examples.wayline.Services] hands it +/// out on a development profile alone. +public class ConsolePushSender implements PushSender { + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + if (targets == null || targets.isEmpty()) { + return; + } + System.out.println("[push] to " + targets.size() + " device(s): " + + message.toJson()); + // Printed is as delivered as this sender gets. + if (delivered != null) { + delivered.run(); + } + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/MisconfiguredPushSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/MisconfiguredPushSender.java new file mode 100644 index 00000000000..03d5dae1efc --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/MisconfiguredPushSender.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +/// What the server sends with when the push settings are incomplete: nothing +/// goes out, and the log says why. +/// +/// The problem is logged once when the server starts and again on every send, +/// naming the settings to fix. Unlike a text message, a notification that does +/// not go out costs nobody a sign-in, so it is not an error to the caller. +public class MisconfiguredPushSender implements PushSender { + private final String problem; + + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredPushSender(String problem) { + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + if (targets != null && !targets.isEmpty()) { + System.err.println("[config] a push notification was not sent: " + problem); + } + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/PushDevices.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/PushDevices.java new file mode 100644 index 00000000000..37fe75b134b --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/PushDevices.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.push; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.push.PushTarget; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.Text; +import com.codenameone.examples.wayline.api.PushDeviceDto; +import com.codenameone.examples.wayline.domain.PushDevice; +import com.codenameone.examples.wayline.repository.ProfileRepository; +import com.codenameone.examples.wayline.repository.PushDeviceRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +// tag::wayline-push-devices[] +/// The devices each account is sent notifications on. +/// +/// The app registers its token when somebody signs in and removes it when they +/// sign out. A token registered by a second account moves to it: the phone +/// belongs to whoever signed in on it last, and the one before is not to be +/// told about the new owner's rides. +@Component +public class PushDevices { + /// How many devices one account keeps. A new one past this replaces the + /// one heard from longest ago: a token the platform has replaced is never + /// removed by its app, and would otherwise be sent to for good. + static final int MAX_PER_ACCOUNT = 8; + + private final PushDeviceRepository devices; + private final ProfileRepository profiles; + + public PushDevices(PushDeviceRepository devices, ProfileRepository profiles) { + this.devices = devices; + this.profiles = profiles; + } + + @Transactional + public PushDeviceDto register(String username, PushDeviceDto device) throws IOException { + PushTarget target = target(device); + String id = key(target); + long now = System.currentTimeMillis(); + // Before anything is read, the account's profile row is held: a no-op + // update that answers 0 when the profile is gone and otherwise keeps + // the row locked until this commits. It settles two races. + // + // Against closing the account: Accounts.erase holds the same row + // first and deletes it before it commits, so a registration either + // runs first (and erase's cleanup then deletes the device with the + // rest) or waits, finds no profile and is refused. Without it, a + // registration authorized just before the closure could insert its + // device after erase removed the account's, leaving a device of an + // account that no longer exists and that nobody can unregister. + // + // Against itself: two devices registering at once for an account one + // short of the cap would both count the same rows in makeRoom(), both + // skip the eviction and both insert, one past the cap. Behind the hold + // the second counts after the first committed, and evicts. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + // The device row itself is not locked. Two accounts registering one + // token at the same moment -- a registration of the account that + // signed out still in flight as the next one signs in -- can leave it + // with the earlier account, but not for long: the app registers again + // each time it comes to the foreground, for whoever is signed in then. + PushDevice row = devices.findById(id); + if (row == null || !username.equals(row.getUsername())) { + // A device new to this account, whether its token is new or moves + // here from another account, takes a place under the cap. + makeRoom(username); + } + if (row == null) { + row = new PushDevice(); + row.setId(id); + row.setProvider(target.getProvider()); + row.setToken(target.getToken()); + fill(row, username, device, now); + devices.insert(row); + } else { + fill(row, username, device, now); + } + return new PushDeviceDto(target.getProvider(), target.getToken(), row.getPlatform()); + } + + /// Removes `username`'s devices heard from longest ago until one more fits + /// under [#MAX_PER_ACCOUNT]. + private void makeRoom(String username) { + List held = new ArrayList(devices.findByUsername(username)); + while (held.size() >= MAX_PER_ACCOUNT) { + PushDevice oldest = held.get(0); + for (PushDevice each : held) { + if (each.getUpdatedAt() < oldest.getUpdatedAt()) { + oldest = each; + } + } + devices.deleteById(oldest.getId()); + held.remove(oldest); + } + } + + /// Forgets a device, if it is this account's. Somebody else's is left alone. + @Transactional + public PushDeviceDto unregister(String username, PushDeviceDto device) throws IOException { + PushTarget target = target(device); + devices.deleteByIdAndUsername(key(target), username); + return new PushDeviceDto(target.getProvider(), target.getToken(), ""); + } + + /// Where to reach `username`: every device they registered. + @Transactional(readOnly = true) + public List targets(String username) { + return targetsOf(username); + } + + /// [#targets] for a caller already inside a transaction. + List targetsOf(String username) { + List out = new ArrayList(); + if (username == null || username.length() == 0) { + return out; + } + for (PushDevice each : devices.findByUsername(username)) { + out.add(PushTarget.of(each.getProvider(), each.getToken())); + } + return out; + } + // end::wayline-push-devices[] + + private static void fill(PushDevice row, String username, PushDeviceDto device, long now) { + row.setUsername(username); + row.setPlatform(Text.optional(device.platform(), 32, "the platform")); + row.setUpdatedAt(now); + } + + /// The device as a target of the push service, which also spells its + /// provider the one way: `gcm` and `fcm` are one device. + private static PushTarget target(PushDeviceDto device) { + if (device == null) { + throw new ResponseStatusException(400, "Tell us the device"); + } + String token = Text.required(device.token(), 1024, "the device's token"); + try { + return PushTarget.of(Text.required(device.provider(), 16, "the push provider"), token); + } catch (IllegalArgumentException err) { + throw new ResponseStatusException(400, err.getMessage()); + } + } + + private static String key(PushTarget target) throws IOException { + return Ids.sha256(target.getProvider() + ":" + target.getToken()); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/PushSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/PushSender.java new file mode 100644 index 00000000000..219d5aff523 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/PushSender.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +// tag::wayline-push-sender[] +/// Sends push notifications to devices. +/// +/// [com.codenameone.examples.wayline.Services] chooses the implementation from +/// `wayline.push.provider`. A notification is a courtesy on top of what the +/// app reads from the server, so sending one never fails the ride it is about: +/// an implementation logs what went wrong and returns. +public interface PushSender { + /// Sends `message` to every one of `targets`. Returns at once; the sending + /// itself may happen later, on another thread. + /// + /// @param delivered run once the push service has taken the message, on + /// whatever thread learnt it; never when it was refused or could not be + /// sent. Null when nobody needs to know. + void send(List targets, PushNotification message, Runnable delivered); +} +// end::wayline-push-sender[] diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/RecordingPushSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/RecordingPushSender.java new file mode 100644 index 00000000000..397a26268f8 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/RecordingPushSender.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Keeps every notification instead of sending it, for the tests to read. +/// +/// What the `test` profile gets: a test signs a phone in, drives a ride, and +/// asks this what each device was sent. Refused on any profile that is not a +/// development one. +public class RecordingPushSender implements PushSender { + private final List sent = new ArrayList(); + private int refusals; + private int unconfirmed; + + /// One notification as one device was sent it. + public static final class Sent { + private final String provider; + private final String token; + private final Map message; + + Sent(String provider, String token, Map message) { + this.provider = provider; + this.token = token; + this.message = message; + } + + public String provider() { + return provider; + } + + public String token() { + return token; + } + + /// The notification as it would go on the wire: `title`, `body`, + /// `data`, `surface` and the rest. + public Map message() { + return message; + } + } + + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + // Outside the lock: what follows a delivery writes to the database. + if (record(targets, message) && delivered != null) { + delivered.run(); + } + } + + /// Records a send, and answers whether the push service would confirm it. + private synchronized boolean record(List targets, PushNotification message) { + if (refusals > 0) { + refusals--; + throw new IllegalStateException("The push provider is unavailable"); + } + if (targets != null) { + for (PushTarget target : targets) { + sent.add(new Sent(target.getProvider(), target.getToken(), message.toMap())); + } + } + if (unconfirmed > 0) { + unconfirmed--; + return false; + } + return true; + } + + /// What the device with `token` was sent, oldest first. + public synchronized List sentTo(String token) { + List out = new ArrayList(); + for (Sent each : sent) { + if (each.token.equals(token)) { + out.add(each); + } + } + return out; + } + + /// Everything sent so far, oldest first. + public synchronized List all() { + return new ArrayList(sent); + } + + public synchronized void clear() { + sent.clear(); + refusals = 0; + unconfirmed = 0; + } + + /// Makes the next `count` sends go out and never be confirmed, as a + /// service that refuses them later, on its own thread, does. + public synchronized void leaveUnconfirmed(int count) { + unconfirmed = count; + } + + /// Makes the next `count` sends throw, as a provider that is down does. + public synchronized void refuse(int count) { + refusals = count; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/RidePushes.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/RidePushes.java new file mode 100644 index 00000000000..a1a6788db68 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/push/RidePushes.java @@ -0,0 +1,543 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.push; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.wayline.Text; +import com.codenameone.examples.wayline.api.RideActivityKeys; +import com.codenameone.examples.wayline.api.RideActivityDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.domain.DriverState; +import com.codenameone.examples.wayline.domain.Preference; +import com.codenameone.examples.wayline.domain.Profile; +import com.codenameone.examples.wayline.domain.Ride; +import com.codenameone.examples.wayline.domain.RideActivity; +import com.codenameone.examples.wayline.repository.DriverStateRepository; +import com.codenameone.examples.wayline.repository.PreferenceRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; +import com.codenameone.examples.wayline.repository.RideActivityRepository; +import com.codenameone.examples.wayline.repository.RideRepository; +import com.codenameone.examples.wayline.ride.Rides; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The push notifications a ride sends, and the Live Activity it keeps up to +/// date on the rider's lock screen. +/// +/// [Rides] calls [#rideChanged] once a change has been committed, the same +/// moment it tells the live channel; [com.codenameone.examples.wayline.driver.Drivers] +/// calls [#driverMoved] for every position a driver on a ride reports. A +/// notification is a courtesy on top of what the app reads from the server, +/// so nothing here ever fails the change it is about: whatever goes wrong is +/// logged. +/// +/// The live channel already tells an app that is open. A notification is for +/// the phone in a pocket: the rider whose driver has arrived, the driver who +/// has been offered a ride. +/// +/// The **Notifications** switches in the app's settings decide what is said: +/// ride updates for every step of a ride, receipts for its end. A switch that +/// is off silences the notification and nothing else, so the Live Activity +/// still follows the ride and still ends with it. +@Component +public class RidePushes { + private static final String RIDER = "rider"; + private static final String DRIVER = "driver"; + + /// How long an end the phone was not told of is tried again. iOS ends a + /// Live Activity on its own within twelve hours, so past that there is + /// nothing left on the lock screen to end. + private static final long END_RETRY_MILLIS = 12L * 60 * 60 * 1000; + + /// How old an end has to be before the sweep sends it again: the call + /// that ended the ride is still sending it until then. + private static final long END_GRACE_MILLIS = 60000L; + + private final PushSender sender; + private final PushDevices devices; + private final RideRepository rides; + private final RideActivityRepository activities; + private final ProfileRepository profiles; + private final DriverStateRepository drivers; + private final PreferenceRepository preferences; + private final long activityMillis; + private final int offerSeconds; + + /// @param activitySeconds how often, at most, a moving car updates the + /// rider's Live Activity + /// @param offerSeconds how long a driver has to answer an offer, which is + /// how long the notification of it is worth delivering + public RidePushes(PushSender sender, PushDevices devices, RideRepository rides, + RideActivityRepository activities, ProfileRepository profiles, + DriverStateRepository drivers, PreferenceRepository preferences, + @Value("${wayline.push.activity.seconds:45}") int activitySeconds, + @Value("${wayline.offer.seconds:15}") int offerSeconds) { + this.sender = sender; + this.devices = devices; + this.rides = rides; + this.activities = activities; + this.profiles = profiles; + this.drivers = drivers; + this.preferences = preferences; + this.activityMillis = activitySeconds * 1000L; + this.offerSeconds = offerSeconds; + } + + /// One notification and the devices it goes to. + static final class Outgoing { + final List to; + final PushNotification message; + /// The ride whose Live Activity this ends, forgotten once the push + /// service takes it; null for every other message. + final String ends; + + Outgoing(List to, PushNotification message) { + this(to, message, null); + } + + Outgoing(List to, PushNotification message, String ends) { + this.to = to; + this.message = message; + this.ends = ends; + } + } + + // tag::wayline-push-ride-changed[] + /// Tells the people concerned about a change to a ride that has been + /// committed. Called after the transaction, so what it reads is the ride as + /// it now is, and a phone that opens the app at once finds it so. + public void rideChanged(String id, RideState state, String rider, String driver) { + try { + send(compose(id, state, rider, driver)); + } catch (IOException | RuntimeException err) { + System.err.println("[push] nothing was sent for ride " + id + " (" + state + "): " + + err); + } + } + // end::wayline-push-ride-changed[] + + /// Sends again the ends of Live Activities the push provider did not take + /// when their ride ended, and forgets those too old to be on screen. + /// + /// Not a transaction itself, as `Rides.settleLeftBehind` is not: the + /// provider is called with none open, and what went wrong with the first + /// end that failed again is reported once the rest have been tried. + @Scheduled(fixedDelay = 60000, initialDelay = 50000, lock = "wayline-activity-ends") + public void endLeftBehind() throws IOException { + long now = System.currentTimeMillis(); + dropEndedBefore(now - END_RETRY_MILLIS); + List left = leftBehind(now - END_GRACE_MILLIS); + Exception failed = null; + for (int iter = 0; iter < left.size(); iter++) { + String id = left.get(iter).getRideId(); + try { + send(ending(id)); + } catch (IOException err) { + failed = failed == null ? err : failed; + } catch (RuntimeException err) { + failed = failed == null ? err : failed; + } + } + if (failed instanceof IOException) { + throw (IOException) failed; + } + if (failed instanceof RuntimeException) { + throw (RuntimeException) failed; + } + } + + @Transactional + private void dropEndedBefore(long before) { + activities.dropEndedBefore(before); + } + + @Transactional(readOnly = true) + private List leftBehind(long before) { + return activities.leftBehind(before, new PageRequest(0, 50)); + } + + @Transactional + private void forget(String id) { + activities.deleteByRideId(id); + } + + /// The end of the Live Activity of ride `id`, sent quietly: the rider + /// was told of the ride's end, or chose not to be, when it happened. + @Transactional(readOnly = true) + private List ending(String id) throws IOException { + List out = new ArrayList(); + RideActivity activity = activities.findById(id); + Ride ride = rides.findById(id); + if (activity == null || ride == null) { + return out; + } + String driver = ride.getDriver(); + Profile who = driver == null || driver.length() == 0 ? null : profiles.findById(driver); + String name = who == null ? "Your driver" : who.getDisplayName(); + out.add(new Outgoing(devices.targetsOf(ride.getRider()), quiet(id, ride.getState()) + .surface(endOf(activity.getActivityId(), ride.getState(), name, who)).build(), id)); + return out; + } + + /// Moves the rider's Live Activity on as the car moves: its minutes to + /// the pickup or the destination. At most once every + /// `wayline.push.activity.seconds`, and only for a ride that has one. + public void driverMoved(String rideId, double lat, double lng) { + try { + send(moved(rideId, lat, lng)); + } catch (IOException | RuntimeException err) { + System.err.println("[push] the Live Activity of ride " + rideId + + " was not updated: " + err); + } + } + + // tag::wayline-push-activity[] + /// Records the Live Activity the rider's phone shows `rideId` in. Only for + /// the ride's rider, and only while a driver is bringing or carrying them: + /// that is when the activity is on screen. + @Transactional + public RideActivityDto attach(String rider, String rideId, RideActivityDto activity) + throws IOException { + String activityId = Text.required(activity == null ? null : activity.activityId(), 190, + "the activity"); + Ride ride = rides.findById(rideId); + if (ride == null || !ride.getRider().equals(rider)) { + throw new ResponseStatusException(404, "No such ride"); + } + // The state check and the insert have to be one step against the ride + // ending: checked from the ride read above, a driver completing it or + // an admin cancelling it could commit in between, its compose() would + // find no activity to end, and this one would be inserted after it and + // stay on the lock screen. holdWhile checks the state in the database + // and keeps the ride's row locked until this commits, so an ending + // either happened first (409 here) or waits and then finds the row. + if (rides.holdWhile(rideId, rider, MEETING) == 0) { + throw new ResponseStatusException(409, "That ride has no driver on the way"); + } + // One activity per ride, the last one attached. A rider following + // their own ride on two phones at once is not a case the sample + // serves: the earlier phone's activity stops moving and the system + // removes it. A row per device is listed in the implementor's guide + // among what a product adds. + RideActivity row = activities.findById(rideId); + if (row == null) { + row = new RideActivity(); + row.setRideId(rideId); + row.setUsername(rider); + row.setActivityId(activityId); + row.setSentAt(System.currentTimeMillis()); + activities.insert(row); + } else { + row.setActivityId(activityId); + row.setSentAt(System.currentTimeMillis()); + } + return new RideActivityDto(activityId); + } + // end::wayline-push-activity[] + + /// Hands each message to the sender. The end of a Live Activity forgets + /// it only once the push service has taken the message, which the sender + /// says later, on its own thread. Until then the row stays marked ended, + /// so a refusal, an outage or a server stopped first leaves it for + /// [#endLeftBehind]: the ride's transition is committed and never retried, + /// so nothing else would end the activity on the lock screen. + private void send(List outgoing) { + for (Outgoing each : outgoing) { + final String ends = each.ends; + if (each.to.isEmpty()) { + // No phone left to tell: nothing will ever take it. + if (ends != null) { + forget(ends); + } + continue; + } + sender.send(each.to, each.message, ends == null ? null : () -> forget(ends)); + } + } + + /// What to send about a change, read and decided in one transaction. A + /// ride that has ended marks its Live Activity ended here; [#rideChanged] + /// forgets it once the end has been sent. + @Transactional + private List compose(String id, RideState state, String rider, String driver) + throws IOException { + List out = new ArrayList(); + Ride ride = rides.findById(id); + if (ride == null) { + return out; + } + // The change this was called for may no longer be the ride's latest. + // Each transition commits and then composes on its own thread, so two + // in quick succession can compose out of order: an acceptance commits, + // the rider cancels and that commits, and only then does the + // acceptance get here. Switching on `state` would tell the rider their + // driver is on the way to a ride they just cancelled. When what was + // committed since differs from `state`, nothing is said: the later + // transition calls this with its own state and says what is true. + // An offer is compared by its driver too, because a lapsed offer goes + // back to REQUESTED and on to OFFERED again to somebody else. + // + // Nothing is cleaned up here either. A ride that has ended since is + // ended by that later call, which reads the Live Activity, sends its + // live-end and then forgets it; deleting the row here first would + // leave that call nothing to end and the activity on the lock screen. + if (ride.getState() != state || (RideState.OFFERED == state + && (driver == null || !driver.equals(ride.getDriver())))) { + return out; + } + Profile who = driver == null || driver.length() == 0 ? null : profiles.findById(driver); + String name = who == null ? "Your driver" : who.getDisplayName(); + String car = who == null ? "" : describe(who); + RideActivity activity = activities.findById(id); + String activityId = activity == null ? null : activity.getActivityId(); + Map surface = null; + PushNotification.Builder toRider = null; + PushNotification.Builder toDriver = null; + // tag::wayline-push-messages[] + switch (state) { + case OFFERED: + toDriver = message(id, state, DRIVER, "New ride request", + "Pickup at " + ride.getPickupAddress() + ". Open Wayline to accept.") + .ttlSeconds(offerSeconds); + break; + case ACCEPTED: + toRider = message(id, state, RIDER, "Your driver is on the way", + name + " is coming" + (car.length() > 0 ? " in a " + car : "") + ". About " + + eta(ride, driver, false) + " min."); + break; + case ARRIVED: + toRider = message(id, state, RIDER, "Your driver is here", + car.length() > 0 ? "Look for " + car + "." : name + " is waiting for you."); + surface = update(activityId, state(state, name, who, 0)); + break; + case IN_PROGRESS: + toRider = message(id, state, RIDER, "Your trip has started", + "Arriving at " + ride.getDropoffAddress() + " in about " + + eta(ride, driver, true) + " min."); + surface = update(activityId, state(state, name, who, eta(ride, driver, true))); + break; + case COMPLETED: + toRider = message(id, state, RIDER, "You've arrived", + "Thanks for riding with Wayline. Your receipt is in the app."); + surface = endOf(activityId, state, name, who); + break; + case CANCELLED_BY_DRIVER: + toRider = message(id, state, RIDER, "Your driver canceled", + "Sorry about that. Request again to find another driver."); + surface = endOf(activityId, state, name, who); + break; + case CANCELLED_BY_ADMIN: + toRider = message(id, state, RIDER, "Your ride was canceled", + "Wayline support canceled this ride."); + toDriver = driver == null || driver.length() == 0 ? null + : message(id, state, DRIVER, "Ride canceled", + "Wayline support canceled this ride."); + surface = endOf(activityId, state, name, who); + break; + case CANCELLED_BY_RIDER: + toDriver = driver == null || driver.length() == 0 ? null + : message(id, state, DRIVER, "Ride canceled", + "The rider canceled. You're available for new requests."); + surface = endOf(activityId, state, name, who); + break; + case NO_DRIVERS: + toRider = message(id, state, RIDER, "No drivers available", + "Nobody could take your ride right now. Try again in a few minutes."); + break; + default: + break; + } + // end::wayline-push-messages[] + if (!state.isActive() && activity != null) { + activities.markEnded(id, System.currentTimeMillis()); + } + if (toRider != null && !wants(rider, RideState.COMPLETED == state)) { + toRider = null; + } + if (toDriver != null && !wants(driver, false)) { + toDriver = null; + } + if (toRider != null) { + if (surface != null) { + toRider.surface(surface); + } + out.add(new Outgoing(devices.targetsOf(rider), toRider.build(), + surface != null && !state.isActive() ? id : null)); + } else if (surface != null) { + // Nothing to say to the rider, but their lock screen still has to + // let go of the ride. + out.add(new Outgoing(devices.targetsOf(rider), quiet(id, state).surface(surface) + .build(), state.isActive() ? null : id)); + } + if (toDriver != null) { + out.add(new Outgoing(devices.targetsOf(driver), toDriver.build())); + } + return out; + } + + // tag::wayline-push-preferences[] + /// Whether `username` asked to be told: of receipts when `receipt`, of + /// ride updates otherwise. An account that never saved its settings gets + /// both, as the settings screen shows it. + private boolean wants(String username, boolean receipt) throws IOException { + Preference row = preferences.findById(username); + if (row == null) { + return true; + } + return receipt ? row.isNotifyReceipts() : row.isNotifyRides(); + } + // end::wayline-push-preferences[] + + /// The silent update of a moving car, when one is due. + @Transactional + private List moved(String rideId, double lat, double lng) throws IOException { + List out = new ArrayList(); + RideActivity activity = activities.findById(rideId); + if (activity == null) { + return out; + } + long now = System.currentTimeMillis(); + // A conditional update, so that two reports arriving together send one + // update between them. + if (activities.claim(rideId, now, now - activityMillis) != 1) { + return out; + } + Ride ride = rides.findById(rideId); + if (ride == null || !(RideState.ACCEPTED == ride.getState() + || RideState.IN_PROGRESS == ride.getState())) { + return out; + } + Profile who = profiles.findById(ride.getDriver()); + String name = who == null ? "Your driver" : who.getDisplayName(); + boolean toDropoff = RideState.IN_PROGRESS == ride.getState(); + int minutes = toDropoff + ? Rides.etaMinutes(lat, lng, ride.getDropoffLat(), ride.getDropoffLng()) + : Rides.etaMinutes(lat, lng, ride.getPickupLat(), ride.getPickupLng()); + out.add(new Outgoing(devices.targetsOf(ride.getRider()), quiet(rideId, ride.getState()) + .surface(PushNotification.liveUpdate(activity.getActivityId(), + state(ride.getState(), name, who, minutes))) + .build())); + return out; + } + + /// A notification for the rider or the driver of a ride: `role` is which, + /// for the app to open the side it is about. + private static PushNotification.Builder message(String id, RideState state, String role, + String title, String body) { + return PushNotification.builder() + .title(title) + .body(body) + .collapseKey("ride-" + id + "-" + role) + .data("rideId", id) + .data("state", state.name()) + .data("role", role); + } + + /// A message the phone acts on without showing anything. + private static PushNotification.Builder quiet(String id, RideState state) { + return PushNotification.builder() + .silent(true) + .collapseKey("ride-" + id + "-activity") + .data("rideId", id) + .data("state", state.name()) + .data("role", RIDER); + } + + private static Map update(String activityId, Map state) { + return activityId == null ? null : PushNotification.liveUpdate(activityId, state); + } + + private static Map end(String activityId, Map state, + boolean dismiss) { + return activityId == null ? null : PushNotification.liveEnd(activityId, state, dismiss); + } + + /// The end of a Live Activity whose ride ended in `state`: a completed + /// ride stays on the lock screen a while showing its arrival, a cancelled + /// one goes at once. + private static Map endOf(String activityId, RideState state, String name, + Profile who) { + return RideState.COMPLETED == state + ? end(activityId, state(state, name, who, 0), false) + : end(activityId, null, true); + } + + /// The Live Activity's state, in the keys the app laid it out with. + static Map state(RideState state, String name, Profile who, int minutes) { + Map out = new HashMap(); + String status; + double progress; + if (RideState.ARRIVED == state) { + status = "Your driver is here"; + progress = RideActivityKeys.ARRIVED; + } else if (RideState.IN_PROGRESS == state) { + status = "On the trip"; + progress = RideActivityKeys.IN_TRIP; + } else if (RideState.COMPLETED == state) { + status = "You've arrived"; + progress = RideActivityKeys.DONE; + } else { + status = "On the way"; + progress = RideActivityKeys.ON_THE_WAY; + } + out.put(RideActivityKeys.STATUS, status); + out.put(RideActivityKeys.PROGRESS, Double.valueOf(progress)); + out.put(RideActivityKeys.DRIVER, name); + out.put(RideActivityKeys.CAR, who == null ? "" : who.getVehicle()); + out.put(RideActivityKeys.PLATE, who == null ? "" : who.getPlate()); + out.put(RideActivityKeys.ETA, minutes > 0 ? minutes + " min" : ""); + return out; + } + + private int eta(Ride ride, String driver, boolean toDropoff) { + DriverState car = driver == null || driver.length() == 0 ? null : drivers.findById(driver); + if (car == null) { + return 1; + } + return toDropoff + ? Rides.etaMinutes(car.getLat(), car.getLng(), ride.getDropoffLat(), + ride.getDropoffLng()) + : Rides.etaMinutes(car.getLat(), car.getLng(), ride.getPickupLat(), + ride.getPickupLng()); + } + + private static final RideState[] MEETING = { + RideState.ACCEPTED, RideState.ARRIVED, RideState.IN_PROGRESS + }; + + private static String describe(Profile who) { + String vehicle = who.getVehicle() == null ? "" : who.getVehicle().trim(); + String plate = who.getPlate() == null ? "" : who.getPlate().trim(); + if (vehicle.length() == 0) { + return plate; + } + return plate.length() == 0 ? vehicle : vehicle + ", " + plate; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/ClosedAccountRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/ClosedAccountRepository.java new file mode 100644 index 00000000000..45415a0959e --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/ClosedAccountRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.ClosedAccount; + +/// The hashes of the e-mails that had an account once and may not have another. +@Repository(entity = ClosedAccount.class, key = String.class) +public interface ClosedAccountRepository extends CrudRepository { + +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverApplicationRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverApplicationRepository.java new file mode 100644 index 00000000000..45aea747af3 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverApplicationRepository.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.DriverApplication; + +import java.util.List; + +/// Applications to drive, one per account at most, keyed by the account. +/// +/// Every move of an application from one status to another is one conditional +/// statement answering how many rows it changed, so that of two callers moving +/// the same application out of the same status the count is 1 for one of them +/// and 0 for the other. Each is marked `clearAutomatically`, which empties the +/// session after it, so an application read before a move is read again after +/// it, as the move left it. +@Repository(entity = DriverApplication.class, key = String.class) +public interface DriverApplicationRepository extends CrudRepository { + /// The applications of these accounts; none for a name that made none. + List findByUsernameIn(List usernames); + + /// The applications of every account that has a driver's state. + @Query("select a from DriverApplication a where a.username in " + + "(select d.username from DriverState d)") + List ofDrivers(); + + /// The name and status of every application, as `{username, status}`. + @Query("select a.username, a.status from DriverApplication a") + List statuses(); + + long countByStatus(String status); + + /// Applications of every status, the latest handed in first. Only those + /// of accounts that still have a profile, as the list names the + /// applicant. + @Query("select a from DriverApplication a where a.username in " + + "(select p.username from Profile p) order by a.submittedAt desc, a.username asc") + List newest(PageRequest page); + + /// Applications in one status, the longest waiting first; only those of + /// accounts that still have a profile. + @Query("select a from DriverApplication a where a.status = :status " + + "and a.username in (select p.username from Profile p) " + + "order by a.submittedAt asc, a.username asc") + List inStatus(@Param("status") String status, PageRequest page); + + /// Moves an application from status `from` to `to`, recording the review. + @Modifying(clearAutomatically = true) + @Query("update DriverApplication a set a.status = :to, a.rejectionReason = :reason, " + + "a.reviewedAt = :at, a.reviewedBy = :by " + + "where a.username = :username and a.status = :from") + int move(@Param("username") String username, @Param("from") String from, + @Param("to") String to, @Param("reason") String reason, @Param("at") long at, + @Param("by") String by); + + /// Marks an application `approved` unless it already is. + @Modifying(clearAutomatically = true) + @Query("update DriverApplication a set a.status = :to, a.rejectionReason = '', " + + "a.reviewedAt = :at, a.reviewedBy = :by " + + "where a.username = :username and a.status <> :to") + int approveUnlessApproved(@Param("username") String username, + @Param("to") String approved, @Param("at") long at, @Param("by") String by); + + /// Hands an application in: from the status it was read in to `to`, with + /// the last rejection's reason cleared. + @Modifying(clearAutomatically = true) + @Query("update DriverApplication a set a.status = :to, a.submittedAt = :at, " + + "a.rejectionReason = '' where a.username = :username and a.status = :from") + int submit(@Param("username") String username, @Param("from") String from, + @Param("to") String to, @Param("at") long at); + + /// Makes the rest of the transaction wait its turn behind any other that + /// holds, or moves, the same application. + /// + /// @return 1, or 0 for an account that has no application yet + @Modifying + @Query("update DriverApplication a set a.status = a.status where a.username = :username") + int hold(@Param("username") String username); + + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverDocumentRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverDocumentRepository.java new file mode 100644 index 00000000000..63015ad1e33 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverDocumentRepository.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.wayline.domain.DriverDocument; + +import java.util.List; + +/// The documents of applications to drive, keyed by +/// `Identifier.of(username, kind)`. +/// +/// A document's row carries the picture, as Base64 text that can run to a +/// couple of megabytes. Only `findById` reads it. Everything that wants to know +/// which documents there are and how they stand selects those fields alone, so +/// listing two hundred applications does not pull two hundred sets of +/// photographs through the server. +@Repository(entity = DriverDocument.class, key = Identifier.class) +public interface DriverDocumentRepository extends CrudRepository { + /// Which documents an account has uploaded, as `{kind, status, + /// uploadedAt}`, without the pictures. + @Query("select d.kind, d.status, d.uploadedAt from DriverDocument d " + + "where d.username = :username") + List summaries(@Param("username") String username); + + /// The same for several accounts at once, as `{username, kind, status, + /// uploadedAt}`. + @Query("select d.username, d.kind, d.status, d.uploadedAt from DriverDocument d " + + "where d.username in :names") + List summaries(@Param("names") List usernames); + + /// Puts a new picture in place of a stored document's, and answers how + /// many rows that changed: 0 says there was none of that kind to replace. + /// A statement, so that the picture being replaced is never read. + @Modifying(clearAutomatically = true) + @Query("update DriverDocument d set d.contentType = :type, d.status = :status, " + + "d.uploadedAt = :at, d.data = :data where d.username = :username and d.kind = :kind") + int replace(@Param("username") String username, @Param("kind") String kind, + @Param("type") String contentType, @Param("status") String status, + @Param("at") long at, @Param("data") String data); + + /// Sets the status of all of an account's documents. + @Modifying(clearAutomatically = true) + @Query("update DriverDocument d set d.status = :status where d.username = :username") + int markAll(@Param("username") String username, @Param("status") String status); + + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverHoursRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverHoursRepository.java new file mode 100644 index 00000000000..6ef95cc2267 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverHoursRepository.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.wayline.domain.DriverHours; + +/// The time each driver spent on line, a row for each day, keyed by +/// `Identifier.of(username, dayNumber)`. +@Repository(entity = DriverHours.class, key = Identifier.class) +public interface DriverHoursRepository extends CrudRepository { + /// Adds time on line to a day that already has a row. The addition is the + /// database's, so reports that overlap are both counted. + /// + /// @return 1, or 0 when the day has no row yet + @Modifying + @Query("update DriverHours h set h.millis = h.millis + :millis " + + "where h.username = :username and h.dayNumber = :day") + int addMillis(@Param("username") String username, @Param("day") long dayNumber, + @Param("millis") long millis); + + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverStateRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverStateRepository.java new file mode 100644 index 00000000000..61779aa3eb3 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/DriverStateRepository.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.domain.DriverState; + +import java.util.List; + +/// Drivers' live state: whether each is on line, where the car is, and the +/// rating. +@Repository(entity = DriverState.class, key = String.class) +public interface DriverStateRepository extends CrudRepository { + /// Every account that has a state, those on line first and then the ones + /// heard from most recently. + @Query("select d from DriverState d order by d.online desc, d.lastSeen desc") + List onLineFirst(); + + /// The drivers on line, heard from since `fresh`, inside a box of + /// latitude and longitude, whose application stands approved, who still + /// have a profile, and who have no ride in hand: one offer, or one ride, + /// at a time. + /// + /// The approval is read here, in the query, and not trusted to have been + /// put right elsewhere: an approval withdrawn takes a driver out of + /// matching at once, whatever their state still says. A state left behind + /// by an account that is gone is not somebody to send a rider to. + /// + /// What the rider asked of the car and the driver is the caller's to + /// apply to the few this answers. + @Query("select d from DriverState d where d.online = TRUE and d.lastSeen >= :fresh " + + "and d.lat between :south and :north and d.lng between :west and :east " + + "and d.username in (select a.username from DriverApplication a " + + "where a.status = :approved) " + + "and d.username in (select p.username from Profile p) " + + "and d.username not in (select r.driver from Ride r where r.state in :withRide)") + List freeInBox(@Param("fresh") long fresh, @Param("south") double south, + @Param("north") double north, @Param("west") double west, + @Param("east") double east, @Param("approved") String approved, + @Param("withRide") RideState[] withRide); + + /// Makes the rest of the transaction wait its turn behind any other that + /// holds the same driver, as [ProfileRepository#hold] does for an + /// account. + /// + /// @return 1, or 0 for a driver who has no state + @Modifying + @Query("update DriverState d set d.lastSeen = d.lastSeen where d.username = :username") + int hold(@Param("username") String username); + + /// Counts a rating toward a driver's. Arithmetic done by the database, so + /// two ratings arriving together are both counted. + /// + /// @return 1, or 0 for a driver who has no state to count it in + @Modifying + @Query("update DriverState d set d.ratingSum = d.ratingSum + :stars, " + + "d.ratingCount = d.ratingCount + 1 where d.username = :username") + int addRating(@Param("username") String username, @Param("stars") long stars); + + /// Takes a driver off line. Changes nothing for an account that never + /// drove. + @Modifying + @Query("update DriverState d set d.online = FALSE where d.username = :username") + int takeOffLine(@Param("username") String username); + + /// The drivers on line who reported in at or after `seenSince`. + long countByOnlineTrueAndLastSeenGreaterThanEqual(long seenSince); + + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/LiveTicketRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/LiveTicketRepository.java new file mode 100644 index 00000000000..1b84547ab97 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/LiveTicketRepository.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.LiveTicket; + +/// The tickets that open a live connection. Only the hash of a ticket is +/// stored, and it is the row's key. +@Repository(entity = LiveTicket.class, key = String.class) +public interface LiveTicketRepository extends CrudRepository { + /// Deletes a ticket and answers how many rows that removed: 1 for the one + /// caller that used it, 0 for anyone presenting it after. A statement and + /// not a read followed by a removal, because that count is the whole + /// point. + int deleteByTicketHash(String ticketHash); + + /// Deletes the tickets that went stale before `now`. + int deleteByExpiresAtLessThan(long now); + + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/ModerationEventRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/ModerationEventRepository.java new file mode 100644 index 00000000000..680e8f19138 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/ModerationEventRepository.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.ModerationEvent; + +import java.util.List; + +/// The record of what the admins did to each account. +@Repository(entity = ModerationEvent.class, key = String.class) +public interface ModerationEventRepository extends CrudRepository { + /// When the newest event of an account was written, or null when it has + /// none. + @Query("select max(e.createdAt) from ModerationEvent e where e.username = :username") + Long newest(@Param("username") String username); + + /// An account's last hundred events, newest first. + List findTop100ByUsernameOrderByCreatedAtDescIdAsc(String username); + + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentCustomerRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentCustomerRepository.java new file mode 100644 index 00000000000..a6934ac2b98 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentCustomerRepository.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.PaymentCustomer; + +/// The payment processor's names for accounts. +@Repository(entity = PaymentCustomer.class, key = String.class) +public interface PaymentCustomerRepository extends CrudRepository { + /// Writes an account's row without changing it, which holds the row until + /// the transaction ends: whatever else writes it waits its turn. + @Modifying + @Query("update PaymentCustomer c set c.reference = c.reference where c.username = :username") + int hold(@Param("username") String username); + + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentMethodRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentMethodRepository.java new file mode 100644 index 00000000000..01b1646738e --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentMethodRepository.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.PaymentMethod; + +import java.util.List; + +/// The cards accounts saved. +@Repository(entity = PaymentMethod.class, key = String.class) +public interface PaymentMethodRepository extends CrudRepository { + /// An account's saved cards, oldest first. + List findByUsernameOrderByCreatedAtAscIdAsc(String username); + + long countByUsername(String username); + + /// The card an account pays with by default, or null when that is cash. + PaymentMethod findFirstByUsernameAndPreferredTrue(String username); + + /// One of an account's cards, or null when the id is not a card of theirs. + PaymentMethod findFirstByIdAndUsername(String id, String username); + + /// When the newest of an account's cards was added, or null with none. + @Query("select max(m.createdAt) from PaymentMethod m where m.username = :username") + Long newestAt(@Param("username") String username); + + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentRepository.java new file mode 100644 index 00000000000..117110cde34 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentRepository.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.Payment; + +import java.util.List; + +/// What each ride was charged, keyed by the ride. +@Repository(entity = Payment.class, key = String.class) +public interface PaymentRepository extends CrudRepository { + /// The payments of several rides, in no order. + List findByRideIdIn(List rideIds); + + /// Payments whose refund the processor took and has not finished, the + /// oldest first. + List findByStatusOrderByCreatedAtAsc(String status, PageRequest page); + + /// Records how a pending refund ended. + /// + /// @return 1 for the one caller that recorded it + @Modifying + @Query("update Payment p set p.status = :status, p.refundedAt = :at " + + "where p.rideId = :id and p.status = 'REFUND_PENDING'") + int endRefund(@Param("id") String rideId, @Param("status") String status, + @Param("at") long at); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentSetupRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentSetupRepository.java new file mode 100644 index 00000000000..e81e7c95c63 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PaymentSetupRepository.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.PaymentSetup; + +/// The card setups accounts began with the payment processor. +@Repository(entity = PaymentSetup.class, key = String.class) +public interface PaymentSetupRepository extends CrudRepository { + /// A card setup an account began, or null. + PaymentSetup findFirstByIdAndUsername(String id, String username); + + /// Marks a setup finished, if it is not yet. + /// + /// @return 1 for the one caller that finished it + @Modifying + @Query("update PaymentSetup s set s.completed = TRUE where s.id = :id and s.completed = FALSE") + int claim(@Param("id") String id); + + /// How many setups an account began after `since` and has not finished. + long countByUsernameAndCompletedFalseAndCreatedAtGreaterThan(String username, long since); + + int deleteByUsername(String username); + + /// Deletes the setups that saved their card. + int deleteByCompletedTrue(); + + /// Deletes the setups begun before `before`, finished or not. + int deleteByCreatedAtLessThan(long before); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PayoutAccountRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PayoutAccountRepository.java new file mode 100644 index 00000000000..8139e1044ef --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PayoutAccountRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.PayoutAccount; + +/// Where each driver is paid, keyed by the driver. +@Repository(entity = PayoutAccount.class, key = String.class) +public interface PayoutAccountRepository extends CrudRepository { + /// Makes the rest of the transaction wait its turn behind any other that + /// holds the same driver's payout account. + /// + /// @return 1, or 0 for a driver who has not said where to be paid + @Modifying + @Query("update PayoutAccount a set a.last4 = a.last4 where a.username = :driver") + int hold(@Param("driver") String driver); + + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PayoutRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PayoutRepository.java new file mode 100644 index 00000000000..78838ab2c19 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PayoutRepository.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.Payout; + +import java.util.List; + +/// The payouts made to drivers. +@Repository(entity = Payout.class, key = String.class) +public interface PayoutRepository extends CrudRepository { + /// The last hundred payouts made to a driver, newest first. + List findTop100ByDriverOrderByCreatedAtDescIdAsc(String driver); + + /// Everything ever paid out to a driver. A sum over no rows is nothing at + /// all to a database, hence the zero to fall back on. + @Query("select coalesce(sum(p.amountCents), 0) from Payout p where p.driver = :driver") + Long paidOut(@Param("driver") String driver); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PhoneCodeRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PhoneCodeRepository.java new file mode 100644 index 00000000000..c3186945f78 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PhoneCodeRepository.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.PhoneCode; + +/// The code an account was texted to prove its phone number. An account has at +/// most one. +@Repository(entity = PhoneCode.class, key = String.class) +public interface PhoneCodeRepository extends CrudRepository { + /// Takes one of the guesses an account's code allows. The database does + /// the sum and applies the limit in the same statement, so of any number + /// of guesses sent at once only as many as are left are let through; + /// nothing is read and written back. + /// + /// The guess is taken of the code whose hash is `codeHash`, and of no + /// other: a new code sent between reading the hash and taking the guess + /// has another hash, and the update changes nothing. + /// + /// The code before it, when one is kept, is guessed at by the same guess, + /// so its count goes up too; its own limit is applied by the caller. + /// + /// Empties the session, so the code is read again after it with the guess + /// counted. + /// + /// @return 1 for a guess taken, 0 when none is left or there is no code + @Modifying(clearAutomatically = true) + @Query("update PhoneCode c set c.attempts = c.attempts + 1, " + + "c.previousAttempts = c.previousAttempts + 1 " + + "where c.username = :username and c.codeHash = :hash and c.attempts < :most") + int takeGuess(@Param("username") String username, @Param("hash") String codeHash, + @Param("most") int most); + + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PreferenceRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PreferenceRepository.java new file mode 100644 index 00000000000..e042343cd56 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PreferenceRepository.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.Preference; + +/// An account's preferences, keyed by its name. An account that never saved any +/// has no row. +@Repository(entity = Preference.class, key = String.class) +public interface PreferenceRepository extends CrudRepository { + int deleteByUsername(String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PricingRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PricingRepository.java new file mode 100644 index 00000000000..a76fdb6064e --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PricingRepository.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.Pricing; + +/// The admin's prices: one row, whose key is [#ONLY]. A migration creates it, +/// with an `updatedAt` of 0 while the prices are still the settings'. +@Repository(entity = Pricing.class, key = Integer.class) +public interface PricingRepository extends CrudRepository { + /// The key of the one row there is. + Integer ONLY = Integer.valueOf(1); + + /// Writes new prices over the one row, in one statement that reads + /// nothing first: two admins saving together each write theirs, and the + /// later one is what stays. + /// + /// @return 1, or 0 when the row is missing + @Modifying + @Query("update Pricing p set p.baseCents = :base, p.perKmCents = :perKm, " + + "p.perMinuteCents = :perMinute, p.minimumCents = :minimum, " + + "p.feePercent = :fee, p.commissionPercent = :commission, p.surge = :surge, " + + "p.comfort = :comfort, p.xl = :xl, p.updatedAt = :at, p.updatedBy = :by " + + "where p.id = :id") + int change(@Param("id") int id, @Param("base") long base, @Param("perKm") long perKm, + @Param("perMinute") long perMinute, @Param("minimum") long minimum, + @Param("fee") double fee, @Param("commission") double commission, + @Param("surge") double surge, @Param("comfort") double comfort, + @Param("xl") double xl, @Param("at") long at, @Param("by") String by); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/ProfileRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/ProfileRepository.java new file mode 100644 index 00000000000..31fb041d115 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/ProfileRepository.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.Profile; + +import java.util.List; + +/// Profiles, keyed by the e-mail an account signs in with. +/// +/// A repository holds the queries of one kind of entity and nothing else: no +/// rule about who may do what, and no transaction of its own. The build writes +/// its implementation, and every call runs on the session of the transaction +/// its caller opened, so a service method marked `@Transactional` is what +/// makes several of these calls one unit of work. Called outside one, it is +/// refused. +@Repository(entity = Profile.class, key = String.class) +public interface ProfileRepository extends CrudRepository { + /// Makes the rest of the transaction wait its turn behind any other that + /// holds the same account: the row is written, to the value it has, and a + /// written row is kept by its writer until it commits. For a check and an + /// insert that must not be interleaved with the same pair made for the + /// same account. Call it before anything is read: what is read after it is + /// what the transaction before this one left, while a row read before it + /// was read before the wait. + /// + /// @return 1, or 0 when the account has no profile + @Modifying + @Query("update Profile p set p.createdAt = p.createdAt where p.username = :username") + int hold(@Param("username") String username); + + /// Every profile, newest first. + @Query("select p from Profile p order by p.createdAt desc, p.username asc") + List newestFirst(); + + /// The profiles of these accounts; none for a name that has none. + List findByUsernameIn(List usernames); + + /// The profiles of every account that has a driver's state: the fleet, + /// and not every account there is. + @Query("select p from Profile p where p.username in (select d.username from DriverState d)") + List drivers(); + + /// The display names of some accounts, as `{username, displayName}`. The + /// two fields and not the profiles: a list of applications shows nothing + /// else of them. + @Query("select p.username, p.displayName from Profile p where p.username in :names") + List displayNames(@Param("names") List usernames); + + long countByFlaggedTrue(); + + /// When each account made since `since` was made. + @Query("select p.createdAt from Profile p where p.createdAt >= :since") + List signUps(@Param("since") long since); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PushDeviceRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PushDeviceRepository.java new file mode 100644 index 00000000000..619e513e956 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/PushDeviceRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.PushDevice; + +import java.util.List; + +/// The devices each account is sent notifications on. +@Repository(entity = PushDevice.class, key = String.class) +public interface PushDeviceRepository extends CrudRepository { + List findByUsername(String username); + + int deleteByUsername(String username); + + int deleteByIdAndUsername(String id, String username); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideActivityRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideActivityRepository.java new file mode 100644 index 00000000000..5e0a7c64c84 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideActivityRepository.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.domain.RideActivity; + +import java.util.List; + +/// The Live Activities riders follow their rides in, by ride. +@Repository(entity = RideActivity.class, key = String.class) +public interface RideActivityRepository extends CrudRepository { + int deleteByRideId(String rideId); + + int deleteByUsername(String username); + + /// Records that a state went out, unless one went out after `before`: + /// 1 for the caller who gets to send, 0 for anyone else at the same moment. + @Modifying + @Query("update RideActivity a set a.sentAt = :now " + + "where a.rideId = :rideId and a.sentAt < :before") + int claim(@Param("rideId") String rideId, @Param("now") long now, + @Param("before") long before); + + /// Records that the ride ended at `now`, unless that is already recorded. + @Modifying + @Query("update RideActivity a set a.endedAt = :now " + + "where a.rideId = :rideId and a.endedAt = 0") + int markEnded(@Param("rideId") String rideId, @Param("now") long now); + + /// The activities whose ride ended before `before` and that are still + /// here, because the phone was not told: oldest first. + @Query("select a from RideActivity a where a.endedAt > 0 and a.endedAt < :before " + + "order by a.endedAt") + List leftBehind(@Param("before") long before, PageRequest page); + + /// Forgets the activities whose ride ended before `before`. + @Modifying + @Query("delete from RideActivity a where a.endedAt > 0 and a.endedAt < :before") + int dropEndedBefore(@Param("before") long before); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideEventRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideEventRepository.java new file mode 100644 index 00000000000..dc9ca139a6f --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideEventRepository.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.domain.RideEvent; + +import java.util.List; + +/// What happened to each ride: one row for every state it came into. +@Repository(entity = RideEvent.class, key = String.class) +public interface RideEventRepository extends CrudRepository { + long countByActorAndState(String actor, RideState state); + + /// How many offers each driver has accepted, as `{driver, count}`. + @Query("select e.actor, count(e) from RideEvent e where e.state = :accepted " + + "group by e.actor") + List countByActor(@Param("accepted") RideState accepted); + + /// When each ride asked for since `since` came into `state`: `{rideId, + /// createdAt}`. The entities have no relationship to join along, so the + /// rides are named by a nested query. + @Query("select e.rideId, e.createdAt from RideEvent e where e.state = :state " + + "and e.rideId in (select r.id from Ride r where r.requestedAt >= :since)") + List reached(@Param("since") long since, @Param("state") RideState state); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RidePassedRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RidePassedRepository.java new file mode 100644 index 00000000000..18e9b4dc975 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RidePassedRepository.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.wayline.domain.RidePassed; + +import java.util.List; + +/// The drivers who passed on a ride or let its offer lapse, keyed by +/// `Identifier.of(rideId, driver)`. +@Repository(entity = RidePassed.class, key = Identifier.class) +public interface RidePassedRepository extends CrudRepository { + List findByRideId(String rideId); + + long countByDriver(String driver); + + /// How many offers each driver has passed on or let lapse, as `{driver, + /// count}`. + @Query("select p.driver, count(p.rideId) from RidePassed p group by p.driver") + List countByDriver(); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideRepository.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideRepository.java new file mode 100644 index 00000000000..19e7a1fce90 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/repository/RideRepository.java @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.domain.Ride; + +import java.util.List; + +/// Rides. +/// +/// Which state may follow which is `RideStateMachine`'s to say; what is here is +/// the statement that makes a move, told the states it may be made from. Every +/// move is one conditional update, and the count it answers is the whole +/// answer: 1 when this call made the move, 0 when the ride was somewhere else by +/// then. The database applies the rule and settles a race in the same breath: +/// of two drivers accepting at once, or a rider cancelling as a driver accepts, +/// one statement changes a row and the other changes none. Each is marked +/// `clearAutomatically`, which empties the session after it, so a ride read +/// before it is read again after it, as the statement left it. +/// +/// A move comes in one of five shapes -- the rider's, the driver's, the +/// driver's taking an offer, the driver's giving one back, and the server's -- +/// each its own statement rather than one assembled at run time. +// tag::wayline-repository[] +@Repository(entity = Ride.class, key = String.class) +public interface RideRepository extends CrudRepository { + /// The ride a rider has in one of `states`, or null. + Ride findFirstByRiderAndStateIn(String rider, RideState[] states); + + /// The ride a driver has in one of `states`, or null. + Ride findFirstByDriverAndStateIn(String driver, RideState[] states); + // end::wayline-repository[] + + /// Whether the account is the rider of a ride in one of `asRider` or the + /// driver of one in one of `asDriver`. + boolean existsByRiderAndStateInOrDriverAndStateIn(String rider, RideState[] asRider, + String driver, RideState[] asDriver); + + boolean existsByDriverAndStateIn(String driver, RideState[] states); + + /// Whether a ride of the rider's in one of `states` is to be paid with + /// this method. + boolean existsByRiderAndMethodIdAndStateIn(String rider, String methodId, + RideState[] states); + + /// Whether a completed ride of the rider's, whose payment is + /// `paymentStatus` and which ended after `since`, is to be charged to + /// this method. + boolean existsByRiderAndStateAndPaymentStatusAndUpdatedAtGreaterThanAndMethodId( + String rider, RideState state, String paymentStatus, long since, String methodId); + + /// The same, to be charged to any method but `methodId`. + boolean existsByRiderAndStateAndPaymentStatusAndUpdatedAtGreaterThanAndMethodIdNot( + String rider, RideState state, String paymentStatus, long since, String methodId); + + /// A rider's rides whose payment is `paymentStatus` and that carry a tip. + List findByRiderAndPaymentStatusAndTipCentsGreaterThan(String rider, + String paymentStatus, long tipCents); + + /// A rider's last fifty rides, newest first. + List findTop50ByRiderOrderByRequestedAtDesc(String rider); + + /// The last fifty of a driver's rides in one of `states`, newest first. + List findTop50ByDriverAndStateInOrderByRequestedAtDesc(String driver, + RideState[] states); + + /// A rider's rides in `state`, newest first. + List findByRiderAndStateOrderByRequestedAtDesc(String rider, RideState state, + PageRequest page); + + /// Everyone's rides in `state`, newest first. + List findByStateOrderByRequestedAtDesc(RideState state, PageRequest page); + + /// The most recent rides of all. + @Query("select r from Ride r order by r.requestedAt desc") + List newest(PageRequest page); + + /// The rides in `state` whose offer ran out before `now`. + List findByStateAndOfferExpiresAtLessThan(RideState state, long now); + + /// The rides in `state`, the one that has waited longest first. + List findByStateOrderByRequestedAtAsc(RideState state); + + /// A driver's rides in `state` that changed at or after a time. + List findByDriverAndStateAndUpdatedAtGreaterThanEqual(String driver, + RideState state, long since); + + long countByDriverAndState(String driver, RideState state); + + long countByRequestedAtGreaterThanEqual(long since); + + long countByStateIn(RideState[] states); + + /// The rides an account took part in, on either side. + long countByRiderOrDriver(String rider, String driver); + + /// The rides an account itself called off, as the rider or as the driver. + long countByRiderAndStateOrDriverAndState(String rider, RideState asRider, String driver, + RideState asDriver); + + // ---------------------------------------------------------------- moves + + /// Moves a ride of `rider`'s from one of `from` to `to`. + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.state = :to, r.updatedAt = :now " + + "where r.id = :id and r.state in :from and r.rider = :rider") + int moveAsRider(@Param("id") String id, @Param("from") RideState[] from, + @Param("to") RideState to, @Param("now") long now, @Param("rider") String rider); + + /// Moves a ride of `driver`'s from one of `from` to `to`. + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.state = :to, r.updatedAt = :now " + + "where r.id = :id and r.state in :from and r.driver = :driver") + int moveAsDriver(@Param("id") String id, @Param("from") RideState[] from, + @Param("to") RideState to, @Param("now") long now, @Param("driver") String driver); + + // tag::wayline-ride-move[] + /// Takes an offer made to `driver`: refused once the offer has lapsed, as + /// of `now`, even in the moment before the sweep takes it back. + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.state = :to, r.updatedAt = :now " + + "where r.id = :id and r.state in :from and r.driver = :driver " + + "and r.offerExpiresAt >= :now") + int takeOffer(@Param("id") String id, @Param("from") RideState[] from, + @Param("to") RideState to, @Param("now") long now, @Param("driver") String driver); + // end::wayline-ride-move[] + + /// Gives a ride of `driver`'s back to be offered again, writing + /// `newDriver` (empty: nobody) as its driver. + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.state = :to, r.updatedAt = :now, r.driver = :newDriver " + + "where r.id = :id and r.state in :from and r.driver = :driver") + int giveBack(@Param("id") String id, @Param("from") RideState[] from, + @Param("to") RideState to, @Param("now") long now, @Param("driver") String driver, + @Param("newDriver") String newDriver); + + /// Holds `rider`'s ride while it is in one of `states`, answering 0 when + /// it is not. The update writes nothing new; it is there for the row lock + /// it takes until the transaction ends, so a move that would take the ride + /// out of `states` waits for this transaction, or has already happened + /// and makes it answer 0. The state is checked by the database at that + /// moment, never from a copy read earlier. + @Modifying + @Query("update Ride r set r.rider = :rider " + + "where r.id = :id and r.rider = :rider and r.state in :states") + int holdWhile(@Param("id") String id, @Param("rider") String rider, + @Param("states") RideState[] states); + + /// A move the server makes itself, whoever has the ride. + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.state = :to, r.updatedAt = :now " + + "where r.id = :id and r.state in :from") + int moveByServer(@Param("id") String id, @Param("from") RideState[] from, + @Param("to") RideState to, @Param("now") long now); + + /// Offers a ride to `driver` until `expiresAt`. + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.state = :to, r.updatedAt = :now, r.driver = :driver, " + + "r.offerExpiresAt = :expiresAt where r.id = :id and r.state in :from") + int offer(@Param("id") String id, @Param("from") RideState[] from, + @Param("to") RideState to, @Param("now") long now, @Param("driver") String driver, + @Param("expiresAt") long expiresAt); + + // ----------------------------------------------------------- the rest + + /// Gives a completed ride its stars, if it has none yet. One statement, + /// for the reason a move is one: a ride rated twice at the same moment is + /// rated once. + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.stars = :stars where r.id = :id and r.rider = :rider " + + "and r.state = 'COMPLETED' and r.stars = 0") + int rate(@Param("id") String id, @Param("rider") String rider, @Param("stars") int stars); + + /// Completed rides whose payment has been pending since before `before` + /// and no longer than since `after`, oldest first: the sweep takes one + /// page at a time, and a backlog read newest first would keep the oldest + /// behind the new arrivals until they aged out of the window unsettled. + @Query("select r from Ride r where r.state = 'COMPLETED' and r.paymentStatus = 'PENDING' " + + "and r.updatedAt < :before and r.updatedAt > :after order by r.updatedAt asc") + List unsettled(@Param("after") long after, @Param("before") long before, + PageRequest page); + + /// Records how the charge for a ride came out. A statement of its own and + /// not a change to a ride read earlier, so that it writes this one column + /// whatever else was written to the ride meanwhile. + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.paymentStatus = :status where r.id = :id") + int paymentStatus(@Param("id") String id, @Param("status") String status); + + /// Sets the tip of a completed, paid ride that has none. + /// + /// @return 1 for the one caller whose tip it now is + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.tipCents = :tip where r.id = :id and r.rider = :rider " + + "and r.state = 'COMPLETED' and r.paymentStatus = 'PAID' and r.tipCents = 0") + int claimTip(@Param("id") String id, @Param("rider") String rider, @Param("tip") long tip); + + /// Takes back a tip that could not be charged. + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.tipCents = 0 where r.id = :id") + int releaseTip(@Param("id") String id); + + /// Marks a paid ride refunded. + /// + /// @return 1 for the one caller that is to refund it + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.paymentStatus = 'REFUNDED' where r.id = :id " + + "and r.paymentStatus = 'PAID'") + int claimRefund(@Param("id") String id); + + /// Gives a refund's claim back: the ride is paid again, if it is still + /// marked refunded. + /// + /// @return 1 when the ride was + @Modifying(clearAutomatically = true) + @Query("update Ride r set r.paymentStatus = 'PAID' where r.id = :id " + + "and r.paymentStatus = 'REFUNDED'") + int releaseRefund(@Param("id") String id); + + // ---------------------------------------------------------------- sums + // A sum over no rows is null and not zero, on every database, so each + // one is wrapped in `coalesce(..., 0)`. + + /// How many rides each driver has in `state`, as `{driver, count}`. + @Query("select r.driver, count(r) from Ride r where r.state = :state group by r.driver") + List countByDriver(@Param("state") RideState state); + + /// How many of a driver's completed rides were paid for, and what the + /// driver's share of their fares came to: `{count, cents}`. + /// + /// The share and not the fare: this is the figure the driving screen + /// shows as earned, and the fare includes the service's commission, + /// which the driver never sees and cannot cash out. + @Query("select count(r), coalesce(sum(r.driverCents), 0) from Ride r " + + "where r.driver = :driver and r.state = 'COMPLETED' and r.paymentStatus = 'PAID'") + Object[] paidTotals(@Param("driver") String driver); + + /// [#paidTotals] for the rides that ended since a time: `{count, cents}`. + @Query("select count(r), coalesce(sum(r.driverCents), 0) from Ride r " + + "where r.driver = :driver and r.state = 'COMPLETED' and r.paymentStatus = 'PAID' " + + "and r.updatedAt >= :since") + Object[] paidTotalsSince(@Param("driver") String driver, @Param("since") long since); + + /// What the driver's share of their completed, paid rides came to since + /// a time. + @Query("select coalesce(sum(r.driverCents), 0) from Ride r where r.driver = :driver " + + "and r.state = 'COMPLETED' and r.paymentStatus = 'PAID' and r.updatedAt >= :since") + Long paidSince(@Param("driver") String driver, @Param("since") long since); + + /// The driver's share of the fares of every ride they completed and that + /// was paid for -- in cash, or by a card whose charge came back. A charge + /// that failed, was refunded, or has not been answered yet is no money of + /// the service's, so it is not the driver's to cash out either. + @Query("select coalesce(sum(r.driverCents), 0) from Ride r where r.driver = :driver " + + "and r.state = 'COMPLETED' and r.paymentStatus = 'PAID'") + Long driverShare(@Param("driver") String driver); + + /// The tips handed to a driver in cash. + @Query("select coalesce(sum(r.tipCents), 0) from Ride r where r.driver = :driver " + + "and r.state = 'COMPLETED' and r.methodId = 'cash'") + Long cashTips(@Param("driver") String driver); + + /// The tips put on a card for a driver whose charge has come back, while + /// the ride is still paid: a refund returns the tip with the fare. + @Query("select coalesce(sum(r.tipCents), 0) from Ride r where r.driver = :driver " + + "and r.state = 'COMPLETED' and r.methodId <> 'cash' and r.paymentStatus = 'PAID' " + + "and r.id in (select p.rideId from Payment p where p.tipRef <> '')") + Long chargedTips(@Param("driver") String driver); + + /// The ids of a driver's rides, completed since a time, whose tip was put + /// on a card and whose charge has come back: [#chargedTips] by ride. + @Query("select r.id from Ride r where r.driver = :driver and r.state = 'COMPLETED' " + + "and r.updatedAt >= :since and r.methodId <> 'cash' and r.paymentStatus = 'PAID' " + + "and r.id in (select p.rideId from Payment p where p.tipRef <> '')") + List tipChargedSince(@Param("driver") String driver, @Param("since") long since); + + /// What riders paid in fares for rides in `state` whose payment is `paid`. + @Query("select coalesce(sum(r.fareCents), 0) from Ride r " + + "where r.state = :state and r.paymentStatus = :paid") + Long fares(@Param("state") RideState state, @Param("paid") String paid); + + /// The fares of the rides in `state` asked for since `since`, paid or not. + @Query("select coalesce(sum(r.fareCents), 0) from Ride r " + + "where r.requestedAt >= :since and r.state = :state") + Long faresSince(@Param("since") long since, @Param("state") RideState state); + + /// The rides asked for since `since`, counted by state: `{state, count}`. + @Query("select r.state, count(r) from Ride r where r.requestedAt >= :since group by r.state") + List countByState(@Param("since") long since); + + /// The same by product: `{product, count}`. + @Query("select r.product, count(r) from Ride r where r.requestedAt >= :since " + + "group by r.product") + List countByProduct(@Param("since") long since); + + /// The rides in `state` asked for since `since`, counted by driver: + /// `{driver, count}`. + @Query("select r.driver, count(r) from Ride r where r.requestedAt >= :since " + + "and r.state = :state group by r.driver") + List countByDriver(@Param("since") long since, @Param("state") RideState state); + + /// One row for each ride asked for since `since`: `{id, requestedAt, + /// state, fareCents, paymentStatus}`. + /// + /// This is what the charts by day and by hour of the day are counted + /// from, in Java. Grouping by a day would mean grouping by `requestedAt` + /// divided by the length of one, and a query cannot say that portably: + /// the number is sent as a bound value, once in the select list and once + /// in the `group by`, and a database that takes a statement's text at its + /// word does not see those as one expression. + @Query("select r.id, r.requestedAt, r.state, r.fareCents, r.paymentStatus from Ride r " + + "where r.requestedAt >= :since") + List timeline(@Param("since") long since); + + /// What a rider paid for rides in `state` whose payment is `paid`: + /// `{fares, fees, tips}`. + @Query("select coalesce(sum(r.fareCents), 0), coalesce(sum(r.feeCents), 0), " + + "coalesce(sum(r.tipCents), 0) from Ride r " + + "where r.rider = :who and r.state = :state and r.paymentStatus = :paid") + Object[] spent(@Param("who") String username, @Param("state") RideState state, + @Param("paid") String paid); +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/DemoRides.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/DemoRides.java new file mode 100644 index 00000000000..5c4e79d5bf5 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/DemoRides.java @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ride; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.wayline.Days; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.api.PricingDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.domain.DriverApplication; +import com.codenameone.examples.wayline.domain.DriverState; +import com.codenameone.examples.wayline.domain.Payment; +import com.codenameone.examples.wayline.domain.Payout; +import com.codenameone.examples.wayline.domain.PayoutAccount; +import com.codenameone.examples.wayline.domain.Ride; +import com.codenameone.examples.wayline.domain.RideEvent; +import com.codenameone.examples.wayline.domain.RidePassed; +import com.codenameone.examples.wayline.geo.Geo; +import com.codenameone.examples.wayline.pay.Payments; +import com.codenameone.examples.wayline.repository.DriverApplicationRepository; +import com.codenameone.examples.wayline.repository.DriverStateRepository; +import com.codenameone.examples.wayline.repository.PaymentRepository; +import com.codenameone.examples.wayline.repository.PayoutAccountRepository; +import com.codenameone.examples.wayline.repository.PayoutRepository; +import com.codenameone.examples.wayline.repository.RideEventRepository; +import com.codenameone.examples.wayline.repository.RidePassedRepository; +import com.codenameone.examples.wayline.repository.RideRepository; + +import java.io.IOException; + +/// Two weeks of rides that were never taken, so that the admin's charts, a +/// driver's earnings and a rider's receipts have something in them the first +/// time a development server is opened. +/// +/// Stored whole, as finished rides with their events and payments, in one +/// transaction: there is no way to take a ride last Tuesday through the API. The +/// numbers come from a fixed sequence and not from a random source, so every +/// server starts with the same fortnight, moved to end today. +@Component +public class DemoRides { + private static final int DAYS = 14; + private static final Object[][] PLACES = { + {"Ferry Building", 37.7955, -122.3937}, + {"Pier 39", 37.8087, -122.4098}, + {"Coit Tower", 37.8024, -122.4058}, + {"Ghirardelli Square", 37.8059, -122.4229}, + {"Lombard Street", 37.8021, -122.4187}, + {"Palace of Fine Arts", 37.8029, -122.4484}, + {"Union Square", 37.7880, -122.4075}, + {"Oracle Park", 37.7786, -122.3893}, + {"Fort Mason", 37.8066, -122.4310}, + {"Transamerica Pyramid", 37.7952, -122.4028}}; + /// The hours rides are asked for at, the busy ones more than once. + private static final int[] HOURS = {7, 8, 8, 9, 12, 13, 15, 17, 18, 18, 19, 20, 22, 23}; + + private final RideRepository rides; + private final RideEventRepository events; + private final RidePassedRepository passes; + private final PaymentRepository paid; + private final DriverStateRepository cars; + private final DriverApplicationRepository applications; + private final PayoutAccountRepository payoutAccounts; + private final PayoutRepository payouts; + private final Fares fares; + private final Payments payments; + private long sequence = 20240229L; + + public DemoRides(RideRepository rides, RideEventRepository events, + RidePassedRepository passes, PaymentRepository paid, DriverStateRepository cars, + DriverApplicationRepository applications, PayoutAccountRepository payoutAccounts, + PayoutRepository payouts, Fares fares, Payments payments) { + this.rides = rides; + this.events = events; + this.passes = passes; + this.paid = paid; + this.cars = cars; + this.applications = applications; + this.payoutAccounts = payoutAccounts; + this.payouts = payouts; + this.fares = fares; + this.payments = payments; + } + + /// Writes the fortnight, shared out among these riders and drivers. The + /// drivers are approved ones, and each ride is of its driver's kind of car. + /// + /// @param untilToday false ends the fortnight yesterday. A test that + /// compares a chart with a picture of it asks for that: rides of today + /// are moved back to before now, so today's bars would depend on the + /// hour the server was started at. + @Transactional + public void seed(String[] riders, String[] drivers, boolean untilToday) throws IOException { + long now = System.currentTimeMillis(); + long today = Days.start(now) - (untilToday ? 0L : Days.MILLIS); + PricingDto pricing = fares.pricing(); + long[] stars = new long[drivers.length]; + long[] rated = new long[drivers.length]; + long[] earnedEarlier = new long[drivers.length]; + long[] lastSeen = new long[drivers.length]; + String[] products = new String[drivers.length]; + for (int iter = 0; iter < drivers.length; iter++) { + DriverApplication made = applications.findById(drivers[iter]); + products[iter] = made == null ? Fares.STANDARD : made.getProduct(); + } + int count = 0; + for (int day = DAYS - 1; day >= 0; day--) { + int asked = 3 + next(4); + for (int number = 0; number < asked; number++, count++) { + int riderIndex = next(riders.length); + int driverIndex = next(drivers.length); + String rider = riders[riderIndex]; + String driver = drivers[driverIndex]; + int from = next(PLACES.length); + int to = (from + 1 + next(PLACES.length - 1)) % PLACES.length; + double meters = Geo.meters(real(from, 1), real(from, 2), real(to, 1), real(to, 2)) + * Fares.ROAD_FACTOR; + double seconds = meters / Fares.METERS_PER_SECOND; + Fares.Price price = Fares.price(pricing, meters, seconds, products[driverIndex]); + long wait = (20 + next(100)) * 1000L; + long pickup = (120 + next(240)) * 1000L; + long whole = wait + pickup + (long) (seconds * 1000d); + long at = today - day * Days.MILLIS + HOURS[next(HOURS.length)] * 3600000L + + next(3600) * 1000L; + // Nothing here happens in the future: a ride that would end + // after now is moved back until it has ended. + at = Math.min(at, now - whole - 60000L); + + int roll = next(20); + RideState state = roll < 15 ? RideState.COMPLETED + : roll == 17 ? RideState.CANCELLED_BY_DRIVER + : roll == 18 ? RideState.NO_DRIVERS : RideState.CANCELLED_BY_RIDER; + boolean completed = RideState.COMPLETED == state; + // A rider who cancels does it before a driver is found half the + // time, and after one has taken the ride the other half. + boolean taken = completed || RideState.CANCELLED_BY_DRIVER == state + || (RideState.CANCELLED_BY_RIDER == state && next(2) == 0); + String[] method = next(3) == 0 ? new String[] {Payments.CASH, Payments.CASH_LABEL} + : payments.choose(rider, null); + boolean cash = Payments.CASH.equals(method[0]); + long tip = completed && !cash && next(3) == 0 ? price.fare * 15L / 100L : 0L; + int given = 0; + if (completed && next(5) != 0) { + int liked = next(10); + given = liked == 0 ? 3 : liked < 4 ? 4 : 5; + stars[driverIndex] += given; + rated[driverIndex]++; + } + long ended = completed ? at + whole : taken ? at + wait + pickup / 2L + : RideState.NO_DRIVERS == state ? at + 90000L : at + 30000L; + String id = Ids.next(); + Ride ride = new Ride(); + ride.setId(id); + ride.setState(state); + ride.setRider(rider); + ride.setDriver(taken ? driver : ""); + ride.setPickupLat(real(from, 1)); + ride.setPickupLng(real(from, 2)); + ride.setPickupAddress(String.valueOf(PLACES[from][0])); + ride.setDropoffLat(real(to, 1)); + ride.setDropoffLng(real(to, 2)); + ride.setDropoffAddress(String.valueOf(PLACES[to][0])); + ride.setFareCents(price.fare); + ride.setCurrency(fares.currency()); + ride.setDistanceMeters(meters); + ride.setDurationSeconds(seconds); + ride.setRequestedAt(at); + ride.setUpdatedAt(ended); + ride.setSearchUntil(at + 90000L); + ride.setStars(given); + ride.setPaymentStatus(completed ? Payments.PAID : Payments.PENDING); + ride.setProduct(products[driverIndex]); + ride.setMethodId(method[0]); + ride.setMethodLabel(method[1]); + ride.setBaseCents(price.base); + ride.setDistanceCents(price.distance); + ride.setTimeCents(price.time); + ride.setFeeCents(price.fee); + ride.setTipCents(tip); + ride.setDriverCents(price.fare - Fares.commission(pricing, price.fare)); + rides.insert(ride); + event(id, RideState.REQUESTED, rider, at); + if (taken) { + event(id, RideState.OFFERED, "", at + 1000L); + event(id, RideState.ACCEPTED, driver, at + wait); + if (count % 7 == 3 && drivers.length > 1) { + // Now and then the first driver asked let it go by. + RidePassed passed = new RidePassed(); + passed.setRideId(id); + passed.setDriver(drivers[(driverIndex + 1) % drivers.length]); + passes.insert(passed); + } + } + if (completed) { + event(id, RideState.ARRIVED, driver, at + wait + pickup); + event(id, RideState.IN_PROGRESS, driver, at + wait + pickup + 60000L); + event(id, state, driver, ended); + Payment payment = new Payment(); + payment.setRideId(id); + payment.setAmountCents(price.fare + price.fee); + payment.setCurrency(fares.currency()); + payment.setStatus(Payments.PAID); + payment.setProviderRef(cash ? Payments.CASH : "sim_ch_ride-" + id); + if (tip > 0L) { + // A tip counts as paid -- in the driver's earnings, + // and for a refund -- once its charge has a reference. + payment.setTipRef("sim_ch_tip-" + id); + } + payment.setCreatedAt(ended); + paid.insert(payment); + lastSeen[driverIndex] = Math.max(lastSeen[driverIndex], ended); + if (ended < now - 7L * Days.MILLIS) { + earnedEarlier[driverIndex] += price.fare + - Fares.commission(pricing, price.fare) + tip; + } + } else { + event(id, state, RideState.CANCELLED_BY_DRIVER == state ? driver + : RideState.NO_DRIVERS == state ? "" : rider, ended); + } + } + } + for (int iter = 0; iter < drivers.length; iter++) { + // Off line, parked where the last ride might have ended, with the + // rating those rides gave. + DriverState car = cars.findById(drivers[iter]); + boolean first = car == null; + if (first) { + car = new DriverState(); + car.setUsername(drivers[iter]); + } + car.setLat(real(iter % PLACES.length, 1)); + car.setLng(real(iter % PLACES.length, 2)); + car.setLastSeen(lastSeen[iter]); + car.setRatingSum(stars[iter]); + car.setRatingCount(rated[iter]); + if (first) { + cars.insert(car); + } + if (earnedEarlier[iter] > 0L) { + // What the first week earned was cashed out at the end of it. + PayoutAccount account = new PayoutAccount(); + account.setUsername(drivers[iter]); + account.setHolder(drivers[iter]); + account.setBankName("Bay Credit Union"); + account.setLast4("67" + (10 + iter)); + payoutAccounts.insert(account); + Payout payout = new Payout(); + payout.setId(Ids.next()); + payout.setDriver(drivers[iter]); + payout.setAmountCents(earnedEarlier[iter]); + payout.setStatus("paid"); + payout.setCreatedAt(now - 7L * Days.MILLIS); + payout.setDestination("Bay Credit Union ****67" + (10 + iter)); + payouts.insert(payout); + } + } + } + + private void event(String ride, RideState state, String actor, long at) throws IOException { + RideEvent event = new RideEvent(); + event.setId(Ids.next()); + event.setRideId(ride); + event.setState(state); + event.setActor(actor); + event.setCreatedAt(at); + events.insert(event); + } + + private static double real(int place, int column) { + return ((Double) PLACES[place][column]).doubleValue(); + } + + /// The next number of the sequence, from 0 to `below` less one: a linear + /// congruential generator, which is all a made-up fortnight needs and is + /// the same on every runtime. + private int next(int below) { + sequence = (sequence * 1103515245L + 12345L) & 0x7fffffffL; + return (int) ((sequence >> 8) % below); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Fares.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Fares.java new file mode 100644 index 00000000000..b578c1a7f0e --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Fares.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ride; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.wayline.Text; +import com.codenameone.examples.wayline.api.PricingDto; +import com.codenameone.examples.wayline.api.RideRequestDto; +import com.codenameone.examples.wayline.domain.Pricing; +import com.codenameone.examples.wayline.geo.Geo; +import com.codenameone.examples.wayline.repository.PricingRepository; + +import java.io.IOException; + +/// What a ride costs. +/// +/// A base fare, plus so much a kilometre and so much a minute, with a floor; +/// the larger products multiply all of it, and so does a surge. On top of the +/// fare the rider pays a service fee, and out of it the service keeps a +/// commission; the rest is the driver's. +/// +/// Money is whole cents in a `long` from end to end: a fare is never a floating +/// point number, here or in the app. +/// +/// The prices start as settings (`wayline.fare.*`) and are the admin's to +/// change, which stores them in the database. They are read from there for +/// every quote and not kept in memory, so a change made through one instance +/// of the server is the price on all of them. +@Component +public class Fares { + public static final String STANDARD = "standard"; + public static final String COMFORT = "comfort"; + public static final String XL = "xl"; + /// Cheapest first, which is the order a quote lists them in. + public static final String[] PRODUCTS = {STANDARD, COMFORT, XL}; + + /// How much longer than the straight line a road route is taken to be when + /// the app did not send one. + static final double ROAD_FACTOR = 1.3d; + /// City driving, for a duration when the app did not send one: 30 km/h. + static final double METERS_PER_SECOND = 8.33d; + + private final PricingRepository prices; + private final PricingDto defaults; + + public Fares(PricingRepository prices, + @Value("${wayline.currency:USD}") String currency, + @Value("${wayline.fare.baseCents:250}") int baseCents, + @Value("${wayline.fare.perKmCents:120}") int perKmCents, + @Value("${wayline.fare.perMinuteCents:30}") int perMinuteCents, + @Value("${wayline.fare.minimumCents:500}") int minimumCents, + @Value("${wayline.fare.serviceFeePercent:10}") int serviceFeePercent, + @Value("${wayline.fare.commissionPercent:20}") int commissionPercent) { + this.prices = prices; + defaults = new PricingDto(currency, baseCents, perKmCents, perMinuteCents, minimumCents, + serviceFeePercent, commissionPercent, 1d, 1.35d, 1.7d); + } + + public String currency() { + return defaults.currency(); + } + + /// The prices in force: the admin's when they have set any, the settings' + /// otherwise. + @Transactional(readOnly = true) + public PricingDto pricing() throws IOException { + return toDto(prices.findById(PricingRepository.ONLY)); + } + + private PricingDto toDto(Pricing row) { + // The row a migration created and nobody has saved over yet. + if (row == null || row.getUpdatedAt() == 0L) { + return defaults; + } + // The currency is a setting and stays one. Rides already taken are in + // it, and changing it would not convert them. + return new PricingDto(defaults.currency(), row.getBaseCents(), row.getPerKmCents(), + row.getPerMinuteCents(), row.getMinimumCents(), row.getFeePercent(), + row.getCommissionPercent(), row.getSurge(), row.getComfort(), row.getXl()); + } + + /// Changes the prices, for the rides asked for from now on. A ride already + /// asked for keeps the price it was quoted. Answers 400 for prices that + /// could only be a mistake. + @Transactional + public PricingDto save(String by, PricingDto wanted) throws IOException { + if (wanted == null) { + throw new ResponseStatusException(400, "No prices to save"); + } + amount(wanted.baseFare(), "the base fare"); + amount(wanted.perKm(), "the price per kilometre"); + amount(wanted.perMinute(), "the price per minute"); + amount(wanted.minimumFare(), "the minimum fare"); + between(wanted.serviceFeePercent(), 0d, 50d, "the service fee"); + between(wanted.commissionPercent(), 0d, 90d, "the commission"); + between(wanted.surgeMultiplier(), 1d, 5d, "the surge multiplier"); + between(wanted.comfortMultiplier(), 1d, 5d, "the comfort multiplier"); + between(wanted.xlMultiplier(), 1d, 5d, "the XL multiplier"); + // The row is always there (V6 creates it), so this only ever updates + // it. Inserting it on the first save was a race: two admins saving + // before anyone had both inserted id 1, and one of them failed. + if (prices.change(PricingRepository.ONLY.intValue(), wanted.baseFare(), wanted.perKm(), + wanted.perMinute(), wanted.minimumFare(), wanted.serviceFeePercent(), + wanted.commissionPercent(), wanted.surgeMultiplier(), + wanted.comfortMultiplier(), wanted.xlMultiplier(), System.currentTimeMillis(), + by) == 0) { + throw new IllegalStateException("The pricing row is missing; the V6 migration " + + "creates it"); + } + return new PricingDto(defaults.currency(), wanted.baseFare(), wanted.perKm(), + wanted.perMinute(), wanted.minimumFare(), wanted.serviceFeePercent(), + wanted.commissionPercent(), wanted.surgeMultiplier(), wanted.comfortMultiplier(), + wanted.xlMultiplier()); + } + + /// The length of the ride to price, in metres. + /// + /// The app sends the length of the route it drew, and that is the better + /// number -- but it is also a number the caller chose, and the fare follows + /// from it. So it is believed only between the straight line and four times + /// it; outside that the server prices from its own estimate. + public double distanceMeters(RideRequestDto request) { + double straight = Geo.meters(request.pickupLat(), request.pickupLng(), request.dropoffLat(), + request.dropoffLng()); + if (request.distanceMeters() >= straight && request.distanceMeters() <= straight * 4) { + return request.distanceMeters(); + } + return straight * ROAD_FACTOR; + } + + /// The duration to price, in seconds; believed on the same terms. + public double durationSeconds(RideRequestDto request, double distanceMeters) { + double estimate = distanceMeters / METERS_PER_SECOND; + if (distanceMeters == request.distanceMeters() && request.durationSeconds() >= estimate / 4 + && request.durationSeconds() <= estimate * 4) { + return request.durationSeconds(); + } + return estimate; + } + + /// A ride of this length and duration, taken as `product`, at `pricing`. + public static Price price(PricingDto pricing, double distanceMeters, double durationSeconds, + String product) { + double times = pricing.surgeMultiplier(); + if (COMFORT.equals(product)) { + times *= pricing.comfortMultiplier(); + } else if (XL.equals(product)) { + times *= pricing.xlMultiplier(); + } + Price price = new Price(); + price.base = Math.round(pricing.baseFare() * times); + price.distance = Math.round(distanceMeters / 1000d * pricing.perKm() * times); + price.time = Math.round(durationSeconds / 60d * pricing.perMinute() * times); + price.fare = price.base + price.distance + price.time; + long minimum = Math.round(pricing.minimumFare() * times); + if (price.fare < minimum) { + // The floor is a larger base fare, so the three parts still add up + // to what is charged. + price.base += minimum - price.fare; + price.fare = minimum; + } + price.fee = Math.round(price.fare * pricing.serviceFeePercent() / 100d); + return price; + } + + /// What the service keeps of a fare. + public static long commission(PricingDto pricing, long fareCents) { + return Math.round(fareCents * pricing.commissionPercent() / 100d); + } + + /// The product a request names; standard when it names none. Answers 400 + /// for one that does not exist. + public static String product(String value) { + return Text.oneOf(value, PRODUCTS, STANDARD, "the kind of ride"); + } + + /// The parts of a price, in cents. `fare` is the first three added up, and + /// `fee` is paid on top of it. + public static final class Price { + public long base; + public long distance; + public long time; + public long fare; + public long fee; + } + + private static void amount(long cents, String what) { + if (cents < 0L || cents > 1000000L) { + throw new ResponseStatusException(400, "That is not an amount for " + what); + } + } + + private static void between(double value, double low, double high, String what) { + // Written so that NaN, which compares false to everything, is refused. + if (!(value >= low && value <= high)) { + throw new ResponseStatusException(400, "That is not a value for " + what); + } + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Matcher.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Matcher.java new file mode 100644 index 00000000000..4363ef2078f --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Matcher.java @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ride; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UsernameNotFoundException; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.domain.DriverApplication; +import com.codenameone.examples.wayline.domain.DriverState; +import com.codenameone.examples.wayline.domain.Profile; +import com.codenameone.examples.wayline.domain.Ride; +import com.codenameone.examples.wayline.domain.RidePassed; +import com.codenameone.examples.wayline.driving.Applications; +import com.codenameone.examples.wayline.geo.Geo; +import com.codenameone.examples.wayline.repository.DriverApplicationRepository; +import com.codenameone.examples.wayline.repository.DriverStateRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; +import com.codenameone.examples.wayline.repository.RidePassedRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Finds the drivers near a point. +/// +/// No spatial index and no spatial database: the query asks for the drivers on +/// line inside a box of latitude and longitude around the point, which an +/// ordinary index answers, and the handful it returns are measured and sorted +/// here. That runs unchanged on SQLite, PostgreSQL and MySQL, and it is enough +/// until one city has more drivers on line than fit in a query result -- at +/// which point the box becomes a geohash column and nothing else changes. +/// +/// The box does not wrap around the 180th meridian. A service operating across +/// it would need a second box. +@Component +public class Matcher { + /// A driver not heard from for this long is not there, whatever their + /// status says: the app reports its position every few seconds while on + /// line, and a phone that lost its signal cannot go off line. + public static final long FRESH_MILLIS = 60000L; + /// How far to look at first, and how far once the first look found nobody. + static final double NEAR_METERS = 3000d; + static final double FAR_METERS = 8000d; + /// How long a ride waits before the search widens. + static final long WIDEN_AFTER_MILLIS = 20000L; + + /// The states of a ride that keep its driver from being offered another. + private static final RideState[] WITH_RIDE = {RideState.OFFERED, RideState.ACCEPTED, + RideState.ARRIVED, RideState.IN_PROGRESS}; + private static final String FEMALE = "female"; + + private final DriverStateRepository drivers; + private final DriverApplicationRepository applications; + private final ProfileRepository profiles; + private final RidePassedRepository passes; + private final JdbcUserDetailsManager users; + + public Matcher(DriverStateRepository drivers, DriverApplicationRepository applications, + ProfileRepository profiles, RidePassedRepository passes, + JdbcUserDetailsManager users) { + this.drivers = drivers; + this.applications = applications; + this.profiles = profiles; + this.passes = passes; + this.users = users; + } + + /// A driver who could be offered a ride: where the car is, what kind it + /// is, and how far it is from the point that was asked about. + public static final class Candidate { + public final String username; + public final double lat; + public final double lng; + public final double heading; + /// The kind of car, as the driver's application has it. + public final String product; + public final double meters; + /// The driver's state as it was read, for whoever wants more of it. + public final DriverState state; + + Candidate(DriverState state, String product, double meters) { + this.state = state; + this.username = state.getUsername(); + this.lat = state.getLat(); + this.lng = state.getLng(); + this.heading = state.getHeading(); + this.product = product; + this.meters = meters; + } + } + + /// The radius to search for a ride that has been waiting `waitedMillis`. + static double radius(long waitedMillis) { + return waitedMillis < WIDEN_AFTER_MILLIS ? NEAR_METERS : FAR_METERS; + } + + /// The drivers on line and free within `radiusMeters` of a point, nearest + /// first, of any kind. + @Transactional(readOnly = true) + public List nearby(double lat, double lng, double radiusMeters) + throws IOException { + return nearby(lat, lng, radiusMeters, null, false, false, false); + } + + /// The drivers a ride could be offered to: on line, free, in reach, and + /// fit for it. + /// + /// A driver is offered rides only while their application stands approved + /// and their account is not blocked. Both are read here, each time, and + /// not trusted to have been put right elsewhere: an approval withdrawn or + /// an account blocked takes a driver out of matching at once, whatever + /// their state still says. The approval is part of the query. What the + /// rider asked of the car and the driver is read from the applications + /// and profiles of the handful the query answers. Whether the account is + /// blocked is the sign-in's to say, and its tables are its own, so each + /// driver still fit for the ride is asked about there. + /// + /// @param product the kind of car the ride asks for, or null for any + /// @param womenOnly whether the rider asked for a woman driver + /// @param wheelchair whether the ride needs a car that takes a wheelchair + /// @param pets whether the ride needs a driver who takes pets + @Transactional(readOnly = true) + public List nearby(double lat, double lng, double radiusMeters, String product, + boolean womenOnly, boolean wheelchair, boolean pets) throws IOException { + double dLat = radiusMeters / Geo.METERS_PER_DEGREE; + double shrink = Math.max(0.01d, Math.cos(Math.toRadians(lat))); + double dLng = radiusMeters / (Geo.METERS_PER_DEGREE * shrink); + List boxed = drivers.freeInBox(System.currentTimeMillis() - FRESH_MILLIS, + lat - dLat, lat + dLat, lng - dLng, lng + dLng, Applications.APPROVED, WITH_RIDE); + List inReach = new ArrayList(); + List names = new ArrayList(); + for (int iter = 0; iter < boxed.size(); iter++) { + DriverState state = boxed.get(iter); + if (Geo.meters(lat, lng, state.getLat(), state.getLng()) > radiusMeters) { + // Inside the box but outside the circle: a corner of it. + continue; + } + inReach.add(state); + names.add(state.getUsername()); + } + if (inReach.isEmpty()) { + return new ArrayList(); + } + Map cars = new HashMap(); + List rows = applications.findByUsernameIn(names); + for (int iter = 0; iter < rows.size(); iter++) { + cars.put(rows.get(iter).getUsername(), rows.get(iter)); + } + Map people = new HashMap(); + if (womenOnly) { + List found = profiles.findByUsernameIn(names); + for (int iter = 0; iter < found.size(); iter++) { + people.put(found.get(iter).getUsername(), found.get(iter)); + } + } + List near = new ArrayList(); + for (int iter = 0; iter < inReach.size(); iter++) { + DriverState state = inReach.get(iter); + DriverApplication car = cars.get(state.getUsername()); + if (car == null) { + // The application went between the two queries, and the + // approval with it. + continue; + } + if (product != null && !product.equals(car.getProduct()) + || wheelchair && !car.isWheelchair() || pets && !car.isPets()) { + continue; + } + if (womenOnly) { + Profile who = people.get(state.getUsername()); + if (who == null || !FEMALE.equals(who.getGender())) { + continue; + } + } + if (!enabled(state.getUsername())) { + continue; + } + double meters = Geo.meters(lat, lng, state.getLat(), state.getLng()); + int at = 0; + while (at < near.size() && near.get(at).meters <= meters) { + at++; + } + near.add(at, new Candidate(state, car.getProduct(), meters)); + } + return near; + } + + /// Whether `driver` is still one a ride could be offered to: on line and + /// heard from lately, free, in reach, approved, not blocked, and fit for + /// what the rider asked. [#pick] read all of that in a transaction of its + /// own, and any of it can have changed before the offer is made; the + /// offer asks again once it holds the driver. + @Transactional(readOnly = true) + public boolean fits(Ride ride, String driver, long waitedMillis) throws IOException { + List near = nearby(ride.getPickupLat(), ride.getPickupLng(), + radius(waitedMillis), ride.getProduct(), ride.isWomenOnly(), + ride.isWheelchair(), ride.isPets()); + for (int iter = 0; iter < near.size(); iter++) { + if (driver.equals(near.get(iter).username)) { + return true; + } + } + return false; + } + + /// Whether the account of this name exists and has not been blocked. + /// + /// Read once: an account closed between asking whether it exists and + /// loading it would otherwise throw, and turn a rider's request into a + /// server error over a driver who is simply no longer there. + boolean enabled(String username) { + try { + return users.loadUserByUsername(username).isEnabled(); + } catch (UsernameNotFoundException gone) { + return false; + } + } + + /// The nearest free driver fit for a ride who has not already passed on it, + /// or null when there is none in reach. + @Transactional(readOnly = true) + public String pick(Ride ride, long waitedMillis) throws IOException { + List near = nearby(ride.getPickupLat(), ride.getPickupLng(), radius(waitedMillis), + ride.getProduct(), ride.isWomenOnly(), ride.isWheelchair(), ride.isPets()); + if (near.isEmpty()) { + return null; + } + List rows = passes.findByRideId(ride.getId()); + List passed = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + passed.add(rows.get(iter).getDriver()); + } + for (int iter = 0; iter < near.size(); iter++) { + String driver = near.get(iter).username; + if (!passed.contains(driver)) { + return driver; + } + } + return null; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/RideStateMachine.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/RideStateMachine.java new file mode 100644 index 00000000000..d86df8f8b44 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/RideStateMachine.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ride; + +import com.codenameone.examples.wayline.api.RideState; + +/// Which state a ride may move to from which. +/// +/// ``` +/// REQUESTED -> OFFERED -> ACCEPTED -> ARRIVED -> IN_PROGRESS -> COMPLETED +/// ^ | +/// +-----------+ a driver passes, or lets the offer lapse +/// ``` +/// +/// The rider can cancel until the trip starts, the driver once they have taken +/// it and until it starts, and a search that finds nobody ends in NO_DRIVERS. +/// An admin can cancel a ride at any point before it ends. +/// Nothing leaves a finished state. +/// +/// This table is the only place the rules are written. A move is made by one +/// `UPDATE ... WHERE state IN (the states it may come from)`, so the database +/// applies the rule and settles a race in the same statement: of two drivers +/// accepting at once, or a rider cancelling as a driver accepts, one statement +/// changes a row and the other changes none. +public final class RideStateMachine { + private static final RideState[] NONE = {}; + private static final RideState[] FROM_REQUESTED = {RideState.REQUESTED}; + private static final RideState[] FROM_OFFERED = {RideState.OFFERED}; + private static final RideState[] FROM_ACCEPTED = {RideState.ACCEPTED}; + private static final RideState[] FROM_ARRIVED = {RideState.ARRIVED}; + private static final RideState[] FROM_IN_PROGRESS = {RideState.IN_PROGRESS}; + private static final RideState[] BEFORE_THE_TRIP = {RideState.REQUESTED, RideState.OFFERED, + RideState.ACCEPTED, RideState.ARRIVED}; + private static final RideState[] UNDER_WAY = {RideState.REQUESTED, RideState.OFFERED, + RideState.ACCEPTED, RideState.ARRIVED, RideState.IN_PROGRESS}; + private static final RideState[] TAKEN_NOT_STARTED = {RideState.ACCEPTED, RideState.ARRIVED}; + + private RideStateMachine() { + } + + /// The states a ride may be in to move to `to`. Empty for a state nothing + /// moves to. + public static RideState[] sources(RideState to) { + if (RideState.OFFERED == to) { + return FROM_REQUESTED; + } + if (RideState.REQUESTED == to || RideState.ACCEPTED == to) { + return FROM_OFFERED; + } + if (RideState.ARRIVED == to) { + return FROM_ACCEPTED; + } + if (RideState.IN_PROGRESS == to) { + return FROM_ARRIVED; + } + if (RideState.COMPLETED == to) { + return FROM_IN_PROGRESS; + } + if (RideState.CANCELLED_BY_RIDER == to) { + return BEFORE_THE_TRIP; + } + if (RideState.CANCELLED_BY_DRIVER == to) { + return TAKEN_NOT_STARTED; + } + if (RideState.CANCELLED_BY_ADMIN == to) { + return UNDER_WAY; + } + if (RideState.NO_DRIVERS == to) { + return FROM_REQUESTED; + } + return NONE; + } + + public static boolean allowed(RideState from, RideState to) { + RideState[] sources = sources(to); + for (int iter = 0; iter < sources.length; iter++) { + if (sources[iter] == from) { + return true; + } + } + return false; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/RiderEndpoint.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/RiderEndpoint.java new file mode 100644 index 00000000000..9c1ae791167 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/RiderEndpoint.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ride; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.wayline.Caller; +import com.codenameone.examples.wayline.Roles; +import com.codenameone.examples.wayline.api.FareQuoteDto; +import com.codenameone.examples.wayline.api.NearbyDriverDto; +import com.codenameone.examples.wayline.api.RatingDto; +import com.codenameone.examples.wayline.api.RideActivityDto; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.api.RideRequestDto; +import com.codenameone.examples.wayline.api.RiderApiServer; +import com.codenameone.examples.wayline.push.RidePushes; + +import java.util.List; + +// tag::wayline-contract-endpoint[] +/// The server's half of `RiderApi`. Every method acts as the signed-in user and +/// on that user's rides only: the rider is never a parameter. +@Component +public class RiderEndpoint implements RiderApiServer { + private final Rides rides; + private final RidePushes pushes; + + public RiderEndpoint(Rides rides, RidePushes pushes) { + this.rides = rides; + this.pushes = pushes; + } + + @Override + public FareQuoteDto quote(RideRequestDto request) throws Exception { + return rides.quote(request); + } + + @Override + public RideDto request(RideRequestDto request) throws Exception { + return rides.request(Caller.name(), request); + } + // end::wayline-contract-endpoint[] + + @Override + public RideDto active() throws Exception { + return rides.activeForRider(Caller.name()); + } + + @Override + public List history() throws Exception { + return rides.historyForRider(Caller.name()); + } + + @Override + public RideDto ride(String id) throws Exception { + return rides.get(Caller.name(), Caller.hasRole(Roles.ADMIN), id); + } + + @Override + public RideDto cancel(String id) throws Exception { + return rides.cancelByRider(Caller.name(), id); + } + + @Override + public List nearby(double lat, double lng) throws Exception { + Caller.name(); + return rides.nearbyDrivers(lat, lng); + } + + @Override + public RideDto rate(String id, RatingDto rating) throws Exception { + return rides.rate(Caller.name(), id, rating == null ? 0 : rating.stars()); + } + + @Override + public RideActivityDto activity(String id, RideActivityDto activity) throws Exception { + return pushes.attach(Caller.name(), id, activity); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Rides.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Rides.java new file mode 100644 index 00000000000..8bc4db9443a --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/ride/Rides.java @@ -0,0 +1,1066 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ride; + +import com.codename1.backend.ConcurrencyFailureException; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.session.Identifier; +import com.codename1.orm.session.PersistenceException; +import com.codenameone.examples.wayline.Ids; +import com.codenameone.examples.wayline.Text; +import com.codenameone.examples.wayline.account.Moderation; +import com.codenameone.examples.wayline.api.FareOptionDto; +import com.codenameone.examples.wayline.api.FareQuoteDto; +import com.codenameone.examples.wayline.api.NearbyDriverDto; +import com.codenameone.examples.wayline.api.PricingDto; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.api.RideRequestDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.domain.DriverState; +import com.codenameone.examples.wayline.domain.Profile; +import com.codenameone.examples.wayline.domain.Ride; +import com.codenameone.examples.wayline.domain.RideEvent; +import com.codenameone.examples.wayline.domain.RidePassed; +import com.codenameone.examples.wayline.geo.Geo; +import com.codenameone.examples.wayline.live.LiveHub; +import com.codenameone.examples.wayline.pay.Payments; +import com.codenameone.examples.wayline.push.RidePushes; +import com.codenameone.examples.wayline.repository.DriverStateRepository; +import com.codenameone.examples.wayline.repository.ProfileRepository; +import com.codenameone.examples.wayline.repository.RideEventRepository; +import com.codenameone.examples.wayline.repository.RidePassedRepository; +import com.codenameone.examples.wayline.repository.RideRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Rides: asking for one, finding it a driver, and taking it through to the end. +/// +/// Every change of state goes through [#move], which makes it with a single +/// conditional update ([RideStateMachine]) and records it. The people concerned +/// are then told over the live channel. What they are told is only that the +/// ride changed; they read the ride itself from here, so there is one account +/// of a ride and the live channel cannot disagree with it. +/// +/// That is also why the telling waits for the change to be committed. An app +/// told of a change asks for the ride at once, over another connection, and +/// must find it changed. So the methods that change a ride come in pairs: a +/// private one that is the transaction, and the public one that calls it and, +/// once it has returned, tells whoever is concerned. +@Component +public class Rides { + /// Rides shorter than this are not rides, and longer than this are not this + /// service's. + static final double MIN_METERS = 100d; + static final double MAX_METERS = 150000d; + private static final String[] DRIVER_GENDERS = {"any", "women"}; + private static final String WOMEN = "women"; + /// What a rider is told about each kind of ride: its name, a line about + /// it, and how many it seats. In the order of [Fares#PRODUCTS]. + private static final String[][] PRODUCT_TEXT = { + {"Wayline", "Affordable everyday rides", "4"}, + {"Comfort", "Newer cars with extra legroom", "4"}, + {"XL", "Room for up to six", "6"}}; + /// How many drivers one search may choose before it leaves the ride to the + /// next tick; see [#search]. + private static final int SEARCH_TRIES = 3; + /// The two people a move can be held to; see [#tryMove]. + private static final String RIDER = "rider"; + private static final String DRIVER = "driver"; + private static final RideState[] ACTIVE = {RideState.REQUESTED, RideState.OFFERED, + RideState.ACCEPTED, RideState.ARRIVED, RideState.IN_PROGRESS}; + private static final RideState[] WITH_DRIVER = {RideState.OFFERED, RideState.ACCEPTED, + RideState.ARRIVED, RideState.IN_PROGRESS}; + private static final RideState[] ENDED_WITH_DRIVER = {RideState.COMPLETED, + RideState.CANCELLED_BY_RIDER, RideState.CANCELLED_BY_DRIVER, + RideState.CANCELLED_BY_ADMIN}; + + private final RideRepository rides; + private final RideEventRepository events; + private final RidePassedRepository passes; + private final DriverStateRepository drivers; + private final ProfileRepository profiles; + private final Matcher matcher; + private final Fares fares; + private final Payments payments; + private final LiveHub live; + private final RidePushes pushes; + private final Moderation moderation; + private final long offerMillis; + private final long searchMillis; + + /// @param offerSeconds how long a driver has to answer an offer + /// @param searchSeconds how long a ride looks for a driver before giving up + public Rides(RideRepository rides, RideEventRepository events, RidePassedRepository passes, + DriverStateRepository drivers, ProfileRepository profiles, Matcher matcher, Fares fares, Payments payments, LiveHub live, RidePushes pushes, + Moderation moderation, + @Value("${wayline.offer.seconds:15}") int offerSeconds, + @Value("${wayline.search.seconds:90}") int searchSeconds) { + this.rides = rides; + this.events = events; + this.passes = passes; + this.drivers = drivers; + this.profiles = profiles; + this.moderation = moderation; + this.matcher = matcher; + this.fares = fares; + this.payments = payments; + this.live = live; + this.pushes = pushes; + offerMillis = offerSeconds * 1000L; + searchMillis = searchSeconds * 1000L; + } + + // ---------------------------------------------------------------- riders + + @Transactional(readOnly = true) + public FareQuoteDto quote(RideRequestDto request) throws IOException { + validate(request); + double distanceMeters = fares.distanceMeters(request); + double durationSeconds = fares.durationSeconds(request, distanceMeters); + String wanted = Fares.product(request.product()); + PricingDto pricing = fares.pricing(); + // The drivers who could take it as the rider asked for it, of every + // kind of car, nearest first; each kind then counts its own. + List near = matcher.nearby(request.pickupLat(), request.pickupLng(), + Matcher.FAR_METERS, null, WOMEN.equals(driverGender(request)), + request.accessibleVehicle(), request.petFriendly()); + List options = new ArrayList(); + long amountCents = 0L; + int driversNearby = 0; + int etaSeconds = 0; + for (int iter = 0; iter < Fares.PRODUCTS.length; iter++) { + String product = Fares.PRODUCTS[iter]; + Fares.Price price = Fares.price(pricing, distanceMeters, durationSeconds, product); + int nearby = 0; + double nearest = -1d; + for (int d = 0; d < near.size(); d++) { + if (product.equals(near.get(d).product)) { + nearby++; + if (nearest < 0d) { + nearest = near.get(d).meters; + } + } + } + int eta = nearest < 0d ? 0 : (int) Math.max(60d, nearest * Fares.ROAD_FACTOR + / Fares.METERS_PER_SECOND); + options.add(new FareOptionDto(product, PRODUCT_TEXT[iter][0], PRODUCT_TEXT[iter][1], + Integer.parseInt(PRODUCT_TEXT[iter][2]), price.fare, price.fee, + price.fare + price.fee, nearby, (eta + 59) / 60)); + if (product.equals(wanted)) { + // The quote's own fields are the kind of ride that was asked + // about, which is the standard one for an app that names none. + amountCents = price.fare; + driversNearby = nearby; + etaSeconds = eta; + } + } + return new FareQuoteDto(amountCents, fares.currency(), distanceMeters, durationSeconds, + driversNearby, etaSeconds, options); + } + + /// The cars near a point, for the map a rider looks at before asking: + /// where they are and what kind, and nothing about who drives them. The + /// positions are rounded to about ten metres, and there are at most twenty. + @Transactional(readOnly = true) + public List nearbyDrivers(double lat, double lng) throws IOException { + if (!Geo.isPoint(lat, lng)) { + throw new ResponseStatusException(400, "That is not a place"); + } + List near = matcher.nearby(lat, lng, Matcher.FAR_METERS); + List out = new ArrayList(); + for (int iter = 0; iter < near.size() && iter < 20; iter++) { + Matcher.Candidate car = near.get(iter); + out.add(new NearbyDriverDto(Math.round(car.lat * 10000d) / 10000d, + Math.round(car.lng * 10000d) / 10000d, car.heading, car.product)); + } + return out; + } + + /// Asks for a ride and offers it to the nearest driver straight away. + public RideDto request(String rider, RideRequestDto request) throws IOException { + validate(request); + String id = attempt(() -> open(rider, request)); + live.rideChanged(id, RideState.REQUESTED, rider, "", ""); + search(id); + return read(id, rider); + } + + @Transactional + private String open(String rider, RideRequestDto request) throws IOException { + // Before anything is read: two requests from one rider take turns + // here, so the second looks for a ride under way only once the first + // has committed its own. Without it both look, both find none, and + // the rider has two rides. + profiles.hold(rider); + Profile profile = profiles.findById(rider); + if (profile == null || !profile.isPhoneVerified()) { + throw new ResponseStatusException(403, "Verify your phone number before you ride"); + } + if (rides.findFirstByRiderAndStateIn(rider, ACTIVE) != null) { + throw new ResponseStatusException(409, "You already have a ride under way"); + } + String product = Fares.product(request.product()); + boolean womenOnly = WOMEN.equals(driverGender(request)); + // Refused here, before there is a ride, when the card named is not the + // rider's. With none named it is their default, which is cash until + // they save a card. + String[] method = payments.choose(rider, request.paymentMethodId()); + double meters = fares.distanceMeters(request); + double seconds = fares.durationSeconds(request, meters); + PricingDto pricing = fares.pricing(); + Fares.Price price = Fares.price(pricing, meters, seconds, product); + long now = System.currentTimeMillis(); + // The fare is fixed here, from the server's own arithmetic, and so is + // the driver's share of it: a change of prices or of commission made + // while the ride is under way is for the rides after it. The quote the + // rider was shown is not sent back and would not be believed. + Ride ride = new Ride(); + ride.setId(Ids.next()); + ride.setState(RideState.REQUESTED); + ride.setRider(rider); + ride.setPickupLat(request.pickupLat()); + ride.setPickupLng(request.pickupLng()); + ride.setPickupAddress(address(request.pickupAddress())); + ride.setDropoffLat(request.dropoffLat()); + ride.setDropoffLng(request.dropoffLng()); + ride.setDropoffAddress(address(request.dropoffAddress())); + ride.setFareCents(price.fare); + ride.setCurrency(fares.currency()); + ride.setDistanceMeters(meters); + ride.setDurationSeconds(seconds); + ride.setRequestedAt(now); + ride.setUpdatedAt(now); + ride.setSearchUntil(now + searchMillis); + ride.setPaymentStatus(Payments.PENDING); + ride.setProduct(product); + ride.setMethodId(method[0]); + ride.setMethodLabel(method[1]); + ride.setBaseCents(price.base); + ride.setDistanceCents(price.distance); + ride.setTimeCents(price.time); + ride.setFeeCents(price.fee); + ride.setDriverCents(price.fare - Fares.commission(pricing, price.fare)); + ride.setWomenOnly(womenOnly); + ride.setQuiet(request.quietRide()); + ride.setWheelchair(request.accessibleVehicle()); + ride.setNote(address(request.note())); + ride.setPets(request.petFriendly()); + rides.insert(ride); + event(ride.getId(), RideState.REQUESTED, rider); + return ride.getId(); + } + + // tag::wayline-service-readonly[] + @Transactional(readOnly = true) + public RideDto activeForRider(String rider) throws IOException { + Ride ride = rides.findFirstByRiderAndStateIn(rider, ACTIVE); + return ride == null ? none() : toDto(ride, rider, new HashMap()); + } + // end::wayline-service-readonly[] + + @Transactional(readOnly = true) + public List historyForRider(String rider) throws IOException { + return list(rides.findTop50ByRiderOrderByRequestedAtDesc(rider), rider); + } + + // tag::wayline-service-transaction[] + public RideDto cancelByRider(String rider, String id) throws IOException { + Change change = attempt(() -> cancelAsRider(rider, id)); + // The driver it was offered to or who had taken it is told as well. + return tell(change); + } + + @Transactional + private Change cancelAsRider(String rider, String id) throws IOException { + Ride before = owned(id, RIDER, rider); + move(id, RideState.CANCELLED_BY_RIDER, rider, RIDER, null, null); + return new Change(id, RideState.CANCELLED_BY_RIDER, rider, before.getDriver(), "", + view(id, rider)); + } + // end::wayline-service-transaction[] + + /// Rates a completed ride, once, and counts it toward the driver's rating. + public RideDto rate(String rider, String id, int stars) throws IOException { + if (stars < 1 || stars > 5) { + throw new ResponseStatusException(400, "A rating is 1 to 5 stars"); + } + return attempt(() -> rated(rider, id, stars)); + } + + @Transactional + private RideDto rated(String rider, String id, int stars) throws IOException { + String driver = owned(id, RIDER, rider).getDriver(); + if (rides.rate(id, rider, stars) != 1) { + throw new ResponseStatusException(409, "That ride cannot be rated now"); + } + drivers.addRating(driver, stars); + return view(id, rider); + } + + // --------------------------------------------------------------- drivers + + /// The offer waiting for this driver's answer, or the ride they are on. + @Transactional(readOnly = true) + public RideDto activeForDriver(String driver) throws IOException { + Ride ride = rides.findFirstByDriverAndStateIn(driver, WITH_DRIVER); + return ride == null ? none() : toDto(ride, driver, new HashMap()); + } + + @Transactional(readOnly = true) + public List historyForDriver(String driver) throws IOException { + return list(rides.findTop50ByDriverAndStateInOrderByRequestedAtDesc(driver, + ENDED_WITH_DRIVER), driver); + } + + public RideDto accept(String driver, String id) throws IOException { + return tell(attempt(() -> step(driver, id, RideState.ACCEPTED))); + } + + /// Passes on an offer. The ride goes back to looking, and not at this + /// driver again. + public RideDto decline(String driver, String id) throws IOException { + tell(attempt(() -> pass(driver, id))); + search(id); + return none(); + } + + @Transactional + private Change pass(String driver, String id) throws IOException { + Ride ride = owned(id, DRIVER, driver); + move(id, RideState.REQUESTED, driver, DRIVER, "", null); + passed(id, driver); + return new Change(id, RideState.REQUESTED, ride.getRider(), "", driver, null); + } + + public RideDto arrived(String driver, String id) throws IOException { + return tell(attempt(() -> step(driver, id, RideState.ARRIVED))); + } + + public RideDto start(String driver, String id) throws IOException { + return tell(attempt(() -> step(driver, id, RideState.IN_PROGRESS))); + } + + /// Ends the trip and charges the rider. + /// + /// In three steps, and only the first and the last are transactions. The + /// charge between them may be a call to a payment provider, which can take + /// seconds, and a transaction held open across it would hold a connection + /// for as long. + public RideDto complete(String driver, String id) throws IOException { + Ride ride = attempt(() -> finish(driver, id)); + // After the move, and only by the request whose update made it: a ride + // completes once, so it is charged once. + String status = payments.settle(ride); + return tell(settled(driver, id, status)); + } + + @Transactional + private Ride finish(String driver, String id) throws IOException { + Ride ride = owned(id, DRIVER, driver); + move(id, RideState.COMPLETED, driver, DRIVER, null, null); + return ride; + } + + /// How long a completed ride may wait for its payment before it is taken + /// to have been left behind. Longer than any charge takes. + private static final long UNSETTLED_MILLIS = 2L * 60L * 1000L; + + /// How long a completed ride's charge goes on being asked for. Short of + /// the day a processor remembers the key a charge was first sent under. + private static final long RETRY_MILLIS = Payments.RETRY_MILLIS; + + /// Finishes the payment of rides that completed and were never settled. + /// + /// Completing a ride commits before the card is charged, and what came of + /// the charge is written after it. A server stopped between the two leaves + /// a ride that is over and says its payment is pending, and nothing would + /// come back for it: the driver's request cannot be made again, the ride + /// having completed. This does. Settling is safe to repeat -- see + /// [Payments#settle] -- so a ride whose card was charged just before the + /// server stopped is recorded as paid and not charged again. + /// + /// A ride whose charge failed without the processor saying whether it took + /// the money is left pending too, and is asked about again here, every + /// minute for [#RETRY_MILLIS]. After that it stays pending and is not asked + /// about: the key that makes asking twice safe is kept by a processor for a + /// day, and a charge sent under it later than that is a second charge. What + /// became of such a ride is for a person to find in the processor's records. + @Scheduled(fixedDelay = 60000, initialDelay = 30000, lock = "wayline-settlement") + public void settleLeftBehind() throws IOException { + long now = System.currentTimeMillis(); + List unsettled = unsettled(now - RETRY_MILLIS, now - UNSETTLED_MILLIS); + Exception failed = null; + for (int iter = 0; iter < unsettled.size(); iter++) { + Ride ride = unsettled.get(iter); + try { + tell(settled(ride.getDriver(), ride.getId(), payments.settle(ride))); + } catch (IOException err) { + failed = failed == null ? err : failed; + } catch (RuntimeException err) { + failed = failed == null ? err : failed; + } + } + if (failed instanceof IOException) { + throw (IOException) failed; + } + if (failed instanceof RuntimeException) { + throw (RuntimeException) failed; + } + } + + @Transactional(readOnly = true) + private List unsettled(long after, long before) { + return rides.unsettled(after, before, new PageRequest(0, 50)); + } + + @Transactional + private Change settled(String driver, String id, String status) throws IOException { + rides.paymentStatus(id, status); + Ride ride = find(id); + return new Change(id, ride.getState(), ride.getRider(), ride.getDriver(), "", + toDto(ride, driver, new HashMap())); + } + + public RideDto cancelByDriver(String driver, String id) throws IOException { + return tell(attempt(() -> step(driver, id, RideState.CANCELLED_BY_DRIVER))); + } + + /// One of a driver's own moves of a ride that is theirs, and the ride as + /// they see it afterwards. + @Transactional + private Change step(String driver, String id, RideState to) throws IOException { + if (RideState.ACCEPTED == to) { + // Blocking a driver is refused while they have a ride taken, and + // takes the driver's profile to look. Taking an offer holds the + // same row first and then asks whether the account is still + // allowed: a block that committed meanwhile is seen here, and a + // block that comes after this commits sees the accepted ride. + // Either way no ride is left with a driver who can no longer + // drive it. + profiles.hold(driver); + if (!matcher.enabled(driver)) { + throw new ResponseStatusException(403, "This account has been blocked"); + } + } + owned(id, DRIVER, driver); + move(id, to, driver, DRIVER, null, null); + Ride ride = find(id); + return new Change(id, ride.getState(), ride.getRider(), ride.getDriver(), "", + toDto(ride, driver, new HashMap())); + } + + // ----------------------------------------------------------------- admin + + /// Ends a ride that has not ended, whoever has it and whatever it was + /// doing. Nothing is charged: a ride pays when it completes, and this one + /// does not. The reason is kept with the rider's account. + public RideDto cancelByAdmin(String by, String id, String reason) throws IOException { + String why = Text.required(reason, 255, "why the ride is cancelled"); + return tell(attempt(() -> cancelAsAdmin(by, id, why))); + } + + @Transactional + private Change cancelAsAdmin(String by, String id, String why) throws IOException { + Ride before = find(id); + if (before == null) { + throw new ResponseStatusException(404, "No such ride"); + } + String rider = before.getRider(); + String driver = before.getDriver(); + move(id, RideState.CANCELLED_BY_ADMIN, by, null, null, null); + moderation.record(rider, by, "cancel_ride", why); + return new Change(id, RideState.CANCELLED_BY_ADMIN, rider, driver, "", + toDto(find(id), driver, new HashMap(), false)); + } + + // ---------------------------------------------------------------- anyone + + /// A ride, for someone entitled to see it: its rider, its driver, or an + /// admin. Anyone else is told it does not exist, which is all they should + /// learn about it. + @Transactional(readOnly = true) + public RideDto get(String viewer, boolean admin, String id) throws IOException { + Ride ride = find(id); + if (ride == null || !(admin || viewer.equals(ride.getRider()) + || viewer.equals(ride.getDriver()))) { + throw new ResponseStatusException(404, "No such ride"); + } + return toDto(ride, admin ? ride.getDriver() : viewer, new HashMap(), + !admin); + } + + /// The most recent rides, for the admin. + @Transactional(readOnly = true) + public List recent() throws IOException { + List rows = rides.newest(new PageRequest(0, 100)); + Map cache = new HashMap(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Ride ride = rows.get(iter); + out.add(toDto(ride, ride.getDriver(), cache, false)); + } + return out; + } + + // -------------------------------------------------------------- matching + + /// Offers a waiting ride to the nearest driver who has not passed on it. + /// Does nothing when nobody is in reach; [#tick] tries again. + /// + /// In two transactions: one that chooses a driver and one that makes the + /// offer. Choosing reads who is free, and two rides asked for together + /// each read the same driver as free; the second transaction is where + /// they take turns over that driver, and it has to open with that -- see + /// [#offer]. + /// + /// A driver the offer finds no longer fit for the ride is passed over for + /// the next, as a decline or a lapse would be, without waiting for the + /// next tick: choosing again reads them as they now are, and does not pick + /// them. Bounded, so drivers changing faster than this can choose cost the + /// ride a tick and no more. + void search(String id) throws IOException { + String[] chosen = new String[1]; + Change change = null; + for (int tries = 0; tries < SEARCH_TRIES; tries++) { + change = attempt(() -> choose(id, chosen)); + if (chosen[0] == null) { + break; + } + change = attempt(() -> offer(id, chosen[0])); + if (change != null) { + break; + } + } + tell(change); + } + + /// Gives up on a ride that has waited too long, or names the driver to + /// offer it to in `chosen`. Returns what it changed, or null for nothing. + @Transactional + private Change choose(String id, String[] chosen) throws IOException { + chosen[0] = null; + Ride ride = find(id); + if (ride == null || RideState.REQUESTED != ride.getState()) { + return null; + } + long now = System.currentTimeMillis(); + if (now > ride.getSearchUntil()) { + return tryMove(id, RideState.NO_DRIVERS, "", null, null, null) + ? new Change(id, RideState.NO_DRIVERS, ride.getRider(), "", "", null) : null; + } + chosen[0] = matcher.pick(ride, now - ride.getRequestedAt()); + return null; + } + + // tag::wayline-ride-offer[] + /// Offers a ride to the driver chosen for it, unless they were given + /// another in the meantime. Returns what it changed, or null for nothing, + /// in which case [#tick] looks again. + /// + /// The driver is held before anything is read, so two rides that chose + /// the same driver come through here one after the other, and the second + /// reads the offer the first one committed. The update of the ride cannot + /// do this on its own: it is conditional on its own ride's row, and each + /// of the two has a row of its own. + /// + /// Everything that made the driver the choice is asked again once they + /// are held: a driver who went off line, drove out of reach, lost their + /// approval or was blocked since [#choose] read them would otherwise get + /// the offer and keep the rider waiting for it to lapse. Not private, so + /// that a test can make the offer to a driver who changed in between. + @Transactional + Change offer(String id, String driver) throws IOException { + if (drivers.hold(driver) == 0 + || rides.findFirstByDriverAndStateIn(driver, WITH_DRIVER) != null) { + return null; + } + Ride ride = find(id); + if (ride == null || RideState.REQUESTED != ride.getState()) { + return null; + } + long now = System.currentTimeMillis(); + if (!matcher.fits(ride, driver, now - ride.getRequestedAt())) { + return null; + } + return tryMove(id, RideState.OFFERED, "", null, driver, Long.valueOf(now + offerMillis)) + ? new Change(id, RideState.OFFERED, ride.getRider(), driver, "", null) : null; + } + // end::wayline-ride-offer[] + + /// Takes back the offers nobody answered and looks again for every ride + /// still waiting. Runs every two seconds, on one instance at a time: `lock` + /// keeps a second server from offering the same ride to two drivers. + /// + /// Not a transaction itself. Each ride is dealt with in one of its own, so + /// a ride that cannot be dealt with costs nobody else their turn; what went + /// wrong with the first such ride is reported once the rest are done. + @Scheduled(fixedDelay = 2000, initialDelay = 2000, lock = "wayline-matching") + public void tick() throws IOException { + Exception failed = null; + List lapsed = lapsedOffers(System.currentTimeMillis()); + for (int iter = 0; iter < lapsed.size(); iter++) { + Ride ride = lapsed.get(iter); + try { + tell(attempt(() -> lapse(ride.getId(), ride.getRider(), ride.getDriver()))); + } catch (IOException err) { + failed = failed == null ? err : failed; + } catch (RuntimeException err) { + failed = failed == null ? err : failed; + } + } + List waiting = waitingRides(); + for (int iter = 0; iter < waiting.size(); iter++) { + try { + search(waiting.get(iter).getId()); + } catch (IOException err) { + failed = failed == null ? err : failed; + } catch (RuntimeException err) { + failed = failed == null ? err : failed; + } + } + if (failed instanceof IOException) { + throw (IOException) failed; + } + if (failed instanceof RuntimeException) { + throw (RuntimeException) failed; + } + } + + @Transactional(readOnly = true) + private List lapsedOffers(long now) throws IOException { + return rides.findByStateAndOfferExpiresAtLessThan(RideState.OFFERED, now); + } + + @Transactional(readOnly = true) + private List waitingRides() throws IOException { + return rides.findByStateOrderByRequestedAtAsc(RideState.REQUESTED); + } + + /// Takes a lapsed offer back from the driver it was made to, or changes + /// nothing and answers null. + @Transactional + private Change lapse(String id, String rider, String driver) throws IOException { + // Held to the driver it lapsed for: if they accepted in the instant + // since the query, this changes nothing and the ride is theirs. + if (!tryMove(id, RideState.REQUESTED, driver, DRIVER, "", null)) { + return null; + } + passed(id, driver); + return new Change(id, RideState.REQUESTED, rider, "", driver, null); + } + + // ------------------------------------------------------------- internals + + /// What a transaction did to a ride, for telling the people concerned once + /// it has committed, and the ride as the caller is to see it. Not private: + /// the transactional methods answer it, and the class the build writes to + /// begin and commit their transactions has to be able to name it. + static final class Change { + final String id; + final RideState state; + final String rider; + final String driver; + /// Someone else to tell: the driver an offer was taken back from. + final String also; + final RideDto view; + + Change(String id, RideState state, String rider, String driver, String also, RideDto view) { + this.id = id; + this.state = state; + this.rider = rider; + this.driver = driver; + this.also = also; + this.view = view; + } + } + + /// Tells the people concerned about a change that has been committed, and + /// answers the view that came with it. Nothing changed, nothing said. + private RideDto tell(Change change) { + if (change == null) { + return null; + } + live.rideChanged(change.id, change.state, change.rider, change.driver, change.also); + // The same moment, and for the same reason: a phone told of a change + // must find it made. + pushes.rideChanged(change.id, change.state, change.rider, change.driver); + return change.view; + } + + /// One of the transactions of this class, for [#attempt] to run. + private interface Work { + T run() throws IOException; + } + + /// Runs a transaction that changes a ride, and runs it again if the database + /// ended it because another transaction changed the same ride first. + /// + /// Every change here is a ride read and then moved by a conditional update, + /// and two of them racing are meant to be settled by that update: the loser + /// changes no rows. Most databases do exactly that. One that holds a + /// transaction to the snapshot it first read from -- MariaDB, as it is + /// configured out of the box -- refuses the loser's update instead, because + /// the ride it read is no longer the ride that is there, and ends its + /// transaction with a [ConcurrencyFailureException]. + /// + /// That is the race lost and not a fault, so it gets the answer the other + /// databases give. The transaction was rolled back as the exception left + /// it, so nothing of it remains; run again, it reads the ride as the winner + /// left it and its update changes no rows. This is the one place that + /// happens, and it has to be outside the transaction: the database has + /// already ended that one, and a statement sent after the failure would run + /// on its own or be refused, depending on the database. + /// + /// Three attempts, because each one lost means someone else moved the ride, + /// and a ride has only so many moves in it. + private static T attempt(Work work) throws IOException { + for (int tries = 1; ; tries++) { + try { + return work.run(); + } catch (PersistenceException err) { + // The session reports what the database said as its own + // unchecked exception, with the database's as the cause. + if (tries == 3 || !ConcurrencyFailureException.isCauseOf(err)) { + throw err; + } + } catch (ConcurrencyFailureException err) { + if (tries == 3) { + throw err; + } + } + } + } + + /// Moves a ride to `to`, or answers 409 when its state does not allow it. + private void move(String id, RideState to, String actor, String ownerField, String newDriver, + Long newOfferExpiresAt) throws IOException { + if (!tryMove(id, to, actor, ownerField, newDriver, newOfferExpiresAt)) { + Ride now = find(id); + throw new ResponseStatusException(409, "That ride is " + + (now == null ? "gone" : describe(now.getState()))); + } + } + + /// The conditional update behind every change of state. True when this call + /// is the one that made the change. + /// + /// The session is empty afterwards, every move in `RideRepository` being + /// marked `clearAutomatically`: a ride read before this is no longer the + /// session's, and is read again by whoever needs it as it is now. + /// + /// @param ownerField [#RIDER] or [#DRIVER] to hold the change to rides + /// whose rider or driver is `actor`, or null for a change the server + /// makes itself + /// @param newDriver the driver to write with the change, or null to leave + /// it as it is + /// @param newOfferExpiresAt when the offer this change makes lapses, or null + // tag::wayline-ride-trymove[] + private boolean tryMove(String id, RideState to, String actor, String ownerField, + String newDriver, Long newOfferExpiresAt) throws IOException { + RideState[] sources = RideStateMachine.sources(to); + if (sources.length == 0) { + return false; + } + long now = System.currentTimeMillis(); + int moved; + if (newOfferExpiresAt != null) { + moved = rides.offer(id, sources, to, now, newDriver, newOfferExpiresAt.longValue()); + } else if (RIDER.equals(ownerField)) { + moved = rides.moveAsRider(id, sources, to, now, actor); + } else if (!DRIVER.equals(ownerField)) { + moved = rides.moveByServer(id, sources, to, now); + } else if (newDriver != null) { + moved = rides.giveBack(id, sources, to, now, actor, newDriver); + } else if (RideState.ACCEPTED == to) { + // An offer that has lapsed cannot be taken, even in the moment + // before the sweep takes it back. + moved = rides.takeOffer(id, sources, to, now, actor); + } else { + moved = rides.moveAsDriver(id, sources, to, now, actor); + } + if (moved != 1) { + return false; + } + event(id, to, actor); + return true; + } + // end::wayline-ride-trymove[] + + private void event(String id, RideState state, String actor) throws IOException { + RideEvent event = new RideEvent(); + event.setId(Ids.next()); + event.setRideId(id); + event.setState(state); + event.setActor(actor); + event.setCreatedAt(System.currentTimeMillis()); + events.insert(event); + } + + private void passed(String id, String driver) { + if (!passes.existsById(Identifier.of(id, driver))) { + RidePassed passed = new RidePassed(); + passed.setRideId(id); + passed.setDriver(driver); + passes.insert(passed); + } + } + + /// The ride, when `actor` is its rider or its driver, whichever `field` + /// names; 404 otherwise. + private Ride owned(String id, String field, String actor) { + Ride ride = find(id); + if (ride == null || !actor.equals(RIDER.equals(field) ? ride.getRider() + : ride.getDriver())) { + throw new ResponseStatusException(404, "No such ride"); + } + return ride; + } + + /// The ride of this id, or null when there is none. An id that cannot be + /// one is not looked for. + private Ride find(String id) { + if (id == null || id.length() == 0 || id.length() > 32) { + return null; + } + return rides.findById(id); + } + + /// The ride as `viewer` sees it, read in a transaction of its own. + @Transactional(readOnly = true) + private RideDto read(String id, String viewer) throws IOException { + return view(id, viewer); + } + + private RideDto view(String id, String viewer) throws IOException { + return toDto(find(id), viewer, new HashMap()); + } + + private List list(List rows, String viewer) throws IOException { + Map cache = new HashMap(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter), viewer, cache)); + } + return out; + } + + static RideDto none() { + return RideDto.none(); + } + + private RideDto toDto(Ride ride, String viewer, Map cache) + throws IOException { + return toDto(ride, viewer, cache, true); + } + + /// @param party whether `viewer` is asking as the ride's rider or driver. + /// An admin is shown a ride as its driver sees it, and is not a party: + /// the two phone numbers are for the two people who have to find each + /// other, and only while they do. + private RideDto toDto(Ride ride, String viewer, Map cache, boolean party) + throws IOException { + RideState state = ride.getState(); + // An account's name is the e-mail it signs in with. The other party + // to a ride is shown a display name, and a phone number while the two + // are meeting; the address is for its owner and for an admin. + boolean asRider = !party || (viewer != null && viewer.equals(ride.getRider())); + boolean asDriver = !party || (viewer != null && viewer.equals(ride.getDriver())); + Profile rider = profile(ride.getRider(), cache); + // What is known of the driver, filled in below for a ride that has one + // this viewer may see. + String driverUsername = null; + String driverName = null; + String driverPhotoInitials = ""; + String vehicle = null; + String plate = null; + String driverPhone = null; + String riderPhone = null; + double driverRating = 0d; + double driverLat = 0d; + double driverLng = 0d; + double driverHeading = 0d; + int etaMinutes = 0; + int offerExpiresInSeconds = 0; + String driver = ride.getDriver(); + boolean offered = RideState.OFFERED == state; + // An offer is between the server and one driver. Until it is taken the + // rider has no driver, and is not shown who was asked. + if (driver.length() > 0 && (!offered || driver.equals(viewer))) { + if (asDriver) { + driverUsername = driver; + } + Profile who = profile(driver, cache); + if (who != null) { + driverName = who.getDisplayName(); + driverPhotoInitials = Text.initials(driverName); + vehicle = who.getVehicle(); + plate = who.getPlate(); + } + boolean meeting = RideState.ACCEPTED == state + || RideState.ARRIVED == state + || RideState.IN_PROGRESS == state; + if (party && meeting && viewer != null) { + if (viewer.equals(ride.getRider()) && who != null) { + driverPhone = who.getPhone(); + } else if (viewer.equals(driver) && rider != null) { + riderPhone = rider.getPhone(); + } + } + DriverState car = drivers.findById(driver); + if (car != null) { + driverRating = car.rating(); + } + // Where the car is belongs to the ride while it brings the two + // together and carries one of them. A ride that has ended stays in + // its rider's history for good, and the driver goes on reporting + // where they are for other people's rides: read from here, a past + // rider could follow them indefinitely. + if (car != null && meeting) { + driverLat = car.getLat(); + driverLng = car.getLng(); + driverHeading = car.getHeading(); + // How long until the car is where it is going next: the pickup + // until the rider is in it, the destination after. + if (RideState.ACCEPTED == state) { + etaMinutes = etaMinutes(driverLat, driverLng, ride.getPickupLat(), + ride.getPickupLng()); + } else if (RideState.IN_PROGRESS == state) { + etaMinutes = etaMinutes(driverLat, driverLng, ride.getDropoffLat(), + ride.getDropoffLng()); + } + } + } + if (offered) { + offerExpiresInSeconds = (int) Math.max(0L, + (ride.getOfferExpiresAt() - System.currentTimeMillis()) / 1000L); + } + long fareCents = ride.getFareCents(); + long serviceFee = ride.getFeeCents(); + long tip = ride.getTipCents(); + return new RideDto( + ride.getId(), + state, + asRider ? ride.getRider() : null, + rider == null ? "" : rider.getDisplayName(), + driverUsername, + driverName, + vehicle, + plate, + driverRating, + ride.getPickupLat(), + ride.getPickupLng(), + ride.getPickupAddress(), + ride.getDropoffLat(), + ride.getDropoffLng(), + ride.getDropoffAddress(), + driverLat, + driverLng, + driverHeading, + fareCents, + ride.getCurrency(), + ride.getDistanceMeters(), + ride.getDurationSeconds(), + ride.getRequestedAt(), + ride.getUpdatedAt(), + ride.getStars(), + ride.getPaymentStatus(), + offerExpiresInSeconds, + ride.getProduct(), + ride.getMethodLabel(), + Payments.CASH.equals(ride.getMethodId()), + serviceFee, + tip, + fareCents + serviceFee + tip, + driverPhotoInitials, + etaMinutes, + ride.isWomenOnly() ? WOMEN : DRIVER_GENDERS[0], + ride.isQuiet(), + ride.isWheelchair(), + ride.isPets(), + ride.getNote(), + driverPhone, + riderPhone); + } + + /// Minutes for a car to drive from one point to another, by the same + /// estimate the fares are made with. Never less than one. + public static int etaMinutes(double fromLat, double fromLng, double toLat, double toLng) { + double seconds = Geo.meters(fromLat, fromLng, toLat, toLng) * Fares.ROAD_FACTOR + / Fares.METERS_PER_SECOND; + return Math.max(1, (int) Math.ceil(seconds / 60d)); + } + + private static String driverGender(RideRequestDto request) { + return Text.oneOf(request.driverGender(), DRIVER_GENDERS, DRIVER_GENDERS[0], "the driver"); + } + + private Profile profile(String username, Map cache) { + if (cache.containsKey(username)) { + return cache.get(username); + } + Profile row = profiles.findById(username); + cache.put(username, row); + return row; + } + + private static void validate(RideRequestDto request) { + if (request == null || !Geo.isPoint(request.pickupLat(), request.pickupLng()) + || !Geo.isPoint(request.dropoffLat(), request.dropoffLng())) { + throw new ResponseStatusException(400, "Choose where the ride starts and ends"); + } + double meters = Geo.meters(request.pickupLat(), request.pickupLng(), request.dropoffLat(), + request.dropoffLng()); + if (meters < MIN_METERS) { + throw new ResponseStatusException(400, "That is too close to need a ride"); + } + if (meters > MAX_METERS) { + throw new ResponseStatusException(400, "That is further than we go"); + } + } + + private static String address(String value) { + String text = value == null ? "" : value.trim(); + return text.length() > 255 ? text.substring(0, 255) : text; + } + + private static String describe(RideState state) { + if (RideState.COMPLETED == state) { + return "already completed"; + } + if (RideState.IN_PROGRESS == state) { + return "already under way"; + } + if (RideState.ACCEPTED == state || RideState.ARRIVED == state) { + return "already taken"; + } + if (RideState.REQUESTED == state || RideState.OFFERED == state) { + return "no longer on offer to you"; + } + return "no longer available"; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/ConsoleSmsSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/ConsoleSmsSender.java new file mode 100644 index 00000000000..7d9c7adc0c7 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/ConsoleSmsSender.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.sms; + +/// Prints the message on the server's console instead of sending it. +/// +/// For development only: [com.codenameone.examples.wayline.Services] hands it +/// out on the `dev`, `test` and `local` profiles alone, and a production server +/// that asks for it is refused with a configuration error. It is not a stand-in +/// for a provider -- nobody receives the text, which is exactly why a +/// development server may then hand the code back to the app. +public class ConsoleSmsSender implements SmsSender { + @Override + public void send(String phone, String text) { + System.out.println("[sms] to " + phone + ": " + text); + } + + @Override + public boolean delivers() { + return false; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/MessageBirdSmsSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/MessageBirdSmsSender.java new file mode 100644 index 00000000000..8068f2f41f9 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/MessageBirdSmsSender.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.sms; + +import com.codename1.backend.Web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Sends through MessageBird's (now Bird's) Messages API: a form POST to +/// `https://rest.messagebird.com/messages` carrying `recipients`, `originator` +/// and `body`, authenticated with an `Authorization: AccessKey ...` header. +/// MessageBird answers 201 when it has accepted the message. +/// +/// Settings: `wayline.sms.messagebird.key` (a live access key) and +/// `wayline.sms.messagebird.originator` (a number, or up to eleven letters and +/// digits). +public class MessageBirdSmsSender implements SmsSender { + private static final String URL = "https://rest.messagebird.com/messages"; + + private final String accessKey; + private final String originator; + + public MessageBirdSmsSender(String accessKey, String originator) { + this.accessKey = accessKey; + this.originator = originator; + } + + @Override + public void send(String phone, String text) throws IOException { + List headers = new ArrayList(); + headers.add("Authorization: AccessKey " + accessKey); + headers.add("Content-Type: application/x-www-form-urlencoded"); + Web.Result sent = Web.request("POST", URL, headers, SmsForm.of("recipients", phone, + "originator", originator, "body", text)); + if (sent.getStatus() != 201) { + // The status only: the body can echo the number back. + throw new IOException("MessageBird answered " + sent.getStatus()); + } + } + + @Override + public boolean delivers() { + return true; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/MisconfiguredSmsSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/MisconfiguredSmsSender.java new file mode 100644 index 00000000000..f0b07efb4d5 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/MisconfiguredSmsSender.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.sms; + +import java.io.IOException; + +/// What the server sends with when the SMS settings are incomplete: every +/// message fails, and says why. +/// +/// The problem is logged once when the server starts and again on every send, +/// naming the settings that are missing, so it is in front of whoever reads the +/// log at either moment. The failure is an `IOException` like any provider +/// outage, which the phone verification answers with 503: a server whose SMS +/// settings were forgotten verifies nobody, rather than everybody. +public class MisconfiguredSmsSender implements SmsSender { + private final String problem; + + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredSmsSender(String problem) { + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public void send(String phone, String text) throws IOException { + System.err.println("[config] a text message was not sent: " + problem); + throw new IOException(problem); + } + + /// True, because it stands in for a provider that would: the demo code a + /// development server hands back is only for the console sender. + @Override + public boolean delivers() { + return true; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/SmsForm.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/SmsForm.java new file mode 100644 index 00000000000..bab664d35db --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/SmsForm.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.sms; + +import java.io.IOException; +import java.net.URLEncoder; + +/// Builds an `application/x-www-form-urlencoded` body, which is what all three +/// providers' send calls take. +final class SmsForm { + private SmsForm() { + } + + /// Names and values in turn, each escaped as UTF-8. + static byte[] of(String... pairs) throws IOException { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < pairs.length; iter += 2) { + if (out.length() > 0) { + out.append('&'); + } + out.append(URLEncoder.encode(pairs[iter], "UTF-8")).append('=') + .append(URLEncoder.encode(pairs[iter + 1], "UTF-8")); + } + return out.toString().getBytes("UTF-8"); + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/SmsSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/SmsSender.java new file mode 100644 index 00000000000..61360a57219 --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/SmsSender.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.sms; + +import java.io.IOException; + +// tag::wayline-sms-sender[] +/// Sends a text message. The one place the server touches an SMS provider, so +/// changing provider is one class. +public interface SmsSender { + /// - `IOException`: when the message could not be handed to the provider + void send(String phone, String text) throws IOException; + + /// Whether a message really reaches a phone. False for the console sender used in + /// development, which is what lets a development server hand the code back to the + /// app in place of the text message nobody is going to receive. + boolean delivers(); +} +// end::wayline-sms-sender[] diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/TwilioSmsSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/TwilioSmsSender.java new file mode 100644 index 00000000000..680baeb397c --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/TwilioSmsSender.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.sms; + +import com.codename1.backend.Base64; +import com.codename1.backend.Web; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; + +/// Sends through Twilio's Messages API: a form POST to +/// `/2010-04-01/Accounts/{sid}/Messages.json`, authenticated with the account +/// SID and auth token as HTTP Basic credentials. Twilio answers 201 when it +/// has queued the message. +/// +/// Settings: `wayline.sms.twilio.sid`, `wayline.sms.twilio.token` and +/// `wayline.sms.twilio.from` (a Twilio number in E.164 form, or a messaging +/// service's sender). +/// +/// The account's credentials are settings of the server and never leave it: the +/// app asks the server for a code and has no SMS credentials of its own to leak. +public class TwilioSmsSender implements SmsSender { + private final String accountSid; + private final String authToken; + private final String from; + + public TwilioSmsSender(String accountSid, String authToken, String from) { + this.accountSid = accountSid; + this.authToken = authToken; + this.from = from; + } + + @Override + public void send(String phone, String text) throws IOException { + List headers = new ArrayList(); + headers.add("Authorization: Basic " + + Base64.encode((accountSid + ":" + authToken).getBytes("UTF-8"))); + headers.add("Content-Type: application/x-www-form-urlencoded"); + Web.Result sent = Web.request("POST", "https://api.twilio.com/2010-04-01/Accounts/" + + URLEncoder.encode(accountSid, "UTF-8") + "/Messages.json", headers, + SmsForm.of("To", phone, "From", from, "Body", text)); + if (!sent.isSuccess()) { + // The status only: the body can echo the number back. + throw new IOException("Twilio answered " + sent.getStatus()); + } + } + + @Override + public boolean delivers() { + return true; + } +} diff --git a/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/VonageSmsSender.java b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/VonageSmsSender.java new file mode 100644 index 00000000000..25d2cc000bc --- /dev/null +++ b/scripts/wayline/backend/src/main/java/com/codenameone/examples/wayline/sms/VonageSmsSender.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.sms; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +// tag::wayline-sms-provider[] +/// Sends through Vonage's (formerly Nexmo's) SMS API: a form POST to +/// `https://rest.nexmo.com/sms/json` carrying `api_key`, `api_secret`, `to`, +/// `from` and `text`. +/// +/// Vonage answers 200 even for a message it refused; the outcome is the +/// `status` of each entry of `messages`, where `"0"` is success and anything +/// else is an error code. A long text is split into parts and each part has an +/// entry, so every one of them has to be `"0"`. +/// +/// Settings: `wayline.sms.vonage.key`, `wayline.sms.vonage.secret` and +/// `wayline.sms.vonage.from` (a virtual number or an alphanumeric sender id). +/// The number is sent without its leading `+`, as Vonage asks. +public class VonageSmsSender implements SmsSender { + private static final String URL = "https://rest.nexmo.com/sms/json"; + + private final String apiKey; + private final String apiSecret; + private final String from; + + public VonageSmsSender(String apiKey, String apiSecret, String from) { + this.apiKey = apiKey; + this.apiSecret = apiSecret; + this.from = from; + } + + @Override + public void send(String phone, String text) throws IOException { + String to = phone.startsWith("+") ? phone.substring(1) : phone; + List headers = new ArrayList(); + headers.add("Content-Type: application/x-www-form-urlencoded"); + Web.Result sent = Web.request("POST", URL, headers, SmsForm.of("api_key", apiKey, + "api_secret", apiSecret, "to", to, "from", from, "text", text)); + if (sent.getStatus() != 200) { + throw new IOException("Vonage answered " + sent.getStatus()); + } + Object messages = Json.parseObject(sent.getBodyAsString()).get("messages"); + if (!(messages instanceof List) || ((List) messages).isEmpty()) { + throw new IOException("Vonage's answer lists no message"); + } + for (Object entry : (List) messages) { + Object status = entry instanceof Map ? ((Map) entry).get("status") : null; + if (!"0".equals(status)) { + // The code only; `error-text` can echo the number back. + throw new IOException("Vonage refused the message with status " + status); + } + } + } + + @Override + public boolean delivers() { + return true; + } +} +// end::wayline-sms-provider[] diff --git a/scripts/wayline/backend/src/main/resources/db/migration/V1__accounts.sql b/scripts/wayline/backend/src/main/resources/db/migration/V1__accounts.sql new file mode 100644 index 00000000000..0e3ba2480e1 --- /dev/null +++ b/scripts/wayline/backend/src/main/resources/db/migration/V1__accounts.sql @@ -0,0 +1,90 @@ +-- What the app knows about an account beyond its sign-in. The sign-in itself -- +-- the password hash, the roles, whether the account is enabled -- lives in the +-- security tables the backend creates (cn1.security.schema.enabled), keyed by +-- the same username. +-- +-- Every script here is written once for SQLite, PostgreSQL and MySQL: no +-- generated keys, no native boolean or timestamp. A yes/no is a SMALLINT 0/1 and +-- a time is milliseconds since the epoch in a BIGINT. +CREATE TABLE profile ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + display_name VARCHAR(120) NOT NULL, + phone VARCHAR(32) NOT NULL, + phone_verified SMALLINT NOT NULL, + vehicle VARCHAR(120) NOT NULL, + plate VARCHAR(32) NOT NULL, + created_at BIGINT NOT NULL, + -- What an account says about itself beyond its name and phone, and what an + -- admin has said about it. Added to the profile one column at a time, each with + -- a default: that is the one form of ALTER TABLE all three engines accept, and + -- the default is what the accounts opened before this script read as. + gender VARCHAR(16) NOT NULL, + language VARCHAR(8) NOT NULL, + flagged SMALLINT NOT NULL, + flag_reason VARCHAR(255) NOT NULL, + emergency_name VARCHAR(120) NOT NULL, + emergency_phone VARCHAR(32) NOT NULL, + home_address VARCHAR(255) NOT NULL, + work_address VARCHAR(255) NOT NULL +); + +-- How one person wants the app and their rides. A row exists once they have +-- saved something; until then the defaults are the server's. +CREATE TABLE preference ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + theme VARCHAR(8) NOT NULL, + units VARCHAR(4) NOT NULL, + notify_rides SMALLINT NOT NULL, + notify_receipts SMALLINT NOT NULL, + notify_promotions SMALLINT NOT NULL, + driver_gender VARCHAR(8) NOT NULL, + quiet_ride SMALLINT NOT NULL, + wheelchair SMALLINT NOT NULL, + pet_friendly SMALLINT NOT NULL, + default_tip INTEGER NOT NULL, + share_trips_with VARCHAR(32) NOT NULL +); + +-- The e-mails of accounts that were closed, as hashes. A closed account's rides +-- stay under its e-mail, so the address is never registered again; the hash is +-- enough to refuse it without keeping a list of who has left. +CREATE TABLE closed_account ( + username_hash VARCHAR(64) NOT NULL PRIMARY KEY, + closed_at BIGINT NOT NULL +); + +-- The verification code an account is waiting on. Only its hash is kept, and +-- that of the code before it while it is still good: text messages are not +-- delivered in the order they were sent. +CREATE TABLE phone_code ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + code_hash VARCHAR(64) NOT NULL, + expires_at BIGINT NOT NULL, + attempts INTEGER NOT NULL, + sent_at BIGINT NOT NULL, + previous_hash VARCHAR(64) NOT NULL DEFAULT '', + previous_expires_at BIGINT NOT NULL DEFAULT 0, + previous_attempts INTEGER NOT NULL DEFAULT 0 +); + +-- Tickets for opening the live channel. A row is deleted by the connection that +-- presents it, which is what makes a ticket good for one connection only -- and +-- keeping them here and not in memory is what lets a second server instance +-- redeem a ticket the first one issued. +CREATE TABLE live_ticket ( + ticket_hash VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + expires_at BIGINT NOT NULL +); + +-- What the admins did to an account and why: flagged it, blocked it, approved +-- or rejected its driving application. Never changed once written. +CREATE TABLE moderation_event ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL, + actor VARCHAR(190) NOT NULL, + action VARCHAR(16) NOT NULL, + reason VARCHAR(255) NOT NULL +); +CREATE INDEX moderation_event_user ON moderation_event (username, created_at); diff --git a/scripts/wayline/backend/src/main/resources/db/migration/V2__drivers.sql b/scripts/wayline/backend/src/main/resources/db/migration/V2__drivers.sql new file mode 100644 index 00000000000..ab67ea85807 --- /dev/null +++ b/scripts/wayline/backend/src/main/resources/db/migration/V2__drivers.sql @@ -0,0 +1,51 @@ +-- A driver's live state: on line or not, and where the car was last seen. +CREATE TABLE driver_state ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + online SMALLINT NOT NULL, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + heading DOUBLE PRECISION NOT NULL, + last_seen BIGINT NOT NULL, + rating_sum BIGINT NOT NULL, + rating_count BIGINT NOT NULL +); +-- Matching asks for the drivers on line inside a latitude band. +CREATE INDEX driver_online ON driver_state (online, lat); + +-- An application to drive: one per account, edited as a draft, handed in, and +-- then approved or rejected by an admin. The DRIVER role follows `approved`, +-- and matching reads the car's product from here. +CREATE TABLE driver_application ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + status VARCHAR(16) NOT NULL, + legal_name VARCHAR(120) NOT NULL, + date_of_birth VARCHAR(10) NOT NULL, + licence_number VARCHAR(40) NOT NULL, + licence_expiry VARCHAR(10) NOT NULL, + vehicle_make VARCHAR(40) NOT NULL, + vehicle_model VARCHAR(40) NOT NULL, + vehicle_year INTEGER NOT NULL, + vehicle_color VARCHAR(24) NOT NULL, + vehicle_plate VARCHAR(32) NOT NULL, + vehicle_seats INTEGER NOT NULL, + product VARCHAR(16) NOT NULL, + -- Whether the car takes a wheelchair. Not called "accessible", which MySQL + -- reserves. + wheelchair SMALLINT NOT NULL, + rejection_reason VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL, + submitted_at BIGINT NOT NULL, + reviewed_at BIGINT NOT NULL, + reviewed_by VARCHAR(190) NOT NULL, + pets SMALLINT NOT NULL +); +CREATE INDEX driver_application_status ON driver_application (status, submitted_at); + +-- How long a driver was on line on each day (days since the epoch, UTC), added +-- to by every position report. +CREATE TABLE driver_hours ( + username VARCHAR(190) NOT NULL, + day_number BIGINT NOT NULL, + millis BIGINT NOT NULL, + PRIMARY KEY (username, day_number) +); diff --git a/scripts/wayline/backend/src/main/resources/db/migration/V4__rides.sql b/scripts/wayline/backend/src/main/resources/db/migration/V4__rides.sql new file mode 100644 index 00000000000..4bd13eeb018 --- /dev/null +++ b/scripts/wayline/backend/src/main/resources/db/migration/V4__rides.sql @@ -0,0 +1,65 @@ +CREATE TABLE ride ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + state VARCHAR(24) NOT NULL, + rider VARCHAR(190) NOT NULL, + -- The driver it is offered to, then the driver who took it. Empty until one + -- is found, and again between one driver passing and the next being asked. + driver VARCHAR(190) NOT NULL, + pickup_lat DOUBLE PRECISION NOT NULL, + pickup_lng DOUBLE PRECISION NOT NULL, + pickup_address VARCHAR(255) NOT NULL, + dropoff_lat DOUBLE PRECISION NOT NULL, + dropoff_lng DOUBLE PRECISION NOT NULL, + dropoff_address VARCHAR(255) NOT NULL, + fare_cents BIGINT NOT NULL, + currency VARCHAR(3) NOT NULL, + distance_meters DOUBLE PRECISION NOT NULL, + duration_seconds DOUBLE PRECISION NOT NULL, + requested_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + -- When the offer in hand lapses, or when the search for a driver gives up. + offer_expires_at BIGINT NOT NULL, + search_until BIGINT NOT NULL, + stars SMALLINT NOT NULL, + payment_status VARCHAR(16) NOT NULL, + -- What the rider asked for beyond two points, how the ride is paid for, and + -- the parts its price is made of. Every amount is cents, fixed when the ride is + -- asked for; the tip and the driver's share are written when they are known. + product VARCHAR(16) NOT NULL, + method_id VARCHAR(32) NOT NULL, + method_label VARCHAR(64) NOT NULL, + base_cents BIGINT NOT NULL, + distance_cents BIGINT NOT NULL, + time_cents BIGINT NOT NULL, + fee_cents BIGINT NOT NULL, + tip_cents BIGINT NOT NULL, + driver_cents BIGINT NOT NULL, + women_only SMALLINT NOT NULL, + quiet SMALLINT NOT NULL, + wheelchair SMALLINT NOT NULL, + note VARCHAR(255) NOT NULL, + -- A rider may ask for a car their pet is welcome in, and a driver says whether + -- theirs is one. Neither is, until someone says so. + pets SMALLINT NOT NULL +); +CREATE INDEX ride_rider ON ride (rider, requested_at); +CREATE INDEX ride_driver ON ride (driver, requested_at); +CREATE INDEX ride_state ON ride (state); + +-- Every change of a ride's state, for the admin's view of what happened. +CREATE TABLE ride_event ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + ride_id VARCHAR(32) NOT NULL, + state VARCHAR(24) NOT NULL, + actor VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX ride_event_ride ON ride_event (ride_id, created_at); + +-- The drivers who passed on a ride, or let its offer lapse: they are not asked +-- again for the same ride. +CREATE TABLE ride_passed ( + ride_id VARCHAR(32) NOT NULL, + driver VARCHAR(190) NOT NULL, + PRIMARY KEY (ride_id, driver) +); diff --git a/scripts/wayline/backend/src/main/resources/db/migration/V5__payments.sql b/scripts/wayline/backend/src/main/resources/db/migration/V5__payments.sql new file mode 100644 index 00000000000..e439a7de9c2 --- /dev/null +++ b/scripts/wayline/backend/src/main/resources/db/migration/V5__payments.sql @@ -0,0 +1,82 @@ +CREATE TABLE payment ( + ride_id VARCHAR(32) NOT NULL PRIMARY KEY, + amount_cents BIGINT NOT NULL, + currency VARCHAR(3) NOT NULL, + status VARCHAR(16) NOT NULL, + provider_ref VARCHAR(64) NOT NULL, + created_at BIGINT NOT NULL, + -- A ride's payment grows a tip, which is a second charge, and a refund. + tip_ref VARCHAR(64) NOT NULL, + refund_ref VARCHAR(64) NOT NULL, + -- The tip is refunded on its own, and a refund the processor has not + -- finished is asked about by both references. + tip_refund_ref VARCHAR(64) NOT NULL, + refund_reason VARCHAR(255) NOT NULL, + refunded_at BIGINT NOT NULL +); + +-- The account a rider has with the payment processor, made the first time +-- they add a card. +CREATE TABLE payment_customer ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + reference VARCHAR(64) NOT NULL +); + +-- A saved card: what the processor calls it, and the few things about it that +-- are safe to show. No card number is ever stored here, or anywhere. +CREATE TABLE payment_method ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + brand VARCHAR(24) NOT NULL, + last4 VARCHAR(4) NOT NULL, + exp_month INTEGER NOT NULL, + exp_year INTEGER NOT NULL, + token VARCHAR(64) NOT NULL, + preferred SMALLINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX payment_method_user ON payment_method (username, created_at); + +-- An attempt to add a card, between its start and its completion. +CREATE TABLE payment_setup ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + reference VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL, + completed SMALLINT NOT NULL +); + +-- Where a driver's payouts go, and the payouts made. +CREATE TABLE payout_account ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + holder VARCHAR(120) NOT NULL, + bank_name VARCHAR(80) NOT NULL, + last4 VARCHAR(4) NOT NULL +); + +CREATE TABLE payout ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + driver VARCHAR(190) NOT NULL, + amount_cents BIGINT NOT NULL, + status VARCHAR(16) NOT NULL, + created_at BIGINT NOT NULL, + destination VARCHAR(120) NOT NULL +); +CREATE INDEX payout_driver ON payout (driver, created_at); + +-- What rides cost, once an admin has changed it: a single row, id 1. Until +-- there is one the prices are the fare settings of the server. +CREATE TABLE pricing ( + id INTEGER NOT NULL PRIMARY KEY, + base_cents BIGINT NOT NULL, + per_km_cents BIGINT NOT NULL, + per_minute_cents BIGINT NOT NULL, + minimum_cents BIGINT NOT NULL, + fee_percent DOUBLE PRECISION NOT NULL, + commission_percent DOUBLE PRECISION NOT NULL, + surge DOUBLE PRECISION NOT NULL, + comfort DOUBLE PRECISION NOT NULL, + xl DOUBLE PRECISION NOT NULL, + updated_at BIGINT NOT NULL, + updated_by VARCHAR(190) NOT NULL +); diff --git a/scripts/wayline/backend/src/main/resources/db/migration/V6__pricing_row.sql b/scripts/wayline/backend/src/main/resources/db/migration/V6__pricing_row.sql new file mode 100644 index 00000000000..184b86064a2 --- /dev/null +++ b/scripts/wayline/backend/src/main/resources/db/migration/V6__pricing_row.sql @@ -0,0 +1,8 @@ +-- The one row of prices, there from the start, so that saving prices only +-- ever updates it. Created on the first save instead, two admins saving +-- together before anyone had would both insert it, and one of them fail. +-- An updated_at of 0 says nobody has set prices yet: the settings' are the +-- ones in force until somebody does. +INSERT INTO pricing (id, base_cents, per_km_cents, per_minute_cents, minimum_cents, + fee_percent, commission_percent, surge, comfort, xl, updated_at, updated_by) +VALUES (1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, ''); diff --git a/scripts/wayline/backend/src/main/resources/db/migration/V7__push.sql b/scripts/wayline/backend/src/main/resources/db/migration/V7__push.sql new file mode 100644 index 00000000000..1eb762ce252 --- /dev/null +++ b/scripts/wayline/backend/src/main/resources/db/migration/V7__push.sql @@ -0,0 +1,31 @@ +-- The devices each account is sent push notifications on. Keyed by the hash of +-- the provider and the token, so one phone is on one account at a time: the +-- one that signed in on it last. +CREATE TABLE push_device ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + provider VARCHAR(16) NOT NULL, + token VARCHAR(1024) NOT NULL, + platform VARCHAR(32) NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE INDEX push_device_username ON push_device (username); + +-- The Live Activity a rider's phone shows a ride in, while the ride lasts. +-- sent_at is when the server last sent it a state, which spaces the updates +-- out: the car reports where it is every few seconds, and the lock screen +-- needs to hear about it far less often than that. ended_at is when the ride +-- ended, 0 while it lasts: the row is kept until the phone has been told, so an +-- end the push provider did not take is sent again. +CREATE TABLE ride_activity ( + ride_id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + activity_id VARCHAR(190) NOT NULL, + sent_at BIGINT NOT NULL, + ended_at BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX ride_activity_ended_at ON ride_activity (ended_at); + +CREATE INDEX ride_activity_username ON ride_activity (username); diff --git a/scripts/wayline/backend/src/main/resources/db/migration/mysql/V3__driver_documents.sql b/scripts/wayline/backend/src/main/resources/db/migration/mysql/V3__driver_documents.sql new file mode 100644 index 00000000000..7ca0d7d3fe9 --- /dev/null +++ b/scripts/wayline/backend/src/main/resources/db/migration/mysql/V3__driver_documents.sql @@ -0,0 +1,19 @@ +-- The pictures a driving application is backed by -- an identity document, a +-- licence, a selfie, the car's registration and its insurance -- one of each +-- kind for an account, as base64 text. +-- +-- In the database and not on a disk beside the server, so every instance of +-- the server reads the same documents and a backup of the database is a backup +-- of them. The server caps what it accepts well below what this column holds. +-- +-- This is the one script written per engine: a document outgrows the 64 KB of +-- MySQL's TEXT, and LONGTEXT is a type the other two do not have. +CREATE TABLE driver_document ( + username VARCHAR(190) NOT NULL, + kind VARCHAR(24) NOT NULL, + content_type VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + uploaded_at BIGINT NOT NULL, + data LONGTEXT NOT NULL, + PRIMARY KEY (username, kind) +); diff --git a/scripts/wayline/backend/src/main/resources/db/migration/postgresql/V3__driver_documents.sql b/scripts/wayline/backend/src/main/resources/db/migration/postgresql/V3__driver_documents.sql new file mode 100644 index 00000000000..fe2270d9be8 --- /dev/null +++ b/scripts/wayline/backend/src/main/resources/db/migration/postgresql/V3__driver_documents.sql @@ -0,0 +1,19 @@ +-- The pictures a driving application is backed by -- an identity document, a +-- licence, a selfie, the car's registration and its insurance -- one of each +-- kind for an account, as base64 text. +-- +-- In the database and not on a disk beside the server, so every instance of +-- the server reads the same documents and a backup of the database is a backup +-- of them. The server caps what it accepts well below what this column holds. +-- +-- This is the one script written per engine: a document outgrows the 64 KB of +-- MySQL's TEXT, and LONGTEXT is a type the other two do not have. +CREATE TABLE driver_document ( + username VARCHAR(190) NOT NULL, + kind VARCHAR(24) NOT NULL, + content_type VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + uploaded_at BIGINT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (username, kind) +); diff --git a/scripts/wayline/backend/src/main/resources/db/migration/sqlite/V3__driver_documents.sql b/scripts/wayline/backend/src/main/resources/db/migration/sqlite/V3__driver_documents.sql new file mode 100644 index 00000000000..fe2270d9be8 --- /dev/null +++ b/scripts/wayline/backend/src/main/resources/db/migration/sqlite/V3__driver_documents.sql @@ -0,0 +1,19 @@ +-- The pictures a driving application is backed by -- an identity document, a +-- licence, a selfie, the car's registration and its insurance -- one of each +-- kind for an account, as base64 text. +-- +-- In the database and not on a disk beside the server, so every instance of +-- the server reads the same documents and a backup of the database is a backup +-- of them. The server caps what it accepts well below what this column holds. +-- +-- This is the one script written per engine: a document outgrows the 64 KB of +-- MySQL's TEXT, and LONGTEXT is a type the other two do not have. +CREATE TABLE driver_document ( + username VARCHAR(190) NOT NULL, + kind VARCHAR(24) NOT NULL, + content_type VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + uploaded_at BIGINT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (username, kind) +); diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/AccessMatrixTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/AccessMatrixTest.java new file mode 100644 index 00000000000..be43085d4cb --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/AccessMatrixTest.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.wayline.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Who may call each of the paths for payments, preferences, driving +/// applications, earnings and administration -- every one of them, with tokens +/// earned by signing in. +/// +/// A list and not a sample, because the rule for a path is written once, far +/// from the method it guards, and a path left out of it is open to everybody. +@BackendTest +class AccessMatrixTest { + /// What any signed-in user may call. `GET` first: those are also asked for + /// with a rider's token, and reading changes nothing. + private static final String[] SIGNED_IN = { + "GET /api/account/preferences", + "GET /api/account/profile", + "GET /api/payments/config", + "GET /api/payments/methods", + "GET /api/payments/receipts", + "GET /api/payments/receipts/no-such-ride", + "GET /api/rides/nearby?lat=37.78&lng=-122.41", + "GET /api/driving/application", + "GET /api/driving/application/documents/licence", + "PUT /api/account/preferences", + "PUT /api/account/profile", + "POST /api/account/password", + "POST /api/account/delete", + "POST /api/account/push", + "POST /api/account/push/remove", + "POST /api/rides/no-such-ride/activity", + "POST /api/payments/methods/setup", + "POST /api/payments/methods/setup/none/complete", + "POST /api/payments/methods/none/default", + "POST /api/payments/methods/none/remove", + "POST /api/rides/no-such-ride/tip", + "PUT /api/driving/application", + "POST /api/driving/application/documents", + "POST /api/driving/application/submit", + }; + private static final String[] DRIVERS = { + "GET /api/driver/earnings/summary", + "GET /api/driver/payouts", + "GET /api/driver/payout-account", + "POST /api/driver/payouts", + "PUT /api/driver/payout-account", + }; + private static final String[] ADMINS = { + "GET /api/admin/stats/series?days=7", + "GET /api/admin/users/rider@wayline.example", + "GET /api/admin/applications", + "GET /api/admin/applications/applicant@wayline.example", + "GET /api/admin/applications/applicant@wayline.example/documents/licence", + "GET /api/admin/payments", + "GET /api/admin/pricing", + "POST /api/admin/users/rider@wayline.example/flag", + "POST /api/admin/applications/applicant@wayline.example/approve", + "POST /api/admin/applications/applicant@wayline.example/reject", + "POST /api/admin/rides/no-such-ride/cancel", + "POST /api/admin/rides/no-such-ride/refund", + "PUT /api/admin/pricing", + }; + + @Autowired + private MockMvc mvc; + + private static MockRequestBuilder request(String call) { + int space = call.indexOf(' '); + String method = call.substring(0, space); + String path = call.substring(space + 1); + MockRequestBuilder request = "GET".equals(method) ? get(path) + : "PUT".equals(method) ? put(path) : post(path); + return "GET".equals(method) ? request + : request.contentType("application/json").content("{}"); + } + + private int answer(String call, String token) throws Exception { + MockRequestBuilder request = request(call).header("Host", SignIn.HOST); + if (token != null) { + request = request.header("Authorization", "Bearer " + token); + } + return Trips.statusOf(mvc, request); + } + + private void refused(String[] calls, String token, int status) throws Exception { + for (int iter = 0; iter < calls.length; iter++) { + assertEquals(status, answer(calls[iter], token), calls[iter]); + } + } + + /// The readers among `calls` get past the door: whatever they answer, it + /// is not a refusal to say who may ask. + private void admitted(String[] calls, String token) throws Exception { + for (int iter = 0; iter < calls.length; iter++) { + if (calls[iter].startsWith("GET ")) { + int status = answer(calls[iter], token); + assertTrue(status != 401 && status != 403, calls[iter] + " answered " + status); + } + } + } + + @Test + void nothingIsAnsweredWithoutSigningIn() throws Exception { + refused(SIGNED_IN, null, 401); + refused(DRIVERS, null, 401); + refused(ADMINS, null, 401); + refused(SIGNED_IN, "not-a-token", 401); + } + + @Test + void aRiderIsKeptOutOfDrivingAndAdministration() throws Exception { + String rider = SignIn.token(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD); + refused(DRIVERS, rider, 403); + refused(ADMINS, rider, 403); + admitted(SIGNED_IN, rider); + // An application is the caller's own: there is no path to another's. + assertEquals(404, answer("GET /api/driving/application/documents/licence", rider)); + } + + @Test + void aDriverIsKeptOutOfAdministration() throws Exception { + String driver = SignIn.token(mvc, DemoAccounts.DRIVER, DemoAccounts.PASSWORD); + refused(ADMINS, driver, 403); + admitted(DRIVERS, driver); + admitted(SIGNED_IN, driver); + } + + @Test + void anAdminReachesAdministrationAndDoesNotDrive() throws Exception { + String admin = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + admitted(ADMINS, admin); + admitted(SIGNED_IN, admin); + refused(DRIVERS, admin, 403); + // The applicant's papers are an admin's to read, and nobody else's. + assertEquals(200, answer("GET /api/admin/applications/applicant@wayline.example" + + "/documents/licence", admin)); + String applicant = SignIn.token(mvc, DemoAccounts.APPLICANT, DemoAccounts.PASSWORD); + assertEquals(200, answer("GET /api/driving/application/documents/licence", applicant)); + assertEquals(403, answer("GET /api/admin/applications/applicant@wayline.example" + + "/documents/licence", applicant)); + // Not approved, so not a driver. + refused(DRIVERS, applicant, 403); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/AccessTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/AccessTest.java new file mode 100644 index 00000000000..8306af80cbf --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/AccessTest.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.account.DemoAccounts; +import com.codenameone.examples.wayline.live.LiveTickets; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// Who may call what, with tokens earned by signing in: these go through the +/// decoder and the role lookup the app's own requests go through. +@BackendTest +class AccessTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private LiveTickets tickets; + + private MockRequestBuilder as(MockRequestBuilder request, String token) { + return request.header("Host", SignIn.HOST).header("Authorization", "Bearer " + token); + } + + @Test + void eachRoleReachesItsOwnPartOfTheApi() throws Exception { + String rider = SignIn.token(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD); + String driver = SignIn.token(mvc, DemoAccounts.DRIVER, DemoAccounts.PASSWORD); + String admin = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + + mvc.perform(as(get("/api/me"), rider)).andExpect(status().isOk()) + .andExpect(jsonPath("$.driver").value(false)); + mvc.perform(as(get("/api/driver/me"), rider)).andExpect(status().isForbidden()); + mvc.perform(as(get("/api/admin/stats"), rider)).andExpect(status().isForbidden()); + + mvc.perform(as(get("/api/driver/me"), driver)).andExpect(status().isOk()) + .andExpect(jsonPath("$.vehicle").value("Blue Toyota Prius")); + mvc.perform(as(get("/api/admin/users"), driver)).andExpect(status().isForbidden()); + // A driver is a rider too. + mvc.perform(as(get("/api/rides/active"), driver)).andExpect(status().isOk()); + + mvc.perform(as(get("/api/admin/stats"), admin)).andExpect(status().isOk()) + .andExpect(jsonPath("$.currency").value("USD")); + mvc.perform(as(get("/api/admin/users"), admin)).andExpect(status().isOk()); + mvc.perform(as(get("/api/admin/drivers"), admin)).andExpect(status().isOk()); + mvc.perform(as(get("/api/admin/rides"), admin)).andExpect(status().isOk()); + // An admin who does not drive is not a driver. + mvc.perform(as(get("/api/driver/me"), admin)).andExpect(status().isForbidden()); + } + + @Test + void aRoleGrantedOrAnAccountSuspendedTakesEffectOnTheTokenInHand() throws Exception { + String username = People.rider(accounts, "promoted"); + String token = SignIn.token(mvc, username, People.PASSWORD); + String admin = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + mvc.perform(as(get("/api/driver/me"), token)).andExpect(status().isForbidden()); + + mvc.perform(as(post("/api/admin/users/" + username + "/roles"), admin) + .contentType("application/json").content("{\"driver\":true,\"admin\":false}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.driver").value(true)); + // The same token: the role is read from the account, not from it. + mvc.perform(as(get("/api/driver/me"), token)).andExpect(status().isOk()); + + mvc.perform(as(post("/api/admin/users/" + username + "/suspend"), admin) + .contentType("application/json").content("{\"suspended\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.suspended").value(true)); + mvc.perform(as(get("/api/me"), token)).andExpect(status().isUnauthorized()); + assertNull(SignIn.session(mvc, username, People.PASSWORD)); + + mvc.perform(as(post("/api/admin/users/" + username + "/suspend"), admin) + .contentType("application/json").content("{\"suspended\":false}")) + .andExpect(status().isOk()); + mvc.perform(as(get("/api/me"), token)).andExpect(status().isOk()); + } + + @Test + void anAdminCannotLockThemselvesOut() throws Exception { + String admin = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + mvc.perform(as(post("/api/admin/users/" + DemoAccounts.ADMIN + "/roles"), admin) + .contentType("application/json").content("{\"driver\":false,\"admin\":false}")) + .andExpect(status().isBadRequest()); + mvc.perform(as(post("/api/admin/users/" + DemoAccounts.ADMIN + "/suspend"), admin) + .contentType("application/json").content("{\"suspended\":true}")) + .andExpect(status().isBadRequest()); + mvc.perform(as(post("/api/admin/users/nobody@test.example/suspend"), admin) + .contentType("application/json").content("{\"suspended\":true}")) + .andExpect(status().isNotFound()); + } + + @Test + void aLiveTicketIsGoodOnce() throws Exception { + String rider = SignIn.token(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD); + mvc.perform(post("/api/live/ticket")).andExpect(status().isUnauthorized()); + String body = mvc.perform(as(post("/api/live/ticket"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.expiresInSeconds").value(30)) + .andReturn().getResponse().getContentAsString(); + String ticket = (String) com.codename1.backend.Json.parseObject(body).get("ticket"); + assertEquals(DemoAccounts.RIDER, tickets.redeem(ticket)); + assertNull(tickets.redeem(ticket)); + assertNull(tickets.redeem("not-a-ticket")); + } + + @Test + void placesAreFoundThroughTheServer() throws Exception { + String rider = SignIn.token(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD); + mvc.perform(get("/api/geo/search?q=pier&lat=37.8&lng=-122.4")) + .andExpect(status().isUnauthorized()); + mvc.perform(as(get("/api/geo/search?q=PIER&lat=37.8&lng=-122.4"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Pier 39")); + // Too little to search for. + mvc.perform(as(get("/api/geo/search?q=pi&lat=37.8&lng=-122.4"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + mvc.perform(as(get("/api/geo/reverse?lat=37.7956&lng=-122.3936"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Ferry Building")) + .andExpect(jsonPath("$.lat").value(37.7956)); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/AccountTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/AccountTest.java new file mode 100644 index 00000000000..c7fdc56adf9 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/AccountTest.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.DataSource; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.wayline.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// Opening an account, signing in to it and proving its phone number, through +/// the same requests the app makes. +@BackendTest +class AccountTest { + @Autowired + private MockMvc mvc; + @Autowired + private DataSource db; + + private static String registration(String email, String password, boolean driver) { + return "{\"email\":\"" + email + "\",\"password\":\"" + password + + "\",\"displayName\":\"Test Person\",\"phone\":\"+1 (555) 010-9999\",\"driver\":" + + driver + ",\"vehicle\":\"Grey Honda Civic\",\"plate\":\"TST 1\"}"; + } + + private String bearer(String username, String password) throws Exception { + return "Bearer " + SignIn.token(mvc, username, password); + } + + @Test + void anAccountIsOpenedThenSignedInTo() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("New.Rider@Example.com", "a-long-password", false))) + .andExpect(status().isOk()) + // The name is the address in lower case, and the phone number is + // stored in one form however it was typed. + .andExpect(jsonPath("$.username").value("new.rider@example.com")) + .andExpect(jsonPath("$.phone").value("+15550109999")) + .andExpect(jsonPath("$.rider").value(true)) + .andExpect(jsonPath("$.driver").value(false)) + .andExpect(jsonPath("$.admin").value(false)) + .andExpect(jsonPath("$.phoneVerified").value(false)); + + // Signing in is not case sensitive about the address either. + mvc.perform(get("/api/me").header("Host", SignIn.HOST).header("Authorization", + bearer("NEW.RIDER@example.com", "a-long-password"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("new.rider@example.com")) + .andExpect(jsonPath("$.displayName").value("Test Person")); + } + + @Test + void anAccountThatCannotBeOpenedSaysWhy() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("short@example.com", "short", false))) + .andExpect(status().isBadRequest()); + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("not-an-address", "a-long-password", false))) + .andExpect(status().isBadRequest()); + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration(DemoAccounts.RIDER, "a-long-password", false))) + .andExpect(status().isConflict()); + } + + @Test + void aWrongPasswordSignsNobodyIn() throws Exception { + assertNull(SignIn.session(mvc, DemoAccounts.RIDER, "not-the-password")); + assertNotNull(SignIn.session(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD)); + } + + @Test + void theApiWantsAToken() throws Exception { + mvc.perform(get("/api/me")).andExpect(status().isUnauthorized()); + mvc.perform(get("/api/me").header("Authorization", "Bearer not-a-token")) + .andExpect(status().isUnauthorized()); + // A session is for signing in. It is not a way into the API. + mvc.perform(get("/api/me").cookie("CN1SESSION", + SignIn.session(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD))) + .andExpect(status().isUnauthorized()); + } + + @Test + void aPhoneNumberIsProvedWithTheCodeThatWasSent() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("phone@example.com", "a-long-password", true))) + .andExpect(status().isOk()) + // Asking to drive opens an application. It does not make a driver. + .andExpect(jsonPath("$.driver").value(false)) + .andExpect(jsonPath("$.driverStatus").value("draft")); + String bearer = bearer("phone@example.com", "a-long-password"); + + String sent = mvc.perform(post("/api/account/phone/start").header("Host", SignIn.HOST) + .header("Authorization", bearer)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + Map challenge = Json.parseObject(sent); + // A test server sends no text message, so it says what the code was. + String code = (String) challenge.get("demoCode"); + assertNotNull(code); + assertEquals(6, code.length()); + + // A second code straight away is refused: each is a text message. + mvc.perform(post("/api/account/phone/start").header("Host", SignIn.HOST) + .header("Authorization", bearer)) + .andExpect(status().isTooManyRequests()); + + String wrong = "000000".equals(code) ? "000001" : "000000"; + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + wrong + "\"}")) + .andExpect(status().isBadRequest()); + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + code + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.phoneVerified").value(true)); + // Used up: the same code is not good twice. + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + code + "\"}")) + .andExpect(status().isBadRequest()); + } + + /// A second code is asked for when the first is slow to arrive, and the + /// first may then arrive last. Both are good until the first one would + /// have expired, and using either uses up both. + @Test + void aCodeStaysGoodWhenAnotherIsSentAfterIt() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("resend@example.com", "a-long-password", false))) + .andExpect(status().isOk()); + String bearer = bearer("resend@example.com", "a-long-password"); + String first = code(bearer); + // The wait between codes has passed. + db.execute("UPDATE phone_code SET sent_at = sent_at - 60000 WHERE username = ?", + new Object[] {"resend@example.com"}); + String second = code(bearer); + + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + first + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.phoneVerified").value(true)); + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + second + "\"}")) + .andExpect(status().isBadRequest()); + + // Once the first has expired it is no longer good, though the second is. + String third = code(bearer); + db.execute("UPDATE phone_code SET sent_at = sent_at - 60000 WHERE username = ?", + new Object[] {"resend@example.com"}); + String fourth = code(bearer); + db.execute("UPDATE phone_code SET previous_expires_at = 1 WHERE username = ?", + new Object[] {"resend@example.com"}); + if (!third.equals(fourth)) { + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + third + "\"}")) + .andExpect(status().isBadRequest()); + } + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + fourth + "\"}")) + .andExpect(status().isOk()); + } + + /// Asking for another code does not give the one before a fresh set of + /// guesses: it keeps what it had left. + @Test + void theCodeBeforeKeepsTheGuessesItHadLeft() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("budget@example.com", "a-long-password", false))) + .andExpect(status().isOk()); + String bearer = bearer("budget@example.com", "a-long-password"); + String first = code(bearer); + // Four of the five guesses (PhoneVerification.MAX_ATTEMPTS) spent. + for (int iter = 0; iter < 4; iter++) { + verify(bearer, other(first, first)).andExpect(status().isBadRequest()); + } + db.execute("UPDATE phone_code SET sent_at = sent_at - 60000 WHERE username = ?", + new Object[] {"budget@example.com"}); + String second = code(bearer); + // The first code's last guess, spent on a wrong one. + verify(bearer, other(first, second)).andExpect(status().isBadRequest()); + // Now the first is out of guesses, though the second is not. + if (!first.equals(second)) { + verify(bearer, first).andExpect(status().isBadRequest()); + } + verify(bearer, second).andExpect(status().isOk()); + } + + /// A six-digit code that is neither of these. + private static String other(String a, String b) { + for (int iter = 0; ; iter++) { + String candidate = String.valueOf(1000000 + iter).substring(1); + if (!candidate.equals(a) && !candidate.equals(b)) { + return candidate; + } + } + } + + private com.codename1.backend.test.ResultActions verify(String bearer, String code) + throws Exception { + return mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + code + "\"}")); + } + + private String code(String bearer) throws Exception { + String sent = mvc.perform(post("/api/account/phone/start").header("Host", SignIn.HOST) + .header("Authorization", bearer)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return (String) Json.parseObject(sent).get("demoCode"); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/DaysTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/DaysTest.java new file mode 100644 index 00000000000..b96028ff468 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/DaysTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +/// Time on line cut at midnight, which is how a driver's hours are credited. +class DaysTest { + /// 2026-03-01 00:00:00 UTC. + private static final long MIDNIGHT = 20513L * Days.MILLIS; + + @Test + void aStretchAcrossMidnightIsCreditedHalfToEachDay() { + // 23:59:40 to 00:00:20: twenty seconds on each side. Credited whole to + // the day it ended in, the new day had all forty. + long day = Days.number(MIDNIGHT); + assertArrayEquals(new long[] {day - 1, 20000L, day, 20000L}, + Days.split(MIDNIGHT - 20000L, MIDNIGHT + 20000L)); + } + + @Test + void aStretchWithinOneDayIsThatDays() { + long day = Days.number(MIDNIGHT); + assertArrayEquals(new long[] {day, 40000L}, + Days.split(MIDNIGHT + 1000L, MIDNIGHT + 41000L)); + // Ending exactly at midnight is all the old day's. + assertArrayEquals(new long[] {day - 1, 40000L}, + Days.split(MIDNIGHT - 40000L, MIDNIGHT)); + // Starting exactly at it is all the new one's. + assertArrayEquals(new long[] {day, 40000L}, Days.split(MIDNIGHT, MIDNIGHT + 40000L)); + assertArrayEquals(new long[0], Days.split(MIDNIGHT, MIDNIGHT)); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/DrivingApplicationTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/DrivingApplicationTest.java new file mode 100644 index 00000000000..4d2bd6b9545 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/DrivingApplicationTest.java @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Applying to drive: a draft, its documents, handing it in, and an admin's +/// answer either way. +@BackendTest +class DrivingApplicationTest { + /// The smallest things the server takes for a picture: the first bytes of a + /// PNG and of a JPEG, as far as the header that gives their size (one + /// pixel), in base64. + private static final String PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"; + private static final String JPEG = "/9j/4AAQSkZJRgABAQAAAQABAAD/wAARCAABAAEDASIAAhEBAxEB"; + private static final String[] KINDS = {"photo_id", "licence", "selfie", "vehicle_registration", + "insurance"}; + private static final String FORM = "{\"legalName\":\"Ada Applicant\"," + + "\"dateOfBirth\":\"1990-05-17\",\"licenceNumber\":\"L-1234\"," + + "\"licenceExpiry\":\"2999-01-31\",\"vehicleMake\":\"Honda\"," + + "\"vehicleModel\":\"Civic\",\"vehicleYear\":2020,\"vehicleColor\":\"Grey\"," + + "\"vehiclePlate\":\"APP 1\",\"vehicleSeats\":4,\"product\":\"standard\"}"; + + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + private static String picture(String kind, String data) { + return "{\"kind\":\"" + kind + "\",\"contentType\":\"image/png\",\"dataBase64\":\"" + data + + "\"}"; + } + + private MockRequestBuilder admin(MockRequestBuilder request) { + return People.asAdmin(request, DemoAccounts.ADMIN); + } + + private void upload(String who, String kind) throws Exception { + mvc.perform(People.asRider(Trips.body(post("/api/driving/application/documents"), + picture(kind, PNG)), who)).andExpect(status().isOk()); + } + + @Test + void anApplicationIsDraftedHandedInRejectedCorrectedAndApproved() throws Exception { + String who = People.rider(accounts, "applies"); + String token = SignIn.token(mvc, who, People.PASSWORD); + + // Nothing yet, and nothing to hand in. + mvc.perform(People.asRider(get("/api/driving/application"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("none")) + .andExpect(jsonPath("$.documents.length()").value(5)) + .andExpect(jsonPath("$.documents[0].status").value("missing")); + mvc.perform(People.asRider(get("/api/me"), who)) + .andExpect(jsonPath("$.driverStatus").value("none")); + + // Half a form is a draft. It saves, and it cannot be handed in. + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), + "{\"legalName\":\"Ada Applicant\",\"vehicleMake\":\"Honda\"}"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("draft")) + .andExpect(jsonPath("$.legalName").value("Ada Applicant")); + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), + "{\"dateOfBirth\":\"17/05/1990\"}"), who)) + .andExpect(status().isBadRequest()); + // Written the right way, and no day that ever was. + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), + "{\"dateOfBirth\":\"1990-02-30\"}"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), + "{\"dateOfBirth\":\"1991-02-29\"}"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), + "{\"dateOfBirth\":\"1992-04-31\"}"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), + "{\"dateOfBirth\":\"1992-02-29\"}"), who)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(post("/api/driving/application/submit"), who)) + .andExpect(status().isBadRequest()); + + // A complete form is still not complete without its documents. + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), FORM), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.vehicleYear").value(2020)); + mvc.perform(People.asRider(post("/api/driving/application/submit"), who)) + .andExpect(status().isBadRequest()); + + // A document has to be a picture, of a kind that exists, of a sane size. + mvc.perform(People.asRider(Trips.body(post("/api/driving/application/documents"), + picture("photo_id", "bm90IGEgcGljdHVyZQ==")), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(Trips.body(post("/api/driving/application/documents"), + picture("passport", PNG)), who)) + .andExpect(status().isBadRequest()); + StringBuilder huge = new StringBuilder(PNG); + while (huge.length() < 2200000) { + huge.append("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + } + mvc.perform(People.asRider(Trips.body(post("/api/driving/application/documents"), + picture("photo_id", huge.toString())), who)) + .andExpect(status().isPayloadTooLarge()); + // A few bytes that say 100000 pixels wide: refused before an admin's + // app tries to decode them. + mvc.perform(People.asRider(Trips.body(post("/api/driving/application/documents"), + picture("photo_id", "iVBORw0KGgoAAAANSUhEUgABhqAAAAAB")), who)) + .andExpect(status().isPayloadTooLarge()); + // A JPEG that never says its size is no picture. + mvc.perform(People.asRider(Trips.body(post("/api/driving/application/documents"), + picture("photo_id", "/9j/4AAQSkZJRgABAQAAAQABAAA=")), who)) + .andExpect(status().isBadRequest()); + + for (int iter = 0; iter < 4; iter++) { + upload(who, KINDS[iter]); + } + // Four of five. + mvc.perform(People.asRider(post("/api/driving/application/submit"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(Trips.body(post("/api/driving/application/documents"), + picture("insurance", JPEG)), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.documents[4].kind").value("insurance")) + .andExpect(jsonPath("$.documents[4].status").value("uploaded")); + mvc.perform(People.asRider(get("/api/driving/application/documents/insurance"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.contentType").value("image/jpeg")) + .andExpect(jsonPath("$.dataBase64").value(JPEG)); + + mvc.perform(People.asRider(post("/api/driving/application/submit"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("pending")); + // In an admin's hands now: not the applicant's to change. + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), FORM), who)) + .andExpect(status().isConflict()); + mvc.perform(People.asRider(Trips.body(post("/api/driving/application/documents"), + picture("selfie", PNG)), who)) + .andExpect(status().isConflict()); + // Still not a driver: by the role, and by the token already in hand. + mvc.perform(People.asRider(get("/api/me"), who)) + .andExpect(jsonPath("$.driver").value(false)) + .andExpect(jsonPath("$.driverStatus").value("pending")); + mvc.perform(get("/api/driver/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isForbidden()); + + // The admin finds it among those waiting, and reads a document. + List waiting = Trips.list(mvc, admin(get("/api/admin/applications?status=pending"))); + boolean found = false; + for (int iter = 0; iter < waiting.size(); iter++) { + found |= who.equals(((Map) waiting.get(iter)).get("username")); + } + assertTrue(found, "the application is waiting"); + mvc.perform(admin(get("/api/admin/applications?status=nonsense"))) + .andExpect(status().isBadRequest()); + mvc.perform(admin(get("/api/admin/applications/" + who))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("applies")) + .andExpect(jsonPath("$.licenceNumber").value("L-1234")); + mvc.perform(admin(get("/api/admin/applications/" + who + "/documents/licence"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.dataBase64").value(PNG)); + mvc.perform(admin(get("/api/admin/applications/nobody@test.example"))) + .andExpect(status().isNotFound()); + + // Rejected, which needs a reason, and the applicant is told it. + mvc.perform(admin(Trips.body(post("/api/admin/applications/" + who + "/reject"), "{}"))) + .andExpect(status().isBadRequest()); + mvc.perform(admin(Trips.body(post("/api/admin/applications/" + who + "/reject"), + "{\"reason\":\"The licence photo is unreadable\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("rejected")) + .andExpect(jsonPath("$.reviewedBy").value(DemoAccounts.ADMIN)); + mvc.perform(admin(post("/api/admin/applications/" + who + "/approve"))) + .andExpect(status().isConflict()); + mvc.perform(People.asRider(get("/api/driving/application"), who)) + .andExpect(jsonPath("$.status").value("rejected")) + .andExpect(jsonPath("$.rejectionReason").value("The licence photo is unreadable")); + + // Corrected and handed in again, and this time approved. + upload(who, "licence"); + mvc.perform(People.asRider(post("/api/driving/application/submit"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("pending")) + .andExpect(jsonPath("$.rejectionReason").value("")); + mvc.perform(admin(post("/api/admin/applications/" + who + "/approve"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("approved")) + .andExpect(jsonPath("$.documents[0].status").value("accepted")); + mvc.perform(admin(post("/api/admin/applications/" + who + "/approve"))) + .andExpect(status().isConflict()); + + // Approval is what grants the role: the same token now drives, in the + // car the application named. + mvc.perform(get("/api/driver/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.approved").value(true)) + .andExpect(jsonPath("$.vehicle").value("Grey Honda Civic")) + .andExpect(jsonPath("$.plate").value("APP 1")) + .andExpect(jsonPath("$.product").value("standard")); + mvc.perform(People.asRider(get("/api/me"), who)) + .andExpect(jsonPath("$.driver").value(true)) + .andExpect(jsonPath("$.rider").value(true)) + .andExpect(jsonPath("$.driverStatus").value("approved")); + // Both answers are on the account's record. + Map detail = Trips.json(mvc, admin(get("/api/admin/users/" + who))); + List events = (List) detail.get("events"); + assertEquals(2, events.size()); + assertEquals("approve", ((Map) events.get(0)).get("action")); + assertEquals("reject", ((Map) events.get(1)).get("action")); + assertEquals("The licence photo is unreadable", ((Map) events.get(1)).get("reason")); + } + + @Test + void onlyAnApprovedDriverGoesOnLine() throws Exception { + // Holding the role is not enough. This account is let into the + // driver's part of the API and has an application nobody has approved. + String who = People.rider(accounts, "unapproved"); + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), FORM), who)) + .andExpect(status().isOk()); + mvc.perform(People.asDriver(Trips.body(post("/api/driver/status"), + People.position(true, 59.9139, 10.7522)), who)) + .andExpect(status().isForbidden()); + mvc.perform(People.asDriver(get("/api/driver/me"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.approved").value(false)) + .andExpect(jsonPath("$.online").value(false)); + + // An admin's switch approves, and withdrawing it takes that back. + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/roles"), + "{\"driver\":true,\"admin\":false}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.driverStatus").value("approved")); + Trips.online(mvc, who, 59.9139, 10.7522); + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/roles"), + "{\"driver\":false,\"admin\":false}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.driver").value(false)) + .andExpect(jsonPath("$.driverStatus").value("rejected")); + mvc.perform(People.asDriver(Trips.body(post("/api/driver/status"), + People.position(true, 59.9139, 10.7522)), who)) + .andExpect(status().isForbidden()); + mvc.perform(People.asDriver(get("/api/driver/me"), who)) + .andExpect(jsonPath("$.online").value(false)); + } + + @Test + void anApplicationIsItsOwnersAndTheAdminsOnly() throws Exception { + String owner = People.rider(accounts, "doc-owner"); + String other = People.rider(accounts, "doc-other"); + upload(owner, "photo_id"); + // There is no address by which one applicant reaches another's: each + // sees their own, and the other has none. + mvc.perform(People.asRider(get("/api/driving/application/documents/photo_id"), other)) + .andExpect(status().isNotFound()); + mvc.perform(People.asRider(get("/api/driving/application"), other)) + .andExpect(jsonPath("$.status").value("none")) + .andExpect(jsonPath("$.username").value(other)); + mvc.perform(People.asRider(get("/api/admin/applications/" + owner + + "/documents/photo_id"), other)) + .andExpect(status().isForbidden()); + mvc.perform(People.asDriver(get("/api/admin/applications/" + owner), other)) + .andExpect(status().isForbidden()); + mvc.perform(get("/api/driving/application/documents/photo_id")) + .andExpect(status().isUnauthorized()); + mvc.perform(admin(get("/api/admin/applications/" + owner + "/documents/photo_id"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.kind").value("photo_id")); + mvc.perform(admin(get("/api/admin/applications/" + owner + "/documents/selfie"))) + .andExpect(status().isNotFound()); + } + + /// A save or an upload authorized just before the account closed, and + /// run just after, keeps nothing: the closure already forgot the + /// application, and nothing would forget it again. + @Test + void aClosedAccountKeepsNoApplication() throws Exception { + String who = People.rider(accounts, "applies-gone"); + upload(who, "photo_id"); + accounts.close(who, People.PASSWORD); + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), FORM), who)) + .andExpect(status().isNotFound()); + mvc.perform(People.asRider(Trips.body(post("/api/driving/application/documents"), + picture("selfie", PNG)), who)).andExpect(status().isNotFound()); + mvc.perform(admin(get("/api/admin/applications/" + who))) + .andExpect(status().isNotFound()); + mvc.perform(admin(get("/api/admin/applications/" + who + "/documents/selfie"))) + .andExpect(status().isNotFound()); + } + + @Test + void theDemoApplicantIsWaitingAndTheDemoDriversAreApproved() throws Exception { + mvc.perform(admin(get("/api/admin/applications/" + DemoAccounts.APPLICANT))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("pending")) + .andExpect(jsonPath("$.documents[2].status").value("uploaded")); + mvc.perform(admin(get("/api/admin/applications/" + DemoAccounts.DRIVER))) + .andExpect(jsonPath("$.status").value("approved")) + .andExpect(jsonPath("$.vehicleMake").value("Toyota")); + mvc.perform(admin(get("/api/admin/applications/" + DemoAccounts.XL_DRIVER))) + .andExpect(jsonPath("$.product").value("xl")) + .andExpect(jsonPath("$.accessible").value(true)); + // Registering to drive, the way the app asks, opens a draft. + mvc.perform(post("/api/account/register").contentType("application/json") + .content("{\"email\":\"wants@test.example\",\"password\":\"a-long-password\"," + + "\"displayName\":\"Wants\",\"phone\":\"+15550113\"," + + "\"wantsToDrive\":true,\"gender\":\"female\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.driver").value(false)) + .andExpect(jsonPath("$.gender").value("female")) + .andExpect(jsonPath("$.driverStatus").value("draft")); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/LiveSocketTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/LiveSocketTest.java new file mode 100644 index 00000000000..1c85931054a --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/LiveSocketTest.java @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.Crypto; +import com.codename1.backend.Tcp; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.LocalServerPort; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.account.DemoAccounts; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.live.LiveHub; +import com.codenameone.examples.wayline.live.LiveTickets; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The live channel over a real socket: a ticket opens it once, and what the +/// server pushes arrives. +@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT) +class LiveSocketTest { + private static final int TEXT = 0x1; + private static final int CLOSE = 0x8; + + @LocalServerPort + private int port; + @Autowired + private LiveTickets tickets; + @Autowired + private LiveHub hub; + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + /// The last frame read: its opcode, and its payload as text. + private int opcode; + private byte[] payload; + + @Test + void aTicketOpensTheChannelOnceAndPushesArrive() throws Exception { + String ticket = tickets.issue(DemoAccounts.RIDER).ticket(); + Tcp rider = open("/ws/live?ticket=" + ticket); + try { + readFrame(rider); + assertEquals("{\"type\":\"ready\"}", text()); + + writeText(rider, "{\"type\":\"ping\"}"); + readFrame(rider); + assertEquals("{\"type\":\"pong\"}", text()); + + // Something happens to a ride of theirs. + hub.rideChanged("ride-1", RideState.ACCEPTED, DemoAccounts.RIDER, DemoAccounts.DRIVER, ""); + readFrame(rider); + assertEquals("{\"type\":\"ride\",\"id\":\"ride-1\",\"state\":\"ACCEPTED\"}", text()); + hub.driverMoved(DemoAccounts.RIDER, "ride-1", 37.8, -122.4, 90); + readFrame(rider); + assertTrue(text().startsWith("{\"type\":\"driver\",\"rideId\":\"ride-1\",\"lat\":37.8"), + text()); + // And to somebody else's, which is none of their business. + hub.rideChanged("ride-2", RideState.ACCEPTED, "other@test.example", DemoAccounts.DRIVER, ""); + hub.rideChanged("ride-3", RideState.COMPLETED, DemoAccounts.RIDER, "", ""); + readFrame(rider); + assertTrue(text().indexOf("ride-3") > 0, text()); + + // The same ticket again opens nothing. + Tcp again = open("/ws/live?ticket=" + ticket); + try { + readFrame(again); + assertEquals(CLOSE, opcode); + assertEquals(4401, closeCode()); + } finally { + again.close(); + } + + // Suspending the account closes what it has open. + hub.drop(DemoAccounts.RIDER); + readFrame(rider); + assertEquals(CLOSE, opcode); + assertEquals(4403, closeCode()); + } finally { + rider.close(); + } + } + + @Test + void changingAnAccountsRolesClosesTheChannelItHasOpen() throws Exception { + // The channel keeps the roles it was opened with, so one opened as an + // admin would go on hearing the fleet after the role was taken away. + String username = People.rider(accounts, "live-roles"); + Tcp channel = open("/ws/live?ticket=" + tickets.issue(username).ticket()); + try { + readFrame(channel); + assertEquals("{\"type\":\"ready\"}", text()); + + mvc.perform(People.asAdmin(Trips.body(post("/api/admin/users/" + username + "/roles"), + "{\"driver\":true,\"admin\":false}"), DemoAccounts.ADMIN)) + .andExpect(status().isOk()); + readFrame(channel); + assertEquals(CLOSE, opcode); + } finally { + channel.close(); + } + } + + @Test + void noTicketNoChannel() throws Exception { + String[] paths = {"/ws/live", "/ws/live?ticket=", "/ws/live?ticket=0123456789abcdef"}; + for (int iter = 0; iter < paths.length; iter++) { + Tcp stranger = open(paths[iter]); + try { + readFrame(stranger); + assertEquals(CLOSE, opcode, paths[iter]); + assertEquals(4401, closeCode()); + } finally { + stranger.close(); + } + } + } + + @Test + void anAdminHearsAboutEveryRideAndTheFleet() throws Exception { + Tcp admin = open("/ws/live?ticket=" + tickets.issue(DemoAccounts.ADMIN).ticket()); + try { + readFrame(admin); + assertEquals("{\"type\":\"ready\"}", text()); + hub.rideChanged("ride-9", RideState.REQUESTED, "someone@test.example", "", ""); + readFrame(admin); + assertTrue(text().indexOf("ride-9") > 0, text()); + hub.fleetChanged(DemoAccounts.DRIVER, true, 37.8, -122.4, 0); + readFrame(admin); + assertTrue(text().startsWith("{\"type\":\"fleet\",\"username\":\"" + DemoAccounts.DRIVER + + "\",\"online\":true"), text()); + } finally { + admin.close(); + } + } + + private Tcp open(String target) throws IOException { + Tcp connection = Tcp.connect("127.0.0.1", port, 5000); + byte[] request = ascii("GET " + target + " HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n"); + connection.write(request, 0, request.length); + StringBuilder head = new StringBuilder(); + // The head ends at the first empty line: CR LF CR LF. + int ending = 0; + while (head.length() < 8192 && ending < 4) { + int c = readByte(connection); + if (c < 0) { + break; + } + head.append((char) c); + ending = c == (ending % 2 == 0 ? '\r' : '\n') ? ending + 1 : (c == '\r' ? 1 : 0); + } + assertTrue(head.toString().startsWith("HTTP/1.1 101"), head.toString()); + return connection; + } + + private String text() throws IOException { + assertEquals(TEXT, opcode); + return new String(payload, "UTF-8"); + } + + private int closeCode() { + return payload.length < 2 ? -1 : ((payload[0] & 0xff) << 8) | (payload[1] & 0xff); + } + + private void readFrame(Tcp connection) throws IOException { + int first = readByte(connection); + if (first < 0) { + throw new IOException("the connection ended with no frame"); + } + opcode = first & 0xf; + int length = readByte(connection) & 0x7f; + if (length == 126) { + length = (readByte(connection) << 8) | readByte(connection); + } + payload = new byte[length]; + int got = 0; + while (got < length) { + int read = connection.read(payload, got, length - got); + if (read <= 0) { + throw new IOException("the connection ended after " + got + " of " + length); + } + got += read; + } + } + + /// A client's frames are masked; a server refuses one that is not. + private static void writeText(Tcp connection, String text) throws IOException { + byte[] body = ascii(text); + byte[] mask = Crypto.randomBytes(4); + byte[] frame = new byte[6 + body.length]; + frame[0] = (byte) (0x80 | TEXT); + frame[1] = (byte) (0x80 | body.length); + System.arraycopy(mask, 0, frame, 2, 4); + for (int iter = 0; iter < body.length; iter++) { + frame[6 + iter] = (byte) (body[iter] ^ mask[iter & 3]); + } + connection.write(frame, 0, frame.length); + } + + private static int readByte(Tcp connection) throws IOException { + byte[] one = new byte[1]; + int read = connection.read(one, 0, 1); + return read <= 0 ? -1 : one[0] & 0xff; + } + + private static byte[] ascii(String value) { + byte[] out = new byte[value.length()]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) value.charAt(iter); + } + return out; + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/MatchingTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/MatchingTest.java new file mode 100644 index 00000000000..7ed8fbe03a4 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/MatchingTest.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.wayline.account.Accounts; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Which driver a ride is offered to, when the ride is particular: the kind of +/// car, a woman at the wheel, room for a wheelchair. +@BackendTest +class MatchingTest { + private static final double LAT = 48.2082; + private static final double LNG = 16.3738; + + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private DataSource db; + + private String ask(String rider, String extra) throws Exception { + return Trips.ride(LAT, LNG, 48.2200, 16.3900, extra); + } + + /// Asks for a ride, checks the state it is left in, and cancels it so the + /// rider and the driver are free for the next one. + private void expect(String rider, String extra, String state) throws Exception { + Map asked = Trips.json(mvc, People.asRider(Trips.body(post("/api/rides"), + ask(rider, extra)), rider)); + assertEquals(state, asked.get("state"), extra); + mvc.perform(People.asRider(post("/api/rides/" + asked.get("id") + "/cancel"), rider)) + .andExpect(status().isOk()); + } + + @Test + void aRideGoesToADriverWhoFitsIt() throws Exception { + // Vienna. + String rider = People.rider(accounts, "vie-rider"); + String standard = People.driver(accounts, "vie-standard"); + String comfort = People.driver(accounts, "vie-comfort"); + // What an admin's review of the application would have recorded. + db.execute("UPDATE driver_application SET product = 'comfort' WHERE username = ?", + new Object[] {comfort}); + Trips.online(mvc, standard, LAT, LNG); + Trips.online(mvc, comfort, LAT + 0.001, LNG); + + // The quote says what there is of each kind, and prices each. + Map quote = Trips.json(mvc, People.asRider(Trips.body(post("/api/rides/quote"), + People.ride(LAT, LNG, 48.2200, 16.3900)), rider)); + List options = (List) quote.get("options"); + assertEquals(3, options.size()); + Map first = (Map) options.get(0); + Map second = (Map) options.get(1); + Map third = (Map) options.get(2); + assertEquals("standard", first.get("product")); + assertEquals("comfort", second.get("product")); + assertEquals("xl", third.get("product")); + assertEquals(1L, Trips.number(first, "driversNearby")); + assertEquals(1L, Trips.number(second, "driversNearby")); + assertEquals(0L, Trips.number(third, "driversNearby")); + assertEquals(4L, Trips.number(first, "seats")); + assertEquals(6L, Trips.number(third, "seats")); + long fare = Trips.number(first, "fare"); + assertEquals(fare, Trips.number(quote, "amountCents")); + assertEquals(fare + Trips.number(first, "serviceFee"), Trips.number(first, "total")); + // Each part of a fare is multiplied and rounded on its own, so the + // dearer kinds are within a cent or two of the multiple. + assertTrue(Math.abs(Trips.number(second, "fare") - fare * 1.35d) <= 2d); + assertTrue(Math.abs(Trips.number(third, "fare") - fare * 1.7d) <= 2d); + // Asked for a kind, the quote is that kind's. + mvc.perform(People.asRider(Trips.body(post("/api/rides/quote"), + ask(rider, "\"product\":\"comfort\"")), rider)) + .andExpect(jsonPath("$.amountCents").value((int) Trips.number(second, "fare"))) + .andExpect(jsonPath("$.driversNearby").value(1)); + mvc.perform(People.asRider(get("/api/rides/nearby?lat=" + LAT + "&lng=" + LNG), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].product").value("standard")) + .andExpect(jsonPath("$[1].product").value("comfort")); + + // A comfort ride goes past the nearer, standard car. + Map asked = Trips.json(mvc, People.asRider(Trips.body(post("/api/rides"), + ask(rider, "\"product\":\"comfort\"")), rider)); + String id = (String) asked.get("id"); + assertEquals("OFFERED", asked.get("state")); + assertEquals("comfort", asked.get("product")); + assertEquals(Trips.number(second, "fare"), Trips.number(asked, "fareCents")); + mvc.perform(People.asDriver(get("/api/driver/active"), standard)) + .andExpect(jsonPath("$.state").value("NONE")); + mvc.perform(People.asDriver(get("/api/driver/active"), comfort)) + .andExpect(jsonPath("$.id").value(id)); + mvc.perform(People.asRider(post("/api/rides/" + id + "/cancel"), rider)) + .andExpect(status().isOk()); + // Nobody drives an XL here, so one waits; and there is no fourth kind. + expect(rider, "\"product\":\"xl\"", "REQUESTED"); + mvc.perform(People.asRider(Trips.body(post("/api/rides"), + ask(rider, "\"product\":\"limousine\"")), rider)) + .andExpect(status().isBadRequest()); + + // A woman driver, asked for: neither is one until one says so. + mvc.perform(People.asRider(Trips.body(post("/api/rides"), + ask(rider, "\"driverGender\":\"men\"")), rider)) + .andExpect(status().isBadRequest()); + expect(rider, "\"driverGender\":\"women\"", "REQUESTED"); + db.execute("UPDATE profile SET gender = 'female' WHERE username = ?", + new Object[] {comfort}); + // Still not for a standard ride: she drives a comfort car. + expect(rider, "\"driverGender\":\"women\"", "REQUESTED"); + mvc.perform(People.asRider(Trips.body(post("/api/rides/quote"), + ask(rider, "\"driverGender\":\"women\"")), rider)) + .andExpect(jsonPath("$.driversNearby").value(0)) + .andExpect(jsonPath("$.options[1].driversNearby").value(1)); + asked = Trips.json(mvc, People.asRider(Trips.body(post("/api/rides"), + ask(rider, "\"driverGender\":\"women\",\"product\":\"comfort\"")), rider)); + id = (String) asked.get("id"); + assertEquals("OFFERED", asked.get("state")); + assertEquals("women", asked.get("driverGender")); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), comfort)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.driverGender").value("women")); + mvc.perform(People.asRider(post("/api/rides/" + id + "/cancel"), rider)) + .andExpect(status().isOk()); + + // A car that takes a wheelchair. + expect(rider, "\"accessibleVehicle\":true", "REQUESTED"); + db.execute("UPDATE driver_application SET wheelchair = 1 WHERE username = ?", + new Object[] {standard}); + expect(rider, "\"accessibleVehicle\":true", "OFFERED"); + + // A driver who takes pets: nobody, until one says so. + expect(rider, "\"petFriendly\":true", "REQUESTED"); + mvc.perform(People.asRider(Trips.body(post("/api/rides/quote"), + ask(rider, "\"petFriendly\":true")), rider)) + .andExpect(jsonPath("$.driversNearby").value(0)); + db.execute("UPDATE driver_application SET pets = 1 WHERE username = ?", + new Object[] {standard}); + expect(rider, "\"petFriendly\":true", "OFFERED"); + + // An approval withdrawn takes a driver out of matching at once, on + // line or not. + expect(rider, "\"product\":\"standard\"", "OFFERED"); + db.execute("UPDATE driver_application SET status = 'rejected' WHERE username = ?", + new Object[] {standard}); + expect(rider, "\"product\":\"standard\"", "REQUESTED"); + mvc.perform(People.asRider(get("/api/rides/nearby?lat=" + LAT + "&lng=" + LNG), rider)) + .andExpect(jsonPath("$.length()").value(1)); + db.execute("UPDATE driver_application SET status = 'approved' WHERE username = ?", + new Object[] {standard}); + Trips.offline(mvc, standard); + Trips.offline(mvc, comfort); + } + + @Test + void aDriverWithNoProfileIsOfferedNothing() throws Exception { + // Zurich. + String rider = People.rider(accounts, "zrh-rider"); + String driver = People.driver(accounts, "zrh-driver"); + Trips.online(mvc, driver, 47.3769, 8.5417); + String ride = People.ride(47.3771, 8.5419, 47.3900, 8.5600); + mvc.perform(People.asRider(Trips.body(post("/api/rides/quote"), ride), rider)) + .andExpect(jsonPath("$.driversNearby").value(1)); + + // What an account that is half gone leaves behind: on line, approved, + // and nobody. Whatever the ride asks for, it is not sent there. + db.execute("DELETE FROM profile WHERE username = ?", new Object[] {driver}); + mvc.perform(People.asRider(Trips.body(post("/api/rides/quote"), ride), rider)) + .andExpect(jsonPath("$.driversNearby").value(0)); + Map asked = Trips.json(mvc, People.asRider(Trips.body(post("/api/rides"), ride), rider)); + assertEquals("REQUESTED", asked.get("state")); + mvc.perform(People.asRider(post("/api/rides/" + asked.get("id") + "/cancel"), rider)) + .andExpect(status().isOk()); + } + + @Test + void aDriverWhoGoesOffLineIsOffLine() throws Exception { + // Two reports close together, so the second counts the time between + // them as time on line: adding that time is a bulk update, and the + // driver's row must already be written when it runs. The pause is + // what makes that time more than nothing: two reports in the same + // millisecond add no time and run no update. + String driver = People.driver(accounts, "ldn-driver"); + Trips.online(mvc, driver, 51.5072, -0.1276); + Thread.sleep(50); + mvc.perform(People.asDriver(Trips.body(post("/api/driver/status"), + People.position(false, 0, 0)), driver)) + .andExpect(jsonPath("$.online").value(false)); + mvc.perform(People.asDriver(get("/api/driver/me"), driver)) + .andExpect(jsonPath("$.online").value(false)); + String ride = People.ride(51.5074, -0.1278, 51.5155, -0.0922); + mvc.perform(People.asRider(Trips.body(post("/api/rides/quote"), ride), + People.rider(accounts, "ldn-rider"))) + .andExpect(jsonPath("$.driversNearby").value(0)); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ModerationTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ModerationTest.java new file mode 100644 index 00000000000..bd02f99b043 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ModerationTest.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// What an admin does about an account or a ride: flag it, block it, cancel +/// it, and the record that is kept of each. +@BackendTest +class ModerationTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + private MockRequestBuilder admin(MockRequestBuilder request) { + return People.asAdmin(request, DemoAccounts.ADMIN); + } + + @Test + void anAccountIsFlaggedThenBlockedAndTheRecordSaysWhy() throws Exception { + String who = People.rider(accounts, "troublesome"); + String token = SignIn.token(mvc, who, People.PASSWORD); + Map before = Trips.json(mvc, admin(get("/api/admin/stats/series?days=7"))); + + // A flag says why, and changes nothing the account can do. + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/flag"), + "{\"flagged\":true}"))) + .andExpect(status().isBadRequest()); + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/flag"), + "{\"flagged\":true,\"reason\":\"Rude to two drivers\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.flagged").value(true)) + .andExpect(jsonPath("$.flagReason").value("Rude to two drivers")) + .andExpect(jsonPath("$.suspended").value(false)); + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.flagged").value(true)); + Map after = Trips.json(mvc, admin(get("/api/admin/stats/series?days=7"))); + assertEquals(Trips.number(before, "flaggedUsers") + 1, Trips.number(after, "flaggedUsers")); + + // Not in the middle of a ride: nobody could end it. Waiting for a + // driver is in the middle of one. + String ride = (String) Trips.json(mvc, People.asRider(Trips.body(post("/api/rides"), + People.ride(41.3874, 2.1686, 41.3974, 2.1786)), who)).get("id"); + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/suspend"), + "{\"suspended\":true}"))) + .andExpect(status().isConflict()); + mvc.perform(People.asRider(post("/api/rides/" + ride + "/cancel"), who)) + .andExpect(status().isOk()); + + // A block is what stops it: the token in hand, and signing in again. + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/suspend"), + "{\"suspended\":true,\"reason\":\"And then a third\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.suspended").value(true)); + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + mvc.perform(get("/api/payments/methods").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + mvc.perform(Trips.body(post("/api/rides").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token), + People.ride(60.1699, 24.9384, 60.1800, 24.9500))) + .andExpect(status().isUnauthorized()); + assertEquals(null, SignIn.session(mvc, who, People.PASSWORD)); + + // The account's record, newest first: who did what, and why. + Map detail = Trips.json(mvc, admin(get("/api/admin/users/" + who))); + assertEquals(who, ((Map) detail.get("user")).get("username")); + List events = (List) detail.get("events"); + assertEquals(2, events.size()); + Map block = (Map) events.get(0); + assertEquals("block", block.get("action")); + assertEquals("And then a third", block.get("reason")); + assertEquals(DemoAccounts.ADMIN, block.get("by")); + assertTrue(Trips.number(block, "at") > 0L); + assertEquals("flag", ((Map) events.get(1)).get("action")); + + // Both are undone, and that is recorded too. + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/suspend"), + "{\"suspended\":false}"))) + .andExpect(status().isOk()); + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/flag"), + "{\"flagged\":false}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.flagged").value(false)) + .andExpect(jsonPath("$.flagReason").value("")); + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + mvc.perform(admin(get("/api/admin/users/" + who))) + .andExpect(jsonPath("$.events.length()").value(4)) + .andExpect(jsonPath("$.events[0].action").value("unflag")) + .andExpect(jsonPath("$.events[1].action").value("unblock")); + mvc.perform(admin(get("/api/admin/users/nobody@test.example"))) + .andExpect(status().isNotFound()); + } + + @Test + void anAdminWhoWasJustRemovedChangesNobody() throws Exception { + // Two admins taking each other's role at the same moment were both + // authorized before either committed. The one whose turn comes second + // is looked at again inside its transaction, and by then is no admin. + String first = People.rider(accounts, "admin-one"); + String second = People.rider(accounts, "admin-two"); + accounts.setRoles(DemoAccounts.ADMIN, first, false, true); + accounts.setRoles(DemoAccounts.ADMIN, second, false, true); + accounts.setRoles(first, second, false, false); + try { + accounts.setRoles(second, first, false, false); + fail("an admin who lost the role still withdrew another's"); + } catch (ResponseStatusException refused) { + assertEquals(403, refused.getStatus()); + } + try { + accounts.suspend(second, first, true, ""); + fail("an admin who lost the role still blocked another"); + } catch (ResponseStatusException refused) { + assertEquals(403, refused.getStatus()); + } + assertTrue(accounts.describe(first).admin(), "the first admin is one still"); + assertFalse(accounts.describe(first).suspended(), "and is not blocked"); + } + + @Test + void theDemoDataHasAFlaggedRiderWithAHistory() throws Exception { + Map detail = Trips.json(mvc, admin(get("/api/admin/users/" + DemoAccounts.FLAGGED_RIDER))); + Map user = (Map) detail.get("user"); + assertEquals(Boolean.TRUE, user.get("flagged")); + assertTrue(Trips.number(detail, "rides") > 0L, "the fortnight gave it rides"); + assertEquals("flag", ((Map) ((List) detail.get("events")).get(0)).get("action")); + } + + @Test + void anAdminCancelsARideThatIsUnderWay() throws Exception { + // Helsinki, where nobody drives: the ride waits. + String rider = People.rider(accounts, "hel-rider"); + String id = (String) Trips.json(mvc, People.asRider(Trips.body(post("/api/rides"), + People.ride(60.1699, 24.9384, 60.1800, 24.9500)), rider)).get("id"); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/cancel"), "{}"))) + .andExpect(status().isBadRequest()); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/cancel"), + "{\"reason\":\"Reported as a prank\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("CANCELLED_BY_ADMIN")); + mvc.perform(People.asRider(get("/api/rides/active"), rider)) + .andExpect(jsonPath("$.state").value("NONE")); + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.state").value("CANCELLED_BY_ADMIN")); + // Over is over, and a ride that never was is not found. + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/cancel"), + "{\"reason\":\"Again\"}"))) + .andExpect(status().isConflict()); + mvc.perform(admin(Trips.body(post("/api/admin/rides/nosuchride/cancel"), + "{\"reason\":\"Again\"}"))) + .andExpect(status().isNotFound()); + // A ride that ended this way cost nothing, so it has no receipt. + mvc.perform(People.asRider(get("/api/payments/receipts/" + id), rider)) + .andExpect(status().isNotFound()); + mvc.perform(admin(get("/api/admin/users/" + rider))) + .andExpect(jsonPath("$.events[0].action").value("cancel_ride")) + .andExpect(jsonPath("$.events[0].reason").value("Reported as a prank")); + } + + @Test + void aDriverKeepsTheRoleUntilTheRideTheyAreOnHasEnded() throws Exception { + // Tallinn. + String rider = People.rider(accounts, "tll-rider"); + String driver = People.driver(accounts, "tll-driver"); + Trips.online(mvc, driver, 59.4370, 24.7536); + String id = (String) Trips.json(mvc, People.asRider(Trips.body(post("/api/rides"), + People.ride(59.4370, 24.7536, 59.4470, 24.7636)), rider)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + + // Withdrawn now, the driver could do nothing more for this rider. + mvc.perform(admin(Trips.body(post("/api/admin/users/" + driver + "/roles"), + "{\"driver\":false,\"admin\":false}"))) + .andExpect(status().isConflict()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/arrived"), driver)) + .andExpect(status().isOk()); + + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/cancel"), + "{\"reason\":\"Driver is leaving\"}"))) + .andExpect(status().isOk()); + mvc.perform(admin(Trips.body(post("/api/admin/users/" + driver + "/roles"), + "{\"driver\":false,\"admin\":false}"))) + .andExpect(status().isOk()); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/PaymentsTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/PaymentsTest.java new file mode 100644 index 00000000000..227d3791a3d --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/PaymentsTest.java @@ -0,0 +1,772 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.account.DemoAccounts; +import com.codenameone.examples.wayline.pay.Payments; +import com.codenameone.examples.wayline.ride.Rides; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Paying: the cards an account keeps, the charge when a ride completes, the +/// tip after it, the refund an admin can make, and what all of it earns the +/// driver. Against the simulated processor, which is what a server with no +/// key configured runs. +@BackendTest +class PaymentsTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Rides rides; + @Autowired + private Payments payments; + @Autowired + private DataSource db; + + private MockRequestBuilder admin(MockRequestBuilder request) { + return People.asAdmin(request, DemoAccounts.ADMIN); + } + + private long setupsOf(String username) throws Exception { + Map row = db.queryOne("SELECT COUNT(*) AS n FROM payment_setup WHERE username = ?", + new Object[] {username}); + return ((Number) row.get("n")).longValue(); + } + + @Test + void openSetupsAreCappedAndTheFinishedAndAbandonedAreSwept() throws Exception { + String rider = People.rider(accounts, "setup-looper"); + // Five open at once is as many as an account may have; each is a + // session made at the processor. + String first = null; + for (int iter = 0; iter < 5; iter++) { + Map setup = Trips.json(mvc, People.asRider(post("/api/payments/methods/setup"), + rider)); + first = first == null ? (String) setup.get("id") : first; + } + mvc.perform(People.asRider(post("/api/payments/methods/setup"), rider)) + .andExpect(status().is(429)); + // Finishing one makes room for another. + mvc.perform(People.asRider(Trips.body(post("/api/payments/methods/setup/" + first + + "/complete"), Trips.card(Trips.VISA)), rider)) + .andExpect(status().isOk()); + String last = (String) Trips.json(mvc, People.asRider( + post("/api/payments/methods/setup"), rider)).get("id"); + assertEquals(6L, setupsOf(rider)); + // So does an hour going by: abandoned setups do not count. + db.execute("UPDATE payment_setup SET created_at = ? WHERE username = ? AND id <> ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 90L * 60L * 1000L), + rider, last}); + Trips.json(mvc, People.asRider(post("/api/payments/methods/setup"), rider)); + + // The sweep deletes the finished one, and an abandoned one once it is + // two hours old; younger ones, and the open ones, stay. + payments.sweepSetups(); + assertEquals(6L, setupsOf(rider)); + db.execute("UPDATE payment_setup SET created_at = ? WHERE username = ? AND created_at < ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 3L * 60L * 60L * 1000L), + rider, Long.valueOf(System.currentTimeMillis() - 60L * 60L * 1000L)}); + payments.sweepSetups(); + assertEquals(2L, setupsOf(rider)); + mvc.perform(People.asRider(Trips.body(post("/api/payments/methods/setup/" + last + + "/complete"), Trips.card(Trips.MASTERCARD)), rider)) + .andExpect(status().isOk()); + } + + @Test + void todaysTripsCountTodaysRidesAlone() throws Exception { + // Tallinn. + String rider = People.rider(accounts, "tll-pay-rider"); + String driver = People.driver(accounts, "tll-pay-driver"); + Trips.online(mvc, driver, 59.4370, 24.7536); + String yesterday = (String) Trips.complete(mvc, rider, driver, People.ride(59.4370, + 24.7536, 59.4470, 24.7636)).get("id"); + Trips.complete(mvc, rider, driver, People.ride(59.4370, 24.7536, 59.4470, 24.7636)); + long now = System.currentTimeMillis(); + db.execute("UPDATE ride SET updated_at = ? WHERE id = ?", new Object[] { + Long.valueOf(now - now % (24L * 60L * 60L * 1000L) - 60L * 60L * 1000L), yesterday}); + Map earned = Trips.json(mvc, People.asDriver(get("/api/driver/earnings"), driver)); + // Both are trips the driver made; one of them was today's, and the + // header's count is of the rides its amount is made of. + assertEquals(2L, Trips.number(earned, "rides")); + assertEquals(1L, Trips.number(earned, "todayRides")); + Trips.offline(mvc, driver); + } + + @Test + void cardsAreSavedChosenAndRemoved() throws Exception { + String rider = People.rider(accounts, "card-holder"); + String other = People.rider(accounts, "card-other"); + mvc.perform(People.asRider(get("/api/payments/config"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.provider").value("simulated")) + .andExpect(jsonPath("$.currency").value("USD")); + // Cash, always, and the default until there is a card. + mvc.perform(People.asRider(get("/api/payments/methods"), rider)) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value("cash")) + .andExpect(jsonPath("$[0].kind").value("cash")) + .andExpect(jsonPath("$[0].isDefault").value(true)); + + // A card that is refused is not saved, and says why; the same setup + // then takes a card that is good. + Map setup = Trips.json(mvc, People.asRider(post("/api/payments/methods/setup"), rider)); + assertEquals(Boolean.FALSE, setup.get("hosted")); + String complete = "/api/payments/methods/setup/" + setup.get("id") + "/complete"; + mvc.perform(People.asRider(Trips.body(post(complete), Trips.card(Trips.DECLINED)), rider)) + .andExpect(status().is(402)); + mvc.perform(People.asRider(Trips.body(post(complete), Trips.card("4242424242424241")), + rider)) + .andExpect(status().is(402)); + // Somebody else cannot finish it for them. + mvc.perform(People.asRider(Trips.body(post(complete), Trips.card(Trips.VISA)), other)) + .andExpect(status().isNotFound()); + mvc.perform(People.asRider(get("/api/payments/methods"), rider)) + .andExpect(jsonPath("$.length()").value(1)); + Map visa = Trips.json(mvc, People.asRider(Trips.body(post(complete), + Trips.card(Trips.VISA)), rider)); + assertEquals("card", visa.get("kind")); + assertEquals("Visa", visa.get("brand")); + assertEquals("4242", visa.get("last4")); + assertEquals("Visa 4242", visa.get("label")); + assertEquals(12L, Trips.number(visa, "expMonth")); + // The first card saved is the one to pay with. + assertEquals(Boolean.TRUE, visa.get("isDefault")); + // A setup is used once. + mvc.perform(People.asRider(Trips.body(post(complete), Trips.card(Trips.VISA)), rider)) + .andExpect(status().isNotFound()); + + Map second = Trips.addCard(mvc, rider, Trips.MASTERCARD); + assertEquals("Mastercard", second.get("brand")); + assertEquals(Boolean.FALSE, second.get("isDefault")); + String visaId = (String) visa.get("id"); + String secondId = (String) second.get("id"); + mvc.perform(People.asRider(post("/api/payments/methods/" + secondId + "/default"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isDefault").value(true)); + mvc.perform(People.asRider(get("/api/payments/methods"), rider)) + .andExpect(jsonPath("$.length()").value(3)) + .andExpect(jsonPath("$[0].id").value(visaId)) + .andExpect(jsonPath("$[0].isDefault").value(false)) + .andExpect(jsonPath("$[1].isDefault").value(true)) + .andExpect(jsonPath("$[2].id").value("cash")) + .andExpect(jsonPath("$[2].isDefault").value(false)); + + // A card is its owner's: nobody else sees it, chooses it or removes it. + mvc.perform(People.asRider(get("/api/payments/methods"), other)) + .andExpect(jsonPath("$.length()").value(1)); + mvc.perform(People.asRider(post("/api/payments/methods/" + visaId + "/default"), other)) + .andExpect(status().isNotFound()); + mvc.perform(People.asRider(post("/api/payments/methods/" + visaId + "/remove"), other)) + .andExpect(status().isNotFound()); + mvc.perform(People.asRider(Trips.body(post("/api/rides"), Trips.ride(52.52, 13.405, + 52.53, 13.42, "\"paymentMethodId\":\"" + visaId + "\"")), other)) + .andExpect(status().isBadRequest()); + + // Removing the default leaves cash as the default; cash stays. + mvc.perform(People.asRider(post("/api/payments/methods/" + secondId + "/remove"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(secondId)); + mvc.perform(People.asRider(get("/api/payments/methods"), rider)) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[1].id").value("cash")) + .andExpect(jsonPath("$[1].isDefault").value(true)); + mvc.perform(People.asRider(post("/api/payments/methods/cash/remove"), rider)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(post("/api/payments/methods/cash/default"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isDefault").value(true)); + } + + @Test + void aRideIsChargedTippedAndRefundedAndTheDriverIsPaid() throws Exception { + // Berlin. + String rider = People.rider(accounts, "ber-rider"); + String driver = People.driver(accounts, "ber-driver"); + String stranger = People.rider(accounts, "ber-stranger"); + Trips.addCard(mvc, rider, Trips.VISA); + Trips.online(mvc, driver, 52.5200, 13.4050); + + // No method named: the default, which is now the card. + Map done = Trips.complete(mvc, rider, driver, People.ride(52.5200, 13.4050, 52.5300, + 13.4200)); + String id = (String) done.get("id"); + long fare = Trips.number(done, "fareCents"); + long fee = Trips.number(done, "serviceFee"); + assertEquals("PAID", done.get("paymentStatus")); + assertEquals("Visa 4242", done.get("paymentMethodLabel")); + assertEquals(Boolean.FALSE, done.get("cash")); + assertEquals("standard", done.get("product")); + assertEquals(Math.round(fare * 10d / 100d), fee); + assertEquals(fare + fee, Trips.number(done, "total")); + assertEquals("B", done.get("driverPhotoInitials")); + + // The receipt adds up, part by part. + Map receipt = Trips.receipt(mvc, rider, id); + assertEquals("paid", receipt.get("status")); + assertEquals("Visa 4242", receipt.get("methodLabel")); + assertEquals(fare, Trips.number(receipt, "baseFare") + Trips.number(receipt, + "distanceFare") + Trips.number(receipt, "timeFare")); + assertEquals(fee, Trips.number(receipt, "serviceFee")); + assertEquals(0L, Trips.number(receipt, "tip")); + assertEquals(fare + fee, Trips.number(receipt, "total")); + assertTrue(Trips.number(receipt, "paidAt") > 0L); + assertEquals("Here", receipt.get("pickupName")); + assertEquals("ber-driver", receipt.get("driverName")); + assertEquals("Green Fiat 500", receipt.get("vehicle")); + // It is the rider's, and nobody else's -- the driver's included. + mvc.perform(People.asRider(get("/api/payments/receipts/" + id), stranger)) + .andExpect(status().isNotFound()); + mvc.perform(People.asDriver(get("/api/payments/receipts/" + id), driver)) + .andExpect(status().isNotFound()); + mvc.perform(People.asRider(get("/api/payments/receipts"), stranger)) + .andExpect(jsonPath("$.length()").value(0)); + mvc.perform(People.asRider(get("/api/payments/receipts"), rider)) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].rideId").value(id)); + + // A tip, once, by the rider. + mvc.perform(People.asRider(Trips.body(post("/api/rides/" + id + "/tip"), + "{\"amount\":300}"), stranger)) + .andExpect(status().isNotFound()); + mvc.perform(People.asRider(Trips.body(post("/api/rides/" + id + "/tip"), + "{\"amount\":0}"), rider)) + .andExpect(status().isBadRequest()); + Map tipped = Trips.json(mvc, People.asRider(Trips.body(post("/api/rides/" + id + "/tip"), + "{\"amount\":300}"), rider)); + assertEquals(300L, Trips.number(tipped, "tip")); + assertEquals(fare + fee + 300L, Trips.number(tipped, "total")); + mvc.perform(People.asRider(Trips.body(post("/api/rides/" + id + "/tip"), + "{\"amount\":300}"), rider)) + .andExpect(status().isConflict()); + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.tip").value(300)); + + // The driver earns the fare less the service's fifth, and all the tip. + long earned = fare - Math.round(fare * 20d / 100d) + 300L; + Map summary = Trips.json(mvc, People.asDriver(get("/api/driver/earnings/summary"), + driver)); + assertEquals("USD", summary.get("currency")); + assertEquals(earned, Trips.number(summary, "today")); + assertEquals(earned, Trips.number(summary, "week")); + assertEquals(earned, Trips.number(summary, "month")); + assertEquals(earned, Trips.number(summary, "balance")); + assertEquals(1L, Trips.number(summary, "trips")); + assertEquals(300L, Trips.number(summary, "tips")); + assertEquals(1d, ((Number) summary.get("acceptanceRate")).doubleValue()); + List daily = (List) summary.get("daily"); + assertEquals(14, daily.size()); + Map today = (Map) daily.get(13); + assertEquals(Days.iso(System.currentTimeMillis()), today.get("date")); + assertEquals(earned, Trips.number(today, "amount")); + assertEquals(1L, Trips.number(today, "trips")); + assertEquals(0L, Trips.number((Map) daily.get(0), "amount")); + // The older call reports the same share, without the tip. + mvc.perform(People.asDriver(get("/api/driver/earnings"), driver)) + .andExpect(jsonPath("$.totalCents").value((int) (earned - 300L))); + + // Nor can an account that is owed money be closed: nobody could cash + // it out afterwards. + mvc.perform(People.asDriver(Trips.body(post("/api/account/delete"), + "{\"password\":\"" + People.PASSWORD + "\"}"), driver)) + .andExpect(status().isConflict()); + + // Cashing out needs somewhere to send it, and empties the balance. + mvc.perform(People.asDriver(get("/api/driver/payouts"), driver)) + .andExpect(jsonPath("$.length()").value(0)); + mvc.perform(People.asDriver(post("/api/driver/payouts"), driver)) + .andExpect(status().isConflict()); + mvc.perform(People.asDriver(get("/api/driver/payout-account"), driver)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accountLast4").value("")); + mvc.perform(People.asDriver(Trips.body(put("/api/driver/payout-account"), + "{\"holder\":\"B Driver\",\"bankName\":\"Spree Bank\"," + + "\"accountLast4\":\"12a4\"}"), driver)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asDriver(Trips.body(put("/api/driver/payout-account"), + "{\"holder\":\"B Driver\",\"bankName\":\"Spree Bank\"," + + "\"accountLast4\":\"1234\"}"), driver)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.bankName").value("Spree Bank")); + Map payout = Trips.json(mvc, People.asDriver(post("/api/driver/payouts"), driver)); + assertEquals(earned, Trips.number(payout, "amount")); + assertEquals("paid", payout.get("status")); + assertEquals("Spree Bank ****1234", payout.get("destination")); + mvc.perform(People.asDriver(post("/api/driver/payouts"), driver)) + .andExpect(status().isConflict()); + mvc.perform(People.asDriver(get("/api/driver/payouts"), driver)) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value((String) payout.get("id"))); + summary = Trips.json(mvc, People.asDriver(get("/api/driver/earnings/summary"), driver)); + assertEquals(0L, Trips.number(summary, "balance")); + assertEquals(earned, Trips.number(summary, "today")); + + // An admin sees every payment, and returns this one, once, saying why. + List all = Trips.list(mvc, admin(get("/api/admin/payments"))); + boolean listed = false; + for (int iter = 0; iter < all.size(); iter++) { + Map row = (Map) all.get(iter); + if (id.equals(row.get("rideId"))) { + listed = true; + assertEquals(rider, row.get("riderUsername")); + } + } + assertTrue(listed, "the admin's list has the ride"); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), "{}"))) + .andExpect(status().isBadRequest()); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), + "{\"reason\":\"The car broke down\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refunded")); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), + "{\"reason\":\"Twice\"}"))) + .andExpect(status().isConflict()); + assertEquals("refunded", Trips.receipt(mvc, rider, id).get("status")); + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.paymentStatus").value("REFUNDED")); + + // The refund took the fare and the tip back, so neither is earned any + // more: not in the day's totals, and not in the balance, which now owes + // what was cashed out. The trip was still driven. + summary = Trips.json(mvc, People.asDriver(get("/api/driver/earnings/summary"), driver)); + assertEquals(0L, Trips.number(summary, "today")); + assertEquals(0L, Trips.number(summary, "month")); + assertEquals(0L, Trips.number((Map) ((List) summary.get("daily")).get(13), "amount")); + assertEquals(1L, Trips.number((Map) ((List) summary.get("daily")).get(13), "trips")); + assertEquals(0L, Trips.number(summary, "tips")); + assertEquals(-earned, Trips.number(summary, "balance")); + mvc.perform(People.asDriver(post("/api/driver/payouts"), driver)) + .andExpect(status().isConflict()); + Trips.offline(mvc, driver); + } + + /// A card tip whose charge nobody can vouch for is retried with the + /// ride's card, so that card stays, and so does its account, until the + /// tip is settled one way or the other. + @Test + void aCardWithATipNobodyCanVouchForStays() throws Exception { + // Bergen. + String rider = People.rider(accounts, "bgo-tip-rider"); + String driver = People.driver(accounts, "bgo-tip-driver"); + String card = (String) Trips.addCard(mvc, rider, Trips.VISA).get("id"); + Trips.online(mvc, driver, 60.3913, 5.3221); + String id = (String) Trips.complete(mvc, rider, driver, People.ride(60.3913, 5.3221, + 60.4013, 5.3321)).get("id"); + Trips.offline(mvc, driver); + db.execute("UPDATE ride SET tip_cents = 300 WHERE id = ?", new Object[] {id}); + mvc.perform(People.asRider(post("/api/payments/methods/" + card + "/remove"), rider)) + .andExpect(status().isConflict()); + mvc.perform(People.asRider(Trips.body(post("/api/account/delete"), + "{\"password\":\"" + People.PASSWORD + "\"}"), rider)) + .andExpect(status().isConflict()); + + // The charge came back: nothing is left to ask about. + db.execute("UPDATE payment SET tip_ref = 'sim_ch_tip' WHERE ride_id = ?", + new Object[] {id}); + mvc.perform(People.asRider(post("/api/payments/methods/" + card + "/remove"), rider)) + .andExpect(status().isOk()); + } + + @Test + void aRideIsNotRefundedWhileItsTipIsBeingCharged() throws Exception { + // Oslo. + String rider = People.rider(accounts, "osl-rider"); + String driver = People.driver(accounts, "osl-driver"); + Trips.addCard(mvc, rider, Trips.VISA); + Trips.online(mvc, driver, 59.9139, 10.7522); + String id = (String) Trips.complete(mvc, rider, driver, People.ride(59.9139, 10.7522, + 59.9239, 10.7622)).get("id"); + + // A tip as it stands between being claimed and its charge coming back: + // on the ride, and with no charge recorded for it. + db.execute("UPDATE ride SET tip_cents = 300 WHERE id = ?", new Object[] {id}); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), + "{\"reason\":\"Too soon\"}"))) + .andExpect(status().isConflict()); + // Refused whole: the ride is still paid, so the refund can be asked + // for again and the tip's charge has a paid ride to land on. + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.paymentStatus").value("PAID")); + // Nor is it the driver's to cash out: nobody knows it was charged. + Map before = Trips.json(mvc, People.asDriver(get("/api/driver/earnings/summary"), + driver)); + assertEquals(0L, Trips.number(before, "tips")); + long fareOnly = Trips.number(before, "balance"); + // Nor earned today: the day's total is what the balance grew by. + assertEquals(fareOnly, Trips.number(before, "today")); + assertEquals(fareOnly, Trips.number(before, "month")); + + // The charge came back. + db.execute("UPDATE payment SET tip_ref = 'sim_ch_tip' WHERE ride_id = ?", + new Object[] {id}); + Map after = Trips.json(mvc, People.asDriver(get("/api/driver/earnings/summary"), + driver)); + assertEquals(300L, Trips.number(after, "tips")); + assertEquals(fareOnly + 300L, Trips.number(after, "balance")); + assertEquals(fareOnly + 300L, Trips.number(after, "today")); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), + "{\"reason\":\"Now\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refunded")); + Trips.offline(mvc, driver); + } + + @Test + void theAdminSeesWhatTheDriverEarnedAndNoMore() throws Exception { + // Vilnius. + String rider = People.rider(accounts, "vno-rider"); + String driver = People.driver(accounts, "vno-driver"); + Trips.addCard(mvc, rider, Trips.VISA); + Trips.online(mvc, driver, 54.6872, 25.2797); + String paid = (String) Trips.complete(mvc, rider, driver, People.ride(54.6872, 25.2797, + 54.6972, 25.2897)).get("id"); + String pending = (String) Trips.complete(mvc, rider, driver, People.ride(54.6872, + 25.2797, 54.6972, 25.2897)).get("id"); + String refunded = (String) Trips.complete(mvc, rider, driver, People.ride(54.6872, + 25.2797, 54.6972, 25.2897)).get("id"); + // A charge nobody has answered yet, a ride refunded, and a tip on the + // paid ride whose charge has not come back. + db.execute("UPDATE ride SET payment_status = 'PENDING' WHERE id = ?", + new Object[] {pending}); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + refunded + "/refund"), + "{\"reason\":\"Wrong address\"}"))) + .andExpect(status().isOk()); + db.execute("UPDATE ride SET tip_cents = 300 WHERE id = ?", new Object[] {paid}); + + long share = ((Number) db.queryOne("SELECT driver_cents FROM ride WHERE id = ?", + new Object[] {paid}).get("driver_cents")).longValue(); + Map summary = Trips.json(mvc, People.asDriver(get("/api/driver/earnings/summary"), + driver)); + Map detail = Trips.json(mvc, admin(get("/api/admin/users/" + driver))); + // The paid ride's share alone, on both screens. + assertEquals(share, Trips.number(summary, "balance")); + assertEquals(share, Trips.number(summary, "month")); + assertEquals(share, Trips.number(detail, "earned")); + + // Once the tip's charge is on record it is earned on both. + db.execute("UPDATE payment SET tip_ref = 'sim_ch_tip' WHERE ride_id = ?", + new Object[] {paid}); + summary = Trips.json(mvc, People.asDriver(get("/api/driver/earnings/summary"), driver)); + detail = Trips.json(mvc, admin(get("/api/admin/users/" + driver))); + assertEquals(share + 300L, Trips.number(summary, "balance")); + assertEquals(share + 300L, Trips.number(detail, "earned")); + Trips.offline(mvc, driver); + } + + @Test + void aRefundTheServerNeverRecordedIsAskedForAgain() throws Exception { + // Bergen. + String rider = People.rider(accounts, "bgo-rider"); + String driver = People.driver(accounts, "bgo-driver"); + Trips.addCard(mvc, rider, Trips.VISA); + Trips.online(mvc, driver, 60.3913, 5.3221); + String id = (String) Trips.complete(mvc, rider, driver, People.ride(60.3913, 5.3221, + 60.4013, 5.3321)).get("id"); + + // A refund as a server that stopped half way leaves it: the ride + // marked, the processor perhaps asked, and nothing written down. + db.execute("UPDATE ride SET payment_status = 'REFUNDED' WHERE id = ?", + new Object[] {id}); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), + "{\"reason\":\"Again\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refunded")); + // And once it is recorded, it is refunded once like any other. + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), + "{\"reason\":\"Twice\"}"))) + .andExpect(status().isConflict()); + Trips.offline(mvc, driver); + } + + @Test + void aRefundTheProcessorHasNotFinishedIsRecordedOnlyOnceItEnds() throws Exception { + // Gothenburg. + String rider = People.rider(accounts, "got-rider"); + String driver = People.driver(accounts, "got-driver"); + String card = (String) Trips.addCard(mvc, rider, Trips.SLOW_REFUND).get("id"); + String method = "\"paymentMethodId\":\"" + card + "\""; + Trips.online(mvc, driver, 57.7089, 11.9746); + String id = (String) Trips.complete(mvc, rider, driver, Trips.ride(57.7089, 11.9746, + 57.7189, 11.9846, method)).get("id"); + + // Taken, and not yet money back: pending, and not to be asked for twice. + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), + "{\"reason\":\"Slow\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refund_pending")); + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.paymentStatus").value("REFUND_PENDING")); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), + "{\"reason\":\"Again\"}"))) + .andExpect(status().isConflict()); + // The processor has finished it by the time it is asked. + payments.settleRefunds(); + assertEquals("refunded", Trips.receipt(mvc, rider, id).get("status")); + + // And one the processor then fails is recorded as failed, never as + // refunded, and is not tried again from here. + db.execute("UPDATE payment_method SET token = 'sim_pm_lost_card' WHERE id = ?", + new Object[] {card}); + String lost = (String) Trips.complete(mvc, rider, driver, Trips.ride(57.7089, 11.9746, + 57.7189, 11.9846, method)).get("id"); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + lost + "/refund"), + "{\"reason\":\"Lost\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refund_pending")); + payments.settleRefunds(); + assertEquals("refund_failed", Trips.receipt(mvc, rider, lost).get("status")); + mvc.perform(People.asRider(get("/api/rides/" + lost), rider)) + .andExpect(jsonPath("$.paymentStatus").value("REFUND_FAILED")); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + lost + "/refund"), + "{\"reason\":\"Again\"}"))) + .andExpect(status().isConflict()); + Trips.offline(mvc, driver); + } + + @Test + void aRideLeftCompletedAndUnpaidIsSettledLater() throws Exception { + // Lisbon. + String rider = People.rider(accounts, "lis-pay-rider"); + String driver = People.driver(accounts, "lis-pay-driver"); + Trips.addCard(mvc, rider, Trips.VISA); + Trips.online(mvc, driver, 38.7223, -9.1393); + Map done = Trips.complete(mvc, rider, driver, People.ride(38.7223, -9.1393, 38.7323, + -9.1293)); + String id = (String) done.get("id"); + long total = Trips.number(done, "fareCents") + Trips.number(done, "serviceFee"); + + // The server stopped after the ride completed and before its payment + // was written. + db.execute("DELETE FROM payment WHERE ride_id = ?", new Object[] {id}); + db.execute("UPDATE ride SET payment_status = 'PENDING' WHERE id = ?", + new Object[] {id}); + + // Completed a moment ago, it may still be at the processor: left alone. + rides.settleLeftBehind(); + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.paymentStatus").value("PENDING")); + + db.execute("UPDATE ride SET updated_at = ? WHERE id = ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 5L * 60L * 1000L), id}); + rides.settleLeftBehind(); + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.paymentStatus").value("PAID")); + Map receipt = Trips.receipt(mvc, rider, id); + assertEquals("paid", receipt.get("status")); + assertEquals(total, Trips.number(receipt, "total")); + + // And once: a second pass finds nothing left to settle. + rides.settleLeftBehind(); + assertEquals(total, Trips.number(Trips.receipt(mvc, rider, id), "total")); + Trips.offline(mvc, driver); + } + + @Test + void aChargeNobodyCanVouchForIsLeftPendingAndAskedForAgain() throws Exception { + // Prague. + String rider = People.rider(accounts, "prg-pay-rider"); + String driver = People.driver(accounts, "prg-pay-driver"); + String card = (String) Trips.addCard(mvc, rider, Trips.NO_ANSWER_WHEN_CHARGED).get("id"); + Trips.online(mvc, driver, 50.0755, 14.4378); + Map done = Trips.complete(mvc, rider, driver, Trips.ride(50.0755, 14.4378, 50.0855, + 14.4478, "\"paymentMethodId\":\"" + card + "\"")); + String id = (String) done.get("id"); + long total = Trips.number(done, "fareCents") + Trips.number(done, "serviceFee"); + + // The processor failed without saying whether it took the money: not + // paid, and not failed either. + assertEquals("COMPLETED", done.get("state")); + assertEquals("PENDING", done.get("paymentStatus")); + // Not money yet, so not the driver's to cash out. + assertEquals(0L, Trips.number(Trips.json(mvc, People.asDriver( + get("/api/driver/earnings/summary"), driver)), "balance")); + // The card is what the ride will be asked about with, so it stays, + // and so does the account it belongs to. + mvc.perform(People.asRider(post("/api/payments/methods/" + card + "/remove"), rider)) + .andExpect(status().isConflict()); + mvc.perform(People.asRider(Trips.body(post("/api/account/delete"), + "{\"password\":\"" + People.PASSWORD + "\"}"), rider)) + .andExpect(status().isConflict()); + db.execute("UPDATE ride SET updated_at = ? WHERE id = ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 5L * 60L * 1000L), id}); + rides.settleLeftBehind(); + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.paymentStatus").value("PENDING")); + + // The processor answers again, and the same ride is asked about. + db.execute("UPDATE payment_method SET token = 'sim_pm_recovered' WHERE id = ?", + new Object[] {card}); + rides.settleLeftBehind(); + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.paymentStatus").value("PAID")); + assertEquals(total, Trips.number(Trips.receipt(mvc, rider, id), "total")); + // Paid now, and the driver's share is in the balance. + assertEquals(Trips.number(done, "fareCents") - Math.round( + Trips.number(done, "fareCents") * 20d / 100d), Trips.number(Trips.json(mvc, + People.asDriver(get("/api/driver/earnings/summary"), driver)), "balance")); + // Settled, and the card can go. + mvc.perform(People.asRider(post("/api/payments/methods/" + card + "/remove"), rider)) + .andExpect(status().isOk()); + Trips.offline(mvc, driver); + } + + @Test + void aTipNobodyCanVouchForStaysOnTheRideUntilItIsAskedForAgain() throws Exception { + // Vienna. + String rider = People.rider(accounts, "vie-pay-rider"); + String driver = People.driver(accounts, "vie-pay-driver"); + String card = (String) Trips.addCard(mvc, rider, Trips.VISA).get("id"); + Trips.online(mvc, driver, 48.2082, 16.3738); + String id = (String) Trips.complete(mvc, rider, driver, People.ride(48.2082, 16.3738, + 48.2182, 16.3838)).get("id"); + + // The processor stops answering, and a tip is asked for. + db.execute("UPDATE payment_method SET token = 'sim_pm_fault_now' WHERE id = ?", + new Object[] {card}); + assertEquals(502, Trips.statusOf(mvc, People.asRider(Trips.body( + post("/api/rides/" + id + "/tip"), "{\"amount\":300}"), rider))); + // It may have been charged, so it stays on the ride, which cannot be + // refunded around it, and cannot be swapped for another amount. + assertEquals(300L, Trips.number(Trips.receipt(mvc, rider, id), "tip")); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), + "{\"reason\":\"Too soon\"}"))) + .andExpect(status().isConflict()); + mvc.perform(People.asRider(Trips.body(post("/api/rides/" + id + "/tip"), + "{\"amount\":500}"), rider)) + .andExpect(status().isConflict()); + + // Asked for again with the same amount once the processor is back. + db.execute("UPDATE payment_method SET token = 'sim_pm_recovered' WHERE id = ?", + new Object[] {card}); + mvc.perform(People.asRider(Trips.body(post("/api/rides/" + id + "/tip"), + "{\"amount\":300}"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tip").value(300)); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + id + "/refund"), + "{\"reason\":\"Now\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refunded")); + Trips.offline(mvc, driver); + } + + @Test + void theCardLimitHoldsForSetupsStartedWhileThereWasRoom() throws Exception { + String rider = People.rider(accounts, "card-collector"); + for (int iter = 0; iter < 7; iter++) { + Trips.addCard(mvc, rider, Trips.VISA); + } + // Two setups started with room for one card. + Map first = Trips.json(mvc, People.asRider(post("/api/payments/methods/setup"), rider)); + Map second = Trips.json(mvc, People.asRider(post("/api/payments/methods/setup"), rider)); + mvc.perform(People.asRider(Trips.body(post("/api/payments/methods/setup/" + + first.get("id") + "/complete"), Trips.card(Trips.MASTERCARD)), rider)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(Trips.body(post("/api/payments/methods/setup/" + + second.get("id") + "/complete"), Trips.card(Trips.MASTERCARD)), rider)) + .andExpect(status().isConflict()); + // Cash and eight cards. + mvc.perform(People.asRider(get("/api/payments/methods"), rider)) + .andExpect(jsonPath("$.length()").value(9)); + } + + @Test + void cashIsRecordedAndACardThatFailsIsReported() throws Exception { + // Dublin. + String rider = People.rider(accounts, "dub-rider"); + String driver = People.driver(accounts, "dub-driver"); + String failing = (String) Trips.addCard(mvc, rider, Trips.FAILS_WHEN_CHARGED).get("id"); + Trips.online(mvc, driver, 53.3498, -6.2603); + + // Cash, asked for by name although a card is the default. + Map cash = Trips.complete(mvc, rider, driver, Trips.ride(53.3498, -6.2603, 53.3600, + -6.2500, "\"paymentMethodId\":\"cash\",\"note\":\"Blue door\",\"quietRide\":true")); + String cashId = (String) cash.get("id"); + assertEquals("PAID", cash.get("paymentStatus")); + assertEquals("Cash", cash.get("paymentMethodLabel")); + assertEquals(Boolean.TRUE, cash.get("cash")); + assertEquals("Blue door", cash.get("note")); + assertEquals(Boolean.TRUE, cash.get("quietRide")); + assertEquals("cash", Trips.receipt(mvc, rider, cashId).get("status")); + // Nothing went to a card, so there is nothing to send back to one. + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + cashId + "/refund"), + "{\"reason\":\"Asked nicely\"}"))) + .andExpect(status().isConflict()); + mvc.perform(People.asRider(Trips.body(post("/api/rides/" + cashId + "/tip"), + "{\"amount\":150}"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tip").value(150)) + .andExpect(jsonPath("$.status").value("cash")); + + // The card that is refused when charged: the ride still ends, and + // says it was not paid for. + Map unpaid = Trips.complete(mvc, rider, driver, Trips.ride(53.3498, -6.2603, 53.3600, + -6.2500, "\"paymentMethodId\":\"" + failing + "\"")); + String unpaidId = (String) unpaid.get("id"); + assertEquals("COMPLETED", unpaid.get("state")); + assertEquals("FAILED", unpaid.get("paymentStatus")); + assertEquals("failed", Trips.receipt(mvc, rider, unpaidId).get("status")); + mvc.perform(People.asRider(Trips.body(post("/api/rides/" + unpaidId + "/tip"), + "{\"amount\":150}"), rider)) + .andExpect(status().isConflict()); + mvc.perform(admin(Trips.body(post("/api/admin/rides/" + unpaidId + "/refund"), + "{\"reason\":\"Nothing to return\"}"))) + .andExpect(status().isConflict()); + // A card with a ride riding on it is not removed until the ride ends. + String waiting = (String) Trips.json(mvc, People.asRider(Trips.body(post("/api/rides"), + Trips.ride(53.3498, -6.2603, 53.3600, -6.2500, "\"paymentMethodId\":\"" + failing + + "\"")), rider)).get("id"); + mvc.perform(People.asRider(post("/api/payments/methods/" + failing + "/remove"), rider)) + .andExpect(status().isConflict()); + mvc.perform(People.asRider(post("/api/rides/" + waiting + "/cancel"), rider)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(post("/api/payments/methods/" + failing + "/remove"), rider)) + .andExpect(status().isOk()); + Trips.offline(mvc, driver); + } + + @Test + void theHostedSetupEndsOnAPageThatSaysSo() throws Exception { + String done = mvc.perform(get("/pay/return?result=done")).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertTrue(done.indexOf("Card added") > 0, done); + String cancelled = mvc.perform(get("/pay/return?result=%3Cscript%3E")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + // Nothing from the address is written into the page. + assertTrue(cancelled.indexOf("No card was added") > 0, cancelled); + assertEquals(-1, cancelled.indexOf("= 300L && base < 300L + admins, "one of them is in force: " + base); + rows = db.queryOne("SELECT COUNT(*) AS n FROM pricing", new Object[0]); + assertEquals(1L, ((Number) rows.get("n")).longValue(), "and there is one row"); + } finally { + // The other tests share these prices. + fares.save(DemoAccounts.ADMIN, before); + } + } + + @Test + void theDashboardsSeriesHasEveryDayAndEveryHour() throws Exception { + Map week = Trips.json(mvc, admin(get("/api/admin/stats/series?days=7"))); + List days = (List) week.get("days"); + assertEquals(7, days.size()); + // Oldest first, ending today, and a day with nothing in it is there. + Map last = (Map) days.get(6); + assertEquals(Days.iso(System.currentTimeMillis()), last.get("date")); + assertEquals(Days.iso(System.currentTimeMillis() - 6L * 86400000L), + ((Map) days.get(0)).get("date")); + for (int iter = 0; iter < days.size(); iter++) { + Map day = (Map) days.get(iter); + assertTrue(Trips.number(day, "rides") >= Trips.number(day, "completed") + + Trips.number(day, "cancelled"), "day " + iter); + assertTrue(Trips.number(day, "revenue") >= 0L); + assertNotNull(day.get("newUsers")); + } + assertEquals(24, ((List) week.get("byHour")).size()); + List products = (List) week.get("byProduct"); + assertEquals(3, products.size()); + assertEquals("standard", ((Map) products.get(0)).get("name")); + assertEquals("comfort", ((Map) products.get(1)).get("name")); + assertEquals("xl", ((Map) products.get(2)).get("name")); + assertEquals("USD", week.get("currency")); + assertNotNull(week.get("byState")); + assertTrue(((List) week.get("topDrivers")).size() <= 5); + // The demo data has somebody waiting to be reviewed and somebody + // flagged, and a fortnight of rides behind them. + assertTrue(Trips.number(week, "pendingApplications") >= 1L); + assertTrue(Trips.number(week, "flaggedUsers") >= 1L); + assertTrue(Trips.number(week, "averageFare") > 0L); + double rate = ((Number) week.get("completionRate")).doubleValue(); + assertTrue(rate > 0d && rate <= 1d, "completion rate " + rate); + long rides = 0L; + for (int iter = 0; iter < days.size(); iter++) { + rides += Trips.number((Map) days.get(iter), "rides"); + } + assertTrue(rides > 0L, "the week has rides in it"); + + // A silly number is the fortnight; and there is a most. + mvc.perform(admin(get("/api/admin/stats/series?days=0"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.days.length()").value(14)); + mvc.perform(admin(get("/api/admin/stats/series?days=1000"))) + .andExpect(jsonPath("$.days.length()").value(90)) + .andExpect(jsonPath("$.byHour.length()").value(24)); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ProcessorFaultsTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ProcessorFaultsTest.java new file mode 100644 index 00000000000..fff4e19e744 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ProcessorFaultsTest.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.DataSource; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Primary; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.TestConfiguration; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.pay.Payments; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Paying, against a processor that fails when a test says so: what is left +/// behind when a request to it fails part way, alone or overlapping another. +/// The service is called directly, on the test's thread, so the request a +/// test lines up inside another runs exactly where it is put. +@BackendTest +class ProcessorFaultsTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Payments payments; + @Autowired + private ScriptedPayments processor; + @Autowired + private DataSource db; + + @TestConfiguration + static class Processor { + @Bean + @Primary + ScriptedPayments scriptedPayments() { + return new ScriptedPayments(); + } + } + + /// The status `call` was refused with, or 200 when it was not. + private static int refusal(Runnable call) { + try { + call.run(); + return 200; + } catch (ResponseStatusException refused) { + return refused.getStatus(); + } + } + + private Runnable refund(final String rideId) { + return new Runnable() { + @Override + public void run() { + try { + payments.refund(rideId, "Overlapping"); + } catch (IOException err) { + throw new IllegalStateException(err); + } + } + }; + } + + private Runnable remove(final String rider, final String card) { + return new Runnable() { + @Override + public void run() { + try { + payments.remove(rider, card); + } catch (IOException err) { + throw new IllegalStateException(err); + } + } + }; + } + + private long balance(String driver) throws Exception { + return Trips.number(Trips.json(mvc, People.asDriver(get("/api/driver/earnings/summary"), + driver)), "balance"); + } + + @Test + void aRemovedCardIsDetachedAtTheProcessorFirst() throws Exception { + processor.reset(); + String rider = People.rider(accounts, "card-detacher"); + String card = (String) Trips.addCard(mvc, rider, Trips.VISA).get("id"); + String token = (String) db.queryOne("SELECT token FROM payment_method WHERE id = ?", + new Object[] {card}).get("token"); + + // A processor that cannot be reached keeps the card: deleted here + // alone, it would stay chargeable there with nothing pointing at it. + processor.detachFails = true; + assertEquals(502, refusal(remove(rider, card))); + mvc.perform(People.asRider(get("/api/payments/methods"), rider)) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].id").value(card)); + assertEquals(0, processor.detached.size()); + + processor.detachFails = false; + assertEquals(200, refusal(remove(rider, card))); + assertEquals(1, processor.detached.size()); + assertEquals(token, processor.detached.get(0)); + mvc.perform(People.asRider(get("/api/payments/methods"), rider)) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + void aCardTheLimitRefusesIsDetachedAtTheProcessor() throws Exception { + processor.reset(); + String rider = People.rider(accounts, "card-overflow"); + for (int iter = 0; iter < 7; iter++) { + Trips.addCard(mvc, rider, Trips.VISA); + } + // Two setups started with room for one card, and a third. + Map first = Trips.json(mvc, People.asRider(post("/api/payments/methods/setup"), rider)); + Map second = Trips.json(mvc, People.asRider(post("/api/payments/methods/setup"), rider)); + Map third = Trips.json(mvc, People.asRider(post("/api/payments/methods/setup"), rider)); + mvc.perform(People.asRider(Trips.body(post("/api/payments/methods/setup/" + + first.get("id") + "/complete"), Trips.card(Trips.MASTERCARD)), rider)) + .andExpect(status().isOk()); + assertEquals(0, processor.detached.size()); + + // The processor attached the second card before the limit refused it; + // with no row here, it is detached there, and no card that was kept. + mvc.perform(People.asRider(Trips.body(post("/api/payments/methods/setup/" + + second.get("id") + "/complete"), Trips.card(Trips.MASTERCARD)), rider)) + .andExpect(status().isConflict()); + assertEquals(1, processor.detached.size()); + Map kept = db.queryOne("SELECT COUNT(*) AS n FROM payment_method WHERE token = ?", + new Object[] {processor.detached.get(0)}); + assertEquals(0L, ((Number) kept.get("n")).longValue()); + + // A detach that fails is logged, and the rider still hears the limit. + processor.detachFails = true; + mvc.perform(People.asRider(Trips.body(post("/api/payments/methods/setup/" + + third.get("id") + "/complete"), Trips.card(Trips.MASTERCARD)), rider)) + .andExpect(status().isConflict()); + assertEquals(1, processor.detached.size()); + mvc.perform(People.asRider(get("/api/payments/methods"), rider)) + .andExpect(jsonPath("$.length()").value(9)); + processor.reset(); + } + + @Test + void aRefundThatFailsDoesNotUndoOneThatOverlappedIt() throws Exception { + processor.reset(); + // Riga. + String rider = People.rider(accounts, "rix-rider"); + String driver = People.driver(accounts, "rix-driver"); + Trips.addCard(mvc, rider, Trips.VISA); + Trips.online(mvc, driver, 56.9496, 24.1052); + String id = (String) Trips.complete(mvc, rider, driver, People.ride(56.9496, 24.1052, + 56.9596, 24.1152)).get("id"); + + // A refund whose processor fails, and while it is at the processor a + // second one -- let through as one that may have been cut short -- + // that succeeds and is recorded. The second answers first. + processor.duringRefund = refund(id); + processor.refundFailures.add(Boolean.FALSE); + processor.refundFailures.add(Boolean.TRUE); + assertEquals(502, refusal(refund(id))); + // The failure gives back nothing: the payment was refunded, so the + // ride is, and its fare is not the driver's to cash out. + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.paymentStatus").value("REFUNDED")); + assertEquals("refunded", Trips.receipt(mvc, rider, id).get("status")); + assertEquals(0L, balance(driver)); + + // The other way round: the overlapping refund fails and gives its + // claim back while the first is still at the processor, and the first + // then succeeds. + final String next = (String) Trips.complete(mvc, rider, driver, People.ride(56.9496, + 24.1052, 56.9596, 24.1152)).get("id"); + processor.duringRefund = new Runnable() { + @Override + public void run() { + assertEquals(502, refusal(refund(next))); + } + }; + processor.refundFailures.add(Boolean.TRUE); + processor.refundFailures.add(Boolean.FALSE); + assertEquals(200, refusal(refund(next))); + Map ride = Trips.json(mvc, People.asRider(get("/api/rides/" + next), rider)); + assertEquals("REFUNDED", ride.get("paymentStatus")); + assertEquals(0L, balance(driver)); + Trips.offline(mvc, driver); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/PushTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/PushTest.java new file mode 100644 index 00000000000..2e23a5ad299 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/PushTest.java @@ -0,0 +1,584 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.DataSource; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.api.PushDeviceDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.push.PushDevices; +import com.codenameone.examples.wayline.push.PushSender; +import com.codenameone.examples.wayline.push.RecordingPushSender; +import com.codenameone.examples.wayline.push.RidePushes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// The push notifications a ride sends, the devices they go to, and the Live +/// Activity a rider follows the driver in. +/// +/// The `test` profile keeps every notification instead of sending it, so each +/// test reads what each phone would have been sent. +@BackendTest +class PushTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private PushSender sender; + @Autowired + private DataSource db; + @Autowired + private PushDevices pushDevices; + @Autowired + private RidePushes pushes; + + private RecordingPushSender sent; + + @BeforeEach + void recording() { + assertTrue(sender instanceof RecordingPushSender, + "the test profile records notifications: " + sender); + sent = (RecordingPushSender) sender; + } + + private static MockRequestBuilder body(MockRequestBuilder request, String json) { + return request.contentType("application/json").content(json); + } + + private static String device(String provider, String token) { + return "{\"provider\":\"" + provider + "\",\"token\":\"" + token + + "\",\"platform\":\"and\"}"; + } + + private void register(String username, String provider, String token) throws Exception { + mvc.perform(People.asRider(body(post("/api/account/push"), device(provider, token)), + username)).andExpect(status().isOk()); + } + + /// The titles `token` was sent, in order; a silent update counts as "". + private String titles(String token) { + StringBuilder out = new StringBuilder(); + for (RecordingPushSender.Sent each : sent.sentTo(token)) { + Object title = each.message().get("title"); + out.append(out.length() > 0 ? "|" : "").append(title == null ? "" : title); + } + return out.toString(); + } + + private static Map data(RecordingPushSender.Sent message) { + return (Map) message.message().get("data"); + } + + private static Map surface(RecordingPushSender.Sent message) { + return (Map) message.message().get("surface"); + } + + private static double progress(Map command) throws Exception { + return ((Number) Json.parseObject((String) command.get("state")).get("progress")) + .doubleValue(); + } + + private static RecordingPushSender.Sent last(List list) { + return list.get(list.size() - 1); + } + + @Test + void aDeviceIsRegisteredInTheProvidersOwnSpelling() throws Exception { + String rider = People.rider(accounts, "push-reg-rider"); + mvc.perform(People.asRider(body(post("/api/account/push"), + device("gcm", "reg-token-1")), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.provider").value("fcm")) + .andExpect(jsonPath("$.token").value("reg-token-1")); + // An alias is the same device, not a second one. + mvc.perform(People.asRider(body(post("/api/account/push"), + device("fcm", "reg-token-1")), rider)).andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/account/push"), + device("pigeon", "reg-token-2")), rider)).andExpect(status().isBadRequest()); + mvc.perform(People.asRider(body(post("/api/account/push"), + device("apns", " ")), rider)).andExpect(status().isBadRequest()); + } + + @Test + void ridersAndDriversAreToldOfEveryStepOfARide() throws Exception { + // Reykjavik. Each test has a city to itself. + String rider = People.rider(accounts, "push-rkv-rider"); + String driver = People.driver(accounts, "push-rkv-driver"); + register(rider, "apns", "rkv-rider-phone"); + register(driver, "fcm", "rkv-driver-phone"); + Trips.online(mvc, driver, 64.1466, -21.9426); + + Map asked = Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(64.1467, -21.9428, 64.1597, -21.9548)), rider)); + String id = (String) asked.get("id"); + // The offer goes to the driver, who has a few seconds to answer it. + List toDriver = sent.sentTo("rkv-driver-phone"); + assertEquals("New ride request", titles("rkv-driver-phone")); + assertEquals(id, data(toDriver.get(0)).get("rideId")); + assertEquals("OFFERED", data(toDriver.get(0)).get("state")); + assertEquals("driver", data(toDriver.get(0)).get("role")); + assertEquals("fcm", toDriver.get(0).provider()); + assertTrue(((Number) toDriver.get(0).message().get("ttl")).intValue() > 0); + assertEquals("", titles("rkv-rider-phone")); + + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + List toRider = sent.sentTo("rkv-rider-phone"); + assertEquals("Your driver is on the way", titles("rkv-rider-phone")); + String body = (String) toRider.get(0).message().get("body"); + assertTrue(body.contains("push-rkv-driver") && body.contains("Green Fiat 500"), body); + assertEquals("apns", toRider.get(0).provider()); + assertEquals("rider", data(toRider.get(0)).get("role")); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/arrived"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/start"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/complete"), driver)) + .andExpect(status().isOk()); + assertEquals("Your driver is on the way|Your driver is here|Your trip has started" + + "|You've arrived", titles("rkv-rider-phone")); + // Without a Live Activity there is nothing to move on. + for (RecordingPushSender.Sent each : sent.sentTo("rkv-rider-phone")) { + assertNull(surface(each)); + assertEquals(id, data(each).get("rideId")); + } + assertEquals("New ride request", titles("rkv-driver-phone")); + } + + @Test + void cancellingTellsTheOtherSide() throws Exception { + // Ljubljana: the driver gives up on the way to the pickup. + String rider = People.rider(accounts, "push-lju-rider"); + String driver = People.driver(accounts, "push-lju-driver"); + register(rider, "fcm", "lju-rider-phone"); + register(driver, "fcm", "lju-driver-phone"); + Trips.online(mvc, driver, 46.0569, 14.5058); + String id = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(46.0570, 14.5054, 46.0670, 14.5174)), rider)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/cancel"), driver)) + .andExpect(status().isOk()); + assertEquals("Your driver is on the way|Your driver canceled", titles("lju-rider-phone")); + + + // Bratislava: the rider changes their mind while the offer is out. + String bratislavaRider = People.rider(accounts, "push-bts-rider"); + String bratislavaDriver = People.driver(accounts, "push-bts-driver"); + register(bratislavaDriver, "fcm", "bts-driver-phone"); + Trips.online(mvc, bratislavaDriver, 48.1486, 17.1077); + String offered = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(48.1487, 17.1076, 48.1601, 17.0909)), bratislavaRider)).get("id"); + mvc.perform(People.asRider(post("/api/rides/" + offered + "/cancel"), bratislavaRider)) + .andExpect(status().isOk()); + assertEquals("New ride request|Ride canceled", titles("bts-driver-phone")); + } + + @Test + void theLiveActivityFollowsTheCarAndEndsWithTheRide() throws Exception { + String rider = People.rider(accounts, "push-cph-rider"); + String driver = People.driver(accounts, "push-cph-driver"); + String other = People.rider(accounts, "push-cph-other"); + register(rider, "apns", "cph-rider-phone"); + Trips.online(mvc, driver, 55.6761, 12.5683); + String id = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(55.6762, 12.5680, 55.6900, 12.5900)), rider)).get("id"); + // No activity before there is a driver on the way, and none on + // somebody else's ride. + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/activity"), + "{\"activityId\":\"act-1\"}"), rider)).andExpect(status().isConflict()); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/activity"), + "{\"activityId\":\"act-1\"}"), other)).andExpect(status().isNotFound()); + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/activity"), + "{\"activityId\":\"act-1\"}"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.activityId").value("act-1")); + + // The car moves. The phone has just drawn the activity, so the first + // update waits its turn... + int before = sent.sentTo("cph-rider-phone").size(); + Trips.online(mvc, driver, 55.6765, 12.5682); + assertEquals(before, sent.sentTo("cph-rider-phone").size()); + // ...and once it is due, one report sends one, silently. + db.execute("UPDATE ride_activity SET sent_at = 0 WHERE ride_id = ?", new Object[] {id}); + Trips.online(mvc, driver, 55.6764, 12.5681); + Trips.online(mvc, driver, 55.6763, 12.5681); + List toRider = sent.sentTo("cph-rider-phone"); + assertEquals(before + 1, toRider.size()); + RecordingPushSender.Sent update = last(toRider); + assertEquals(Boolean.TRUE, update.message().get("silent")); + Map command = surface(update); + assertEquals("live-update", command.get("operation")); + assertEquals("act-1", command.get("id")); + Map state = Json.parseObject((String) command.get("state")); + assertEquals("On the way", state.get("status")); + assertEquals("push-cph-driver", state.get("driver")); + assertEquals("Green Fiat 500", state.get("car")); + assertEquals("T push-cph-driver", state.get("plate")); + assertEquals(0.25d, ((Number) state.get("progress")).doubleValue(), 0.001d); + assertTrue(((String) state.get("eta")).endsWith(" min"), state.toString()); + + // A change of state moves it on at once, with the notification. + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/arrived"), driver)) + .andExpect(status().isOk()); + command = surface(last(sent.sentTo("cph-rider-phone"))); + assertEquals("live-update", command.get("operation")); + assertEquals(0.5d, progress(command), 0.001d); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/start"), driver)) + .andExpect(status().isOk()); + assertEquals(0.75d, progress(surface(last(sent.sentTo("cph-rider-phone")))), 0.001d); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/complete"), driver)) + .andExpect(status().isOk()); + RecordingPushSender.Sent done = last(sent.sentTo("cph-rider-phone")); + assertEquals("You've arrived", done.message().get("title")); + assertEquals("live-end", surface(done).get("operation")); + assertEquals("act-1", surface(done).get("id")); + // And it is forgotten: the car goes on reporting, the rider hears no more. + int after = sent.sentTo("cph-rider-phone").size(); + Trips.online(mvc, driver, 55.6800, 12.5700); + assertEquals(after, sent.sentTo("cph-rider-phone").size()); + } + + @Test + void theSettingsDecideWhatIsSaidButNotWhatIsShown() throws Exception { + // Sofia: a rider who turned ride updates off hears nothing of the + // driver, while the lock screen still lets go of the cancelled ride. + String rider = People.rider(accounts, "push-sof-rider"); + String driver = People.driver(accounts, "push-sof-driver"); + String quiet = "{\"language\":\"en\",\"theme\":\"system\",\"units\":\"km\"," + + "\"notifyRideUpdates\":false,\"notifyReceipts\":true," + + "\"notifyPromotions\":false,\"driverGender\":\"\",\"quietRide\":false," + + "\"accessibleVehicle\":false,\"petFriendly\":false," + + "\"defaultPaymentMethodId\":\"cash\",\"defaultTipPercent\":0," + + "\"shareTripsWith\":\"\"}"; + mvc.perform(People.asRider(body(put("/api/account/preferences"), quiet), rider)) + .andExpect(status().isOk()); + register(rider, "apns", "sof-rider-phone"); + Trips.online(mvc, driver, 42.6977, 23.3219); + String id = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(42.6978, 23.3220, 42.7054, 23.3112)), rider)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/activity"), + "{\"activityId\":\"act-sof\"}"), rider)).andExpect(status().isOk()); + assertEquals("", titles("sof-rider-phone")); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/cancel"), driver)) + .andExpect(status().isOk()); + List toRider = sent.sentTo("sof-rider-phone"); + assertEquals(1, toRider.size()); + assertEquals(Boolean.TRUE, toRider.get(0).message().get("silent")); + assertEquals("live-end", surface(toRider.get(0)).get("operation")); + } + + @Test + void aPhoneBelongsToWhoeverSignedInOnItLast() throws Exception { + String first = People.rider(accounts, "push-ams-first"); + String second = People.rider(accounts, "push-ams-second"); + String driver = People.driver(accounts, "push-ams-driver"); + register(first, "fcm", "ams-shared-phone"); + register(second, "fcm", "ams-shared-phone"); + register(first, "fcm", "ams-first-tablet"); + Trips.online(mvc, driver, 52.3676, 4.9041); + String id = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(52.3677, 4.9040, 52.3800, 4.9100)), first)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + assertEquals("", titles("ams-shared-phone")); + assertEquals("Your driver is on the way", titles("ams-first-tablet")); + + // Signing out forgets the device, and only one's own. + mvc.perform(People.asRider(body(post("/api/account/push/remove"), + device("fcm", "ams-shared-phone")), first)).andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/account/push/remove"), + device("fcm", "ams-first-tablet")), first)).andExpect(status().isOk()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/arrived"), driver)) + .andExpect(status().isOk()); + assertEquals("Your driver is on the way", titles("ams-first-tablet")); + + // Closing the account forgets every device it had. + String rider = People.rider(accounts, "push-ams-closing"); + register(rider, "apns", "ams-closing-phone"); + accounts.close(rider, People.PASSWORD); + assertEquals(0L, devices(rider)); + assertEquals(1L, devices(second)); + } + + @Test + void anActivityIsRefusedOnceTheRideHasEnded() throws Exception { + // Helsinki: the ride is completed. Oslo: the driver cancels it. + String rider = People.rider(accounts, "push-hel-rider"); + String driver = People.driver(accounts, "push-hel-driver"); + Trips.online(mvc, driver, 60.1699, 24.9384); + String done = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(60.1700, 24.9383, 60.1800, 24.9500)), rider)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + done + "/accept"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + done + "/arrived"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + done + "/start"), driver)) + .andExpect(status().isOk()); + // Still on the road: the activity is taken. + mvc.perform(People.asRider(body(post("/api/rides/" + done + "/activity"), + "{\"activityId\":\"act-hel\"}"), rider)).andExpect(status().isOk()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + done + "/complete"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/rides/" + done + "/activity"), + "{\"activityId\":\"act-hel-2\"}"), rider)).andExpect(status().isConflict()); + + String oslo = People.rider(accounts, "push-osl-rider"); + String oslodriver = People.driver(accounts, "push-osl-driver"); + Trips.online(mvc, oslodriver, 59.9139, 10.7522); + String canceled = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(59.9140, 10.7521, 59.9250, 10.7600)), oslo)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + canceled + "/accept"), + oslodriver)).andExpect(status().isOk()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + canceled + "/cancel"), + oslodriver)).andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/rides/" + canceled + "/activity"), + "{\"activityId\":\"act-osl\"}"), oslo)).andExpect(status().isConflict()); + } + + /// An end the push provider refused is not lost with the activity: the + /// ride's transition is never retried, so the sweep sends it again. + @Test + void anEndTheProviderRefusedIsSentAgain() throws Exception { + String rider = People.rider(accounts, "push-rey-rider"); + String driver = People.driver(accounts, "push-rey-driver"); + register(rider, "apns", "rey-rider-phone"); + Trips.online(mvc, driver, 64.1466, -21.9426); + String id = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(64.1467, -21.9425, 64.1500, -21.9000)), rider)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/activity"), + "{\"activityId\":\"act-rey\"}"), rider)).andExpect(status().isOk()); + + sent.refuse(1); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/cancel"), driver)) + .andExpect(status().isOk()); + assertEquals(1L, activities(id), "the refused end keeps its activity"); + int before = sent.sentTo("rey-rider-phone").size(); + + // Not while the call that ended the ride may still be sending it... + pushes.endLeftBehind(); + assertEquals(before, sent.sentTo("rey-rider-phone").size()); + // ...and once that is past, once. + db.execute("UPDATE ride_activity SET ended_at = ? WHERE ride_id = ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 120000L), id}); + pushes.endLeftBehind(); + List toRider = sent.sentTo("rey-rider-phone"); + assertEquals(before + 1, toRider.size()); + Map command = surface(last(toRider)); + assertEquals("live-end", command.get("operation")); + assertEquals("act-rey", command.get("id")); + assertEquals(0L, activities(id)); + pushes.endLeftBehind(); + assertEquals(before + 1, sent.sentTo("rey-rider-phone").size()); + } + + /// A send that returned is not a send the service took: the real sender + /// hands the message to another thread and learns later, and only then is + /// the activity forgotten. + @Test + void anEndTheServiceNeverConfirmedIsKept() throws Exception { + String rider = People.rider(accounts, "push-tll-rider"); + String driver = People.driver(accounts, "push-tll-driver"); + register(rider, "apns", "tll-rider-phone"); + Trips.online(mvc, driver, 59.4370, 24.7536); + String id = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(59.4371, 24.7535, 59.4400, 24.7700)), rider)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/activity"), + "{\"activityId\":\"act-tll\"}"), rider)).andExpect(status().isOk()); + + sent.leaveUnconfirmed(1); + mvc.perform(People.asRider(post("/api/rides/" + id + "/cancel"), rider)) + .andExpect(status().isOk()); + assertEquals("live-end", surface(last(sent.sentTo("tll-rider-phone"))).get("operation")); + assertEquals(1L, activities(id), "sent, but never confirmed"); + + db.execute("UPDATE ride_activity SET ended_at = ? WHERE ride_id = ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 120000L), id}); + pushes.endLeftBehind(); + assertEquals(0L, activities(id)); + } + + /// An end still undelivered when iOS would have ended the activity itself + /// is dropped, not sent. + @Test + void anEndTooOldToBeOnScreenIsDropped() throws Exception { + db.execute("INSERT INTO ride_activity (ride_id, username, activity_id, sent_at, ended_at) " + + "VALUES ('old-ride', 'push-old-rider', 'act-old', 0, 1)", new Object[0]); + int before = sent.all().size(); + pushes.endLeftBehind(); + assertEquals(0L, activities("old-ride")); + assertEquals(before, sent.all().size()); + } + + @Test + void aTokenTakenFromAnotherAccountStillRespectsTheCap() throws Exception { + String full = People.rider(accounts, "push-cap-full"); + String other = People.rider(accounts, "push-cap-other"); + for (int i = 0; i < 8; i++) { + register(full, "fcm", "cap-full-" + i); + } + assertEquals(8L, devices(full)); + // Make one device clearly the one heard from longest ago. + db.execute("UPDATE push_device SET updated_at = 1 WHERE token = ?", + new Object[] {"cap-full-3"}); + register(other, "fcm", "cap-moving-phone"); + register(full, "fcm", "cap-moving-phone"); + assertEquals(8L, devices(full)); + assertEquals(0L, devices(other)); + assertEquals(0L, tokens("cap-full-3")); + assertEquals(1L, tokens("cap-moving-phone")); + } + + /// A registration that reaches the database after the account was closed + /// is refused, and leaves no device behind: the profile it holds first is + /// gone. This is the order a registration authorized just before the + /// closure and run just after it would see. + @Test + void aClosedAccountRegistersNoDevice() throws Exception { + String rider = People.rider(accounts, "push-gone-rider"); + accounts.close(rider, People.PASSWORD); + try { + pushDevices.register(rider, new PushDeviceDto("fcm", "gone-rider-phone", "and")); + fail("a device was registered for a closed account"); + } catch (ResponseStatusException refused) { + assertEquals(404, refused.getStatus()); + } + assertEquals(0L, tokens("gone-rider-phone")); + assertEquals(0L, devices(rider)); + } + + /// Registrations one after another for a full account each make their + /// own room, oldest first. Taken together, the registrations wait for one + /// another behind the account's profile row, so this is also what two of + /// them sent at once come to. + @Test + void eachNewDeviceOfAFullAccountReplacesTheOldest() throws Exception { + String full = People.rider(accounts, "push-cap2-full"); + for (int i = 0; i < 8; i++) { + pushDevices.register(full, new PushDeviceDto("fcm", "cap2-full-" + i, "and")); + } + db.execute("UPDATE push_device SET updated_at = 1 WHERE token = ?", + new Object[] {"cap2-full-5"}); + db.execute("UPDATE push_device SET updated_at = 2 WHERE token = ?", + new Object[] {"cap2-full-2"}); + pushDevices.register(full, new PushDeviceDto("fcm", "cap2-new-a", "and")); + pushDevices.register(full, new PushDeviceDto("apns", "cap2-new-b", "ios")); + assertEquals(8L, devices(full)); + assertEquals(0L, tokens("cap2-full-5")); + assertEquals(0L, tokens("cap2-full-2")); + assertEquals(1L, tokens("cap2-new-a")); + assertEquals(1L, tokens("cap2-new-b")); + } + + /// The notification of a change that is no longer the ride's latest says + /// nothing, in either order: composed after the ride was cancelled it does + /// not tell the rider a driver is coming, and composed before the + /// cancellation's own it leaves the Live Activity for that one to end. + @Test + void aStaleChangeSaysNothingAndLeavesTheActivityToTheLaterOne() throws Exception { + // Valletta: the acceptance is composed after the cancellation was. + String rider = People.rider(accounts, "push-mla-rider"); + String driver = People.driver(accounts, "push-mla-driver"); + register(rider, "apns", "mla-rider-phone"); + Trips.online(mvc, driver, 35.8989, 14.5146); + String id = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(35.8990, 14.5145, 35.9100, 14.5000)), rider)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/activity"), + "{\"activityId\":\"act-mla\"}"), rider)).andExpect(status().isOk()); + mvc.perform(People.asRider(post("/api/rides/" + id + "/cancel"), rider)) + .andExpect(status().isOk()); + int told = sent.sentTo("mla-rider-phone").size(); + assertEquals("live-end", surface(last(sent.sentTo("mla-rider-phone"))) + .get("operation")); + pushes.rideChanged(id, RideState.ACCEPTED, rider, driver); + assertEquals(told, sent.sentTo("mla-rider-phone").size()); + + // Gdansk: the acceptance is composed after the ride was cancelled but + // before the cancellation is. It neither speaks nor forgets the + // activity, and the cancellation then ends it. + String gdnRider = People.rider(accounts, "push-gdn-rider"); + String gdnDriver = People.driver(accounts, "push-gdn-driver"); + register(gdnRider, "apns", "gdn-rider-phone"); + Trips.online(mvc, gdnDriver, 54.3520, 18.6466); + String gdn = (String) Trips.json(mvc, People.asRider(body(post("/api/rides"), + People.ride(54.3521, 18.6465, 54.3650, 18.6300)), gdnRider)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + gdn + "/accept"), gdnDriver)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/rides/" + gdn + "/activity"), + "{\"activityId\":\"act-gdn\"}"), gdnRider)).andExpect(status().isOk()); + int before = sent.sentTo("gdn-rider-phone").size(); + db.execute("UPDATE ride SET state = 'CANCELLED_BY_RIDER' WHERE id = ?", + new Object[] {gdn}); + pushes.rideChanged(gdn, RideState.ACCEPTED, gdnRider, gdnDriver); + assertEquals(before, sent.sentTo("gdn-rider-phone").size()); + assertEquals(1L, activities(gdn)); + pushes.rideChanged(gdn, RideState.CANCELLED_BY_RIDER, gdnRider, gdnDriver); + RecordingPushSender.Sent ended = last(sent.sentTo("gdn-rider-phone")); + assertEquals("live-end", surface(ended).get("operation")); + assertEquals("act-gdn", surface(ended).get("id")); + assertEquals(0L, activities(gdn)); + } + + private long activities(String rideId) throws Exception { + Map row = db.queryOne("SELECT COUNT(*) AS n FROM ride_activity WHERE ride_id = ?", + new Object[] {rideId}); + return ((Number) row.get("n")).longValue(); + } + + private long tokens(String token) throws Exception { + Map row = db.queryOne("SELECT COUNT(*) AS n FROM push_device WHERE token = ?", + new Object[] {token}); + return ((Number) row.get("n")).longValue(); + } + + private long devices(String username) throws Exception { + Map row = db.queryOne("SELECT COUNT(*) AS n FROM push_device WHERE username = ?", + new Object[] {username}); + return ((Number) row.get("n")).longValue(); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/RideFlowTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/RideFlowTest.java new file mode 100644 index 00000000000..4e6bb6bf49b --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/RideFlowTest.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.DataSource; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.account.DemoAccounts; +import com.codenameone.examples.wayline.ride.Rides; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A ride from request to rating, and the ways it can go otherwise. +@BackendTest +class RideFlowTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Rides rides; + @Autowired + private DataSource db; + + private Map json(MockRequestBuilder request) throws Exception { + return Json.parseObject(mvc.perform(request).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + } + + private static MockRequestBuilder body(MockRequestBuilder request, String json) { + return request.contentType("application/json").content(json); + } + + @Test + void aRideFromRequestToRating() throws Exception { + // Lisbon. Each test has a city to itself. + String rider = People.rider(accounts, "lis-rider"); + String driver = People.driver(accounts, "lis-driver"); + String ride = People.ride(38.7223, -9.1393, 38.7369, -9.1427); + + // Nobody is on line yet. + mvc.perform(People.asRider(body(post("/api/rides/quote"), ride), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.driversNearby").value(0)) + .andExpect(jsonPath("$.currency").value("USD")); + + mvc.perform(People.asDriver(body(post("/api/driver/status"), + People.position(true, 38.7250, -9.1400)), driver)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.online").value(true)); + Map quote = json(People.asRider(body(post("/api/rides/quote"), ride), rider)); + assertEquals(1L, ((Number) quote.get("driversNearby")).longValue()); + long fare = ((Number) quote.get("amountCents")).longValue(); + assertTrue(fare >= 500, "the minimum fare, at least: " + fare); + + // Asking offers it to the driver at once. The rider is waiting, and is + // not told who was asked. + Map asked = json(People.asRider(body(post("/api/rides"), ride), rider)); + String id = (String) asked.get("id"); + assertEquals("OFFERED", asked.get("state")); + assertEquals(null, asked.get("driverUsername")); + assertEquals(fare, ((Number) asked.get("fareCents")).longValue()); + mvc.perform(People.asRider(body(post("/api/rides"), ride), rider)) + .andExpect(status().isConflict()); + + // The driver sees the offer, with the clock running, and takes it. + mvc.perform(People.asDriver(get("/api/driver/active"), driver)) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.state").value("OFFERED")) + .andExpect(jsonPath("$.riderName").value("lis-rider")); + // Not before it is taken. + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/start"), driver)) + .andExpect(status().isConflict()); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("ACCEPTED")); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isConflict()); + + // Now the rider has a driver, a car to look for and a position to follow. + mvc.perform(People.asRider(get("/api/rides/active"), rider)) + .andExpect(jsonPath("$.state").value("ACCEPTED")) + .andExpect(jsonPath("$.driverName").value("lis-driver")) + .andExpect(jsonPath("$.vehicle").value("Green Fiat 500")) + .andExpect(jsonPath("$.driverLat").value(38.725)); + // The two of them can now reach each other, and each is told the + // other's number and not their own. An admin looking at the ride is + // told neither. + Map ridersView = json(People.asRider(get("/api/rides/" + id), rider)); + assertEquals("+15550111", ridersView.get("driverPhone")); + assertEquals(null, ridersView.get("riderPhone")); + Map driversView = json(People.asDriver(get("/api/driver/active"), driver)); + assertEquals("+15550111", driversView.get("riderPhone")); + assertEquals(null, driversView.get("driverPhone")); + // A name is the e-mail its account signs in with. Each is given their + // own and not the other's; the admin is given both. + assertEquals(rider, ridersView.get("riderUsername")); + assertEquals(null, ridersView.get("driverUsername")); + assertEquals(driver, driversView.get("driverUsername")); + assertEquals(null, driversView.get("riderUsername")); + Map adminsView = json(People.asAdmin(get("/api/rides/" + id), DemoAccounts.ADMIN)); + assertEquals(rider, adminsView.get("riderUsername")); + assertEquals(driver, adminsView.get("driverUsername")); + assertEquals(null, adminsView.get("driverPhone")); + assertEquals(null, adminsView.get("riderPhone")); + // A driver on a ride stays on line. + mvc.perform(People.asDriver(body(post("/api/driver/status"), + People.position(false, 38.7250, -9.1400)), driver)) + .andExpect(status().isConflict()); + + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/arrived"), driver)) + .andExpect(jsonPath("$.state").value("ARRIVED")); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/start"), driver)) + .andExpect(jsonPath("$.state").value("IN_PROGRESS")); + // Too late to cancel. + mvc.perform(People.asRider(post("/api/rides/" + id + "/cancel"), rider)) + .andExpect(status().isConflict()); + // And not yet something to rate. + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/rate"), "{\"stars\":5}"), rider)) + .andExpect(status().isConflict()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/complete"), driver)) + .andExpect(jsonPath("$.state").value("COMPLETED")) + .andExpect(jsonPath("$.paymentStatus").value("PAID")); + + mvc.perform(People.asRider(get("/api/rides/active"), rider)) + .andExpect(jsonPath("$.state").value("NONE")); + // The ride is over, and so is the reason to have each other's number. + assertEquals(null, json(People.asRider(get("/api/rides/" + id), rider)).get("driverPhone")); + assertEquals(null, json(People.asDriver(get("/api/rides/" + id), driver)).get("riderPhone")); + // And to know where the driver is, who goes on reporting it for the + // next ride: not from the ride, and not from the history it stays in. + mvc.perform(People.asDriver(body(post("/api/driver/status"), + People.position(true, 38.7300, -9.1500)), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.driverName").value("lis-driver")) + .andExpect(jsonPath("$.driverLat").value(0.0)) + .andExpect(jsonPath("$.driverLng").value(0.0)); + mvc.perform(People.asRider(get("/api/rides/history"), rider)) + .andExpect(jsonPath("$[0].driverLat").value(0.0)) + .andExpect(jsonPath("$[0].driverLng").value(0.0)); + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/rate"), "{\"stars\":9}"), rider)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/rate"), "{\"stars\":4}"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rating").value(4)) + .andExpect(jsonPath("$.driverRating").value(4.0)); + mvc.perform(People.asRider(body(post("/api/rides/" + id + "/rate"), "{\"stars\":1}"), rider)) + .andExpect(status().isConflict()); + + mvc.perform(People.asRider(get("/api/rides/history"), rider)) + .andExpect(jsonPath("$[0].id").value(id)) + .andExpect(jsonPath("$[0].state").value("COMPLETED")); + Map earned = json(People.asDriver(get("/api/driver/earnings"), driver)); + // The driver's share, not the fare: the service keeps a fifth, and + // the driving screen must not show it as earned. + long share = fare - Math.round(fare * 20d / 100d); + assertEquals(share, ((Number) earned.get("totalCents")).longValue()); + assertEquals(share, ((Number) earned.get("todayCents")).longValue()); + assertEquals(share, ((Number) earned.get("weekCents")).longValue()); + assertEquals(1L, ((Number) earned.get("rides")).longValue()); + } + + @Test + void anOfferPassesFromDriverToDriverThenGivesUp() throws Exception { + // Madrid, with two drivers: one beside the pickup and one a kilometre off. + String rider = People.rider(accounts, "mad-rider"); + String near = People.driver(accounts, "mad-near"); + String far = People.driver(accounts, "mad-far"); + mvc.perform(People.asDriver(body(post("/api/driver/status"), + People.position(true, 40.4169, -3.7036)), near)).andExpect(status().isOk()); + mvc.perform(People.asDriver(body(post("/api/driver/status"), + People.position(true, 40.4260, -3.7036)), far)).andExpect(status().isOk()); + + Map asked = json(People.asRider(body(post("/api/rides"), + People.ride(40.4168, -3.7038, 40.4300, -3.6900)), rider)); + String id = (String) asked.get("id"); + // The nearer one is asked first, and only that one. + mvc.perform(People.asDriver(get("/api/driver/active"), near)) + .andExpect(jsonPath("$.id").value(id)); + mvc.perform(People.asDriver(get("/api/driver/active"), far)) + .andExpect(jsonPath("$.state").value("NONE")); + // An offer is one driver's. Another cannot take it, or learn of it. + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), far)) + .andExpect(status().isNotFound()); + + // Passing on it sends it straight to the other. + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/decline"), near)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("NONE")); + mvc.perform(People.asDriver(get("/api/driver/active"), far)) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.state").value("OFFERED")); + + // That one says nothing. Time is moved on by hand: the offer lapsed. + db.execute("UPDATE ride SET offer_expires_at = 1 WHERE id = ?", new Object[] {id}); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), far)) + .andExpect(status().isConflict()); + rides.tick(); + // Both have passed now, so it waits, offered to nobody. + mvc.perform(People.asRider(get("/api/rides/active"), rider)) + .andExpect(jsonPath("$.state").value("REQUESTED")); + mvc.perform(People.asDriver(get("/api/driver/active"), near)) + .andExpect(jsonPath("$.state").value("NONE")); + + // And when the search runs out of time, the rider is told so. + db.execute("UPDATE ride SET search_until = 1 WHERE id = ?", new Object[] {id}); + rides.tick(); + mvc.perform(People.asRider(get("/api/rides/" + id), rider)) + .andExpect(jsonPath("$.state").value("NO_DRIVERS")); + mvc.perform(People.asRider(get("/api/rides/active"), rider)) + .andExpect(jsonPath("$.state").value("NONE")); + } + + @Test + void aRiderCancelsAndTheDriverIsFree() throws Exception { + // Rome. + String rider = People.rider(accounts, "rom-rider"); + String driver = People.driver(accounts, "rom-driver"); + String stranger = People.rider(accounts, "rom-stranger"); + mvc.perform(People.asDriver(body(post("/api/driver/status"), + People.position(true, 41.9028, 12.4964)), driver)).andExpect(status().isOk()); + String ride = People.ride(41.9030, 12.4960, 41.8902, 12.4922); + String id = (String) json(People.asRider(body(post("/api/rides"), ride), rider)).get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + + // Somebody else's ride does not exist, to read or to cancel. + mvc.perform(People.asRider(get("/api/rides/" + id), stranger)) + .andExpect(status().isNotFound()); + mvc.perform(People.asRider(post("/api/rides/" + id + "/cancel"), stranger)) + .andExpect(status().isNotFound()); + + mvc.perform(People.asRider(post("/api/rides/" + id + "/cancel"), rider)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("CANCELLED_BY_RIDER")); + mvc.perform(People.asDriver(get("/api/driver/active"), driver)) + .andExpect(jsonPath("$.state").value("NONE")); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/arrived"), driver)) + .andExpect(status().isConflict()); + // Free again, so the next ride comes to the same driver. + String next = (String) json(People.asRider(body(post("/api/rides"), ride), rider)).get("id"); + mvc.perform(People.asDriver(get("/api/driver/active"), driver)) + .andExpect(jsonPath("$.id").value(next)); + mvc.perform(People.asRider(post("/api/rides/" + next + "/cancel"), rider)) + .andExpect(status().isOk()); + } + + @Test + void aRideNeedsAProvedPhoneAndSomewhereToGo() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content("{\"email\":\"unproved@test.example\",\"password\":\"a-long-password\"," + + "\"displayName\":\"Unproved\",\"phone\":\"+15550112\"}")) + .andExpect(status().isOk()); + mvc.perform(People.asRider(body(post("/api/rides"), + People.ride(48.8566, 2.3522, 48.8606, 2.3376)), "unproved@test.example")) + .andExpect(status().isForbidden()); + String rider = People.rider(accounts, "par-rider"); + // The same point twice, and a point that is not on the planet. + mvc.perform(People.asRider(body(post("/api/rides"), + People.ride(48.8566, 2.3522, 48.8566, 2.3522)), rider)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(body(post("/api/rides/quote"), + People.ride(148.8566, 2.3522, 48.8606, 2.3376)), rider)) + .andExpect(status().isBadRequest()); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/RideRaceTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/RideRaceTest.java new file mode 100644 index 00000000000..1cdfae0d215 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/RideRaceTest.java @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.DataSource; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.ride.Rides; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A ride answered from several places at the same moment. +/// +/// One statement decides each change of state, and the number of rows it +/// changed is the answer. These tests are the ones that would notice that +/// becoming a read followed by a write: they send the same answer from many +/// threads at once and count how many were told it was theirs. +/// +/// The threads call the service and not the HTTP layer. What is being tested +/// is the transaction each call opens, and the service is where it opens. +@BackendTest +class RideRaceTest { + private static final int THREADS = 8; + /// The outcomes of one call: the ride was taken, or the status it was + /// refused with. + private static final int TAKEN = 200; + + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Rides rides; + @Autowired + private DataSource db; + + /// Runs `calls` at the same moment, each on a thread of its own, and waits + /// for them all. + private static void together(Runnable[] calls) throws Exception { + // A count and a flag, polled: the class library a compiled server + // links against has the atomics and no latch. + final AtomicInteger ready = new AtomicInteger(); + final AtomicBoolean go = new AtomicBoolean(); + Thread[] threads = new Thread[calls.length]; + for (int iter = 0; iter < calls.length; iter++) { + final Runnable call = calls[iter]; + threads[iter] = new Thread(new Runnable() { + @Override + public void run() { + ready.incrementAndGet(); + long giveUp = System.currentTimeMillis() + 30000L; + while (!go.get()) { + if (System.currentTimeMillis() > giveUp) { + return; + } + Thread.yield(); + } + call.run(); + } + }, "race-" + iter); + threads[iter].start(); + } + long giveUp = System.currentTimeMillis() + 30000L; + while (ready.get() < calls.length) { + assertTrue(System.currentTimeMillis() < giveUp, "the threads started"); + Thread.sleep(1L); + } + go.set(true); + for (int iter = 0; iter < threads.length; iter++) { + threads[iter].join(60000L); + assertTrue(!threads[iter].isAlive(), "thread " + iter + " finished"); + } + } + + /// A call of `accept` that writes what came of it into `outcomes[slot]`: + /// [#TAKEN], the status of the refusal, or -1 for anything else. + private Runnable accept(final String driver, final String id, final int[] outcomes, + final Throwable[] unexpected, final int slot) { + return new Runnable() { + @Override + public void run() { + try { + RideDto ride = rides.accept(driver, id); + outcomes[slot] = RideState.ACCEPTED == ride.state() ? TAKEN : -1; + } catch (ResponseStatusException refused) { + outcomes[slot] = refused.getStatus(); + } catch (Throwable err) { + outcomes[slot] = -1; + unexpected[slot] = err; + } + } + }; + } + + private static int count(int[] outcomes, int wanted) { + int found = 0; + for (int iter = 0; iter < outcomes.length; iter++) { + if (outcomes[iter] == wanted) { + found++; + } + } + return found; + } + + private static void noneUnexpected(Throwable[] unexpected) { + for (int iter = 0; iter < unexpected.length; iter++) { + if (unexpected[iter] != null) { + throw new AssertionError("call " + iter + " failed", unexpected[iter]); + } + } + } + + private long events(String id, String state) throws Exception { + Map row = db.queryOne("SELECT COUNT(*) AS n FROM ride_event WHERE ride_id = ? " + + "AND state = ?", new Object[] {id, state}); + return ((Number) row.get("n")).longValue(); + } + + /// A ride offered to `driver`, who is on line beside the pickup. + private String offered(String rider, String driver, double lat, double lng) throws Exception { + Trips.online(mvc, driver, lat, lng); + Map asked = Trips.json(mvc, People.asRider(Trips.body(post("/api/rides"), + People.ride(lat + 0.0002, lng + 0.0002, lat + 0.02, lng + 0.02)), rider)); + assertEquals("OFFERED", asked.get("state")); + String id = (String) asked.get("id"); + assertEquals(id, Trips.json(mvc, People.asDriver(get("/api/driver/active"), driver)) + .get("id")); + return id; + } + + @Test + void anOfferAcceptedEightTimesAtOnceIsTakenOnce() throws Exception { + // Warsaw. + String rider = People.rider(accounts, "waw-rider"); + String driver = People.driver(accounts, "waw-driver"); + String id = offered(rider, driver, 52.2297, 21.0122); + + int[] outcomes = new int[THREADS]; + Throwable[] unexpected = new Throwable[THREADS]; + Runnable[] calls = new Runnable[THREADS]; + for (int iter = 0; iter < THREADS; iter++) { + calls[iter] = accept(driver, id, outcomes, unexpected, iter); + } + together(calls); + + noneUnexpected(unexpected); + assertEquals(1, count(outcomes, TAKEN), "one call took the ride"); + assertEquals(THREADS - 1, count(outcomes, 409), "and every other was told it was taken"); + // What happened is on record once, too. + assertEquals(1L, events(id, "ACCEPTED")); + assertEquals("ACCEPTED", Trips.json(mvc, People.asRider(get("/api/rides/" + id), rider)) + .get("state")); + } + + @Test + void anOfferThatLapsedIsNotTakenWhileItIsBeingTakenBack() throws Exception { + // Athens. + String rider = People.rider(accounts, "ath-rider"); + final String driver = People.driver(accounts, "ath-driver"); + final String id = offered(rider, driver, 37.9838, 23.7275); + + // Time is moved on by hand: the offer lapsed, and nothing has swept it + // up yet. Taking it is refused already. + db.execute("UPDATE ride SET offer_expires_at = 1 WHERE id = ?", new Object[] {id}); + int[] alone = new int[1]; + Throwable[] none = new Throwable[1]; + accept(driver, id, alone, none, 0).run(); + noneUnexpected(none); + if (alone[0] != 404) { + // 404 is the sweep having run between the two lines above: the + // offer is gone and so, to this driver, is the ride. + assertEquals(409, alone[0]); + } + + // Now the sweep and the driver's answers at the same moment. + int[] outcomes = new int[THREADS]; + Throwable[] unexpected = new Throwable[THREADS + 1]; + Runnable[] calls = new Runnable[THREADS + 1]; + for (int iter = 0; iter < THREADS; iter++) { + calls[iter] = accept(driver, id, outcomes, unexpected, iter); + } + calls[THREADS] = new Runnable() { + @Override + public void run() { + try { + rides.tick(); + } catch (Throwable err) { + unexpected[THREADS] = err; + } + } + }; + together(calls); + + noneUnexpected(unexpected); + assertEquals(0, count(outcomes, TAKEN), "nobody took a lapsed offer"); + // Refused as lapsed before the sweep, and as no longer theirs after it. + assertEquals(THREADS, count(outcomes, 409) + count(outcomes, 404)); + assertEquals(0L, events(id, "ACCEPTED")); + // The only driver in reach has let it go by, so it waits. + assertEquals("REQUESTED", Trips.json(mvc, People.asRider(get("/api/rides/" + id), rider)) + .get("state")); + assertEquals("NONE", Trips.json(mvc, People.asDriver(get("/api/driver/active"), driver)) + .get("state")); + mvc.perform(People.asRider(post("/api/rides/" + id + "/cancel"), rider)); + } + + @Test + void anOfferTakenBackIsNoLongerTheDriversToTake() throws Exception { + // Brussels. + String rider = People.rider(accounts, "bru-rider"); + String driver = People.driver(accounts, "bru-driver"); + String id = offered(rider, driver, 50.8503, 4.3517); + + // The offer lapses and the sweep takes it back. The ride is no longer + // this driver's, and a ride that is not theirs is answered as no ride + // at all: 404, as it is for any ride of somebody else's. + db.execute("UPDATE ride SET offer_expires_at = 1 WHERE id = ?", new Object[] {id}); + rides.tick(); + int[] outcome = new int[1]; + Throwable[] none = new Throwable[1]; + accept(driver, id, outcome, none, 0).run(); + noneUnexpected(none); + assertEquals(404, outcome[0]); + assertEquals(0L, events(id, "ACCEPTED")); + assertEquals("REQUESTED", Trips.json(mvc, People.asRider(get("/api/rides/" + id), rider)) + .get("state")); + mvc.perform(People.asRider(post("/api/rides/" + id + "/cancel"), rider)); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/RideStateMachineTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/RideStateMachineTest.java new file mode 100644 index 00000000000..240e8250dc0 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/RideStateMachineTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.ride.RideStateMachine; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RideStateMachineTest { + private static final RideState[] ALL = {RideState.REQUESTED, RideState.OFFERED, + RideState.ACCEPTED, RideState.ARRIVED, RideState.IN_PROGRESS, RideState.COMPLETED, + RideState.CANCELLED_BY_RIDER, RideState.CANCELLED_BY_DRIVER, RideState.NO_DRIVERS}; + + @Test + void aRideGoesForwardOneStepAtATime() { + assertTrue(RideStateMachine.allowed(RideState.REQUESTED, RideState.OFFERED)); + assertTrue(RideStateMachine.allowed(RideState.OFFERED, RideState.ACCEPTED)); + assertTrue(RideStateMachine.allowed(RideState.ACCEPTED, RideState.ARRIVED)); + assertTrue(RideStateMachine.allowed(RideState.ARRIVED, RideState.IN_PROGRESS)); + assertTrue(RideStateMachine.allowed(RideState.IN_PROGRESS, RideState.COMPLETED)); + // No skipping: a ride nobody accepted cannot start, and one that has + // not started cannot be completed. + assertFalse(RideStateMachine.allowed(RideState.REQUESTED, RideState.ACCEPTED)); + assertFalse(RideStateMachine.allowed(RideState.OFFERED, RideState.IN_PROGRESS)); + assertFalse(RideStateMachine.allowed(RideState.ACCEPTED, RideState.COMPLETED)); + } + + @Test + void anOfferNobodyTookGoesBackToWaiting() { + assertTrue(RideStateMachine.allowed(RideState.OFFERED, RideState.REQUESTED)); + assertFalse(RideStateMachine.allowed(RideState.ACCEPTED, RideState.REQUESTED)); + } + + @Test + void cancellingStopsWhenTheTripStarts() { + assertTrue(RideStateMachine.allowed(RideState.REQUESTED, RideState.CANCELLED_BY_RIDER)); + assertTrue(RideStateMachine.allowed(RideState.ARRIVED, RideState.CANCELLED_BY_RIDER)); + assertFalse(RideStateMachine.allowed(RideState.IN_PROGRESS, RideState.CANCELLED_BY_RIDER)); + // A driver can only cancel a ride they took. + assertFalse(RideStateMachine.allowed(RideState.OFFERED, RideState.CANCELLED_BY_DRIVER)); + assertTrue(RideStateMachine.allowed(RideState.ACCEPTED, RideState.CANCELLED_BY_DRIVER)); + assertFalse(RideStateMachine.allowed(RideState.IN_PROGRESS, RideState.CANCELLED_BY_DRIVER)); + } + + @Test + void nothingLeavesAFinishedState() { + RideState[] finished = {RideState.COMPLETED, RideState.CANCELLED_BY_RIDER, + RideState.CANCELLED_BY_DRIVER, RideState.NO_DRIVERS}; + for (int from = 0; from < finished.length; from++) { + for (int to = 0; to < ALL.length; to++) { + assertFalse(RideStateMachine.allowed(finished[from], ALL[to]), + finished[from] + " -> " + ALL[to]); + } + } + assertEquals(0, RideStateMachine.sources(RideState.NONE).length); + assertTrue(RideState.ARRIVED.isActive()); + assertFalse(RideState.COMPLETED.isActive()); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ScriptedPayments.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ScriptedPayments.java new file mode 100644 index 00000000000..79c3eb0a3a7 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ScriptedPayments.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codenameone.examples.wayline.pay.SimulatedPayments; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The simulated processor with a test's hand on it: the test says whether +/// refunds and card detaches fail, and what happens while a refund is at the +/// processor -- which is how two requests that overlap are lined up one inside +/// the other, in the order a test wants, on one thread. +final class ScriptedPayments extends SimulatedPayments { + /// Run inside the next refund, before it answers, and then forgotten. + Runnable duringRefund; + /// How the coming refunds answer, in the order they answer -- a refund + /// run inside another answers before it. True fails one without saying + /// whether it was made; once the list is empty every refund succeeds. + final List refundFailures = new ArrayList(); + /// Whether detaching a card fails the same way. + boolean detachFails; + /// The tokens of the cards detached, in order. + final List detached = new ArrayList(); + + /// Back to a processor that does what the simulated one does. + void reset() { + duringRefund = null; + refundFailures.clear(); + detachFails = false; + detached.clear(); + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + Runnable during = duringRefund; + duringRefund = null; + if (during != null) { + during.run(); + } + if (!refundFailures.isEmpty() && refundFailures.remove(0).booleanValue()) { + throw new IOException("The scripted processor did not answer"); + } + return super.refund(chargeReference, amountCents); + } + + @Override + public void detachCard(String customer, String token) throws IOException { + if (detachFails) { + throw new IOException("The scripted processor did not answer"); + } + detached.add(token); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/SettingsTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/SettingsTest.java new file mode 100644 index 00000000000..b916088db13 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/SettingsTest.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// What someone changes about their own account: how the app behaves for them, +/// who they are, their password, and whether they have an account at all. +@BackendTest +class SettingsTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + @Test + void preferencesAreSavedAndReadBack() throws Exception { + String who = People.rider(accounts, "prefers"); + mvc.perform(People.asRider(get("/api/account/preferences"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.language").value("en")) + .andExpect(jsonPath("$.theme").value("system")) + .andExpect(jsonPath("$.units").value("km")) + .andExpect(jsonPath("$.notifyRideUpdates").value(true)) + .andExpect(jsonPath("$.driverGender").value("any")) + .andExpect(jsonPath("$.defaultPaymentMethodId").value("cash")); + + String card = (String) Trips.addCard(mvc, who, Trips.VISA).get("id"); + String wanted = "{\"language\":\"he\",\"theme\":\"dark\",\"units\":\"mi\"," + + "\"notifyRideUpdates\":false,\"notifyReceipts\":true,\"notifyPromotions\":true," + + "\"driverGender\":\"women\",\"quietRide\":true,\"accessibleVehicle\":true," + + "\"petFriendly\":true,\"defaultPaymentMethodId\":\"cash\"," + + "\"defaultTipPercent\":15,\"shareTripsWith\":\"+1 (555) 010-2222\"}"; + mvc.perform(People.asRider(Trips.body(put("/api/account/preferences"), wanted), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.language").value("he")); + Map saved = Trips.json(mvc, People.asRider(get("/api/account/preferences"), who)); + assertEquals("he", saved.get("language")); + assertEquals("dark", saved.get("theme")); + assertEquals("mi", saved.get("units")); + assertEquals(Boolean.FALSE, saved.get("notifyRideUpdates")); + assertEquals(Boolean.TRUE, saved.get("notifyReceipts")); + assertEquals(Boolean.TRUE, saved.get("notifyPromotions")); + assertEquals("women", saved.get("driverGender")); + assertEquals(Boolean.TRUE, saved.get("quietRide")); + assertEquals(Boolean.TRUE, saved.get("accessibleVehicle")); + assertEquals(Boolean.TRUE, saved.get("petFriendly")); + assertEquals(15L, Trips.number(saved, "defaultTipPercent")); + // Stored in the one form a phone number has. + assertEquals("+15550102222", saved.get("shareTripsWith")); + // The default way to pay is the mark on the card itself: saving it + // here and reading the cards agree. + assertEquals("cash", saved.get("defaultPaymentMethodId")); + mvc.perform(People.asRider(get("/api/payments/methods"), who)) + .andExpect(jsonPath("$[0].id").value(card)) + .andExpect(jsonPath("$[0].isDefault").value(false)) + .andExpect(jsonPath("$[1].isDefault").value(true)); + mvc.perform(People.asRider(get("/api/me"), who)) + .andExpect(jsonPath("$.language").value("he")); + + // Each is one of the values it can take, or it is refused whole. + String[] wrong = {"{\"language\":\"xx\"}", "{\"theme\":\"neon\"}", "{\"units\":\"ft\"}", + "{\"driverGender\":\"men\"}", "{\"defaultTipPercent\":12}", + "{\"shareTripsWith\":\"not a number\"}", + "{\"defaultPaymentMethodId\":\"someoneelsescard\"}"}; + for (int iter = 0; iter < wrong.length; iter++) { + int answered = Trips.statusOf(mvc, People.asRider(Trips.body( + put("/api/account/preferences"), wrong[iter]), who)); + assertEquals(iter == wrong.length - 1 ? 404 : 400, answered, wrong[iter]); + } + mvc.perform(People.asRider(get("/api/account/preferences"), who)) + .andExpect(jsonPath("$.language").value("he")) + .andExpect(jsonPath("$.theme").value("dark")); + } + + @Test + void aProfileIsSavedAndReadBack() throws Exception { + String who = People.rider(accounts, "profiled"); + mvc.perform(People.asRider(get("/api/account/profile"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("profiled")) + .andExpect(jsonPath("$.gender").value("unspecified")); + mvc.perform(People.asRider(Trips.body(put("/api/account/profile"), + "{\"name\":\"Pat Profiled\",\"gender\":\"nonbinary\"," + + "\"emergencyContactName\":\"Sam\"," + + "\"emergencyContactPhone\":\"+1 555 010 3333\"," + + "\"homeAddress\":\"1 Home St\",\"workAddress\":\"2 Work Ave\"}"), + who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayName").value("Pat Profiled")) + .andExpect(jsonPath("$.gender").value("nonbinary")); + mvc.perform(People.asRider(get("/api/account/profile"), who)) + .andExpect(jsonPath("$.name").value("Pat Profiled")) + .andExpect(jsonPath("$.emergencyContactName").value("Sam")) + .andExpect(jsonPath("$.emergencyContactPhone").value("+15550103333")) + .andExpect(jsonPath("$.homeAddress").value("1 Home St")) + .andExpect(jsonPath("$.workAddress").value("2 Work Ave")); + mvc.perform(People.asRider(Trips.body(put("/api/account/profile"), + "{\"name\":\"\",\"gender\":\"female\"}"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(Trips.body(put("/api/account/profile"), + "{\"name\":\"Pat\",\"gender\":\"other\"}"), who)) + .andExpect(status().isBadRequest()); + } + + @Test + void aPasswordIsChangedByWhoeverKnowsTheOldOne() throws Exception { + String who = People.rider(accounts, "rekeyed"); + mvc.perform(People.asRider(Trips.body(post("/api/account/password"), + "{\"current\":\"not-the-password\",\"replacement\":\"another-long-one\"}"), + who)) + .andExpect(status().isForbidden()); + mvc.perform(People.asRider(Trips.body(post("/api/account/password"), + "{\"current\":\"" + People.PASSWORD + "\",\"replacement\":\"short\"}"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(Trips.body(post("/api/account/password"), + "{\"current\":\"" + People.PASSWORD + + "\",\"replacement\":\"another-long-one\"}"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value(who)); + assertEquals(null, SignIn.session(mvc, who, People.PASSWORD)); + assertEquals(false, SignIn.session(mvc, who, "another-long-one") == null); + } + + @Test + void aChangedPasswordSignsTheOtherDevicesOut() throws Exception { + String who = People.rider(accounts, "two-devices"); + Map phone = SignIn.tokens(mvc, who, People.PASSWORD); + Map tablet = SignIn.tokens(mvc, who, People.PASSWORD); + + mvc.perform(Trips.body(post("/api/account/password"), + "{\"current\":\"" + People.PASSWORD + + "\",\"replacement\":\"another-long-one\"}") + .header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + phone.get("access_token"))) + .andExpect(status().isOk()); + + // The tablet's refresh token asked for no password, and would have + // gone on working for whoever held the tablet. + mvc.perform(SignIn.formPost("/oauth2/token", "grant_type", "refresh_token", + "refresh_token", (String) tablet.get("refresh_token"), + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andExpect(status().isBadRequest()); + // The device the password was changed on stays signed in. + mvc.perform(SignIn.formPost("/oauth2/token", "grant_type", "refresh_token", + "refresh_token", (String) phone.get("refresh_token"), + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andExpect(status().isOk()); + } + + @Test + void anAccountIsClosedForGood() throws Exception { + // Warsaw, where nobody drives. + String who = People.rider(accounts, "leaving"); + String token = SignIn.token(mvc, who, People.PASSWORD); + Trips.addCard(mvc, who, Trips.VISA); + mvc.perform(People.asRider(Trips.body(put("/api/driving/application"), + "{\"legalName\":\"Lee Leaving\"}"), who)).andExpect(status().isOk()); + + // Not with a ride under way: somebody may be driving to it. + String ride = (String) Trips.json(mvc, People.asRider(Trips.body(post("/api/rides"), + People.ride(52.2297, 21.0122, 52.2400, 21.0300)), who)).get("id"); + String closure = "{\"password\":\"" + People.PASSWORD + "\"}"; + mvc.perform(People.asRider(Trips.body(post("/api/account/delete"), closure), who)) + .andExpect(status().isConflict()); + mvc.perform(People.asRider(post("/api/rides/" + ride + "/cancel"), who)) + .andExpect(status().isOk()); + + // Not on the strength of a token: the password is asked for again. + mvc.perform(People.asRider(post("/api/account/delete"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asRider(Trips.body(post("/api/account/delete"), "{}"), who)) + .andExpect(status().isForbidden()); + mvc.perform(People.asRider(Trips.body(post("/api/account/delete"), + "{\"password\":\"not-the-password\"}"), who)) + .andExpect(status().isForbidden()); + mvc.perform(People.asRider(get("/api/me"), who)).andExpect(status().isOk()); + + mvc.perform(People.asRider(Trips.body(post("/api/account/delete"), closure), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value(who)); + // Gone: the token, the password, and the account an admin would look up. + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + assertEquals(null, SignIn.session(mvc, who, People.PASSWORD)); + mvc.perform(People.asAdmin(get("/api/admin/users/" + who), DemoAccounts.ADMIN)) + .andExpect(status().isNotFound()); + mvc.perform(People.asAdmin(get("/api/admin/applications/" + who), DemoAccounts.ADMIN)) + .andExpect(status().isNotFound()); + // And the address is retired with it. The rides the account took are + // kept under it, so whoever registered it next would be handed them. + mvc.perform(post("/api/account/register").contentType("application/json") + .content("{\"email\":\"" + who + "\",\"password\":\"" + People.PASSWORD + + "\",\"displayName\":\"Somebody else\"," + + "\"phone\":\"+15550112\"}")) + .andExpect(status().isConflict()); + + // An admin's account is not closed this way. + mvc.perform(People.asAdmin(Trips.body(post("/api/account/delete"), + "{\"password\":\"" + DemoAccounts.PASSWORD + "\"}"), DemoAccounts.ADMIN)) + .andExpect(status().isConflict()); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/SignIn.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/SignIn.java new file mode 100644 index 00000000000..39a5c147efe --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/SignIn.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.Json; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; + +/// Signs a user in the way the app does, for the tests that need a real token: +/// the password for a session, the session for an authorization code, the code +/// for tokens. +final class SignIn { + /// The PKCE pair of RFC 7636's appendix B. + private static final String VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private static final String CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + static final String HOST = "wayline.test:8080"; + + private SignIn() { + } + + static MockRequestBuilder formPost(String path, String... pairs) { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < pairs.length; i += 2) { + if (body.length() > 0) { + body.append('&'); + } + body.append(OAuth2Parameters.encode(pairs[i])).append('=') + .append(OAuth2Parameters.encode(pairs[i + 1])); + } + return post(path).contentType("application/x-www-form-urlencoded").content(body.toString()); + } + + /// The session cookie a correct password earns, or null for a wrong one. + static String session(MockMvc mvc, String username, String password) throws Exception { + return mvc.perform(formPost("/login", "username", username, "password", password) + .header("Host", HOST)) + .andReturn().getResponse().getCookie("CN1SESSION"); + } + + /// The token response for a user: `access_token`, `refresh_token` and the + /// rest. + static Map tokens(MockMvc mvc, String username, String password) throws Exception { + String session = session(mvc, username, password); + if (session == null) { + throw new IllegalStateException("could not sign in as " + username); + } + String location = mvc.perform(get("/oauth2/authorize?response_type=code&client_id=" + + SecurityConfig.CLIENT_ID + "&redirect_uri=" + + OAuth2Parameters.encode(SecurityConfig.LOOPBACK_REDIRECT) + + "&scope=openid%20profile&state=s-1&nonce=n-1&code_challenge=" + CHALLENGE + + "&code_challenge_method=S256") + .header("Host", HOST).cookie("CN1SESSION", session)) + .andReturn().getResponse().getRedirectedUrl(); + if (location == null || location.indexOf("code=") < 0) { + throw new IllegalStateException("no authorization code: " + location); + } + int at = location.indexOf("code=") + 5; + int end = location.indexOf('&', at); + String code = location.substring(at, end < 0 ? location.length() : end); + String body = mvc.perform(formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", code, + "redirect_uri", SecurityConfig.LOOPBACK_REDIRECT, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID).header("Host", HOST)) + .andReturn().getResponse().getContentAsString(); + return Json.parseObject(body); + } + + /// A bearer token for a user. + static String token(MockMvc mvc, String username, String password) throws Exception { + Object token = tokens(mvc, username, password).get("access_token"); + if (!(token instanceof String)) { + throw new IllegalStateException("no access token for " + username); + } + return (String) token; + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/StripePaymentsTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/StripePaymentsTest.java new file mode 100644 index 00000000000..d8b1fe9a215 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/StripePaymentsTest.java @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codenameone.examples.wayline.pay.PaymentDeclinedException; +import com.codenameone.examples.wayline.pay.PaymentProvider; +import com.codenameone.examples.wayline.pay.StripePayments; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// The Stripe provider, held to what it sends and what it makes of the +/// answers. The network is replaced by a script of canned answers, so this +/// needs no key and reaches nobody: what it proves is the requests' shape and +/// the reading of Stripe's JSON, which is all of the provider there is. +class StripePaymentsTest { + private static final String RETURN = "https://wayline.example/pay/return"; + + /// Answers each request with the next canned reply and keeps what was + /// asked. + private static final class Script implements StripePayments.Transport { + final List asked = new ArrayList(); + final List forms = new ArrayList(); + final List keys = new ArrayList(); + private final List replies = new ArrayList(); + + Script reply(int status, String body) { + replies.add(new StripePayments.Reply(status, body)); + return this; + } + + @Override + public StripePayments.Reply send(String method, String path, String form, + String idempotencyKey) throws IOException { + asked.add(method + " " + path); + forms.add(form); + keys.add(idempotencyKey); + if (replies.isEmpty()) { + throw new IOException("Nothing more was expected to be asked"); + } + return replies.remove(0); + } + } + + private static void has(String form, String pair) { + assertTrue(("&" + form + "&").indexOf("&" + pair + "&") >= 0, pair + " in " + form); + } + + @Test + void aCustomerIsMadeWithTheAccountsName() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"cus_123\",\"object\":\"customer\"}"); + StripePayments stripe = new StripePayments(script, RETURN); + assertEquals("stripe", stripe.name()); + assertEquals("cus_123", stripe.createCustomer("ada@wayline.example", "Ada & Co")); + assertEquals("POST /v1/customers", script.asked.get(0)); + String form = script.forms.get(0); + // Escaped, name and value both: an ampersand in a name must not start + // another field. + has(form, "email=ada%40wayline.example"); + has(form, "name=Ada+%26+Co"); + has(form, "metadata%5Bwayline_user%5D=ada%40wayline.example"); + // The same request made twice makes one customer: the same key, which + // does not spell the address out. + String key = script.keys.get(0); + assertTrue(key.startsWith("customer-") && key.indexOf("ada") < 0, key); + script.reply(200, "{\"id\":\"cus_123\",\"object\":\"customer\"}"); + stripe.createCustomer("ada@wayline.example", "Ada & Co"); + assertEquals(key, script.keys.get(1)); + script.reply(200, "{\"id\":\"cus_124\",\"object\":\"customer\"}"); + stripe.createCustomer("ada@wayline.example", "Ada"); + assertTrue(!key.equals(script.keys.get(2)), "another name is another request"); + } + + @Test + void aCustomerIsDeletedAndOneAlreadyGoneCountsAsDeleted() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"cus_123\",\"deleted\":true}") + .reply(404, "{\"error\":{\"type\":\"invalid_request_error\"," + + "\"code\":\"resource_missing\"}}") + .reply(500, "{}"); + StripePayments stripe = new StripePayments(script, RETURN); + stripe.forgetCustomer("cus_123"); + assertEquals("DELETE /v1/customers/cus_123", script.asked.get(0)); + assertEquals(null, script.forms.get(0)); + // Asked again after an answer that was lost. + stripe.forgetCustomer("cus_123"); + // Anything else is not a deletion, and the closure it is part of fails. + try { + stripe.forgetCustomer("cus_123"); + throw new AssertionError("A processor that failed must not read as deleted"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("500") >= 0, expected.getMessage()); + } + } + + @Test + void aRemovedCardIsDetachedAndOneAlreadyDetachedCountsAsDetached() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"pm_1\",\"object\":\"payment_method\",\"customer\":null}") + // Gone with its customer. + .reply(404, "{\"error\":{\"type\":\"invalid_request_error\"," + + "\"code\":\"resource_missing\"}}") + // Asked again after an answer that was lost: Stripe refuses to + // detach a card attached to nobody, and the card says so. + .reply(400, "{\"error\":{\"type\":\"invalid_request_error\"}}") + .reply(200, "{\"id\":\"pm_1\",\"object\":\"payment_method\",\"customer\":null}") + // Refused while the card is still this customer's: not detached. + .reply(400, "{\"error\":{\"type\":\"invalid_request_error\"}}") + .reply(200, "{\"id\":\"pm_1\",\"object\":\"payment_method\"," + + "\"customer\":\"cus_123\"}") + .reply(500, "{}") + .reply(500, "{}"); + StripePayments stripe = new StripePayments(script, RETURN); + stripe.detachCard("cus_123", "pm_1"); + assertEquals("POST /v1/payment_methods/pm_1/detach", script.asked.get(0)); + stripe.detachCard("cus_123", "pm_1"); + stripe.detachCard("cus_123", "pm_1"); + assertEquals("GET /v1/payment_methods/pm_1", script.asked.get(3)); + for (int iter = 0; iter < 2; iter++) { + try { + stripe.detachCard("cus_123", "pm_1"); + throw new AssertionError("A card still attached must not read as detached"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("detaching") >= 0, + expected.getMessage()); + } + } + } + + @Test + void aCardIsAddedOnAHostedPageAndReadBack() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"cs_test_1\",\"object\":\"checkout.session\"," + + "\"url\":\"https://checkout.stripe.com/c/pay/cs_test_1\"}") + .reply(200, "{\"id\":\"cs_test_1\",\"status\":\"complete\"," + + "\"customer\":\"cus_123\",\"setup_intent\":{\"id\":\"seti_1\"," + + "\"status\":\"succeeded\",\"payment_method\":{\"id\":\"pm_1\"," + + "\"type\":\"card\",\"card\":{\"brand\":\"visa\",\"last4\":\"4242\"," + + "\"exp_month\":8,\"exp_year\":2031}}}}"); + StripePayments stripe = new StripePayments(script, RETURN); + PaymentProvider.Setup setup = stripe.startCardSetup("cus_123", "setup9"); + assertTrue(setup.hosted); + assertEquals("cs_test_1", setup.reference); + assertEquals("https://checkout.stripe.com/c/pay/cs_test_1", setup.url); + assertEquals("POST /v1/checkout/sessions", script.asked.get(0)); + String form = script.forms.get(0); + has(form, "mode=setup"); + has(form, "customer=cus_123"); + has(form, "payment_method_types%5B0%5D=card"); + has(form, "client_reference_id=setup9"); + has(form, "success_url=https%3A%2F%2Fwayline.example%2Fpay%2Freturn%3Fresult%3Ddone"); + has(form, "cancel_url=https%3A%2F%2Fwayline.example%2Fpay%2Freturn%3Fresult%3Dcancelled"); + + // No card is sent: it was typed on the processor's page. + PaymentProvider.Card card = stripe.finishCardSetup("cus_123", "cs_test_1", null); + assertEquals("GET /v1/checkout/sessions/cs_test_1" + + "?expand%5B%5D=setup_intent.payment_method", script.asked.get(1)); + assertEquals(null, script.forms.get(1)); + assertEquals("pm_1", card.token); + assertEquals("Visa", card.brand); + assertEquals("4242", card.last4); + assertEquals(8, card.expMonth); + assertEquals(2031, card.expYear); + } + + @Test + void aSetupNotFinishedOrSomebodyElsesSavesNoCard() throws Exception { + // The user closed the page: the session is still open, with no card. + Script script = new Script().reply(200, "{\"id\":\"cs_test_2\",\"status\":\"open\"," + + "\"customer\":\"cus_123\",\"setup_intent\":null}"); + try { + new StripePayments(script, RETURN).finishCardSetup("cus_123", "cs_test_2", null); + fail("An unfinished setup saved a card"); + } catch (PaymentDeclinedException expected) { + assertTrue(expected.getMessage().indexOf("not added") > 0); + } + script = new Script().reply(200, "{\"id\":\"cs_test_3\",\"status\":\"complete\"," + + "\"customer\":\"cus_999\",\"setup_intent\":{\"status\":\"succeeded\"," + + "\"payment_method\":{\"id\":\"pm_9\",\"card\":{\"brand\":\"visa\"," + + "\"last4\":\"1111\",\"exp_month\":1,\"exp_year\":2030}}}}"); + try { + new StripePayments(script, RETURN).finishCardSetup("cus_123", "cs_test_3", null); + fail("Another customer's session saved a card"); + } catch (PaymentDeclinedException wrong) { + fail("That is a fault, not a refusal"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("another customer") > 0); + } + // A page to open is the whole point of starting one. + script = new Script().reply(200, "{\"id\":\"cs_test_4\"}"); + try { + new StripePayments(script, RETURN).startCardSetup("cus_123", "setup1"); + fail("A setup with no page was started"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("no page") > 0); + } + } + + @Test + void aRideIsChargedOnceWithNobodyAtTheScreen() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"pi_1\",\"object\":\"payment_intent\"," + + "\"status\":\"succeeded\",\"amount\":1840}"); + StripePayments stripe = new StripePayments(script, RETURN); + assertEquals("pi_1", stripe.charge("ride-77", "cus_123", "pm_1", 1840L, "USD", + "Wayline ride")); + assertEquals("POST /v1/payment_intents", script.asked.get(0)); + String form = script.forms.get(0); + has(form, "amount=1840"); + has(form, "currency=usd"); + has(form, "customer=cus_123"); + has(form, "payment_method=pm_1"); + has(form, "off_session=true"); + has(form, "confirm=true"); + has(form, "description=Wayline+ride"); + has(form, "metadata%5Bwayline_charge%5D=ride-77"); + // The key that makes a charge sent twice a charge made once. + assertEquals("ride-77", script.keys.get(0)); + } + + @Test + void aChargeTheBankWantsApprovedOrRefusesIsADecline() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"pi_2\"," + + "\"status\":\"requires_action\"}"); + try { + new StripePayments(script, RETURN).charge("k1", "cus_123", "pm_1", 500L, "USD", "x"); + fail("A charge awaiting approval was taken as made"); + } catch (PaymentDeclinedException expected) { + assertTrue(expected.getMessage().indexOf("approval") > 0); + } + // What Stripe answers for a card that is refused: 402, and words + // written for the cardholder. + script = new Script().reply(402, "{\"error\":{\"type\":\"card_error\"," + + "\"code\":\"card_declined\",\"decline_code\":\"insufficient_funds\"," + + "\"message\":\"Your card has insufficient funds.\"}}"); + try { + new StripePayments(script, RETURN).charge("k2", "cus_123", "pm_1", 500L, "USD", "x"); + fail("A declined charge was taken as made"); + } catch (PaymentDeclinedException expected) { + assertEquals("Your card has insufficient funds.", expected.getMessage()); + } + // Anything else is a fault, reported by status and code and without + // the processor's message, which can quote the request. + script = new Script().reply(401, "{\"error\":{\"type\":\"invalid_request_error\"," + + "\"code\":\"api_key_invalid\",\"message\":\"Invalid API Key provided: sk_x\"}}"); + try { + new StripePayments(script, RETURN).charge("k3", "cus_123", "pm_1", 500L, "USD", "x"); + fail("A request the processor rejected was taken as made"); + } catch (PaymentDeclinedException wrong) { + fail("That is a fault, not a refusal"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("401") > 0, expected.getMessage()); + assertTrue(expected.getMessage().indexOf("api_key_invalid") > 0); + assertEquals(-1, expected.getMessage().indexOf("sk_x")); + } + script = new Script().reply(502, "Bad gateway"); + try { + new StripePayments(script, RETURN).charge("k4", "cus_123", "pm_1", 500L, "USD", "x"); + fail("An answer that is not JSON was taken as a charge"); + } catch (PaymentDeclinedException wrong) { + fail("That is a fault, not a refusal"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("502") > 0, expected.getMessage()); + } + } + + @Test + void aRefundReturnsTheChargeOnce() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"re_1\",\"object\":\"refund\",\"status\":\"succeeded\"}") + .reply(200, "{\"id\":\"re_2\",\"object\":\"refund\",\"status\":\"failed\"}"); + StripePayments stripe = new StripePayments(script, RETURN); + PaymentProvider.Refund made = stripe.refund("pi_1", 1840L); + assertEquals("re_1", made.reference); + assertFalse(made.pending); + assertEquals("POST /v1/refunds", script.asked.get(0)); + has(script.forms.get(0), "payment_intent=pi_1"); + has(script.forms.get(0), "amount=1840"); + assertEquals("refund-pi_1", script.keys.get(0)); + try { + stripe.refund("pi_1", 1840L); + fail("A refund that failed was taken as made"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("failed") > 0); + } + } + + @Test + void aPendingRefundIsAskedAboutUntilItEnds() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"re_3\",\"object\":\"refund\",\"status\":\"pending\"}") + .reply(200, "{\"id\":\"re_4\",\"object\":\"refund\"," + + "\"status\":\"requires_action\"}") + .reply(200, "{\"id\":\"re_3\",\"object\":\"refund\",\"status\":\"pending\"}") + .reply(200, "{\"id\":\"re_3\",\"object\":\"refund\",\"status\":\"succeeded\"}") + .reply(200, "{\"id\":\"re_4\",\"object\":\"refund\",\"status\":\"canceled\"}") + .reply(200, "{\"id\":\"re_5\",\"object\":\"refund\",\"status\":\"failed\"}"); + StripePayments stripe = new StripePayments(script, RETURN); + // Taken, but not yet money back: neither is a refund to record as made. + PaymentProvider.Refund pending = stripe.refund("pi_3", 900L); + assertEquals("re_3", pending.reference); + assertTrue(pending.pending); + assertTrue(stripe.refund("pi_4", 900L).pending); + assertEquals(PaymentProvider.REFUND_PENDING, stripe.refundStatus("re_3")); + assertEquals("GET /v1/refunds/re_3", script.asked.get(2)); + assertEquals(PaymentProvider.REFUND_SUCCEEDED, stripe.refundStatus("re_3")); + // Canceled returned no more money than failed did. + assertEquals(PaymentProvider.REFUND_FAILED, stripe.refundStatus("re_4")); + assertEquals(PaymentProvider.REFUND_FAILED, stripe.refundStatus("re_5")); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/Trips.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/Trips.java new file mode 100644 index 00000000000..dc221475145 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/Trips.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.Json; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.status; + +/// The steps several tests take on the way to what they are about: a driver +/// going on line, a card being saved, a ride being driven to its end. +final class Trips { + static final String VISA = "4242424242424242"; + static final String MASTERCARD = "5555555555554444"; + /// Refused when it is saved. + static final String DECLINED = "4000000000000002"; + /// Saved without complaint, and refused when it is charged. + static final String FAILS_WHEN_CHARGED = "4000000000000341"; + /// Saved without complaint, and charging it fails without an answer. + static final String NO_ANSWER_WHEN_CHARGED = "4000000000000119"; + /// Charged without complaint; a refund of it is pending at first. + static final String SLOW_REFUND = "4000000000007726"; + + private Trips() { + } + + static MockRequestBuilder body(MockRequestBuilder request, String json) { + return request.contentType("application/json").content(json); + } + + /// The object a request that succeeds answers with. + static Map json(MockMvc mvc, MockRequestBuilder request) throws Exception { + return Json.parseObject(mvc.perform(request).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + } + + /// The list a request that succeeds answers with. + static List list(MockMvc mvc, MockRequestBuilder request) throws Exception { + return (List) Json.parse(mvc.perform(request).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + } + + /// The status a request is answered with, whatever it is. + static int statusOf(MockMvc mvc, MockRequestBuilder request) throws Exception { + return mvc.perform(request).andReturn().getResponse().getStatus(); + } + + static long number(Map object, String field) { + return ((Number) object.get(field)).longValue(); + } + + /// A ride request with more said about it: `extra` is further members of + /// the object, as JSON, without the braces. + static String ride(double fromLat, double fromLng, double toLat, double toLng, String extra) { + String plain = People.ride(fromLat, fromLng, toLat, toLng); + return plain.substring(0, plain.length() - 1) + "," + extra + "}"; + } + + static void online(MockMvc mvc, String driver, double lat, double lng) throws Exception { + mvc.perform(People.asDriver(body(post("/api/driver/status"), + People.position(true, lat, lng)), driver)).andExpect(status().isOk()); + } + + static void offline(MockMvc mvc, String driver) throws Exception { + mvc.perform(People.asDriver(body(post("/api/driver/status"), + People.position(false, 0, 0)), driver)).andExpect(status().isOk()); + } + + static String card(String number) { + // A few years off whenever this runs: a card is refused both when it + // has expired and when its date is further away than any card's is. + int year = Integer.parseInt(Days.iso(System.currentTimeMillis()).substring(0, 4)) + 3; + return "{\"number\":\"" + number + "\",\"expMonth\":12,\"expYear\":" + year + + ",\"cvc\":\"123\",\"holder\":\"A Rider\"}"; + } + + /// Saves a card and returns it as the server describes it. + static Map addCard(MockMvc mvc, String rider, String number) throws Exception { + Map setup = json(mvc, People.asRider(post("/api/payments/methods/setup"), rider)); + return json(mvc, People.asRider(body(post("/api/payments/methods/setup/" + setup.get("id") + + "/complete"), card(number)), rider)); + } + + /// Asks for a ride and has `driver`, who is on line beside the pickup, take + /// it to its end. Returns the ride as the driver is shown it on completing. + static Map complete(MockMvc mvc, String rider, String driver, String ride) throws Exception { + String id = (String) json(mvc, People.asRider(body(post("/api/rides"), ride), rider)) + .get("id"); + mvc.perform(People.asDriver(post("/api/driver/offers/" + id + "/accept"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/arrived"), driver)) + .andExpect(status().isOk()); + mvc.perform(People.asDriver(post("/api/driver/rides/" + id + "/start"), driver)) + .andExpect(status().isOk()); + return json(mvc, People.asDriver(post("/api/driver/rides/" + id + "/complete"), driver)); + } + + static Map receipt(MockMvc mvc, String rider, String rideId) throws Exception { + return json(mvc, People.asRider(get("/api/payments/receipts/" + rideId), rider)); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/WebSignInTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/WebSignInTest.java new file mode 100644 index 00000000000..7c02c574cdf --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/WebSignInTest.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockResponse; +import com.codenameone.examples.wayline.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Signing in from the app running in a browser, which sees only how each +/// request ended: no redirect it can stop at, and no cookie it can read. +@BackendTest +class WebSignInTest { + private static final String VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private static final String CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + private static final String HERE = "http://" + SignIn.HOST + SecurityConfig.WEB_REDIRECT_PATH; + + @Autowired + private MockMvc mvc; + + private String authorize(String session, String redirect) throws Exception { + return mvc.perform(get("/oauth2/authorize?response_type=code&client_id=" + + SecurityConfig.CLIENT_ID + "&redirect_uri=" + OAuth2Parameters.encode(redirect) + + "&scope=openid%20profile&state=s-web&code_challenge=" + CHALLENGE + + "&code_challenge_method=S256") + .header("Host", SignIn.HOST).cookie("CN1SESSION", session)) + .andReturn().getResponse().getRedirectedUrl(); + } + + @Test + void thePasswordIsAnsweredWithAStatus() throws Exception { + MockResponse accepted = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.RIDER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST)) + .andExpect(status().isOk()).andReturn().getResponse(); + // Not a redirect: a browser would follow one and report the page at its end. + assertNull(accepted.getRedirectedUrl()); + assertNotNull(accepted.getCookie("CN1SESSION")); + + MockResponse refused = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.RIDER, "password", "not-the-password") + .header("Host", SignIn.HOST)) + .andExpect(status().isUnauthorized()).andReturn().getResponse(); + assertNull(refused.getRedirectedUrl()); + assertNull(refused.getCookie("CN1SESSION")); + } + + @Test + void theCodeComesBackThroughThisServer() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD); + String location = authorize(session, HERE); + assertNotNull(location); + assertTrue(location.startsWith(HERE + "?"), location); + + // What the browser does next, and what the app reads. + String answer = mvc.perform(get(location.substring(("http://" + SignIn.HOST).length())) + .header("Host", SignIn.HOST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("s-web")) + .andReturn().getResponse().getContentAsString(); + String code = (String) Json.parseObject(answer).get("code"); + assertNotNull(code); + + Map tokens = Json.parseObject(mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", code, + "redirect_uri", HERE, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + String token = (String) tokens.get("access_token"); + assertNotNull(token); + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } + + @Test + void aCodeIsNeverSentToAnotherServer() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD); + // Same path, somebody else's server: the address has to be this one. + String elsewhere = authorize(session, "http://attacker.example" + SecurityConfig.WEB_REDIRECT_PATH); + assertTrue(elsewhere == null || elsewhere.indexOf("attacker.example") < 0, String.valueOf(elsewhere)); + String otherPath = authorize(session, "http://" + SignIn.HOST + "/somewhere/else"); + assertTrue(otherPath == null || otherPath.indexOf("code=") < 0, String.valueOf(otherPath)); + } + + @Test + void theInstalledAppStillSignsInItsOwnWay() throws Exception { + assertNotNull(SignIn.token(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD)); + } + + @Test + void theCodePageSaysWhenThereIsNoCode() throws Exception { + mvc.perform(get(SecurityConfig.WEB_REDIRECT_PATH + "?error=access_denied&state=s-web") + .header("Host", SignIn.HOST)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("access_denied")); + } + + @Test + void aSignInAnotherSiteSubmittedIsRefused() throws Exception { + MockResponse forged = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.RIDER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()).andReturn().getResponse(); + assertNull(forged.getCookie("CN1SESSION")); + // A page in a sandboxed frame, or opened from a file. + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.RIDER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "null")) + .andExpect(status().isForbidden()); + // The same name on another port or scheme is another site. + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.RIDER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "https://" + SignIn.HOST)) + .andExpect(status().isForbidden()); + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.RIDER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "http://wayline.test:9090")) + .andExpect(status().isForbidden()); + // The rest of what changes something on this chain is held to it too. + mvc.perform(SignIn.formPost("/oauth2/token", "grant_type", "authorization_code") + .header("Host", SignIn.HOST).header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()); + mvc.perform(SignIn.formPost("/logout") + .header("Host", SignIn.HOST).header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()); + } + + @Test + void aSignInFromThisServersOwnPageIsAccepted() throws Exception { + // A browser spells the host in lower case; the comparison does not care. + MockResponse own = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.RIDER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "HTTP://WAYLINE.TEST:8080")) + .andExpect(status().isOk()).andReturn().getResponse(); + assertNotNull(own.getCookie("CN1SESSION")); + // And the installed app, which is not a browser and names no origin. + assertNotNull(SignIn.session(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD)); + } + + @Test + void theOriginIsHeldToTheIssuerWhenOneIsConfigured() { + assertTrue(SecurityConfig.sameOrigin("https://wayline.example", "https://wayline.example")); + assertTrue(SecurityConfig.sameOrigin("https://WAYLINE.example", "https://wayline.example/auth")); + assertTrue(!SecurityConfig.sameOrigin("https://wayline.example.attacker.example", + "https://wayline.example")); + assertTrue(!SecurityConfig.sameOrigin("http://wayline.example", "https://wayline.example")); + assertTrue(!SecurityConfig.sameOrigin("https://wayline.example:8443", "https://wayline.example")); + // With an issuer the address a request arrived at is never consulted. + assertEquals("https://wayline.example", SecurityConfig.here("https://wayline.example")); + // With none, and no request in hand, there is no address to accept. + assertNull(SecurityConfig.here("")); + } + + @Test + void theSessionCookieIsNotSentFromOtherSitesOrReadByScripts() throws Exception { + String header = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.RIDER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST)) + .andReturn().getResponse().getHeader("Set-Cookie"); + assertNotNull(header); + assertTrue(header.indexOf("HttpOnly") > 0, header); + assertTrue(header.indexOf("SameSite=Lax") > 0 || header.indexOf("SameSite=Strict") > 0, header); + } + + @Test + void theSessionEndsWhenTheAppHasItsTokens() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.RIDER, DemoAccounts.PASSWORD); + String location = authorize(session, HERE); + assertTrue(location.indexOf("code=") > 0, location); + String answer = mvc.perform(get(location.substring(("http://" + SignIn.HOST).length())) + .header("Host", SignIn.HOST)) + .andReturn().getResponse().getContentAsString(); + Map tokens = Json.parseObject(mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", (String) Json.parseObject(answer).get("code"), + "redirect_uri", HERE, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andReturn().getResponse().getContentAsString()); + + // Asking nicely does not end it: only a POST does. + mvc.perform(get("/logout").header("Host", SignIn.HOST).cookie("CN1SESSION", session)); + assertTrue(authorize(session, HERE).indexOf("code=") > 0); + + MockResponse ended = mvc.perform(SignIn.formPost("/logout") + .header("Host", SignIn.HOST).header("Origin", "http://" + SignIn.HOST) + .cookie("CN1SESSION", session)) + .andExpect(status().isNoContent()).andReturn().getResponse(); + assertNull(ended.getRedirectedUrl()); + // The cookie earns no more codes... + String after = authorize(session, HERE); + assertTrue(after == null || after.indexOf("code=") < 0, String.valueOf(after)); + // ...and the tokens it earned are untouched. + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + tokens.get("access_token"))) + .andExpect(status().isOk()); + mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "refresh_token", + "refresh_token", (String) tokens.get("refresh_token"), + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andExpect(status().isOk()); + } + + @Test + void theCodePageIsDataAndNothingElse() throws Exception { + MockResponse page = mvc.perform(get(SecurityConfig.WEB_REDIRECT_PATH + + "?code=%3Cscript%3Ealert(1)%3C/script%3E&state=s").header("Host", SignIn.HOST)) + .andExpect(status().isOk()).andReturn().getResponse(); + assertTrue(String.valueOf(page.getContentType()).startsWith("application/json"), + String.valueOf(page.getContentType())); + assertEquals("nosniff", page.getHeader("X-Content-Type-Options")); + assertEquals("no-store", page.getHeader("Cache-Control")); + } + + @Test + void anIssuerIsTheOnlyWebAddressWhenOneIsConfigured() { + com.codename1.backend.security.oauth2.server.authorization.RegisteredClient app = + com.codename1.backend.security.oauth2.server.authorization.RegisteredClient + .withId("wayline").clientId(SecurityConfig.CLIENT_ID) + .clientAuthenticationMethod(com.codename1.backend.security.oauth2.core + .ClientAuthenticationMethod.NONE) + .authorizationGrantType(com.codename1.backend.security.oauth2.core + .AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(SecurityConfig.LOOPBACK_REDIRECT).build(); + java.util.Set uris = SecurityConfig.withWebRedirect(app, "https://wayline.example") + .getRedirectUris(); + assertTrue(uris.contains("https://wayline.example/signin/code"), uris.toString()); + assertTrue(uris.contains(SecurityConfig.LOOPBACK_REDIRECT), uris.toString()); + assertEquals(2, uris.size(), uris.toString()); + // With none configured and no request in hand, nothing is added. + assertEquals(1, SecurityConfig.withWebRedirect(app, "").getRedirectUris().size()); + } +} diff --git a/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ride/OfferRecheckTest.java b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ride/OfferRecheckTest.java new file mode 100644 index 00000000000..63c690c4081 --- /dev/null +++ b/scripts/wayline/backend/src/test/java/com/codenameone/examples/wayline/ride/OfferRecheckTest.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ride; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codenameone.examples.wayline.account.Accounts; +import com.codenameone.examples.wayline.account.DemoAccounts; +import com.codenameone.examples.wayline.api.DriverStatusDto; +import com.codenameone.examples.wayline.api.RegisterDto; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.api.RideRequestDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.driver.Drivers; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// An offer made to a driver who stopped being fit for the ride after the +/// search chose them. +/// +/// Choosing and offering are two transactions, and a driver can go off line, +/// drive away, lose their approval or be blocked in between. Each of those is +/// set up here and the offer made to them anyway, which is the second +/// transaction running after the first read them as they were. It sits in +/// this package because the offer is not part of the service's public face. +@BackendTest +class OfferRecheckTest { + // Porto. + private static final double LAT = 41.1579; + private static final double LNG = -8.6291; + + @Autowired + private Accounts accounts; + @Autowired + private Rides rides; + @Autowired + private Drivers drivers; + @Autowired + private DataSource db; + + private String open(String name, boolean driver) throws Exception { + String username = accounts.register(new RegisterDto(name + "@test.example", + "a-long-password", name, "+15550111", driver, "Green Fiat 500", "T " + name, + false, null)).username(); + accounts.markPhoneVerified(username); + if (driver) { + accounts.setRoles(DemoAccounts.ADMIN, username, true, false); + } + return username; + } + + private void at(String driver, boolean online, double lat, double lng) throws Exception { + drivers.report(driver, new DriverStatusDto(online, lat, lng, 90d)); + } + + private void notOffered(String id, String driver, String why) throws Exception { + assertNull(rides.offer(id, driver), why); + assertEquals(RideState.REQUESTED, rides.get("", true, id).state(), why); + } + + @Test + void aDriverNoLongerFitForTheRideIsNotOfferedIt() throws Exception { + String rider = open("opo-rider", false); + String driver = open("opo-driver", true); + // Asked for with nobody on line, so it waits. The driver is kept unfit + // from here until the end, so the scheduled search, which runs + // alongside, has nobody to offer it to either. + String id = rides.request(rider, new RideRequestDto(LAT, LNG, "Here", LAT + 0.02, + LNG + 0.02, "There", 0d, 0d, null, null, null, false, false, false, null)).id(); + assertEquals(RideState.REQUESTED, rides.get("", true, id).state()); + + // On line, a hundred kilometres away. + at(driver, true, LAT + 1d, LNG); + notOffered(id, driver, "a driver out of reach"); + + // In reach, and blocked. + accounts.suspend(DemoAccounts.ADMIN, driver, true, "a test"); + at(driver, true, LAT, LNG); + notOffered(id, driver, "a blocked driver"); + + // No longer approved. + db.execute("UPDATE driver_application SET status = 'rejected' WHERE username = ?", + new Object[] {driver}); + accounts.suspend(DemoAccounts.ADMIN, driver, false, "a test"); + notOffered(id, driver, "a driver whose approval was withdrawn"); + + // Not heard from in a long while, whatever their status says. + db.execute("UPDATE driver_state SET last_seen = 1 WHERE username = ?", + new Object[] {driver}); + db.execute("UPDATE driver_application SET status = 'approved' WHERE username = ?", + new Object[] {driver}); + notOffered(id, driver, "a driver not heard from"); + + // Off line. + at(driver, false, 0d, 0d); + notOffered(id, driver, "a driver off line"); + + // And once fit again, the ride is theirs: offered here, or by the + // scheduled search if it got there first. + at(driver, true, LAT, LNG); + rides.offer(id, driver); + RideDto now = rides.get("", true, id); + assertEquals(RideState.OFFERED, now.state()); + assertEquals(driver, now.driverUsername()); + rides.cancelByAdmin(DemoAccounts.ADMIN, id, "the test is over"); + at(driver, false, 0d, 0d); + } +} diff --git a/scripts/wayline/check-template.sh b/scripts/wayline/check-template.sh new file mode 100755 index 00000000000..03fa00ee1c4 --- /dev/null +++ b/scripts/wayline/check-template.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Builds what the Initializr generates from this project. +# +# check-template.sh [work-directory] +# +# The Initializr offers this application as a template, from resources that +# scripts/sync-initializr-wayline.py derives from it. That the resources match +# is one question; this is the other: whether a project generated from them, +# under another name and in another package, still builds. The real generator +# makes the project, and it is then built against this checkout: +# +# the server's tests pass against the contract module +# the app builds from common, without platform modules and with them, and +# the client half of the contract is generated into it +# +# Needs this checkout's artifacts installed (core, javase, css-compiler, the +# Maven plugin, backend, backend-test), a JDK 17 or newer as JAVA_HOME, python3 +# and unzip. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +MVN="${MVN:-mvn}" +WORK="${1:-$HERE/target/template-check}" + +log() { echo "[wayline-template] $*" >&2; } + +python3 "$ROOT/scripts/sync-initializr-wayline.py" + +# The version this checkout installs, which the generated poms are pointed at: +# the release the Initializr generates against does not have this project's +# build support until one that follows this change. +VERSION="$(sed -n 's:.*\(.*\).*:\1:p' "$ROOT/maven/pom.xml" | head -n 1)" +[ -n "$VERSION" ] || { log "could not read the version from maven/pom.xml"; exit 1; } + +rm -rf "$WORK" +mkdir -p "$WORK" +log "generating the Initializr's downloads" +python3 "$ROOT/scripts/tests/generate-initializr-fixtures.py" "$WORK/fixtures" > "$WORK/fixtures.log" 2>&1 \ + || { tail -n 60 "$WORK/fixtures.log" >&2; log "the generator failed"; exit 1; } + +# extract -- a generated project, pointed at this checkout's version +extract() { + local dir="$WORK/$1" + mkdir -p "$dir" + unzip -q "$WORK/fixtures/WAYLINE-$1-INTELLIJ.zip" -d "$dir" + python3 - "$dir/pom.xml" "$VERSION" <<'PY' +import re, sys +path, version = sys.argv[1], sys.argv[2] +text = open(path, encoding='utf-8').read() +for tag in ('cn1.plugin.version', 'cn1.version'): + text, count = re.subn(r'<%s>[^<]*' % (tag, tag), '<%s>%s' % (tag, version, tag), text) + assert count == 1, (tag, count) +open(path, 'w', encoding='utf-8').write(text) +PY + echo "$dir" +} + +# The fixtures are generated as com.example.probe.LauncherProbe. +PACKAGE=com/example/probe + +minimal="$(extract APP_WITH_BACKEND)" +log "running the generated server's tests" +(cd "$minimal" && "$MVN" -B -q -Dcodename1.platform=backend -pl shared,backend -am test) +ls "$minimal"/backend/target/surefire-reports/TEST-*.xml >/dev/null 2>&1 \ + || { log "the generated server's tests did not run"; exit 1; } +[ -f "$minimal/backend/target/generated-sources/cn1-annotations/$PACKAGE/api/RiderApiServer.java" ] \ + || { log "the server's half of the contract was not generated"; exit 1; } + +for layout in APP_WITH_BACKEND FULL; do + project="$WORK/$layout" + [ -d "$project" ] || project="$(extract "$layout")" + log "building the generated app ($layout)" + (cd "$project" && "$MVN" -B -q -Dcodename1.platform=javase -Djava.awt.headless=true -DskipTests package) + for built in "classes/theme.res" "classes/$PACKAGE/api/RiderApiImpl.class" "classes/cn1app/RestClientBootstrap.class"; do + [ -f "$project/common/target/$built" ] \ + || { log "$layout: common/target/$built was not built"; exit 1; } + done +done +log "passed" diff --git a/scripts/wayline/common/codenameone_settings.properties b/scripts/wayline/common/codenameone_settings.properties new file mode 100644 index 00000000000..afb58d95fc7 --- /dev/null +++ b/scripts/wayline/common/codenameone_settings.properties @@ -0,0 +1,58 @@ +codename1.android.keystore= +codename1.android.keystoreAlias= +codename1.android.keystorePassword= +codename1.arg.and.themeMode=modern +# A server started on this machine is reached over plain HTTP, which Android +# refuses by default. Remove this once the app talks to a server with a +# certificate. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" +codename1.arg.desktop.interactiveScrollbars=true +codename1.arg.desktop.titleBar=native +codename1.arg.ios.newStorageLocation=true +# What iOS shows when someone applying to drive photographs a document. +codename1.arg.ios.NSCameraUsageDescription=The camera photographs the documents of your application to drive. +# What iOS shows when they pick a picture of a document they already have. +codename1.arg.ios.NSPhotoLibraryUsageDescription=Your photo library is where you pick a picture of a document from. +# What iOS shows before Face ID unlocks the sign-in kept in secure storage. +codename1.arg.ios.NSFaceIDUsageDescription=Authenticate to securely access your account +# What iOS shows when the app first asks where the device is. +codename1.arg.ios.NSLocationWhenInUseUsageDescription=Your location is where a ride picks you up, and where riders find you while you drive. +# While a driver is online the app keeps reporting where they are from the +# background: iOS needs the location background mode and the Always prompt's +# text, Android a foreground service of the location type. +codename1.arg.ios.background_modes=location +codename1.arg.ios.locationUsageDescription=Your location is where a ride picks you up, and where riders find you while you drive. +codename1.arg.ios.NSLocationAlwaysAndWhenInUseUsageDescription=While you are online as a driver, Wayline keeps sharing your location with riders and dispatch, even with the app in the background. +codename1.arg.android.foregroundServiceType=location +# Push notifications. With push included the build adds the +# remote-notification background mode and the push entitlement itself. +codename1.arg.ios.includePush=true +# Android push is off until the app has its Firebase file: a build that +# uses PushClient without google-services.json stops with an error. Once +# native/android/google-services.json is in place, delete this line. +codename1.arg.android.messagingService=none +# The ride on the lock screen updates as the driver moves. +codename1.arg.ios.surfaces.frequentUpdates=true +codename1.arg.ios.themeMode=modern +codename1.arg.java.version=17 +codename1.arg.nativeTheme=native +codename1.cssTheme=true +codename1.displayName=Wayline +codename1.icon=icon.png +codename1.ios.appid=Q5GHSKAL2F.com.codenameone.examples.wayline +codename1.ios.certificate= +codename1.ios.certificatePassword= +codename1.ios.debug.certificate= +codename1.ios.debug.certificatePassword= +codename1.ios.debug.provision= +codename1.ios.provision= +codename1.ios.release.certificate= +codename1.ios.release.certificatePassword= +codename1.ios.release.provision= +codename1.kotlin=false +codename1.languageLevel=5 +codename1.mainName=Wayline +codename1.packageName=com.codenameone.examples.wayline +codename1.secondaryTitle=Hello World +codename1.vendor=CodenameOne +codename1.version=1.0 diff --git a/scripts/wayline/common/icon.png b/scripts/wayline/common/icon.png new file mode 100644 index 00000000000..1f4fa5dd252 Binary files /dev/null and b/scripts/wayline/common/icon.png differ diff --git a/scripts/wayline/common/pom.xml b/scripts/wayline/common/pom.xml new file mode 100644 index 00000000000..6dd9fbfa5a5 --- /dev/null +++ b/scripts/wayline/common/pom.xml @@ -0,0 +1,1375 @@ + + + 4.0.0 + + com.codenameone.examples.wayline + wayline + 1.0-SNAPSHOT + + com.codenameone.examples.wayline + wayline-common + 1.0-SNAPSHOT + jar + + + + + com.codenameone + codenameone-core + provided + + + + ${project.groupId} + wayline-shared + ${project.version} + + + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + android-compat + + + ${basedir}/src/main/android + + + + + com.codenameone + codenameone-android-compat + provided + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 2.2.10 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + process-resources + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + ${project.basedir}/src/main/android/kotlin + ${project.basedir}/src/main/android/java + ${project.build.directory}/generated-sources/android + + + -no-reflect + -no-jdk + + + + + test-compile + process-test-sources + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + cn1-javase-natives + + + ${basedir}/../javase/pom.xml + + + + + + com.codenameone + codenameone-maven-plugin + + + compile-javase-natives + process-classes + + compile-javase-natives + + + javase + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.surefire + surefire-junit-platform + 3.0.0-M5 + + + + ${skipTests} + + ${project.build.directory}/cn1-javase/classes + + + + + + + + + cn1-host-javase + + + codename1.platform + javase + + + ${basedir}/../javase/pom.xml + + + + javase + javase + none + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javase + validate + + hosted-platform + + + javase + + + + build-javase + package + + build + + + javase + + + + + + + + + + cn1-host-android + + + codename1.platform + android + + + ${basedir}/../android/pom.xml + + + + android + android + android-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-android + validate + + hosted-platform + + + android + + + + build-android + package + + build + + + android + + + + + + + + + + cn1-host-ios + + + codename1.platform + ios + + + ${basedir}/../ios/pom.xml + + + + ios + ios + ios-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-ios + validate + + hosted-platform + + + ios + + + + build-ios + package + + build + + + ios + + + + + + + + + + cn1-host-javascript + + + codename1.platform + javascript + + + ${basedir}/../javascript/pom.xml + + + + javascript + javascript + local-javascript + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javascript + validate + + hosted-platform + + + javascript + + + + build-javascript + package + + build + + + javascript + + + + + + + + + + cn1-host-win + + + codename1.platform + win + + + ${basedir}/../win/pom.xml + + + + win + win + windows-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-win + validate + + hosted-platform + + + win + + + + build-windows + package + + build + + + win + + + + + + + + + + cn1-host-linux + + + codename1.platform + linux + + + ${basedir}/../linux/pom.xml + + + + linux + linux + linux-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-linux + validate + + hosted-platform + + + linux + + + + build-linux + package + + build + + + linux + + + + + + + + + + simulator + + javase + com.codename1.impl.javase.Simulator + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + ${codename1.exec.args.debug} + ${codename1.exec.args.runjdwp.transport} + + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + idea-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator-idea + verify + + java + + + ${cn1.hosted.javase.skip} + + ${project.build.directory}/cn1-javase/classes + + + ${codename1.mainClass} + + + + codename1.css.compiler.args.input + ${codename1.css.compiler.args.input} + + + codename1.css.compiler.args.output + ${codename1.css.compiler.args.output} + + + codename1.css.compiler.args.merge + ${codename1.css.compiler.args.merge} + + + + cn1.class.path + ${cn1.class.path} + + + + + + + + + + + + debug-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=n,address=${jpda.address} + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + debug-eclipse + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=y,address=${jpda.address},suspend=y + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + run-desktop + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-desktop + verify + + java + + + ${cn1.hosted.javase.skip} + ${codename1.packageName}.${codename1.mainName}Stub + + + ${project.build.directory}/cn1-javase/classes + ${project.build.directory}/cn1-javase/desktop + ${basedir}/src/desktop/resources + ${basedir}/../javase/src/desktop/resources + + + + + + + + + + + executable-jar + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + javase-executable-jar + package + + javase-executable-jar + + + javase + + + + + + + + + + test + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + cn1-tests + test + + test + + + javase + + + + + + + + + + run-android + + + + com.codenameone + codenameone-maven-plugin + + + + run-android-hosted + validate + + hosted-platform + + + android + + + + + + maven-antrun-plugin + + + adb-install + verify + + run + + + ${cn1.hosted.android.skip} + + Running adb install + + + + + + Trying to start app on device using adb + + + + + + + + + + + + + + + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + win + + windows + win + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + + + transcode-svg + generate-sources + + transcode-svg + + + + + compile-android-res + generate-sources + + compile-android-res + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + + remap-android + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + + + + + + diff --git a/scripts/wayline/common/src/desktop/resources/NativeTheme.res b/scripts/wayline/common/src/desktop/resources/NativeTheme.res new file mode 100644 index 00000000000..83e067b6969 Binary files /dev/null and b/scripts/wayline/common/src/desktop/resources/NativeTheme.res differ diff --git a/scripts/wayline/common/src/main/css/theme.css b/scripts/wayline/common/src/main/css/theme.css new file mode 100644 index 00000000000..cbc14953994 --- /dev/null +++ b/scripts/wayline/common/src/main/css/theme.css @@ -0,0 +1,1692 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +/* + * The whole look of the app. + * + * COLOURS AND SHAPE -- change these and nothing else to re-skin the app. Every + * rule below takes its colours from here; none names a colour of its own. Each + * colour has a second value for dark mode, used by the block at the end of the + * file. + */ +#Constants { + includeNativeBool: true; + defaultSourceDPIInt: "0"; + /* ONE TYPE SCALE, in millimetres as a phone draws them: 2.1 caption, + 2.5 secondary, 2.75 body, 2.9 control, 3.5 heading, 4.4 title, 7 display. + A desktop draws a millimetre in far fewer pixels and is read from + further away, so every size is a quarter larger there. */ + device-desktop-font-scale: "1.25"; + /* Screens change quickly: nothing waits for an animation. */ + transitionSpeedInt: "200"; + /* The switch, sized from the Switch style's font. */ + switchThumbScaleY: "1.5"; + switchTrackScaleY: "1.8"; + switchTrackScaleX: "3.1"; + switchThumbInsetMM: "0.35"; + switchTrackOffOutlineWidthMM: "0"; + switchTrackOnOutlineWidthMM: "0"; + switchThumbWidthScale: "1.0"; + --brand: #1f5eff; + --brand-pressed: #1747c4; + --brand-contrast: #ffffff; + --surface: #ffffff; + --surface-alt: #f3f4f7; + --surface-press: #e6e9ee; + --text: #12161c; + --text-muted: #667085; + --line: #e6e8ed; + --line-strong: #cfd4dc; + --success: #12805c; + --warning: #b25e09; + --danger: #c5292a; + /* The pale ground a selected card and a badge sit on. */ + --brand-tint: #edf2ff; + --success-tint: #e4f4ed; + --warning-tint: #fcf0df; + --danger-tint: #fcebeb; + + --brand-dark: #6d93ff; + --brand-pressed-dark: #4f78ee; + --brand-contrast-dark: #0b1020; + --surface-dark: #12161c; + --surface-alt-dark: #1c222b; + --surface-press-dark: #2a313c; + --text-dark: #f2f4f7; + --text-muted-dark: #98a2b3; + --line-dark: #2a313c; + --line-strong-dark: #3b4452; + --success-dark: #47c79a; + --warning-dark: #f0a24b; + --danger-dark: #ff7a7a; + --brand-tint-dark: #1a2440; + --success-tint-dark: #15302a; + --warning-tint-dark: #33270f; + --danger-tint-dark: #3a1f22; + + /* ONE RADIUS for anything card- or button-shaped, a smaller one for what + sits inside a card, and the sheet's. */ + --radius: 2.4mm; + --radius-small: 1.5mm; + + /* What floats over the map: the shadow under it, the dimming behind an + open menu, and the corners of the sheet. */ + --shadow: #101828; + /* Under a card with square-ish corners. Black, and without a blur where it + is used: that is the one shadow a rounded rectangle draws for itself. + Any other is rendered to pictures of the card when the theme is + compiled, and a build that cannot render them has no card at all. */ + --shadow-card: rgba(0, 0, 0, 0.14); + --scrim: #000000; + --radius-sheet: 4.4mm; + /* The map's own drawings. The edge is the outline that keeps a pin, the + car and the route readable over any tile. */ + --map-edge: #ffffff; + --map-edge-dark: #0b1020; + --car: #14171f; + --car-dark: #f2f4f7; +} + +/* ---------------------------------------------------------------- surfaces */ + +WlForm { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +WlPage { + background-color: var(--surface); + padding: 3mm 3.6mm 4mm 3.6mm; + margin: 0; +} + +/* The coloured top of the welcome screen. */ +WlHero { + background-color: var(--brand); + padding: 14mm 4.4mm 8mm 4.4mm; + margin: 0; +} + +WlHeroTitle { + color: var(--brand-contrast); + font-family: "native:MainBold"; + font-size: 7mm; + padding: 2.4mm 0 0.8mm 0; + margin: 0; + background-color: transparent; +} + +WlHeroText { + color: var(--brand-contrast); + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +WlHeroIcon { + color: var(--brand-contrast); + padding: 0; + margin: 0; + background-color: transparent; +} + +/* The panel that rises over the map. */ +WlCard { + background-color: var(--surface); + border-radius: var(--radius-sheet) var(--radius-sheet) 0 0; + padding: 3.6mm 3.6mm 4mm 3.6mm; + margin: 0; +} + +/* A panel that floats over the map with a gap around it. */ +WlFloat { + background-color: var(--surface); + border-radius: var(--radius); + padding: 2.4mm 3.2mm 2.4mm 3.2mm; + margin: 2.4mm; +} + +WlPanel { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.8mm 3mm 2.8mm 3mm; + margin: 1.2mm 0 1.2mm 0; +} + +WlDivider { + background-color: var(--line); + padding: 0; + margin: 2mm 0 2mm 0; +} + +/* -------------------------------------------------------------------- text */ + +WlTitle { + color: var(--text); + font-family: "native:MainBold"; + font-size: 4.4mm; + padding: 0.6mm 0 1mm 0; + margin: 0; + background-color: transparent; +} + +WlHeading { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.5mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +WlText { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.75mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +WlMuted { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +WlLabel { + color: var(--text-muted); + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 2.6mm 0 1mm 0.3mm; + margin: 0; + background-color: transparent; +} + +WlPrice { + color: var(--text); + font-family: "native:MainBold"; + font-size: 4.4mm; + padding: 0; + margin: 0; + text-align: right; + background-color: transparent; +} + +/* The same, where it is the headline of a sheet: it starts where the + sheet's words start. */ +WlFare { + color: var(--text); + font-family: "native:MainBold"; + font-size: 5.2mm; + padding: 0; + margin: 0; + text-align: left; + background-color: transparent; +} + +WlError { + color: var(--danger); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 1mm 0 1mm 0; + margin: 0; + background-color: transparent; +} + +WlNotice { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 2mm 0 1mm 0; +} + +/* ----------------------------------------------------------------- buttons */ + +WlPrimary { + color: var(--brand-contrast); + background-color: var(--brand); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 0; +} + +WlPrimary.pressed { + background-color: var(--brand-pressed); +} + +WlPrimary.disabled { + color: var(--text-muted); + background-color: var(--surface-press); +} + +WlSecondary { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 0; +} + +WlSecondary.pressed { + background-color: var(--surface-press); +} + +WlDanger { + color: var(--danger); + background-color: var(--danger-tint); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 0; +} + +WlDanger.pressed { + background-color: var(--surface-press); +} + +WlLink { + color: var(--brand); + background-color: transparent; + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.75mm; + text-align: center; + padding: 2.4mm 2mm 2.4mm 2mm; + margin: 0; +} + +WlSecondary.disabled, WlDanger.disabled, WlLink.disabled { + color: var(--line-strong); +} + +WlLink.pressed { + color: var(--brand-pressed); + background-color: var(--brand-tint); +} + +/* The round buttons that sit on the map. */ +WlMapButton { + color: var(--text); + background: cn1-round-border; + background-color: var(--surface); + cn1-box-shadow-spread: 0.5mm; + cn1-box-shadow-blur: 1.6mm; + cn1-box-shadow-h: 0.5; + cn1-box-shadow-v: 0.7; + cn1-box-shadow-color: var(--shadow); + padding: 2.4mm; + margin: 1.2mm 2.4mm 1.2mm 2.4mm; +} + +WlMapButton.pressed { + background-color: var(--surface-alt); +} + +/* The same button on a bar: flat, because nothing on a bar floats. */ +WlBarButton { + color: var(--text); + background: cn1-round-border; + background-color: var(--surface); + padding: 2.4mm; + margin: 0.6mm 0.6mm 0.6mm 1.2mm; +} + +WlBarButton.pressed { + background-color: var(--surface-alt); +} + +/* "Where to?" -- looks like a field, behaves like a button. */ +WlSearchBar { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 3.1mm; + text-align: left; + padding: 2.9mm 3.2mm 2.9mm 3.2mm; + margin: 0.8mm 0 0.8mm 0; +} + +WlSearchBar.pressed { + background-color: var(--surface-press); +} + +/* ------------------------------------------------------------------ fields */ + +WlField { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 2.7mm 3mm 2.7mm 3mm; + margin: 0; +} + +/* The field being typed in says so. */ +WlField.selected { + background-color: var(--brand-tint); +} + +WlField.disabled { + color: var(--text-muted); +} + +WlFieldHint { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 2.7mm 3mm 2.7mm 3mm; + margin: 0; + background-color: transparent; +} + +/* ------------------------------------------------------------------- lists */ + +WlRow { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 2.6mm 0 2.6mm 0; + margin: 0; +} + +WlRow.pressed { + background-color: var(--surface-alt); +} + +WlRowIcon { + color: var(--text-muted); + padding: 0 3mm 0 0.4mm; + margin: 0; + background-color: transparent; +} + +WlRowTitle { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.75mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +WlRowDetail { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.4mm; + padding: 0.3mm 0 0 0; + margin: 0; + background-color: transparent; +} + +WlRowValue { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.75mm; + text-align: right; + padding: 0 0 0 2mm; + margin: 0; + background-color: transparent; +} + +/* ------------------------------------------------------------------ badges */ + +WlBadge { + color: var(--text-muted); + background: cn1-pill-border; + background-color: var(--surface-alt); + font-family: "native:MainBold"; + font-size: 2.05mm; + padding: 0.7mm 2mm 0.7mm 2mm; + margin: 0 0 0 2mm; +} + +WlBadgeGood { + cn1-derive: WlBadge; + color: var(--success); + background-color: var(--success-tint); +} + +WlBadgeWarn { + cn1-derive: WlBadge; + color: var(--warning); + background-color: var(--warning-tint); +} + +WlBadgeBad { + cn1-derive: WlBadge; + color: var(--danger); + background-color: var(--danger-tint); +} + +/* ------------------------------------------------------------------- admin */ + +WlStat { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.6mm 2.6mm 2.6mm 2.6mm; + margin: 0.8mm; +} + +WlStatValue { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.9mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +WlStatLabel { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.3mm; + padding: 0.3mm 0 0 0; + margin: 0; + background-color: transparent; +} + +/* --------------------------------------------------------------- title bar */ + +/* The strip the clock and the battery are drawn over. */ +StatusBar { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +Toolbar { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 0.6mm 0 0.6mm 0; + margin: 0; +} + +Title { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.05mm; + background-color: transparent; +} + +TitleCommand { + color: var(--text); + background-color: transparent; +} + +BackCommand { + color: var(--text); + background-color: transparent; +} + +/* --------------------------------------------------------------------------- + * MAP. A marker is an icon drawn in its style's colour, and the route is a + * line in WlRoute's. + */ +WlMarkerPickup { + color: var(--success); + background-color: var(--map-edge); +} + +WlMarkerDropoff { + color: var(--danger); + background-color: var(--map-edge); +} + +WlMarkerCar { + color: var(--car); + background-color: var(--map-edge); +} + +WlRoute { + color: var(--brand); + background-color: var(--map-edge); +} + +WlStar { + color: var(--warning); + background-color: transparent; + padding: 1.2mm; + margin: 0.6mm 0 0.6mm 0; +} + +WlStar.pressed { + color: var(--text-muted); +} + +/* The switch: the thumb is the colour, the track the background. */ +Switch { + color: #ffffff; + background-color: var(--line-strong); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0.6mm 0 0.6mm 2mm; + margin: 0; +} + +Switch.selected { + color: #ffffff; + background-color: var(--brand); +} + +Switch.disabled { + color: var(--surface-alt); + background-color: var(--line); +} + +/* --------------------------------------------------------------------------- + * OVER THE MAP. The clear strip the status bar is drawn in, which the map + * runs under, the bar of floating controls, + * the sheet at the bottom and what goes in each. + */ +WlStatusShade { + background-color: transparent; + padding: 0; + margin: 0; +} + +WlMapBar { + background-color: transparent; + padding: 1.2mm 2.4mm 0 0; + margin: 0; +} + +/* "Where to?" -- a card that floats over the map and behaves like a button. */ +WlSearchCard { + color: var(--text); + background-color: var(--surface); + border-radius: var(--radius); + cn1-box-shadow-spread: 0.5mm; + cn1-box-shadow-blur: 0; + cn1-box-shadow-h: 0.5; + cn1-box-shadow-v: 0.7; + cn1-box-shadow-color: var(--shadow-card); + font-family: "native:MainBold"; + font-size: 3.1mm; + text-align: left; + padding: 3mm 3.2mm 3mm 3.2mm; + margin: 0.5mm 0 0.5mm 0; +} + +WlSearchCard.pressed { + background-color: var(--surface-alt); +} + +/* From where to where, once there is somewhere to go. */ +WlTripCard { + cn1-derive: WlSearchCard; + padding: 1.9mm 3.2mm 1.9mm 3.2mm; +} + +WlTripStop { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: left; + padding: 0.7mm 0 0.7mm 0; + margin: 0; +} + +/* The driver's card at the top: on line or off, and today's takings. */ +WlStatusCard { + cn1-derive: WlSearchCard; + padding: 1.7mm 3.2mm 1.7mm 2mm; +} + +WlStatusValue { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.3mm; + text-align: right; + padding: 0; + margin: 0; +} + +WlStatusDetail { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.15mm; + text-align: right; + padding: 0; + margin: 0; +} + +WlMapTitle { + cn1-derive: WlSearchCard; + font-size: 3.05mm; + padding: 3mm 3.2mm 3mm 3.2mm; +} + +/* One short fact, in a pill under the bar. */ +WlChip { + color: var(--text); + background: cn1-pill-border; + background-color: var(--surface); + cn1-box-shadow-spread: 0.4mm; + cn1-box-shadow-blur: 1.4mm; + cn1-box-shadow-h: 0.5; + cn1-box-shadow-v: 0.7; + cn1-box-shadow-color: var(--shadow); + font-family: "native:MainBold"; + font-size: 2.4mm; + padding: 1.4mm 3mm 1.4mm 2.6mm; + margin: 1.2mm 2mm 1mm 2mm; +} + +WlSheet { + background-color: var(--surface); + border-radius: var(--radius-sheet) var(--radius-sheet) 0 0; + padding: 0.4mm 3.6mm 3.2mm 3.6mm; + margin: 0; +} + +/* The handle of the sheet: the colour is the bar's. */ +WlGrip { + color: var(--line-strong); + background-color: transparent; + padding: 1.6mm 0 1.8mm 0; + margin: 0; +} + +/* The countdown: the colour is the time left, the background the time gone. */ +WlRingTrack { + color: var(--line); + background-color: transparent; +} + +WlRing { + color: var(--brand); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.3mm; + padding: 0.8mm; + margin: 0 0 0 2.4mm; +} + +WlPulse { + color: var(--brand); + background-color: transparent; + padding: 0.8mm; + margin: 0.8mm 0 0.8mm 0; +} + +/* Home and Work, side by side on the idle sheet. */ +WlShortcut { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.75mm; + text-align: left; + padding: 2.6mm 3mm 2.6mm 3mm; + margin: 2mm 0.8mm 0.8mm 0.8mm; +} + +WlShortcut.pressed { + background-color: var(--surface-press); +} + +WlDriverCard { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.8mm 3mm 2.8mm 3mm; + margin: 2mm 0 1mm 0; +} + +WlAvatar { + color: var(--brand-contrast); + background: cn1-round-border; + background-color: var(--brand); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.3mm; + margin: 0 3mm 0 0; +} + +/* A number plate reads as one, so it is boxed like one. */ +WlPlate { + color: var(--text); + background-color: var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--radius-small); + font-family: "native:MainBold"; + font-size: 2.5mm; + text-align: center; + padding: 0.9mm 2mm 0.9mm 2mm; + margin: 1mm 0 0 0; +} + +WlActions { + background-color: transparent; + padding: 0; + margin: 0.8mm 0 0 0; +} + +/* Call, message, share: an icon with a word under it. */ +WlAction { + color: var(--text); + background-color: transparent; + border-radius: var(--radius); + font-family: "native:MainRegular"; + font-size: 2.2mm; + text-align: center; + padding: 1.8mm 0 1.8mm 0; + margin: 0; +} + +WlAction.pressed { + color: var(--brand); + background-color: var(--surface-alt); +} + +WlActionDanger { + cn1-derive: WlAction; + color: var(--danger); +} + +WlActionDanger.pressed { + color: var(--danger); + background-color: var(--danger-tint); +} + +/* The one large round control an off-line driver has. */ +WlGo { + color: var(--brand-contrast); + background: cn1-round-border; + background-color: var(--brand); + cn1-box-shadow-spread: 0.6mm; + cn1-box-shadow-blur: 2mm; + cn1-box-shadow-h: 0.5; + cn1-box-shadow-v: 0.7; + cn1-box-shadow-color: var(--shadow); + font-family: "native:MainBold"; + font-size: 4.2mm; + text-align: center; + padding: 5.6mm; + margin: 1.6mm; +} + +WlGo.pressed { + background-color: var(--brand-pressed); +} + +/* ------------------------------------------------------ records and charts */ + +/* A list that fills a screen: rows from edge to edge, each with the page's + margin inside it. */ +WlList { + background-color: var(--surface); + padding: 0 3.6mm 3.2mm 3.6mm; + margin: 0; +} +/* One line of a receipt: what, and how much. */ +WlFact { + background-color: transparent; + padding: 1.1mm 0 1.1mm 0; + margin: 0; +} +WlFactLabel { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: left; + padding: 0; + margin: 0; +} +WlFactValue { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: right; + padding: 0 0 0 3mm; + margin: 0; +} +/* The line the others add up to. */ +WlFactTotal { + cn1-derive: WlFactValue; + font-family: "native:MainBold"; + font-size: 3.3mm; +} +/* The one thing a screen is about, said large at its top. */ +WlBanner { + background-color: transparent; + padding: 5mm 3mm 3mm 3mm; + margin: 0; +} +WlBannerIcon { + color: var(--brand); + background: cn1-round-border; + background-color: var(--brand-tint); + padding: 3.4mm; + margin: 0 0 2.4mm 0; +} +WlBannerIconGood { + cn1-derive: WlBannerIcon; + color: var(--success); + background-color: var(--success-tint); +} +WlBannerIconWarn { + cn1-derive: WlBannerIcon; + color: var(--warning); + background-color: var(--warning-tint); +} +WlBannerIconBad { + cn1-derive: WlBannerIcon; + color: var(--danger); + background-color: var(--danger-tint); +} +WlBannerTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.5mm; + text-align: center; + padding: 0.4mm 0 0.4mm 0; + margin: 0; +} +WlBannerText { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: center; + padding: 0.4mm 2mm 0.4mm 2mm; + margin: 0; +} +/* The amount a receipt is for. */ +WlAmount { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 7mm; + text-align: center; + padding: 1mm 0 0.4mm 0; + margin: 0; +} +/* A group of rows under one heading. */ +WlGroup { + background-color: transparent; + padding: 0; + margin: 0 0 0.8mm 0; +} +/* A chart or a breakdown, boxed with its title. */ +WlTile { + background-color: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 3mm 3mm 3mm 3mm; + margin: 0.8mm 0.8mm 1.6mm 0.8mm; +} +/* A bar chart: the colour is the bars'. */ +WlChart { + color: var(--brand); + background-color: transparent; + padding: 1.6mm 0 0 0; + margin: 0; +} +WlChartPeak { + color: var(--brand-pressed); + background-color: transparent; +} +WlChartGrid { + color: var(--line); + background-color: transparent; +} +WlChartAxis { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2mm; + padding: 0.8mm 0 0 0; + margin: 0; +} +/* The number a chart adds up to, over the chart. */ +WlChartValue { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 4.2mm; + text-align: left; + padding: 0; + margin: 0; +} +/* A share along a track: the colour is the share, the background the track. */ +WlMeter { + color: var(--brand); + background-color: transparent; + padding: 0.8mm 0 1.6mm 0; + margin: 0; +} +/* Something waiting for the admin, at the top of the dashboard. */ +WlAlert { + color: var(--warning); + background-color: var(--warning-tint); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: left; + padding: 2.6mm 3mm 2.6mm 3mm; + margin: 0.8mm; +} +WlAlert.pressed { + background-color: var(--surface-press); +} + +/* ----------------------------------------------------------------- choosing */ + +/* One of several things to pick from, of which one is picked: a kind of car, + a way to pay. */ +WlOption { + background-color: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 0.8mm 0 0.8mm 0; +} +WlOption.pressed { + background-color: var(--surface-alt); +} +WlOptionPicked { + cn1-derive: WlOption; + background-color: var(--brand-tint); + border: 2px solid var(--brand); +} +WlOptionIcon { + color: var(--text); + background-color: transparent; + padding: 0 2.4mm 0 0; + margin: 0; +} +/* A payment card, drawn as one. */ +WlCardFace { + background-color: var(--brand); + border-radius: var(--radius); + padding: 4mm 4mm 3.4mm 4mm; + margin: 1mm 0 2.4mm 0; +} +WlCardBrand { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: right; + padding: 0; + margin: 0; +} +WlCardNumber { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.7mm; + text-align: left; + padding: 4.8mm 0 2.4mm 0; + margin: 0; +} +WlCardSmall { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.3mm; + padding: 0; + margin: 0; +} +/* Two fields side by side: the space between them is theirs. */ +WlPairLeft { + background-color: transparent; + padding: 0 1.2mm 0 0; + margin: 0; +} +WlPairRight { + background-color: transparent; + padding: 0 0 0 1.2mm; + margin: 0; +} +/* A picture of a document, small, beside what it is. */ +WlThumb { + color: var(--text-muted); + background-color: var(--surface-alt); + border-radius: var(--radius-small); + padding: 2mm; + margin: 0 3mm 0 0; +} +/* The same picture, filling the screen. */ +WlPicture { + background-color: var(--scrim); + padding: 0; + margin: 0; +} +/* How far through the steps of a form: "Step 2 of 5". */ +WlStepLabel { + color: var(--brand); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.2mm; + text-align: left; + padding: 0 0 0.4mm 0; + margin: 0; +} +/* What is fixed to the foot of a form of several steps. */ +WlFooter { + background-color: var(--surface); + border-top: 1px solid var(--line); + padding: 1mm 3.6mm 1.6mm 3.6mm; + margin: 0; +} + +/* ---------------------------------------------------------------- the menu */ + +/* Dimmed by the colour's own alpha. `opacity` would not do it: a style's + opacity fades what a component draws over its background, and the + background itself is filled at the background's transparency -- so an opaque + black with an opacity is still a black screen. */ +WlScrim { + background-color: rgba(0, 0, 0, 0.45); + padding: 0; + margin: 0; +} + +WlMenu { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +WlMenuTop { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 5mm 4mm 3.6mm 4mm; + margin: 0; +} + +WlMenuName { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.5mm; + text-align: left; + padding: 0; + margin: 0; +} + +WlMenuDetail { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.4mm; + text-align: left; + padding: 0.2mm 0 0 0; + margin: 0; +} + +/* Which of the three apps this is. */ +WlModeBadge { + color: var(--brand); + background: cn1-pill-border; + background-color: var(--brand-tint); + font-family: "native:MainBold"; + font-size: 2.1mm; + text-align: center; + padding: 0.8mm 2.4mm 0.8mm 2.4mm; + margin: 2.4mm 0 0 0; +} + +WlMenuLabel { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.1mm; + text-align: left; + padding: 3.6mm 4mm 1mm 4mm; + margin: 0; +} + +WlMenuItem { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.9mm; + text-align: left; + padding: 2.6mm 4mm 2.6mm 4mm; + margin: 0; +} + +WlMenuItem.pressed { + background-color: var(--surface-alt); +} + +WlMenuBottom { + background-color: transparent; + border-top: 1px solid var(--line); + padding: 0.8mm 0 1.6mm 0; + margin: 0; +} + +/* The top of a home screen that has no map: the menu, and what this is. */ +WlHomeBar { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 0.6mm 3.6mm 0.6mm 0; + margin: 0; +} + +/* ---------------------------------------------------------------- settings */ + +WlSection { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.1mm; + text-align: left; + padding: 5mm 0 0.8mm 0; + margin: 0; +} + +/* A few options side by side, one of them chosen. */ +WlSegments { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 0.5mm; + margin: 0.8mm 0 1.6mm 0; +} + +WlSegment { + color: var(--text-muted); + background-color: transparent; + border-radius: 1.9mm; + font-family: "native:MainBold"; + font-size: 2.5mm; + text-align: center; + padding: 2mm 0.8mm 2mm 0.8mm; + margin: 0.3mm; +} + +WlSegment.selected { + color: var(--text); + background-color: var(--surface); +} + +/* A toggle that is on wears the pressed style, so the two say the same. */ +WlSegment.pressed { + color: var(--text); + background-color: var(--surface); +} + + +WlSegment.disabled { + color: var(--line-strong); +} + +/* The outline of rows that are on their way: the colour is the bars'. */ +WlSkeleton { + color: var(--surface-alt); + background-color: transparent; + padding: 1mm 0 0 0; + margin: 0; +} + +/* "Pull to refresh", over a list being pulled. */ +PullToRefresh { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.3mm; + text-align: center; + padding: 1.6mm; + margin: 0; +} + +/* What the app says in passing, over the foot of the screen. */ +ToastBar { + background-color: var(--text); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 2.4mm 3.6mm 3.2mm 3.6mm; +} + +ToastBarMessage { + color: var(--surface); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0; + margin: 0; +} + +/* ------------------------------------------------- the admin console, wide */ + +/* On a tablet on its side, a desktop or a browser the console is three + columns: where to go, a list, and what was picked from it. */ +WlSide { + background-color: var(--surface-alt); + border-right: 1px solid var(--line); + padding: 2.4mm 1.6mm 2.4mm 1.6mm; + margin: 0; +} + +WlSideName { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.05mm; + padding: 0.8mm 2.4mm 0 2.4mm; + margin: 0; +} + +WlSideDetail { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.3mm; + padding: 0 2.4mm 2.4mm 2.4mm; + margin: 0; +} + +WlSideLabel { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.15mm; + padding: 3.2mm 2.4mm 0.8mm 2.4mm; + margin: 0; +} + +WlSideItem { + color: var(--text); + background-color: transparent; + border-radius: var(--radius-small); + font-family: "native:MainRegular"; + font-size: 2.8mm; + text-align: left; + padding: 1.9mm 2.4mm 1.9mm 2.4mm; + margin: 0.2mm 0 0.2mm 0; +} + +WlSideItem.pressed { + background-color: var(--line); +} + +WlSidePicked { + cn1-derive: WlSideItem; + color: var(--brand-contrast); + background-color: var(--brand); + font-family: "native:MainBold"; +} + +WlMaster { + background-color: var(--surface); + border-right: 1px solid var(--line); + padding: 0; + margin: 0; +} + +WlPaneBar { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 1.2mm 4mm 1.2mm 4mm; + margin: 0; +} + +WlPaneTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.5mm; + padding: 1.6mm 0 1.6mm 0; + margin: 0; +} + +/* The row of a list whose detail is open beside it. */ +WlRowPicked { + cn1-derive: WlRow; + background-color: var(--surface-alt); +} + +/* + * THE DESKTOP'S OWN LOOK. On macOS, Windows and Linux, and in a desktop + * browser, the platform's theme is under this one (the nativeTheme=native build + * hint), and the admin console wears it: Layouts.dress() gives each component + * the WlDesk twin of its style. A twin derives from the platform's control + * where there is one -- a button, a field, a segmented control, a group box -- + * and takes its colours from the palette every desktop theme declares, so the + * same rules are Aqua on a Mac, Fluent on Windows and Adwaita on Linux. Nothing + * here names the brand; the accent is the platform's. + * + * The values in WlDeskPalette are only what the compiler needs to resolve + * var(): each use is bound to the name, and the installed theme's own value + * replaces it when the app runs. They are declared outside #Constants so that + * they are not exported over the theme's. + */ +WlDeskPalette { + --window-bg-color: #ececec; + --window-bg-color-dark: #1e1e1e; + --view-bg-color: #ffffff; + --view-bg-color-dark: #1e1e1e; + --control-bg-color: #ffffff; + --control-bg-color-dark: #2c2c2c; + --headerbar-bg-color: #e8e8e8; + --headerbar-bg-color-dark: #2a2a2a; + --text-color: #1a1a1a; + --text-color-dark: #ffffff; + --text-secondary-color: #5d5d5d; + --text-secondary-color-dark: #c0c0c0; + --separator-color: #d8d8d8; + --separator-color-dark: #3a3a3a; + --accent-color: #007aff; + --accent-color-dark: #0a84ff; + --accent-fg-color: #ffffff; + --accent-fg-color-dark: #ffffff; + background-color: transparent; + padding: 0; + margin: 0; +} + +WlDeskForm { background-color: var(--window-bg-color); padding: 0; margin: 0; } +WlDeskPage { background-color: var(--window-bg-color); padding: 3mm 5mm 4mm 5mm; margin: 0; } +WlDeskList { background-color: var(--view-bg-color); padding: 0 3mm 2mm 3mm; margin: 0; } +WlDeskMaster { background-color: var(--view-bg-color); border-right: 1px solid #c6c6c8; padding: 0; margin: 0; } +WlDeskHomeBar { background-color: var(--headerbar-bg-color); border-bottom: 1px solid #c6c6c8; padding: 1mm 4mm 1mm 0; margin: 0; } +WlDeskPaneBar { background-color: var(--window-bg-color); border-bottom: 1px solid #c6c6c8; padding: 1mm 5mm 1mm 5mm; margin: 0; } +WlDeskPaneTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; padding: 1.6mm 0 1.6mm 0; margin: 0; } +WlDeskBarButton { cn1-derive: Button; padding: 1.2mm; margin: 0 2mm 0 0; } + +WlDeskSide { background-color: var(--headerbar-bg-color); border-right: 1px solid #c6c6c8; padding: 2.5mm 2mm 2.5mm 2mm; margin: 0; } +WlDeskSideName { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.7mm; padding: 1mm 2.6mm 0 2.6mm; margin: 0; } +WlDeskSideDetail { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.25mm; padding: 0 2.6mm 2mm 2.6mm; margin: 0; } +WlDeskSideLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.15mm; padding: 3mm 2.6mm 1mm 2.6mm; margin: 0; } +WlDeskSideItem { color: var(--text-color); background-color: transparent; border-radius: 1.4mm; font-family: "native:MainRegular"; font-size: 2.65mm; text-align: left; padding: 1.6mm 2.6mm 1.6mm 2.6mm; margin: 0.2mm 0 0.2mm 0; } +WlDeskSideItem.pressed { background-color: var(--separator-color); } +WlDeskSidePicked { cn1-derive: WlDeskSideItem; color: var(--accent-fg-color); background-color: var(--accent-color); } + +WlDeskPrimary { cn1-derive: RaisedButton; text-align: center; margin: 2mm 0 1mm 0; } +WlDeskSecondary { cn1-derive: Button; text-align: center; margin: 1.5mm 0 1mm 0; } +WlDeskDanger { cn1-derive: Button; color: var(--danger); text-align: center; margin: 1.5mm 0 1mm 0; } +WlDeskLink { cn1-derive: Link; padding: 1.6mm 0 1.6mm 0; } +WlDeskField { cn1-derive: TextField; margin: 0; } +WlDeskFieldHint { cn1-derive: TextHint; } +WlDeskSegments { cn1-derive: TabsContainer; border-radius: 1.6mm; padding: 0.4mm; margin: 1mm 0 2mm 0; } +/* Four of these share the master pane of the console, which is narrow: the + platform's own tab padding leaves "Cancelled" no room and it was drawn cut + off at both ends. */ +WlDeskSegment { cn1-derive: Tab; text-align: center; padding: 1.4mm 0.4mm 1.4mm 0.4mm; margin: 0; } +/* Something waiting is the one thing on the console that is not the + platform's grey: it wears the warning tint the phone's does. */ +WlDeskAlert { color: var(--warning); background-color: var(--warning-tint); border-radius: 1.6mm; font-family: "native:MainBold"; font-size: 2.6mm; text-align: left; padding: 2mm 3mm 2mm 3mm; margin: 1mm 0 1mm 0; } +WlDeskAlert.pressed { color: var(--warning); background-color: var(--warning-tint); border-radius: 1.6mm; font-family: "native:MainBold"; font-size: 2.6mm; text-align: left; padding: 2mm 3mm 2mm 3mm; margin: 1mm 0 1mm 0; } + +WlDeskRow { background-color: transparent; border-bottom: 1px solid #d8d8d8; padding: 2mm 1mm 2mm 1mm; margin: 0; } +WlDeskRow.pressed { background-color: var(--separator-color); } +WlDeskRowPicked { background-color: var(--separator-color); border-bottom: 1px solid #d8d8d8; border-radius: 1.4mm; padding: 2mm 1mm 2mm 1mm; margin: 0; } +WlDeskRowIcon { color: var(--text-secondary-color); background-color: transparent; padding: 0 2.6mm 0 0; margin: 0; } +WlDeskRowTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.7mm; padding: 0; margin: 0; } +WlDeskRowDetail { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; padding: 0.4mm 0 0 0; margin: 0; } +WlDeskRowValue { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.65mm; text-align: right; padding: 0; margin: 0; } + +WlDeskSection { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.8mm; text-align: left; padding: 4mm 0 1.2mm 0; margin: 0; } +WlDeskTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 4.4mm; padding: 0 0 2mm 0; margin: 0; } +WlDeskHeading { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; padding: 0; margin: 0; } +WlDeskText { color: var(--text-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.65mm; padding: 1mm 0 1mm 0; margin: 0; } +WlDeskMuted { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.4mm; padding: 0.5mm 0 0.5mm 0; margin: 0; } +WlDeskLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.4mm; padding: 2.4mm 0 0.8mm 0; margin: 0; } +WlDeskError { color: var(--danger); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.5mm; padding: 1mm 0 1mm 0; margin: 0; } +WlDeskNotice { cn1-derive: GroupBox; color: var(--text-color); font-family: "native:MainRegular"; font-size: 2.55mm; padding: 2.4mm; margin: 2mm 0 1mm 0; } +WlDeskPanel { cn1-derive: GroupBox; padding: 2.6mm 3mm 2.6mm 3mm; margin: 1mm 0 1mm 0; } +WlDeskFact { background-color: transparent; padding: 1.2mm 0 1.2mm 0; margin: 0; } +WlDeskFactLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.55mm; padding: 0; margin: 0; } +WlDeskFactValue { color: var(--text-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.55mm; text-align: right; padding: 0; margin: 0; } +WlDeskFactTotal { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.7mm; text-align: right; padding: 0; margin: 0; } +WlDeskAmount { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 6.4mm; text-align: center; padding: 2mm 0 1mm 0; margin: 0; } +WlDeskBannerIcon { color: var(--text-secondary-color); background-color: transparent; padding: 6mm 0 2mm 0; margin: 0; } +WlDeskBannerTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 3.05mm; text-align: center; padding: 0; margin: 0; } +WlDeskBannerText { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.5mm; text-align: center; padding: 1mm 0 0 0; margin: 0; } + +/* The numbers and the charts have no control on any desktop: they keep their + shape, in a group box, in the platform's colours. */ +WlDeskTile { cn1-derive: GroupBox; padding: 3mm 3.4mm 3mm 3.4mm; margin: 1.2mm; } +WlDeskStat { cn1-derive: GroupBox; padding: 2.6mm 3mm 2.6mm 3mm; margin: 1.2mm; } +WlDeskStatValue { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 4.3mm; padding: 0; margin: 0; } +WlDeskStatLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; padding: 0.6mm 0 0 0; margin: 0; } +WlDeskChartValue { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 4.3mm; padding: 0.6mm 0 0 0; margin: 0; } +WlDeskChartAxis { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.1mm; padding: 0.6mm 0 0 0; margin: 0; } + + +/* + * DARK MODE. The same rules, with the second value of each colour. A rule added + * above that names a colour needs its line here. + */ +@media (prefers-color-scheme: dark) { + WlForm { background-color: var(--surface-dark); } + WlPage { background-color: var(--surface-dark); } + WlHero { background-color: var(--brand-dark); } + WlHeroTitle { color: var(--brand-contrast-dark); } + WlHeroText { color: var(--brand-contrast-dark); } + WlHeroIcon { color: var(--brand-contrast-dark); } + WlCard { background-color: var(--surface-dark); } + WlFloat { background-color: var(--surface-dark); } + WlPanel { background-color: var(--surface-alt-dark); } + WlDivider { background-color: var(--line-dark); } + WlTitle { color: var(--text-dark); } + WlHeading { color: var(--text-dark); } + WlText { color: var(--text-dark); } + WlMuted { color: var(--text-muted-dark); } + WlLabel { color: var(--text-muted-dark); } + WlPrice { color: var(--text-dark); } + WlFare { color: var(--text-dark); } + WlError { color: var(--danger-dark); } + WlNotice { color: var(--text-dark); background-color: var(--surface-alt-dark); } + WlPrimary { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + WlPrimary.pressed { background-color: var(--brand-pressed-dark); } + WlPrimary.disabled { color: var(--text-muted-dark); background-color: var(--surface-press-dark); } + WlSecondary { color: var(--text-dark); background-color: var(--surface-alt-dark); } + WlSecondary.pressed { background-color: var(--surface-press-dark); } + WlDanger { color: var(--danger-dark); background-color: var(--danger-tint-dark); } + WlDanger.pressed { background-color: var(--surface-press-dark); } + WlLink { color: var(--brand-dark); } + WlLink.pressed { color: var(--brand-pressed-dark); background-color: var(--brand-tint-dark); } + WlMapButton { background: cn1-round-border; color: var(--text-dark); background-color: var(--surface-dark); } + WlMapButton.pressed { background: cn1-round-border; background-color: var(--surface-alt-dark); } + WlBarButton { background: cn1-round-border; color: var(--text-dark); background-color: var(--surface-dark); } + WlBarButton.pressed { background: cn1-round-border; background-color: var(--surface-alt-dark); } + WlSearchBar { color: var(--text-dark); background-color: var(--surface-alt-dark); } + WlSearchBar.pressed { background-color: var(--surface-press-dark); } + WlField { color: var(--text-dark); background-color: var(--surface-alt-dark); } + WlFieldHint { color: var(--text-muted-dark); } + WlRow { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + WlRow.pressed { background-color: var(--surface-alt-dark); } + WlRowIcon { color: var(--text-muted-dark); } + WlRowTitle { color: var(--text-dark); } + WlRowDetail { color: var(--text-muted-dark); } + WlRowValue { color: var(--text-dark); } + WlBadge { background: cn1-pill-border; color: var(--text-muted-dark); background-color: var(--surface-alt-dark); } + WlBadgeGood { background: cn1-pill-border; color: var(--success-dark); background-color: var(--success-tint-dark); } + WlBadgeWarn { background: cn1-pill-border; color: var(--warning-dark); background-color: var(--warning-tint-dark); } + WlBadgeBad { background: cn1-pill-border; color: var(--danger-dark); background-color: var(--danger-tint-dark); } + WlStat { background-color: var(--surface-alt-dark); } + WlStatValue { color: var(--text-dark); } + WlStatLabel { color: var(--text-muted-dark); } + StatusBar { background-color: var(--surface-dark); } + Toolbar { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + Title { color: var(--text-dark); } + TitleCommand { color: var(--text-dark); } + BackCommand { color: var(--text-dark); } + WlMarkerPickup { color: var(--success-dark); background-color: var(--map-edge-dark); } + WlMarkerDropoff { color: var(--danger-dark); background-color: var(--map-edge-dark); } + WlMarkerCar { color: var(--car-dark); background-color: var(--map-edge-dark); } + WlRoute { color: var(--brand-dark); background-color: var(--map-edge-dark); } + WlSearchCard { color: var(--text-dark); background-color: var(--surface-alt-dark); } + WlSearchCard.pressed { background-color: var(--line-dark); } + WlTripCard { color: var(--text-dark); background-color: var(--surface-alt-dark); } + WlTripStop { color: var(--text-dark); } + WlStatusCard { color: var(--text-dark); background-color: var(--surface-alt-dark); } + WlStatusValue { color: var(--text-dark); } + WlStatusDetail { color: var(--text-muted-dark); } + WlMapTitle { color: var(--text-dark); background-color: var(--surface-alt-dark); } + WlChip { background: cn1-pill-border; color: var(--text-dark); background-color: var(--surface-alt-dark); } + WlSheet { background-color: var(--surface-dark); } + WlGrip { color: var(--line-strong-dark); } + WlRing { color: var(--brand-dark); } + WlRingTrack { color: var(--line-dark); } + WlPulse { color: var(--brand-dark); } + WlShortcut { color: var(--text-dark); background-color: var(--surface-alt-dark); } + WlShortcut.pressed { background-color: var(--surface-press-dark); } + WlDriverCard { background-color: var(--surface-alt-dark); } + WlAvatar { background: cn1-round-border; color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + WlPlate { color: var(--text-dark); background-color: var(--surface-dark); border: 1px solid var(--line-strong-dark); } + WlAction { color: var(--text-dark); } + WlAction.pressed { color: var(--brand-dark); background-color: var(--surface-alt-dark); } + WlActionDanger { color: var(--danger-dark); } + WlActionDanger.pressed { color: var(--danger-dark); background-color: var(--danger-tint-dark); } + WlGo { background: cn1-round-border; color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + WlGo.pressed { background: cn1-round-border; background-color: var(--brand-pressed-dark); } + WlMenu { background-color: var(--surface-dark); } + WlMenuTop { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + WlMenuName { color: var(--text-dark); } + WlMenuDetail { color: var(--text-muted-dark); } + WlModeBadge { background: cn1-pill-border; color: var(--brand-dark); background-color: var(--brand-tint-dark); } + WlMenuLabel { color: var(--text-muted-dark); } + WlMenuItem { color: var(--text-dark); } + WlMenuItem.pressed { background-color: var(--surface-alt-dark); } + WlMenuBottom { border-top: 1px solid var(--line-dark); } + WlHomeBar { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + WlSection { color: var(--text-dark); } + WlSegments { background-color: var(--surface-alt-dark); } + WlSegment { color: var(--text-muted-dark); } + WlSegment.selected { color: var(--text-dark); background-color: var(--surface-press-dark); } + WlSegment.pressed { color: var(--text-dark); background-color: var(--surface-press-dark); } + WlList { background-color: var(--surface-dark); } + WlFactLabel { color: var(--text-muted-dark); } + WlFactValue { color: var(--text-dark); } + WlFactTotal { color: var(--text-dark); } + WlBannerIcon { background: cn1-round-border; color: var(--brand-dark); background-color: var(--brand-tint-dark); } + WlBannerIconGood { background: cn1-round-border; color: var(--success-dark); background-color: var(--success-tint-dark); } + WlBannerIconWarn { background: cn1-round-border; color: var(--warning-dark); background-color: var(--warning-tint-dark); } + WlBannerIconBad { background: cn1-round-border; color: var(--danger-dark); background-color: var(--danger-tint-dark); } + WlBannerTitle { color: var(--text-dark); } + WlBannerText { color: var(--text-muted-dark); } + WlAmount { color: var(--text-dark); } + WlTile { background-color: var(--surface-dark); border: 1px solid var(--line-dark); } + WlChart { color: var(--brand-dark); } + WlChartPeak { color: var(--brand-pressed-dark); } + WlChartGrid { color: var(--line-dark); } + WlChartAxis { color: var(--text-muted-dark); } + WlChartValue { color: var(--text-dark); } + WlMeter { color: var(--brand-dark); } + WlAlert { color: var(--warning-dark); background-color: var(--warning-tint-dark); } + WlAlert.pressed { background-color: var(--surface-press-dark); } + WlOption { background-color: var(--surface-dark); border: 1px solid var(--line-dark); } + WlOption.pressed { background-color: var(--surface-alt-dark); } + WlOptionPicked { background-color: var(--brand-tint-dark); border: 2px solid var(--brand-dark); } + WlOptionIcon { color: var(--text-dark); } + WlCardFace { background-color: var(--brand-dark); } + WlCardBrand { color: var(--brand-contrast-dark); } + WlCardNumber { color: var(--brand-contrast-dark); } + WlCardSmall { color: var(--brand-contrast-dark); } + WlThumb { color: var(--text-muted-dark); background-color: var(--surface-alt-dark); } + WlStepLabel { color: var(--brand-dark); } + WlFooter { background-color: var(--surface-dark); border-top: 1px solid var(--line-dark); } + WlStar { color: var(--warning-dark); } + WlSide { background-color: var(--surface-alt-dark); border-right: 1px solid var(--line-dark); } + WlSideName { color: var(--text-dark); } + WlSideDetail { color: var(--text-muted-dark); } + WlSideLabel { color: var(--text-muted-dark); } + WlSideItem { color: var(--text-dark); } + WlSideItem.pressed { background-color: var(--line-dark); } + WlSidePicked { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + WlMaster { background-color: var(--surface-dark); border-right: 1px solid var(--line-dark); } + WlPaneBar { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + WlPaneTitle { color: var(--text-dark); } + WlRowPicked { background-color: var(--surface-alt-dark); } + WlSecondary.disabled, WlDanger.disabled, WlLink.disabled { color: var(--line-strong-dark); } + WlField.selected { background-color: var(--brand-tint-dark); } + WlField.disabled { color: var(--text-muted-dark); } + WlStar.pressed { color: var(--text-muted-dark); } + Switch { color: #ffffff; background-color: var(--line-strong-dark); } + Switch.selected { color: #ffffff; background-color: var(--brand-dark); } + Switch.disabled { color: var(--text-muted-dark); background-color: var(--line-dark); } + WlSegment.disabled { color: var(--line-strong-dark); } + WlSkeleton { color: var(--surface-alt-dark); } + PullToRefresh { color: var(--text-muted-dark); } + ToastBar { background-color: var(--text-dark); } + ToastBarMessage { color: var(--surface-dark); } + WlDeskForm { background-color: var(--window-bg-color-dark); } + WlDeskPage { background-color: var(--window-bg-color-dark); } + WlDeskList { background-color: var(--view-bg-color-dark); } + WlDeskMaster { background-color: var(--view-bg-color-dark); border-right: 1px solid #3a3a3a; } + WlDeskHomeBar { background-color: var(--headerbar-bg-color-dark); border-bottom: 1px solid #3a3a3a; } + WlDeskPaneBar { background-color: var(--window-bg-color-dark); border-bottom: 1px solid #3a3a3a; } + WlDeskPaneTitle { color: var(--text-color-dark); } + WlDeskSide { background-color: var(--headerbar-bg-color-dark); border-right: 1px solid #3a3a3a; } + WlDeskSideName { color: var(--text-color-dark); } + WlDeskSideDetail { color: var(--text-secondary-color-dark); } + WlDeskSideLabel { color: var(--text-secondary-color-dark); } + WlDeskSideItem { color: var(--text-color-dark); } + WlDeskSideItem.pressed { background-color: var(--separator-color-dark); } + WlDeskSidePicked { color: var(--accent-fg-color-dark); background-color: var(--accent-color-dark); } + WlDeskDanger { color: var(--danger-dark); } + WlDeskRow { border-bottom: 1px solid #3a3a3a; } + WlDeskRow.pressed { background-color: var(--separator-color-dark); } + WlDeskRowPicked { background-color: var(--separator-color-dark); border-bottom: 1px solid #3a3a3a; } + WlDeskAlert { color: var(--warning-dark); background-color: var(--warning-tint-dark); } + WlDeskAlert.pressed { color: var(--warning-dark); background-color: var(--warning-tint-dark); } + WlDeskRowIcon { color: var(--text-secondary-color-dark); } + WlDeskRowTitle { color: var(--text-color-dark); } + WlDeskRowDetail { color: var(--text-secondary-color-dark); } + WlDeskRowValue { color: var(--text-color-dark); } + WlDeskSection { color: var(--text-color-dark); } + WlDeskTitle { color: var(--text-color-dark); } + WlDeskHeading { color: var(--text-color-dark); } + WlDeskText { color: var(--text-color-dark); } + WlDeskMuted { color: var(--text-secondary-color-dark); } + WlDeskLabel { color: var(--text-secondary-color-dark); } + WlDeskError { color: var(--danger-dark); } + WlDeskNotice { color: var(--text-color-dark); } + WlDeskFactLabel { color: var(--text-secondary-color-dark); } + WlDeskFactValue { color: var(--text-color-dark); } + WlDeskFactTotal { color: var(--text-color-dark); } + WlDeskAmount { color: var(--text-color-dark); } + WlDeskBannerIcon { color: var(--text-secondary-color-dark); } + WlDeskBannerTitle { color: var(--text-color-dark); } + WlDeskBannerText { color: var(--text-secondary-color-dark); } + WlDeskStatValue { color: var(--text-color-dark); } + WlDeskStatLabel { color: var(--text-secondary-color-dark); } + WlDeskChartValue { color: var(--text-color-dark); } + WlDeskChartAxis { color: var(--text-secondary-color-dark); } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/AppConfig.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/AppConfig.java new file mode 100644 index 00000000000..c89502e7b64 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/AppConfig.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.io.Preferences; +import com.codename1.ui.CN; + +/// The few things about the app that are settings and not code. +public final class AppConfig { + /// Where the server is when nothing says otherwise: a server started on + /// this machine with `cn1:backend`. Change this to your deployment's address + /// before you ship; until then the settings screen can point a build at + /// another server, which is how a phone reaches a laptop on the same network. + public static final String DEFAULT_SERVER = "http://localhost:8080"; + /// The id the server knows this app by. The same constant is in the + /// server's `SecurityConfig`. + public static final String CLIENT_ID = "wayline-app"; + /// The address the server hands the authorization code back to. The app + /// reads the code out of the redirect itself and never opens this address, + /// so it only has to be one the server has on its list. + public static final String REDIRECT_URI = "http://127.0.0.1/callback"; + /// The path the server hands the code back on when the app runs in a + /// browser. The same constant is in the server's `SecurityConfig`. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + // tag::wayline-push-key[] + /// The app's push key, from the Push section of the Codename One console. + /// Empty, the app registers for no notifications: everything still works, + /// and a phone in a pocket simply hears nothing until it is opened. + public static final String PUSH_APP_KEY = ""; + // end::wayline-push-key[] + /// Where the map opens when the device will not say where it is. + public static final double DEFAULT_LAT = 37.8080; + public static final double DEFAULT_LNG = -122.4120; + + private static final String SERVER_KEY = "wayline.server"; + private static final String MODE_KEY = "wayline.mode"; + private static final String MODE_OWNER_KEY = "wayline.mode.owner"; + + private AppConfig() { + } + + /// The server's address, with no trailing slash. A `wayline.server.url` + /// system property wins, which is what the tests use; then the address saved + /// from the settings screen; then, in a browser, the address the page came + /// from, since the server that hosts the app is the server it talks to; + /// then [#DEFAULT_SERVER]. + public static String serverUrl() { + String url = System.getProperty("wayline.server.url"); + if (url == null || url.length() == 0) { + String origin = pageOrigin(); + // In a browser it is always the page's own server: see + // serverSwitchable(). An address saved before that is ignored. + url = origin != null ? origin : Preferences.get(SERVER_KEY, DEFAULT_SERVER); + } + return clean(url); + } + + /// Whether the app may be pointed at another server. Not in a browser: + /// there the authorization code is sent back to the page's own server + /// ([#redirectUri]), and another server has not registered that address + /// as one of its own, so every sign-in after a switch would be refused. + /// The hosted app is the server's own, and talks to that server. + public static boolean serverSwitchable() { + return pageOrigin() == null; + } + + /// The address of the page the app is running in -- `https://host`, with + /// no path -- or null when it is not running in a browser. + public static String pageOrigin() { + String origin = CN.getProperty("browser.window.location.origin", null); + if (origin == null || !(origin.regionMatches(true, 0, "http://", 0, 7) + || origin.regionMatches(true, 0, "https://", 0, 8))) { + return null; + } + return clean(origin); + } + + /// Where the server is asked to send the authorization code. + /// + /// The installed app names the loopback address and reads the code off the + /// redirect without following it. A browser follows a redirect itself and + /// will not show the page an answer from another origin, so there the code + /// is sent to the server the page came from, which hands it back. + public static String redirectUri() { + String origin = pageOrigin(); + return origin == null ? REDIRECT_URI : origin + WEB_REDIRECT_PATH; + } + + public static void setServerUrl(String url) { + Preferences.set(SERVER_KEY, clean(url)); + } + + /// The mode the account `owner` (see + /// [com.codenameone.examples.wayline.net.Session#owner(String)]) chose last + /// -- `rider`, `driver` or `admin` -- or + /// empty when they have chosen none, or the choice on record is someone + /// else's. A choice recorded with no name is anyone's: that is how a test + /// puts the app in a mode before it signs in. + public static String mode(String owner) { + String chosenBy = Preferences.get(MODE_OWNER_KEY, ""); + if (chosenBy.length() > 0 && !chosenBy.equals(owner)) { + return ""; + } + return Preferences.get(MODE_KEY, ""); + } + + /// Records `mode` as chosen by whoever signs in next. + public static void setMode(String mode) { + setMode(mode, ""); + } + + public static void setMode(String mode, String username) { + Preferences.set(MODE_KEY, mode == null ? "" : mode); + Preferences.set(MODE_OWNER_KEY, username == null ? "" : username); + } + + /// Whether `url` names the server `current` does, as [#setServerUrl] + /// would store it. + public static boolean sameServer(String url, String current) { + return clean(url).equals(current); + } + + private static String clean(String url) { + String text = url == null ? "" : url.trim(); + while (text.endsWith("/")) { + text = text.substring(0, text.length() - 1); + } + if (text.length() == 0) { + return DEFAULT_SERVER; + } + // Compared without folding the case: a scheme is ASCII, and + // toLowerCase() follows the device's language. + if (!text.regionMatches(true, 0, "http://", 0, 7) + && !text.regionMatches(true, 0, "https://", 0, 8)) { + text = "http://" + text; + } + return text; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/Prefs.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/Prefs.java new file mode 100644 index 00000000000..b0f21ad47fa --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/Prefs.java @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.io.Preferences; +import com.codenameone.examples.wayline.api.PlaceDto; +import java.util.ArrayList; +import java.util.List; + +/// What the user has chosen in Settings, and the places they go back to. +/// +/// Every screen reads and writes a preference through this class and through +/// nothing else, so where the values live is decided in one place. Today that +/// is the device: `Preferences`, which survives a restart and is gone with the +/// app. A server that keeps the same values for the account plugs in at +/// [#onChange]: it is told the name of each value as it changes, and puts the +/// account's values in with the setters when it has fetched them. No screen +/// has to change for that. +public final class Prefs { + /// Told when a value changes, with the value's name. + public interface Listener { + void changed(String name); + } + + public static final String LANGUAGE = "language"; + public static final String THEME = "theme"; + public static final String UNITS = "units"; + public static final String NOTIFY_RIDE_UPDATES = "notifyRideUpdates"; + public static final String NOTIFY_RECEIPTS = "notifyReceipts"; + public static final String NOTIFY_PROMOTIONS = "notifyPromotions"; + public static final String DRIVER_GENDER = "driverGender"; + public static final String QUIET_RIDE = "quietRide"; + public static final String ACCESSIBLE_VEHICLE = "accessibleVehicle"; + public static final String PET_FRIENDLY = "petFriendly"; + public static final String PAYMENT_METHOD = "defaultPaymentMethodId"; + public static final String TIP_PERCENT = "defaultTipPercent"; + public static final String SHARE_TRIPS = "shareTrips"; + public static final String EMERGENCY_NAME = "emergencyContactName"; + public static final String EMERGENCY_PHONE = "emergencyContactPhone"; + public static final String HOME = "home"; + public static final String WORK = "work"; + public static final String RECENTS = "recents"; + /// Whose the recent destinations are: see [#ownedBy]. + private static final String RECENTS_OWNER = "recentsOwner"; + + public static final String THEME_SYSTEM = "system"; + public static final String THEME_LIGHT = "light"; + public static final String THEME_DARK = "dark"; + public static final String UNITS_KM = "km"; + public static final String UNITS_MILES = "mi"; + public static final String GENDER_ANY = "any"; + public static final String GENDER_WOMEN = "women"; + /// The one way to pay that needs nothing set up. + public static final String PAYMENT_CASH = "cash"; + + private static final String PREFIX = "wayline.pref."; + /// The plain values that are a copy of the account's; see [#ownedBy]. + private static final String[] ACCOUNT_OWNED = {NOTIFY_RIDE_UPDATES, NOTIFY_RECEIPTS, + NOTIFY_PROMOTIONS, DRIVER_GENDER, QUIET_RIDE, ACCESSIBLE_VEHICLE, PET_FRIENDLY, + PAYMENT_METHOD, TIP_PERCENT, SHARE_TRIPS, EMERGENCY_NAME, EMERGENCY_PHONE}; + /// How many destinations are remembered. + private static final int RECENT_LIMIT = 4; + + private static Listener listener; + + private Prefs() { + } + + /// Names the one listener told of every change. A value put in by the + /// listener itself is reported to it like any other, so a listener that + /// stores what it is told must not answer by setting the value again. + public static void onChange(Listener told) { + listener = told; + } + + // -------------------------------------------------------------- appearance + + /// The language chosen, as its two-letter code; empty while the user has + /// chosen none and the device's language decides. + public static String language() { + return text(LANGUAGE, ""); + } + + public static void setLanguage(String code) { + put(LANGUAGE, code); + } + + /// [#THEME_SYSTEM], [#THEME_LIGHT] or [#THEME_DARK]. + public static String theme() { + return text(THEME, THEME_SYSTEM); + } + + public static void setTheme(String theme) { + put(THEME, theme); + } + + /// [#UNITS_KM] or [#UNITS_MILES]. + public static String units() { + return text(UNITS, UNITS_KM); + } + + public static void setUnits(String units) { + put(UNITS, units); + } + + // ----------------------------------------------------------- notifications + + public static boolean notifyRideUpdates() { + return flag(NOTIFY_RIDE_UPDATES, true); + } + + public static void setNotifyRideUpdates(boolean on) { + put(NOTIFY_RIDE_UPDATES, on); + } + + public static boolean notifyReceipts() { + return flag(NOTIFY_RECEIPTS, true); + } + + public static void setNotifyReceipts(boolean on) { + put(NOTIFY_RECEIPTS, on); + } + + public static boolean notifyPromotions() { + return flag(NOTIFY_PROMOTIONS, false); + } + + public static void setNotifyPromotions(boolean on) { + put(NOTIFY_PROMOTIONS, on); + } + + // -------------------------------------------------------------- the ride + + /// Who the rider wants at the wheel: [#GENDER_ANY] or [#GENDER_WOMEN]. + public static String driverGender() { + return text(DRIVER_GENDER, GENDER_ANY); + } + + public static void setDriverGender(String gender) { + put(DRIVER_GENDER, gender); + } + + public static boolean quietRide() { + return flag(QUIET_RIDE, false); + } + + public static void setQuietRide(boolean on) { + put(QUIET_RIDE, on); + } + + public static boolean accessibleVehicle() { + return flag(ACCESSIBLE_VEHICLE, false); + } + + public static void setAccessibleVehicle(boolean on) { + put(ACCESSIBLE_VEHICLE, on); + } + + public static boolean petFriendly() { + return flag(PET_FRIENDLY, false); + } + + public static void setPetFriendly(boolean on) { + put(PET_FRIENDLY, on); + } + + // --------------------------------------------------------------- payment + + /// The id of the way the user pays unless they say otherwise for a ride. + public static String paymentMethod() { + return text(PAYMENT_METHOD, PAYMENT_CASH); + } + + public static void setPaymentMethod(String id) { + put(PAYMENT_METHOD, id); + } + + /// The tip offered first on a receipt, as a percentage of the fare. + public static int tipPercent() { + return Preferences.get(PREFIX + TIP_PERCENT, 0); + } + + public static void setTipPercent(int percent) { + if (percent != tipPercent()) { + Preferences.set(PREFIX + TIP_PERCENT, percent); + changed(TIP_PERCENT); + } + } + + // ---------------------------------------------------- privacy and safety + + /// Whether a ride under way is shared with the emergency contact. + public static boolean shareTrips() { + return flag(SHARE_TRIPS, false); + } + + public static void setShareTrips(boolean on) { + put(SHARE_TRIPS, on); + } + + public static String emergencyContactName() { + return text(EMERGENCY_NAME, ""); + } + + public static void setEmergencyContactName(String name) { + put(EMERGENCY_NAME, name); + } + + public static String emergencyContactPhone() { + return text(EMERGENCY_PHONE, ""); + } + + public static void setEmergencyContactPhone(String phone) { + put(EMERGENCY_PHONE, phone); + } + + // ---------------------------------------------------------------- places + + /// Home, or null while the user has named none. + public static PlaceDto home() { + return place(HOME); + } + + public static void setHome(PlaceDto place) { + store(HOME, place); + changed(HOME); + } + + /// Work, or null while the user has named none. + public static PlaceDto work() { + return place(WORK); + } + + public static void setWork(PlaceDto place) { + store(WORK, place); + changed(WORK); + } + + /// The last few destinations, the newest first. + public static List recents() { + List all = new ArrayList(); + for (int iter = 0; iter < RECENT_LIMIT; iter++) { + PlaceDto place = place(RECENTS + "." + iter); + if (place != null) { + all.add(place); + } + } + return all; + } + + /// Remembers `place` as the destination chosen last. + public static void addRecent(PlaceDto place) { + if (place == null || place.name() == null || place.name().length() == 0) { + return; + } + List all = recents(); + for (int iter = all.size() - 1; iter >= 0; iter--) { + if (place.name().equals(all.get(iter).name())) { + all.remove(iter); + } + } + all.add(0, place); + for (int iter = 0; iter < RECENT_LIMIT; iter++) { + store(RECENTS + "." + iter, iter < all.size() ? all.get(iter) : null); + } + changed(RECENTS); + } + + /// Says whose device this is now, by the name they signed in with; empty + /// for nobody. When the account changes, what the device holds of the + /// last one is dropped and not shown to the next: the recent + /// destinations, which are kept here only and say where a person goes, + /// and the copy of everything the account keeps on the server -- home, + /// work, the emergency contact, the card paid with, the ride options. + /// That copy is replaced when the new account's values arrive, but they + /// may not arrive, and then the last person's would be on screen and go + /// out with the next save as the new person's own. How the app looks + /// (language, theme, units) is the device's and stays. The same account + /// signing in again finds its own still there. + /// + /// The listener is told of the recent destinations alone. It sends what + /// it is told of to the server, and values dropped here were not changed + /// by anyone: reported, they would be saved over the new account's. + /// + /// @param account the account, with its server, as + /// [com.codenameone.examples.wayline.net.Session#owner(String)] names + /// it, or empty for none + public static void ownedBy(String account) { + String owner = account == null ? "" : account; + if (owner.equals(text(RECENTS_OWNER, ""))) { + return; + } + for (int iter = 0; iter < RECENT_LIMIT; iter++) { + store(RECENTS + "." + iter, null); + } + store(HOME, null); + store(WORK, null); + for (String name : ACCOUNT_OWNED) { + Preferences.delete(PREFIX + name); + } + Preferences.set(PREFIX + RECENTS_OWNER, owner); + changed(RECENTS); + } + + // ------------------------------------------------------------- the store + + private static String text(String name, String fallback) { + return Preferences.get(PREFIX + name, fallback); + } + + private static boolean flag(String name, boolean fallback) { + return Preferences.get(PREFIX + name, fallback); + } + + private static void put(String name, String value) { + String wanted = value == null ? "" : value.trim(); + if (!wanted.equals(text(name, null))) { + Preferences.set(PREFIX + name, wanted); + changed(name); + } + } + + private static void put(String name, boolean value) { + // Compared with the opposite as the fallback, so a value never stored + // is stored the first time whatever it is. + if (flag(name, !value) != value) { + Preferences.set(PREFIX + name, value); + changed(name); + } + } + + private static PlaceDto place(String name) { + String title = text(name + ".name", ""); + if (title.length() == 0) { + return null; + } + PlaceDto place = new PlaceDto(title, text(name + ".address", ""), + Preferences.get(PREFIX + name + ".lat", 0d), + Preferences.get(PREFIX + name + ".lng", 0d)); + return place; + } + + private static void store(String name, PlaceDto place) { + Preferences.set(PREFIX + name + ".name", place == null ? "" : place.name()); + Preferences.set(PREFIX + name + ".address", + place == null || place.address() == null ? "" : place.address()); + Preferences.set(PREFIX + name + ".lat", place == null ? 0d : place.lat()); + Preferences.set(PREFIX + name + ".lng", place == null ? 0d : place.lng()); + } + + private static void changed(String name) { + if (listener != null) { + listener.changed(name); + } + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/Telemetry.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/Telemetry.java new file mode 100644 index 00000000000..873a5a4f6ce --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/Telemetry.java @@ -0,0 +1,359 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.analytics.AnalyticsProvider; +import com.codename1.analytics.CodenameOneAnalyticsProvider; +import com.codename1.analytics.LoggingAnalyticsProvider; +import com.codename1.ui.CN; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.ui.Lang; + +/// What the app reports about how it is used, and whether it may. +/// +/// This is the only class that talks to `com.codename1.analytics`. A screen +/// says what happened by calling one of the methods here, so the whole +/// vocabulary -- every event name and every parameter -- is in this file, and +/// it is here that the one rule is kept: nothing that says who someone is or +/// where they are ever goes into an event. No name, e-mail address, phone +/// number, address, position, card number or ride id; an amount of money goes +/// as the bracket it falls in. What is known about the user is two things: the +/// mode they are in and the language they read. +/// +/// Nothing is reported until the user has said yes. The library starts with +/// consent withheld and drops every call made without it, so a method here is +/// always safe to call. The answer is asked for once, after the first sign-in +/// on a device ([#asked()], `ui.ConsentForm`), can be changed in Settings at +/// any time ([#allow(boolean)]), and is kept by the library on the device, in +/// `Preferences`, with the made-up id that tells one installation from the +/// next. Deleting the account forgets both ([#forget()]). +/// +/// On a device the reports go to Codename One's own service, which shows them +/// in the developer console. That service knows which app is reporting from +/// the identity a build stamps into it, so the simulator, which has none, only +/// writes what it would have sent to the log. +public final class Telemetry { + private static final String ACCOUNT = "account"; + private static final String RIDE = "ride"; + private static final String PAYMENT = "payment"; + private static final String DRIVER = "driver"; + private static final String ADMIN = "admin"; + private static final String SETTINGS = "settings"; + + /// The screen reported last, which the next one names as where it came from. + private static String screen; + /// The screen that is showing, reported or not. + private static String showing; + private static String role; + /// The role and the language as they were last reported. + private static String described; + private static boolean started; + + private Telemetry() { + } + + /// Says where the reports go. Called once, when the app starts. + public static void start() { + if (started) { + return; + } + started = true; + if (CN.isSimulator()) { + Analytics.addProvider(new LoggingAnalyticsProvider()); + } else { + Analytics.addProvider(new CodenameOneAnalyticsProvider()); + } + } + + /// Sends the reports to `providers` and nowhere else. For a test, which + /// wants to see what was reported and wants none of it to leave. + public static void use(AnalyticsProvider... providers) { + started = true; + Analytics.clearProviders(); + for (AnalyticsProvider provider : providers) { + Analytics.addProvider(provider); + } + screen = null; + described = null; + } + + // --------------------------------------------------------------- consent + + /// Whether the user has answered, either way. + public static boolean asked() { + return Analytics.getConsent() != null; + } + + /// Whether the user said yes. + public static boolean allowed() { + AnalyticsConsent consent = Analytics.getConsent(); + return consent != null && consent.isAnalytics(); + } + + /// Records the user's answer. A yes covers how the app is used and the + /// errors it runs into; nothing here follows a person or serves an + /// advertisement, and those are never granted. + public static void allow(boolean yes) { + boolean was = allowed(); + Analytics.setConsent(AnalyticsConsent.builder() + .analytics(yes) + .crashReporting(yes) + .personalization(false) + .adStorage(false) + .build()); + if (yes && !was) { + // What was dropped while the answer was no: who is using the app, + // in the two words kept about them, and the screen they are on. + screen = null; + described = null; + describe(); + if (showing != null) { + screen(showing); + } + } + } + + /// Takes the answer back, so that it is asked for again. + public static void unask() { + Analytics.setConsent(null); + } + + /// The account is gone: the installation gets a new id, which cuts what is + /// reported from now on from everything reported before, and whoever signs + /// in next is asked for themselves. + public static void forget() { + // What is waiting was gathered under the id that is about to go, and + // is sent under it. + Analytics.flush(); + Analytics.resetClientId(); + Analytics.setConsent(null); + role = null; + screen = null; + described = null; + } + + /// Sends what is waiting. Reports are gathered and sent a batch at a + /// time; this is for when the app is put away. + public static void flush() { + Analytics.flush(); + } + + // ------------------------------------------------------- screens and who + + /// A screen is showing. `name` is the one the screen was built with, which + /// is not translated and does not change with its title. + public static void screen(String name) { + if (name == null || name.length() == 0) { + return; + } + showing = name; + if (name.equals(screen) || !allowed()) { + return; + } + Analytics.screen(name, screen); + screen = name; + } + + /// The user is in `mode`: rider, driver or admin. + public static void role(String mode) { + role = mode; + describe(); + } + + /// Reports the role and the language, when either is news: the home + /// screen is built again for every change of look, and says its mode each + /// time. + private static void describe() { + if (role == null || !allowed()) { + return; + } + String language = Lang.current(); + String now = role + "/" + language; + if (!now.equals(described)) { + described = now; + Analytics.setUserProperty("role", role); + Analytics.setUserProperty("language", language); + } + } + + // --------------------------------------------------------------- account + + public static void signedIn() { + send(ACCOUNT, "login", "method", "password"); + } + + /// @param reason `user` when they chose to, `expired` when the session + /// ended under them, `deleted` when the account was closed + public static void signedOut(String reason) { + send(ACCOUNT, "logout", "reason", reason); + role = null; + } + + /// @param driving whether they came to drive + public static void registered(boolean driving) { + send(ACCOUNT, "sign_up", "method", "password", "intent", driving ? "driver" : "rider"); + } + + public static void phoneVerified() { + send(ACCOUNT, "phone_verified"); + } + + // ------------------------------------------------------------------ rides + + /// @param products how many kinds of car were priced + public static void rideQuoted(int products, double meters) { + send(RIDE, "ride_quoted", "products", Integer.valueOf(products), "distance", + distance(meters)); + } + + public static void rideRequested(String product, boolean cash) { + send(RIDE, "ride_requested", "product", product, "payment", cash ? "cash" : "card"); + } + + /// @param by `rider`, `driver` or `admin` + /// @param state the state the ride was in + public static void rideCancelled(String by, RideState state) { + send(RIDE, "ride_cancelled", "by", by, "state", state == null ? "" : state.name()); + } + + /// @param side whose device saw it end: `rider` or `driver`. A ride that + /// both of them use the app for is reported by both, so a count of + /// rides takes one side. + public static void rideCompleted(String side, long fareCents, boolean cash) { + send(RIDE, "ride_completed", "side", side, "fare", fare(fareCents), "payment", + cash ? "cash" : "card"); + } + + public static void rated(int stars) { + send(RIDE, "rating_given", "stars", Integer.valueOf(stars)); + } + + // --------------------------------------------------------------- payment + + /// @param where `ride` at the end of the ride, `receipt` from the receipt + /// @param percent the share of the fare, or 0 for an amount typed in + public static void tipped(String where, int percent) { + send(PAYMENT, "tip_added", "where", where, "share", + percent > 0 ? percent + "_percent" : "custom"); + } + + public static void cardAdded() { + send(PAYMENT, "card_added"); + } + + public static void cardRemoved() { + send(PAYMENT, "card_removed"); + } + + // --------------------------------------------------------------- driving + + /// @param again whether this is an application sent back, being put right + public static void applicationStarted(boolean again) { + send(DRIVER, "driver_application_started", "resubmission", Boolean.valueOf(again)); + } + + public static void applicationSubmitted() { + send(DRIVER, "driver_application_submitted"); + } + + public static void driverOnline(boolean online) { + send(DRIVER, online ? "driver_online" : "driver_offline"); + } + + public static void offerAnswered(boolean accepted) { + send(DRIVER, accepted ? "offer_accepted" : "offer_declined"); + } + + public static void payoutRequested(long cents) { + send(DRIVER, "payout_requested", "amount", fare(cents)); + } + + // ----------------------------------------------------------------- admin + + public static void applicationReviewed(boolean approved) { + send(ADMIN, approved ? "admin_application_approved" : "admin_application_rejected"); + } + + public static void userFlagged(boolean flagged) { + send(ADMIN, flagged ? "admin_user_flagged" : "admin_user_unflagged"); + } + + public static void userBlocked(boolean blocked) { + send(ADMIN, blocked ? "admin_user_blocked" : "admin_user_unblocked"); + } + + /// @param from the screen it was done from + public static void rideRefunded(String from) { + send(ADMIN, "admin_ride_refunded", "from", from); + } + + public static void pricingChanged() { + send(ADMIN, "admin_pricing_changed"); + } + + // -------------------------------------------------------------- settings + + public static void languageChanged(String code) { + send(SETTINGS, "language_changed", "language", code); + describe(); + } + + public static void themeChanged(String theme) { + send(SETTINGS, "theme_changed", "theme", theme); + } + + // ---------------------------------------------------------------- errors + + /// Something failed that the app caught and carried on from. Only what + /// kind of failure it was is reported: a message can quote what the user + /// typed, or the file it was reading. + public static void error(Throwable failed) { + if (failed != null) { + Analytics.crash(failed, failed.getClass().getName(), false); + } + } + + // ------------------------------------------------------------- the store + + /// The bracket an amount of money falls in, in whole units of the + /// currency. The amount itself, beside a time, can be matched to a ride. + static String fare(long cents) { + long units = cents / 100; + return units < 5 ? "under_5" : units < 10 ? "5_to_10" : units < 20 ? "10_to_20" + : units < 35 ? "20_to_35" : units < 50 ? "35_to_50" : "50_or_more"; + } + + /// How far, as a bracket in kilometres. + static String distance(double meters) { + return meters < 2000 ? "under_2km" : meters < 5000 ? "2_to_5km" + : meters < 10000 ? "5_to_10km" : meters < 25000 ? "10_to_25km" : "25km_or_more"; + } + + /// Reports `name` with `pairs`: a parameter's name, then its value. + private static void send(String category, String name, Object... pairs) { + AnalyticsEvent.Builder event = AnalyticsEvent.create(name).category(category); + for (int iter = 0; iter + 1 < pairs.length; iter += 2) { + event.param((String) pairs[iter], pairs[iter + 1]); + } + Analytics.event(event.build()); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/Wayline.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/Wayline.java new file mode 100644 index 00000000000..236ec6553ab --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/Wayline.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.system.Lifecycle; +import com.codenameone.examples.wayline.driver.Shift; +import com.codenameone.examples.wayline.map.Locator; +import com.codenameone.examples.wayline.net.Account; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.notify.Notifications; +import com.codenameone.examples.wayline.pay.CardForm; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Look; +import com.codenameone.examples.wayline.ui.Nav; + +/// The app. It opens on whatever the stored session allows: the mode the user +/// was last in when they are still signed in, the welcome screen when not. +/// +/// Where things are: +/// +/// - `net` -- the session, and the typed clients generated from the contracts in +/// the `shared` module. Nothing else in the app builds a URL. +/// - `live` -- the WebSocket the server pushes changes down. +/// - `notify` -- push notifications, for a phone that is not looking. +/// - `ui`, `rider`, `driver`, `admin` -- the screens. +/// - `map` -- the map, its markers and the device's position. +/// - `src/main/css/theme.css` -- every colour, font and margin. +/// - `src/main/l10n` -- every word, in each language the app speaks. +/// - `Prefs` -- what the user chose in Settings. +/// - `Telemetry` -- what is reported about how the app is used, once the user +/// has agreed to it. +public class Wayline extends Lifecycle { + @Override + public void runApp() { + // Before the first screen is built: a screen keeps the look and the + // words it was built with. + Look.apply(); + Lang.apply(); + Telemetry.start(); + Session.start(AppConfig.serverUrl()); + Account.start(); + Nav.launch(); + } + + /// Coming back to the app is how a card added on the payment provider's + /// page is noticed: the browser it opened in tells the app nothing. + /// + /// It is also when the app asks for its push token, which the platform + /// answers at once when nothing has changed, and when the position is + /// looked for again if going to the background put the search down. + @Override + public void start() { + super.start(); + CardForm.resumed(); + Shift.resumed(); + Locator.resumed(); + Notifications.start(); + } + + /// The app is put away, which is the last sure moment to send the usage + /// reports gathered since the last batch. + /// + /// A driver on line goes on reporting where the car is from the + /// background; anyone else's search for the position is put down until + /// the app is back. + @Override + public void stop() { + super.stop(); + Telemetry.flush(); + Shift.paused(); + Locator.paused(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/AdminForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/AdminForm.java new file mode 100644 index 00000000000..d3aa0faa556 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/AdminForm.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.wayline.ui.Layouts; +import com.codenameone.examples.wayline.ui.Menu; +import com.codenameone.examples.wayline.ui.Nav; +import com.codenameone.examples.wayline.ui.Ui; + +/// Administering: what needs an answer, how the service is doing right now and +/// over the last weeks, and the way to everything an admin looks after. +/// +/// Everything here is behind `/api/admin`, which the server opens to the +/// `ADMIN` role and to nobody else. That is where the protection is. This +/// screen is reached only when `/api/me` says the user is an admin, but that is +/// so the others are not shown something useless -- not what keeps them out. +/// +/// It is laid out for the room there is. This is the phone's: the [Dashboard] +/// with the places under it and in a drawer, each a screen of its own. On a +/// wide screen -- a tablet on its side, a desktop, a browser -- it is the +/// [Console] instead, with the places down the side and a list beside what was +/// picked from it. Which one is decided when the screen is built, and a window +/// resized across the line, or a device turned, builds it again. +public class AdminForm implements Nav.Home { + private static final int POLL_MILLIS = 5000; + + private final Console console; + private final Form form; + private Dashboard dashboard; + + public AdminForm() { + if (Layouts.wide()) { + console = new Console(); + form = console.form(); + return; + } + console = null; + form = Ui.home("Admin"); + final Menu menu = new Menu(form, Nav.ADMIN); + dashboard = new Dashboard(false, new Dashboard.Places() { + @Override + public void applications() { + ApplicationsForm.show(form); + } + + @Override + public void flagged() { + UsersForm.show(form, UsersForm.FLAGGED); + } + }); + menu.item(FontImage.MATERIAL_INSIGHTS, "Dashboard", "home", () -> { }); + menu.item(FontImage.MATERIAL_ASSIGNMENT_IND, "Applications", "applications", + () -> ApplicationsForm.show(form)); + menu.item(FontImage.MATERIAL_GROUP, "People", "users", () -> UsersForm.show(form, "")); + menu.item(FontImage.MATERIAL_RECEIPT_LONG, "Rides", "rides", + () -> AdminRidesForm.show(form)); + menu.item(FontImage.MATERIAL_PAYMENTS, "Payments", "payments", + () -> PaymentsForm.show(form)); + menu.item(FontImage.MATERIAL_MAP, "Live map", "fleet", this::fleet); + menu.item(FontImage.MATERIAL_SELL, "Pricing", "pricing", () -> PricingForm.show(form)); + + // The bar a title bar would be, with the menu where the map screens + // have it, and the mode said beside the title: an admin who also rides + // should never have to wonder which app this is. + Container bar = new Container(new BorderLayout()); + bar.setUIID("WlHomeBar"); + bar.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(menu.flatButton())); + bar.add(BorderLayout.CENTER, FlowLayout.encloseLeftMiddle( + Ui.label("Operations", "WlHeading"))); + // In the menu the badge stands under a name and keeps a gap above it; + // here it is alone on the bar's centre line. + Label admin = Ui.badge("Admin", "WlModeBadge"); + admin.getAllStyles().setMargin(0, 0, 0, 0); + bar.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(admin)); + + Container page = dashboard.page(); + Ui.section(page, "Manage"); + Container manage = new Container(BoxLayout.y()); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_ASSIGNMENT_IND, "Applications", + "People who asked to drive, and their papers", null), "applications", + e -> ApplicationsForm.show(form))); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_GROUP, "People", + "Roles, flags, and blocking an account", null), "users", + e -> UsersForm.show(form, ""))); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_RECEIPT_LONG, "Rides", + "The most recent, in every state", null), "rides", + e -> AdminRidesForm.show(form))); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_PAYMENTS, "Payments", + "What was charged, and refunds", null), "payments", + e -> PaymentsForm.show(form))); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_MAP, "Live map", + "Where every car online is", null), "fleet", e -> fleet())); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_SELL, "Pricing", + "What a ride costs, and the service's share", null), "pricing", + e -> PricingForm.show(form))); + page.add(manage); + + Container screen = new Container(new BorderLayout()); + // The strip the status bar is drawn in is the bar's colour, not + // whatever is behind the screen. + screen.setUIID("WlForm"); + screen.setSafeArea(true); + screen.add(BorderLayout.NORTH, bar); + screen.add(BorderLayout.CENTER, page); + form.add(screen); + menu.install(); + // A narrow window on a desktop is still a desktop's. + Layouts.dress(form); + form.addShowListener(e -> { + if (Layouts.wide()) { + // Come back to from a screen that was made wide meanwhile. + CN.callSerially(Nav::home); + return; + } + dashboard.shown(); + }); + Layouts.onChange(form, Nav::home); + UITimer.timer(POLL_MILLIS, true, form, dashboard::load); + } + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + if (console != null) { + console.start(); + return; + } + dashboard.start(); + // The window was a console a moment ago, on one of its places: that + // place is where the admin still is. + final String place = Console.resumed(); + if (place != null) { + CN.callSerially(() -> open(place)); + } + } + + public void show() { + start(); + form.show(); + } + + private void open(String place) { + if ("applications".equals(place)) { + ApplicationsForm.show(form); + } else if ("users".equals(place)) { + UsersForm.show(form, ""); + } else if ("rides".equals(place)) { + AdminRidesForm.show(form); + } else if ("payments".equals(place)) { + PaymentsForm.show(form); + } else if ("pricing".equals(place)) { + PricingForm.show(form); + } + } + + private void fleet() { + new FleetForm(form).show(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/AdminRidesForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/AdminRidesForm.java new file mode 100644 index 00000000000..13eeaec1524 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/AdminRidesForm.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.ReasonDto; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Ui; +import java.util.List; + +/// Every ride, for an admin: which are under way, which finished and which +/// did not, and for each what can still be done about it -- cancelling one that +/// is under way, returning the money for one that is paid. +public final class AdminRidesForm { + private static final String[] FILTERS = {"all", "active", "completed", "cancelled"}; + private static final String[] FILTER_NAMES = {"All", "Under way", "Completed", "Cancelled"}; + + private final Panes panes; + private final Container list = Ui.list("rides"); + private List rides; + private String filter = FILTERS[0]; + + private AdminRidesForm(Panes panes) { + this.panes = panes; + Container top = new Container(BoxLayout.y()); + top.setUIID("WlPage"); + top.add(Ui.choice("filter", FILTER_NAMES, FILTERS, filter, value -> { + filter = value; + fill(); + })); + Ui.pull(list, this::load); + panes.list("Rides", "Rides", Panes.listed(top, list), this::load); + } + + static void show(Form previous) { + show(Panes.phone(previous)); + } + + static void show(Panes panes) { + new AdminRidesForm(panes); + } + + private void load() { + Api.admin().rides(Net.to(found -> { + rides = found; + fill(); + }, Ui.failed(list, this::load))); + } + + private void fill() { + list.removeAll(); + int shown = 0; + for (int iter = 0; rides != null && iter < rides.size(); iter++) { + final RideDto ride = rides.get(iter); + if (!passes(ride)) { + continue; + } + String people = ride.driverName() == null || ride.driverName().length() == 0 + ? ride.riderName() + : Lang.tr("{0} with {1}", ride.riderName(), ride.driverName()); + Container value = new Container(BoxLayout.y()); + value.add(Ui.plain(Ui.money(ride.total() > 0 ? ride.total() : ride.fareCents(), + ride.currency()), "WlRowValue")); + value.add(Ui.badge(Ui.state(ride.state()), Ui.stateBadge(ride.state()))); + final Container picked = Ui.row(FontImage.MATERIAL_PLACE, ride.dropoffAddress(), + Ui.when(ride.requestedAt()) + Ui.DOT + people, value); + list.add(Ui.tap(picked, "ride-" + shown, e -> { + panes.picked(picked); + show(ride); + })); + shown++; + } + if (shown == 0 && rides != null) { + list.add(Ui.empty(FontImage.MATERIAL_RECEIPT_LONG, "No rides here", null)); + } + panes.refresh(); + } + + private boolean passes(RideDto ride) { + if ("active".equals(filter)) { + return ride.state().isActive(); + } + if ("completed".equals(filter)) { + return RideState.COMPLETED == ride.state(); + } + if ("cancelled".equals(filter)) { + return !ride.state().isActive() && RideState.COMPLETED != ride.state(); + } + return true; + } + + private void show(final RideDto ride) { + Container page = Ui.page(); + Label total = Ui.plain(Ui.money(ride.total() > 0 ? ride.total() : ride.fareCents(), + ride.currency()), "WlAmount"); + page.add(total); + Label state = Ui.badge(Ui.state(ride.state()), Ui.stateBadge(ride.state())); + state.setName("rideState"); + page.add(FlowLayout.encloseCenter(state)); + page.add(FlowLayout.encloseCenter(Ui.plain(Ui.when(ride.requestedAt()), "WlMuted"))); + + Ui.section(page, "Trip"); + page.add(Ui.row(FontImage.MATERIAL_TRIP_ORIGIN, ride.pickupAddress(), null, null)); + page.add(Ui.row(FontImage.MATERIAL_PLACE, ride.dropoffAddress(), + Ui.distance(ride.distanceMeters()) + Ui.DOT + Ui.minutes(ride.durationSeconds()), + null)); + Ui.section(page, "People"); + page.add(Ui.row(FontImage.MATERIAL_HAIL, ride.riderName(), ride.riderUsername(), null)); + if (ride.driverName() != null && ride.driverName().length() > 0) { + page.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, ride.driverName(), + ride.vehicle() + (ride.plate() == null || ride.plate().length() == 0 ? "" + : Ui.DOT + ride.plate()), null)); + } + Ui.section(page, "Payment"); + page.add(Ui.fact("Kind of ride", Lang.tr(Ui.product(ride.product())))); + page.add(Ui.fact("Fare", Ui.money(ride.fareCents(), ride.currency()))); + page.add(Ui.fact("Service fee", Ui.money(ride.serviceFee(), ride.currency()))); + page.add(Ui.fact("Tip", Ui.money(ride.tip(), ride.currency()))); + page.add(Ui.fact("Paid with", ride.cash() ? Lang.tr("Cash") : ride.paymentMethodLabel())); + page.add(Ui.fact("Payment", Lang.tr(payment(ride.paymentStatus())))); + + if (ride.state().isActive()) { + page.add(Ui.danger("Cancel this ride", "cancelRide", e -> panes.reason( + "Cancel ride", Lang.tr("The rider and the driver are both told, and " + + "nobody is charged."), "Cancel ride", why -> { + ReasonDto reason = new ReasonDto(why); + Api.admin().cancelRide(ride.id(), reason, Net.to(cancelled -> { + Telemetry.rideCancelled("admin", ride.state()); + panes.home(); + }, Ui::fail)); + }))); + } + if ("PAID".equals(ride.paymentStatus()) && !ride.cash()) { + page.add(Ui.danger("Refund this ride", "refund", e -> panes.reason("Refund", + Lang.tr("{0} goes back to the card it came from.", Ui.money( + ride.total() > 0 ? ride.total() : ride.fareCents(), ride.currency())), + "Refund", why -> { + ReasonDto reason = new ReasonDto(why); + Api.admin().refundRide(ride.id(), reason, Net.to(refunded -> { + Telemetry.rideRefunded("rides"); + // The processor may not have returned the money + // yet; the receipt says which. + Ui.say("refund_pending".equals(refunded.status()) + ? "The refund is pending" : "The ride was refunded"); + panes.home(); + }, Ui::fail)); + }))); + } + panes.detail("Ride", "RideDetail", page); + } + + private static String payment(String status) { + return "PAID".equals(status) ? "Paid" : "REFUNDED".equals(status) ? "Refunded" + : "REFUND_PENDING".equals(status) ? "Refund pending" + : "REFUND_FAILED".equals(status) ? "Refund failed" + : "FAILED".equals(status) ? "Payment failed" : "Payment pending"; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/ApplicationsForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/ApplicationsForm.java new file mode 100644 index 00000000000..6ef01d1e438 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/ApplicationsForm.java @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.components.ScaleImageLabel; +import com.codename1.io.Log; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.util.Base64; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.DocumentContentDto; +import com.codenameone.examples.wayline.api.DocumentDto; +import com.codenameone.examples.wayline.api.DriverApplicationDto; +import com.codenameone.examples.wayline.api.ReasonDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Ui; +import java.util.List; + +/// The people who asked to drive, and the decision about each. +/// +/// An application is approved or sent back with a reason. Approving is what +/// makes the account a driver; nothing else in the app or on the server does. +/// The documents are pictures only an admin and the applicant can fetch, one +/// at a time, when they are opened. +public final class ApplicationsForm { + private static final String[] KINDS = {"photo_id", "licence", "selfie", + "vehicle_registration", "insurance"}; + private static final String[] KIND_NAMES = {"Photo ID", "Driving licence", "A photo of you", + "Vehicle registration", "Insurance"}; + + private final Panes panes; + private final Container list = Ui.list("applications"); + private String status = "pending"; + + private ApplicationsForm(Panes panes) { + this.panes = panes; + Container top = new Container(BoxLayout.y()); + top.setUIID("WlPage"); + top.add(Ui.choice("status", new String[] {"Waiting", "Approved", "Sent back"}, + new String[] {"pending", "approved", "rejected"}, status, value -> { + status = value; + load(); + })); + Ui.pull(list, this::load); + panes.list("Applications", "Applications", Panes.listed(top, list), this::load); + } + + static void show(Form previous) { + show(Panes.phone(previous)); + } + + static void show(Panes panes) { + new ApplicationsForm(panes); + } + + private void load() { + final String asked = status; + Api.admin().applications(asked, Net.to(found -> { + if (asked.equals(status)) { + fill(found); + } + }, Ui.failed(list, this::load))); + } + + private void fill(List found) { + list.removeAll(); + if (found == null || found.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_INBOX, "Nothing here", + "pending".equals(status) ? "Every application has had its answer." : null)); + } + for (int iter = 0; found != null && iter < found.size(); iter++) { + final DriverApplicationDto application = found.get(iter); + Container row = Ui.row((char) 0, application.name(), car(application), + Ui.badge(word(application.status()), badge(application.status()))); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle( + Ui.avatar(application.name()))); + final Container picked = row; + list.add(Ui.tap(row, "application-" + application.username(), e -> { + panes.picked(picked); + review(application.username()); + })); + } + panes.refresh(); + } + + /// One application in full, fetched fresh: it may have been answered by + /// another admin since the list was drawn. + private void review(final String username) { + Api.admin().application(username, Net.to(application -> show(application), Ui::fail)); + } + + private void show(final DriverApplicationDto application) { + Container page = Ui.page(); + Label state = Ui.badge(word(application.status()), badge(application.status())); + state.setName("applicationStatus"); + page.add(Ui.person(application.name(), application.username(), state)); + if ("rejected".equals(application.status())) { + page.add(Ui.text(application.rejectionReason(), "WlNotice")); + } + + Ui.section(page, "Applicant"); + page.add(Ui.fact("Legal name", application.legalName())); + page.add(Ui.fact("Date of birth", application.dateOfBirth())); + page.add(Ui.fact("Licence", application.licenceNumber())); + page.add(Ui.fact("Licence expires", application.licenceExpiry())); + if (application.submittedAt() > 0) { + page.add(Ui.fact("Submitted", Ui.when(application.submittedAt()))); + } + if (application.reviewedAt() > 0) { + page.add(Ui.fact("Reviewed", Ui.when(application.reviewedAt()))); + page.add(Ui.fact("Reviewed by", application.reviewedBy())); + } + + Ui.section(page, "Vehicle"); + page.add(Ui.fact("Car", car(application))); + page.add(Ui.fact("Licence plate", application.vehiclePlate())); + page.add(Ui.fact("Seats for riders", String.valueOf(application.vehicleSeats()))); + page.add(Ui.fact("Kind of ride", Lang.tr(Ui.product(application.product())))); + page.add(Ui.fact("Takes a wheelchair", Lang.tr(application.accessible() ? "Yes" : "No"))); + page.add(Ui.fact("Takes pets", Lang.tr(application.petFriendly() ? "Yes" : "No"))); + + Ui.section(page, "Documents"); + for (int iter = 0; iter < KINDS.length; iter++) { + final String kind = KINDS[iter]; + final String title = KIND_NAMES[iter]; + String had = status(application, kind); + boolean missing = "missing".equals(had); + Container row = Ui.row(FontImage.MATERIAL_IMAGE, title, + missing ? "Not provided" : "Tap to open", Ui.badge( + missing ? "Missing" : "Provided", + missing ? "WlBadgeBad" : "WlBadgeGood")); + if (missing) { + page.add(row); + } else { + page.add(Ui.tap(row, "document-" + kind, + e -> picture(application.username(), kind, title))); + } + } + + if ("pending".equals(application.status())) { + page.add(Ui.primary("Approve", "approve", e -> Api.admin().approve( + application.username(), Net.to(approved -> { + Telemetry.applicationReviewed(true); + Ui.say(Lang.tr("{0} can now drive", application.name())); + panes.home(); + }, Ui::fail)))); + page.add(Ui.danger("Send back", "reject", e -> panes.reason("Send back", + Lang.tr("{0} sees what you write, and can apply again once it is put " + + "right.", application.name()), "Send back", why -> { + ReasonDto reason = new ReasonDto(why); + Api.admin().reject(application.username(), reason, Net.to(rejected -> { + Telemetry.applicationReviewed(false); + panes.home(); + }, Ui::fail)); + }))); + } + panes.detail(application.name(), "ApplicationReview", page); + } + + /// A document, as large as the screen allows. + private void picture(String username, String kind, String title) { + final Container stage = new Container(new BorderLayout()); + stage.setUIID("WlPicture"); + panes.over(title, "Document", stage); + Api.admin().document(username, kind, Net.to(content -> { + Image image = decode(content); + if (image == null) { + stage.add(BorderLayout.CENTER, Ui.empty(FontImage.MATERIAL_BROKEN_IMAGE, + "This picture could not be opened", null)); + } else { + ScaleImageLabel shown = new ScaleImageLabel(image); + shown.setUIID("WlPicture"); + shown.setBackgroundType(Style.BACKGROUND_IMAGE_SCALED_FIT); + shown.setName("picture"); + stage.add(BorderLayout.CENTER, shown); + } + panes.refresh(); + }, Ui::fail)); + } + + private static Image decode(DocumentContentDto content) { + if (content == null || content.dataBase64() == null || content.dataBase64().length() == 0) { + return null; + } + try { + byte[] data = Base64.decode(content.dataBase64().getBytes("UTF-8")); + return data == null ? null : Image.createImage(data, 0, data.length); + } catch (java.io.UnsupportedEncodingException impossible) { + Log.e(impossible); + return null; + } catch (RuntimeException unreadable) { + // Not a picture after all; the screen says so. + Log.e(unreadable); + Telemetry.error(unreadable); + return null; + } + } + + private static String status(DriverApplicationDto application, String kind) { + for (int iter = 0; application.documents() != null && iter < application.documents().size(); + iter++) { + DocumentDto document = application.documents().get(iter); + if (kind.equals(document.kind())) { + return document.status(); + } + } + return "missing"; + } + + private static String car(DriverApplicationDto application) { + StringBuilder car = new StringBuilder(); + String[] words = {application.vehicleColor(), application.vehicleMake(), + application.vehicleModel(), + application.vehicleYear() > 0 ? String.valueOf(application.vehicleYear()) : ""}; + for (int iter = 0; iter < words.length; iter++) { + if (words[iter] != null && words[iter].length() > 0) { + car.append(car.length() > 0 ? " " : "").append(words[iter]); + } + } + return car.toString(); + } + + static String word(String status) { + return "pending".equals(status) ? "Waiting" : "approved".equals(status) ? "Approved" + : "rejected".equals(status) ? "Sent back" : "Draft"; + } + + static String badge(String status) { + return "pending".equals(status) ? "WlBadgeWarn" : "approved".equals(status) + ? "WlBadgeGood" : "rejected".equals(status) ? "WlBadgeBad" : "WlBadge"; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/Console.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/Console.java new file mode 100644 index 00000000000..68409020007 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/Console.java @@ -0,0 +1,436 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Layouts; +import com.codenameone.examples.wayline.ui.Nav; +import com.codenameone.examples.wayline.ui.SettingsForm; +import com.codenameone.examples.wayline.ui.Ui; +import java.util.ArrayList; +import java.util.List; + +/// The admin console on a wide screen: a tablet on its side, a desktop window, +/// a browser. +/// +/// One screen, in three parts. The places an admin goes are always in view +/// down the side, where a phone keeps them in a drawer. Beside them is the +/// place that is open: the dashboard, or a list. And beside a list is what was +/// picked from it -- an application, an account, a ride, a receipt -- so that +/// working through a list does not mean going back to it each time. +/// +/// The parts themselves are the phone's. Each is written against [Panes], and +/// the ones here put a list and its detail side by side where the phone's +/// make each a screen. +/// +/// On a desktop the window has a menu bar, and the places are in it too, each +/// with a key. Where the platform's own theme is installed the console wears +/// it: see [Layouts#dress(Container)]. +final class Console { + private static final int POLL_MILLIS = 5000; + private static final String HOME = "home"; + private static final String FLEET = "fleet"; + private static final float SIDE_MM = 56f; + private static final float MASTER_MM = 100f; + + /// The places, in the order they are listed: an id, and what it is called. + private static final String[][] PLACES = { + {HOME, "Dashboard"}, {"applications", "Applications"}, {"users", "People"}, + {"rides", "Rides"}, {"payments", "Payments"}, {FLEET, "Live map"}, + {"pricing", "Pricing"}}; + private static final char[] ICONS = {FontImage.MATERIAL_INSIGHTS, + FontImage.MATERIAL_ASSIGNMENT_IND, FontImage.MATERIAL_GROUP, + FontImage.MATERIAL_RECEIPT_LONG, FontImage.MATERIAL_PAYMENTS, FontImage.MATERIAL_MAP, + FontImage.MATERIAL_SELL}; + + /// The place that was open when the window stopped being wide, or the + /// device was turned: where the console opens when it is wide again. + private static String resume = HOME; + + private final Form form = new Form("", new BorderLayout()); + private final Container side; + private final Container body = new Container(new BorderLayout()); + private final Button[] items = new Button[PLACES.length]; + private final Wide panes = new Wide(); + private final Dashboard dashboard; + /// Whether the window, and not the app, has the title bar and the menu. + private final boolean window; + private String place = HOME; + + Console() { + form.setUIID("WlForm"); + form.setName("Admin"); + Ui.watch(form); + form.setScrollable(false); + window = CN.isDesktop() && form.getToolbar().getParent() == null; + if (!window) { + // Everything a title bar would say is down the side. + form.getToolbar().hideToolbar(); + } + dashboard = new Dashboard(true, new Dashboard.Places() { + @Override + public void applications() { + go("applications"); + } + + @Override + public void flagged() { + mark("users"); + UsersForm.show(panes, UsersForm.FLAGGED); + } + }); + side = side(); + Container screen = new Container(new BorderLayout()); + screen.setSafeArea(true); + screen.add(BorderLayout.WEST, side); + screen.add(BorderLayout.CENTER, body); + form.add(BorderLayout.CENTER, screen); + if (window) { + menus(); + } + form.addShowListener(e -> { + if (!Layouts.wide()) { + // It was left wide, for the map or the settings, and the + // window was made narrow while it was away. + narrowed(); + return; + } + if (HOME.equals(place)) { + dashboard.shown(); + } + }); + form.addSizeChangedListener(e -> dashboard.fit()); + Layouts.onChange(form, this::narrowed); + UITimer.timer(POLL_MILLIS, true, form, () -> { + if (HOME.equals(place)) { + dashboard.load(); + } + }); + } + + Form form() { + return form; + } + + /// Fills the console in, at the place it was last on. + void start() { + dashboard.start(); + String to = resume; + resume = HOME; + go(to); + } + + private void narrowed() { + resume = place; + Nav.home(); + } + + /// The place a narrow console should open, having just stopped being a + /// wide one; null when there is none, which is the dashboard. + static String resumed() { + String to = resume; + resume = HOME; + return HOME.equals(to) ? null : to; + } + + /// Opens one of the places. + private void go(String id) { + if (FLEET.equals(id)) { + // A map wants the whole window. + new FleetForm(form).show(); + return; + } + mark(id); + if ("applications".equals(id)) { + ApplicationsForm.show(panes); + } else if ("users".equals(id)) { + UsersForm.show(panes, ""); + } else if ("rides".equals(id)) { + AdminRidesForm.show(panes); + } else if ("payments".equals(id)) { + PaymentsForm.show(panes); + } else if ("pricing".equals(id)) { + PricingForm.show(panes); + } else { + panes.clear(); + body.removeAll(); + body.add(BorderLayout.CENTER, dashboard.page()); + title("Dashboard"); + Ui.viewed("Admin"); + panes.refresh(); + if (form.isVisible() && CN.getCurrentForm() == form) { + dashboard.shown(); + } + } + } + + /// Shows `id` as the place that is open. + private void mark(String id) { + place = id; + for (int iter = 0; iter < items.length; iter++) { + items[iter].setUIID(PLACES[iter][0].equals(id) ? "WlSidePicked" : "WlSideItem"); + } + Layouts.dress(side); + // An icon is drawn in the colour of the style it was made for. + for (int iter = 0; iter < items.length; iter++) { + Ui.icon(items[iter], ICONS[iter], 3.4f); + } + } + + private void title(String title) { + if (window) { + form.setTitle(Lang.tr(title)); + } + } + + private Container side() { + Container panel = new Container(new BorderLayout()) { + @Override + protected Dimension calcPreferredSize() { + Dimension size = super.calcPreferredSize(); + size.setWidth(CN.convertToPixels(SIDE_MM)); + return size; + } + }; + panel.setUIID("WlSide"); + panel.setName("side"); + UserDto user = Session.user(); + Container who = new Container(BoxLayout.y()); + who.add(Ui.plain(user.displayName(), "WlSideName")); + who.add(Ui.plain(user.username(), "WlSideDetail")); + + Container list = new Container(BoxLayout.y()); + list.setScrollableY(true); + list.setScrollVisible(false); + for (int iter = 0; iter < PLACES.length; iter++) { + final String id = PLACES[iter][0]; + items[iter] = entry(ICONS[iter], PLACES[iter][1], "nav-" + id, () -> go(id)); + list.add(items[iter]); + } + // The other modes this account's roles open. Everyone may ride. + list.add(Ui.label("Switch to", "WlSideLabel")); + list.add(entry(FontImage.MATERIAL_HAIL, "Riding", "switch-" + Nav.RIDER, + () -> Nav.switchTo(Nav.RIDER))); + if (user.driver()) { + list.add(entry(FontImage.MATERIAL_DIRECTIONS_CAR, "Driving", "switch-" + Nav.DRIVER, + () -> Nav.switchTo(Nav.DRIVER))); + } + + Container bottom = new Container(BoxLayout.y()); + bottom.add(entry(FontImage.MATERIAL_SETTINGS, "Settings", "menu-settings", + () -> SettingsForm.show(form))); + bottom.add(entry(FontImage.MATERIAL_LOGOUT, "Sign out", "menu-signOut", Nav::signOut)); + + panel.add(BorderLayout.NORTH, who); + panel.add(BorderLayout.CENTER, list); + panel.add(BorderLayout.SOUTH, bottom); + return panel; + } + + private static Button entry(char icon, String text, String name, final Runnable go) { + Button entry = new Button(text, "WlSideItem"); + entry.setName(name); + Ui.icon(entry, icon, 3.4f); + entry.addActionListener(e -> go.run()); + return entry; + } + + /// The window's menu bar: the places under Go, each with a key, and + /// Settings and signing out where each platform keeps them. Called only + /// where the window has a menu bar, which is where commands put in the + /// title bar's side menu are shown. + private void menus() { + for (int iter = 0; iter < PLACES.length; iter++) { + final String id = PLACES[iter][0]; + Command go = Command.create(Lang.tr(PLACES[iter][1]), null, e -> go(id)); + go.setDesktopMenu(Lang.tr("Go")); + go.setDesktopShortcut((char) ('1' + iter)); + form.getToolbar().addCommandToSideMenu(go); + } + Command settings = Command.create(Lang.tr("Settings"), null, + e -> SettingsForm.show(form)); + settings.setDesktopMenu(Command.DESKTOP_MENU_PREFERENCES); + settings.setDesktopShortcut(','); + form.getToolbar().addCommandToSideMenu(settings); + Command signOut = Command.create(Lang.tr("Sign out"), null, e -> Nav.signOut()); + signOut.setDesktopMenu(Command.DESKTOP_MENU_FILE); + form.getToolbar().addCommandToSideMenu(signOut); + } + + /// One thing shown in the detail's place. + private static final class Shown { + final String title; + final String name; + final Component content; + + Shown(String title, String name, Component content) { + this.title = title; + this.name = name; + this.content = content; + } + } + + /// The panes of a wide screen: the list on the left of the room, and what + /// is opened from it on the right. + private final class Wide extends Panes { + private final List open = new ArrayList(); + private final Container detail = new Container(new BorderLayout()); + private Runnable shown; + private Component picked; + + void clear() { + open.clear(); + shown = null; + picked = null; + } + + @Override + void list(String title, String name, Component content, Runnable shown) { + clear(); + this.shown = shown; + Container master = new Container(new BorderLayout()) { + @Override + protected Dimension calcPreferredSize() { + Dimension size = super.calcPreferredSize(); + // A list is as wide as a phone, and never most of the room. + size.setWidth(Math.min(CN.convertToPixels(MASTER_MM), + body.getWidth() > 0 ? body.getWidth() / 2 : Integer.MAX_VALUE)); + return size; + } + }; + master.setUIID("WlMaster"); + master.setName(name); + master.add(BorderLayout.CENTER, content); + body.removeAll(); + body.add(BorderLayout.WEST, master); + body.add(BorderLayout.CENTER, detail); + title(title); + Ui.viewed(name); + show(); + shown.run(); + } + + @Override + void page(String title, String name, Component content) { + clear(); + open.add(new Shown(title, name, content)); + body.removeAll(); + body.add(BorderLayout.CENTER, detail); + title(title); + show(); + } + + @Override + void detail(String title, String name, Component content) { + open.clear(); + over(title, name, content); + } + + @Override + void over(String title, String name, Component content) { + open.add(new Shown(title, name, content)); + show(); + } + + @Override + void back() { + if (!open.isEmpty()) { + open.remove(open.size() - 1); + } + show(); + } + + @Override + void home() { + open.clear(); + picked = null; + show(); + if (shown != null) { + shown.run(); + } + } + + @Override + void refresh() { + Layouts.dress(form); + Ui.refresh(form); + } + + @Override + Form form() { + return form; + } + + @Override + void picked(Component row) { + if (picked != null) { + picked.setUIID("WlRow"); + } + picked = row; + row.setUIID("WlRowPicked"); + } + + /// Puts what is on top in the detail's place, or says that nothing is + /// picked. + private void show() { + detail.removeAll(); + if (open.isEmpty()) { + Container none = Ui.empty(FontImage.MATERIAL_TOUCH_APP, "Nothing selected", + "Pick a row from the list to see it here."); + none.setName("nothing"); + detail.add(BorderLayout.CENTER, none); + } else { + Shown top = open.get(open.size() - 1); + Ui.viewed(top.name); + Container bar = new Container(new BorderLayout()); + bar.setUIID("WlPaneBar"); + // Something shown over the detail is left the way it is on a + // phone: back. The detail itself has nowhere to go back to. + if (open.size() > 1) { + Button back = Ui.flat(FontImage.MATERIAL_ARROW_BACK, "pane-back", + e -> back()); + bar.add(BorderLayout.WEST, back); + } + bar.add(BorderLayout.CENTER, Ui.label(top.title, "WlPaneTitle")); + Container holder = new Container(new BorderLayout()); + holder.setName(top.name); + // It may still be in the holder it was shown in before + // something was put over it. + top.content.remove(); + holder.add(BorderLayout.CENTER, top.content); + detail.add(BorderLayout.NORTH, bar); + detail.add(BorderLayout.CENTER, holder); + } + refresh(); + } + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/Dashboard.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/Dashboard.java new file mode 100644 index 00000000000..02c1a3bdf34 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/Dashboard.java @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.wayline.api.AdminStatsDto; +import com.codenameone.examples.wayline.api.DayStatsDto; +import com.codenameone.examples.wayline.api.DriverDto; +import com.codenameone.examples.wayline.api.NameCountDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.api.StatsSeriesDto; +import com.codenameone.examples.wayline.live.LiveChannel; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.Chart; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Layouts; +import com.codenameone.examples.wayline.ui.StatRow; +import com.codenameone.examples.wayline.ui.Ui; +import java.util.List; + +/// The numbers of the admin console: what needs an answer, how the service is +/// doing right now, and how it has done over the last weeks. +/// +/// It is the same on a phone and on a wide screen, laid out for the room there +/// is. On a phone everything is one column, with the four numbers two by two. +/// On a wide screen the numbers are one row and the charts are side by side, +/// in as many columns as fit -- see [#fit()], which is what a resized window +/// calls. +final class Dashboard extends LiveChannel.Adapter { + /// A chart is not drawn narrower than this, in millimetres. + private static final float TILE_MM = 88f; + + /// Where the things that want attention lead. + interface Places { + void applications(); + + void flagged(); + } + + private final boolean wide; + private final Places places; + private final Container page; + private final Container alerts = new Container(BoxLayout.y()); + private final StatRow numbers; + private final Container series = new Container(BoxLayout.y()); + private int days = 14; + private int columns; + private StatsSeriesDto last; + + /// @param wide whether to lay out for a wide screen + Dashboard(boolean wide, Places places) { + this.wide = wide; + this.places = places; + numbers = Ui.stats(wide ? 4 : 2); + if (wide) { + // Not Ui.page(): that keeps to a column, and this is the one page + // that is meant to use the width. + page = new Container(BoxLayout.y()); + page.setUIID("WlPage"); + page.setScrollableY(true); + page.setScrollVisible(false); + } else { + page = Ui.page(); + page.setSafeArea(false); + } + page.setName("dashboard"); + alerts.setName("alerts"); + page.add(alerts); + numbers.setName("stats"); + Ui.section(page, "Right now"); + page.add(numbers); + Ui.section(page, "Trends"); + Container range = Ui.choice("range", new String[] {"7 days", "14 days", "30 days"}, + new String[] {"7", "14", "30"}, String.valueOf(days), value -> { + days = Integer.parseInt(value); + loadSeries(); + }); + // On a wide screen the choice is as wide as it needs to be, not the + // whole width of the window. + page.add(wide ? FlowLayout.encloseIn(range) : range); + series.setName("series"); + page.add(series); + } + + /// The page, for the screen to place. + Container page() { + return page; + } + + /// Shows the numbers as nothing, before the first answer. + void start() { + fill(new AdminStatsDto(0, 0, 0, 0, 0, 0, 0L, null)); + } + + /// The page came into view: everything is fetched again. + void shown() { + LiveChannel.listen(this); + load(); + loadSeries(); + } + + @Override + public void rideChanged(String id, String state) { + load(); + } + + void load() { + Api.admin().stats(Net.to(this::fill, (status, message) -> { })); + } + + private void loadSeries() { + final int asked = days; + Api.admin().statsSeries(asked, Net.to(found -> { + if (asked == days) { + fill(found); + } + }, (status, message) -> { })); + } + + /// The room changed: the charts are laid out again if a different number + /// of them now fits side by side. + void fit() { + if (wide && last != null && columns != columns()) { + fill(last); + } + } + + private int columns() { + if (!wide) { + return 1; + } + int width = page.getWidth() > 0 ? page.getWidth() : CN.getDisplayWidth() * 3 / 4; + return Layouts.columns(width, TILE_MM, 3); + } + + private void refresh() { + Layouts.dress(page); + Form form = page.getComponentForm(); + if (form != null) { + Ui.refresh(form); + } + } + + private void fill(AdminStatsDto stats) { + numbers.clear(); + numbers.stat(String.valueOf(stats.ridesToday()), "Rides today", "ridesToday"); + numbers.stat(String.valueOf(stats.ridesActive()), "Under way", "ridesActive"); + numbers.stat(stats.driversOnline() + " / " + stats.drivers(), "Drivers online", + "driversOnline"); + numbers.stat(Ui.money(stats.revenueCents(), stats.currency()), "Fares collected", + "revenue"); + refresh(); + } + + private void fill(StatsSeriesDto found) { + last = found; + alerts.removeAll(); + if (found.pendingApplications() > 0) { + alerts.add(alert(FontImage.MATERIAL_ASSIGNMENT_IND, found.pendingApplications() == 1 + ? Lang.tr("1 application to review") + : Lang.tr("{0} applications to review", + String.valueOf(found.pendingApplications())), "alert-applications", + places::applications)); + } + if (found.flaggedUsers() > 0) { + alerts.add(alert(FontImage.MATERIAL_FLAG, found.flaggedUsers() == 1 + ? Lang.tr("1 flagged account") : Lang.tr("{0} flagged accounts", + String.valueOf(found.flaggedUsers())), "alert-flagged", + places::flagged)); + } + series.removeAll(); + int count = found.days() == null ? 0 : found.days().size(); + double[] rides = new double[count]; + double[] revenue = new double[count]; + int asked = 0; + int completed = 0; + int cancelled = 0; + long taken = 0; + int joined = 0; + for (int iter = 0; iter < count; iter++) { + DayStatsDto day = found.days().get(iter); + rides[iter] = day.rides(); + revenue[iter] = day.revenue(); + asked += day.rides(); + completed += day.completed(); + cancelled += day.cancelled(); + taken += day.revenue(); + joined += day.newUsers(); + } + + StatRow rates = Ui.stats(3); + rates.stat(Math.round(found.completionRate() * 100d) + "%", "Completed", + "completionRate"); + rates.stat(Ui.money(found.averageFare(), found.currency()), "Average fare", + "averageFare"); + rates.stat(wait(found.averageWaitSeconds()), "Average wait", "averageWait"); + + // One column on a phone, in the order they are added. On a wide screen + // as many columns as fit, dealt out in turn: each tile keeps its own + // height, which a grid of tiles would not let it. + columns = columns(); + Container[] column = new Container[columns]; + if (columns == 1) { + column[0] = series; + } else { + series.add(rates); + Container grid = new Container(new GridLayout(1, columns)); + for (int iter = 0; iter < columns; iter++) { + column[iter] = new Container(BoxLayout.y()); + grid.add(column[iter]); + } + series.add(grid); + } + int next = 0; + String from = Lang.tr("{0} days ago", String.valueOf(Math.max(1, count - 1))); + String to = Lang.tr("Today"); + column[next++ % columns].add(tile("Rides", String.valueOf(asked), "seriesRides", + Lang.tr("{0} completed, {1} cancelled", String.valueOf(completed), + String.valueOf(cancelled)), + Chart.of("chartRides", rides, from, to))); + column[next++ % columns].add(tile("Fares", Ui.money(taken, found.currency()), + "seriesRevenue", Lang.tr("{0} new accounts", String.valueOf(joined)), + Chart.of("chartRevenue", revenue, from, to))); + if (columns == 1) { + series.add(rates); + } + + int hours = found.byHour() == null ? 0 : found.byHour().size(); + double[] hourly = new double[hours]; + for (int iter = 0; iter < hours; iter++) { + Integer value = found.byHour().get(iter); + hourly[iter] = value == null ? 0 : value.intValue(); + } + column[next++ % columns].add(tile("Requests by hour", null, null, + "The busy hours, in UTC", Chart.of("chartHours", hourly, "00:00", "23:00"))); + column[next++ % columns].add(shares("By outcome", found.byState(), true)); + column[next++ % columns].add(shares("By kind of ride", found.byProduct(), false)); + + if (found.topDrivers() != null && !found.topDrivers().isEmpty()) { + Container top = box("Top drivers"); + for (int iter = 0; iter < found.topDrivers().size(); iter++) { + DriverDto driver = found.topDrivers().get(iter); + Container row = Ui.row((char) 0, driver.displayName(), driver.vehicle(), + Ui.plain(Lang.tr("{0} rides", String.valueOf(driver.rides())), + "WlRowValue")); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle( + Ui.avatar(driver.displayName()))); + top.add(row); + } + column[next++ % columns].add(top); + } + refresh(); + } + + /// Something that wants the admin's attention, as a button that goes to it. + private static Button alert(char icon, String text, String name, final Runnable go) { + Button alert = new Button("", "WlAlert"); + alert.setShouldLocalize(false); + alert.setText(text); + alert.setName(name); + Ui.icon(alert, icon, 3.4f); + alert.addActionListener(e -> go.run()); + return alert; + } + + private static Container box(String title) { + Container box = new Container(BoxLayout.y()); + box.setUIID("WlTile"); + box.add(Ui.label(title, "WlStatLabel")); + return box; + } + + /// A chart with what it adds up to written over it. + private static Container tile(String title, String value, String name, String detail, + Container chart) { + Container tile = box(title); + if (value != null) { + Label total = Ui.plain(value, "WlChartValue"); + total.setName(name); + tile.add(total); + } + tile.add(Ui.plain(Lang.tr(detail), "WlMuted")); + tile.add(chart); + return tile; + } + + /// How a total divides: each part with its count and its share as a bar. + private static Container shares(String title, List parts, boolean states) { + Container tile = box(title); + long total = 0; + for (int iter = 0; parts != null && iter < parts.size(); iter++) { + total += parts.get(iter).count(); + } + for (int iter = 0; parts != null && iter < parts.size(); iter++) { + NameCountDto part = parts.get(iter); + String name = states ? Ui.state(RideState.named(part.name())) : Ui.product(part.name()); + tile.add(Ui.fact(name.length() == 0 ? part.name() : name, + String.valueOf(part.count()))); + // A total of nothing has no shares; the bars are then empty. + tile.add(new Chart.Meter(total == 0 ? 0 : part.count() / (double) total)); + } + return tile; + } + + private static String wait(double seconds) { + if (seconds < 90) { + return Lang.tr("{0} s", String.valueOf(Math.round(seconds))); + } + return Ui.minutes(seconds); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/FleetForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/FleetForm.java new file mode 100644 index 00000000000..4d6ceb5f129 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/FleetForm.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.maps.LatLng; +import com.codename1.ui.Command; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codenameone.examples.wayline.api.DriverDto; +import com.codenameone.examples.wayline.live.LiveChannel; +import com.codenameone.examples.wayline.map.CarMarker; +import com.codenameone.examples.wayline.map.Locator; +import com.codenameone.examples.wayline.map.MapStage; +import com.codenameone.examples.wayline.map.Maps; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Ui; +import com.codename1.ui.util.UITimer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Every car online, on one map, moving as the drivers report in. +public class FleetForm extends LiveChannel.Adapter { + private static final int POLL_MILLIS = 10000; + + private final Form form = Ui.home("Fleet"); + private final MapStage stage; + private final Label count = Ui.label("", "WlHeading"); + private final Map cars = new HashMap(); + + FleetForm(final Form previous) { + // No title bar to carry the back arrow, so the round button over the + // map is it -- and the screen's back command too, which is what the + // device's own back button and gesture run. + Command back = Command.create("", null, e -> previous.showBack()); + form.setBackCommand(back); + stage = new MapStage(Locator.here(), Maps.CITY_ZOOM, + Ui.round(FontImage.MATERIAL_ARROW_BACK, "back", e -> previous.showBack())); + stage.header(Ui.label("Live map", "WlMapTitle")); + stage.addRound(FontImage.MATERIAL_ZOOM_OUT_MAP, "recentre", this::frame); + count.setName("fleetCount"); + stage.sheet().peek().add(count); + stage.sheet().peek().add(Ui.label("Cars move as their drivers report in.", "WlMuted")); + stage.sheet().ready(false); + form.add(stage.layers()); + form.addShowListener(e -> { + LiveChannel.listen(this); + load(); + }); + // The channel moves the cars; this catches the one whose driver went + // quiet without saying so. + UITimer.timer(POLL_MILLIS, true, form, this::load); + } + + void show() { + counted(); + form.show(); + } + + private void load() { + Api.admin().drivers(Net.to(this::fill, Ui::fail)); + } + + private void fill(List drivers) { + Map gone = new HashMap(cars); + for (int iter = 0; drivers != null && iter < drivers.size(); iter++) { + DriverDto driver = drivers.get(iter); + if (driver.online()) { + gone.remove(driver.username()); + place(driver.username(), driver.lat(), driver.lng(), driver.heading()); + } + } + for (Map.Entry left : gone.entrySet()) { + left.getValue().remove(); + cars.remove(left.getKey()); + } + counted(); + } + + @Override + public void fleetChanged(String username, boolean online, double lat, double lng, + double heading) { + if (online) { + place(username, lat, lng, heading); + } else { + CarMarker car = cars.remove(username); + if (car != null) { + car.remove(); + } + } + counted(); + } + + private void place(String username, double lat, double lng, double heading) { + LatLng at = new LatLng(lat, lng); + CarMarker car = cars.get(username); + if (car == null) { + cars.put(username, new CarMarker(stage.map, form, at, heading)); + } else { + car.moveTo(at, heading); + } + } + + /// Moves the camera to take in every car there is. + private void frame() { + List all = new ArrayList(); + for (CarMarker car : cars.values()) { + all.add(car.position()); + } + if (all.isEmpty()) { + stage.map.moveCamera(Locator.here(), Maps.CITY_ZOOM); + } else { + stage.frame(all.toArray(new LatLng[all.size()])); + } + } + + private void counted() { + int size = cars.size(); + count.setText(size == 0 ? Lang.tr("No cars online") : size == 1 + ? Lang.tr("1 car online") : Lang.tr("{0} cars online", String.valueOf(size))); + stage.map.repaint(); + Ui.refresh(form); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/Panes.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/Panes.java new file mode 100644 index 00000000000..f510df29e01 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/Panes.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.wayline.ui.Layouts; +import com.codenameone.examples.wayline.ui.Nav; +import com.codenameone.examples.wayline.ui.Ui; +import java.util.ArrayList; +import java.util.List; + +/// Where a part of the admin console shows what it has. +/// +/// Every part is a list and what is opened from it: an application, an +/// account, a ride. On a phone each of those is a screen of its own, reached +/// from the one before and left with the back arrow. On a wide screen the list +/// stays where it is and what is opened from it is shown beside it. The parts +/// are written once, against this, and do not know which they are in: +/// +/// - [#list] shows the list. +/// - [#detail] shows what was picked from it, in place of whatever was open. +/// - [#over] shows something on top of that: a document, the question "why?". +/// - [#back] leaves the last of those; [#home] leaves all of them. +/// +/// [#phone(Form)] is the phone's. The wide one is [Console]'s. +abstract class Panes { + /// Shows a part's list. + /// + /// @param name what a test waits for + /// @param shown run each time the list comes into view, to fetch what is in it + abstract void list(String title, String name, Component content, Runnable shown); + + /// Shows a part that is one page and has nothing to open: the prices. + abstract void page(String title, String name, Component content); + + /// Shows what was picked from the list, in place of whatever was open. + abstract void detail(String title, String name, Component content); + + /// Shows `content` on top of what is open. + abstract void over(String title, String name, Component content); + + /// Leaves what is on top. + abstract void back(); + + /// Back to the list alone, which is fetched again. + abstract void home(); + + /// Lays out again what is showing, after it changed. + abstract void refresh(); + + /// The screen all of this is on, for something that needs one to return to. + abstract Form form(); + + /// Marks `row` as the one that is open, where the list stays in view. + void picked(Component row) { + } + + /// Asks why, on top of what is open. See [Ui#reason]. + void reason(String title, String text, String action, Ui.Reason reason) { + over(title, "Reason", Ui.reasonPage(text, action, reason)); + } + + /// The panes of a phone: a screen each. + /// + /// @param previous where the back arrow of the list leads + static Panes phone(final Form previous) { + return new Panes() { + private final List
screens = new ArrayList(); + + @Override + void list(String title, String name, Component content, final Runnable shown) { + Form form = Ui.form(title, name); + Ui.back(form, previous); + form.add(BorderLayout.CENTER, content); + form.addShowListener(e -> shown.run()); + watch(form); + screens.clear(); + screens.add(form); + form.show(); + } + + @Override + void page(String title, String name, Component content) { + list(title, name, content, () -> { }); + } + + @Override + void detail(String title, String name, Component content) { + while (screens.size() > 1) { + screens.remove(screens.size() - 1); + } + over(title, name, content); + } + + @Override + void over(String title, String name, Component content) { + Form form = Ui.form(title, name); + Ui.back(form, this::back); + form.add(BorderLayout.CENTER, content); + watch(form); + screens.add(form); + form.show(); + } + + /// A screen made for a narrow window is of no use to a wide one: + /// the console is built again, as the wide one. And on a desktop + /// a narrow window still wears the desktop's look. + private void watch(Form form) { + Layouts.dress(form); + Layouts.onChange(form, Nav::home); + } + + @Override + void back() { + if (screens.size() > 1) { + screens.remove(screens.size() - 1); + } + form().showBack(); + } + + @Override + void home() { + while (screens.size() > 1) { + screens.remove(screens.size() - 1); + } + form().showBack(); + } + + @Override + void refresh() { + Layouts.dress(form()); + Ui.refresh(form()); + } + + @Override + Form form() { + return screens.get(screens.size() - 1); + } + }; + } + + /// A list with what filters it above it, as the content of [#list]. + static Container listed(Component filters, Component list) { + Container content = new Container(new BorderLayout()); + if (filters != null) { + content.add(BorderLayout.NORTH, filters); + } + content.add(BorderLayout.CENTER, list); + return content; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/PaymentsForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/PaymentsForm.java new file mode 100644 index 00000000000..b679ffe98cb --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/PaymentsForm.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.ReasonDto; +import com.codenameone.examples.wayline.api.ReceiptDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.pay.ReceiptForm; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Ui; +import java.util.List; + +/// What was charged, newest first, and for each whether it went through. A +/// payment opens as its receipt, which is where an admin returns the money. +public final class PaymentsForm { + private PaymentsForm() { + } + + static void show(Form previous) { + show(Panes.phone(previous)); + } + + static void show(final Panes panes) { + final Container list = Ui.list("payments"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.admin().payments(Net.to(found -> fill(panes, list, found), + Ui.failed(list, () -> load[0].run()))); + panes.list("Payments", "Payments", Panes.listed(null, list), load[0]); + } + + /// A payment, opened: its receipt, and the way to return the money. + private static void open(final Panes panes, final ReceiptDto payment) { + Container page = ReceiptForm.page(payment, true); + if ("paid".equals(payment.status())) { + page.add(Ui.danger("Refund this ride", "refund", e -> panes.reason("Refund", + Lang.tr("{0} goes back to the card it came from.", + Ui.money(payment.total(), payment.currency())), "Refund", why -> { + ReasonDto reason = new ReasonDto(why); + Api.admin().refundRide(payment.rideId(), reason, Net.to(refunded -> { + Telemetry.rideRefunded("payments"); + open(panes, refunded); + }, Ui::fail)); + }))); + } + panes.detail("Receipt", "Receipt", page); + } + + private static void fill(final Panes panes, Container list, List payments) { + list.removeAll(); + if (payments == null || payments.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_PAYMENTS, "No payments yet", + "A ride shows up here when it is paid for.")); + } + for (int iter = 0; payments != null && iter < payments.size(); iter++) { + final ReceiptDto payment = payments.get(iter); + Container value = new Container(BoxLayout.y()); + value.add(Ui.plain(Ui.money(payment.total(), payment.currency()), "WlRowValue")); + value.add(Ui.badge(ReceiptForm.status(payment.status()), + ReceiptForm.badge(payment.status()))); + String who = payment.riderUsername() == null ? "" : payment.riderUsername(); + String how = "cash".equals(payment.status()) ? Lang.tr("Cash") : payment.methodLabel(); + final Container picked = Ui.row("cash".equals(payment.status()) + ? FontImage.MATERIAL_PAYMENTS : FontImage.MATERIAL_CREDIT_CARD, + payment.dropoffName(), + who + (who.length() > 0 && how != null && how.length() > 0 ? Ui.DOT : "") + + (how == null ? "" : how), value); + list.add(Ui.tap(picked, "payment-" + iter, e -> { + panes.picked(picked); + open(panes, payment); + })); + } + panes.refresh(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/PricingForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/PricingForm.java new file mode 100644 index 00000000000..fdda1c51fa6 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/PricingForm.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.PricingDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Ui; + +/// What a ride costs: the parts of a fare, the service's share, and what the +/// roomier cars and the busy hours multiply it by. +/// +/// A change is for the rides asked for from then on; a ride already quoted +/// keeps its price. The server refuses numbers that could only be a mistake, +/// and says which. +public final class PricingForm { + private PricingForm() { + } + + static void show(final Form previous) { + show(Panes.phone(previous)); + } + + static void show(final Panes panes) { + final Container page = Ui.page(); + panes.page("Pricing", "Pricing", page); + Api.admin().pricing(Net.to(pricing -> fill(panes, page, pricing), Ui::fail)); + } + + private static void fill(final Panes panes, final Container page, final PricingDto pricing) { + page.removeAll(); + Ui.section(page, Lang.tr("The fare, in {0}", pricing.currency())); + final TextField base = money(page, "Base fare", "baseFare", pricing.baseFare(), null); + Container pair = new Container(new GridLayout(1, 2)); + Container left = side("WlPairLeft"); + Container right = side("WlPairRight"); + final TextField perKm = money(left, "Per kilometre", "perKm", pricing.perKm(), null); + final TextField perMinute = money(right, "Per minute", "perMinute", pricing.perMinute(), + null); + pair.add(left).add(right); + page.add(pair); + final TextField minimum = money(page, "Minimum fare", "minimumFare", pricing.minimumFare(), + null); + + Ui.section(page, "The service's share"); + pair = new Container(new GridLayout(1, 2)); + left = side("WlPairLeft"); + right = side("WlPairRight"); + final TextField fee = number(left, "Service fee, %", "serviceFeePercent", + pricing.serviceFeePercent()); + final TextField commission = number(right, "Commission, %", "commissionPercent", + pricing.commissionPercent()); + pair.add(left).add(right); + page.add(pair); + page.add(Ui.text("The fee is added to the fare and paid by the rider. The commission " + + "is kept from the fare; the rest is the driver's.", "WlMuted")); + + Ui.section(page, "Multipliers"); + final TextField surge = number(page, "Surge, while demand is high", "surgeMultiplier", + pricing.surgeMultiplier()); + pair = new Container(new GridLayout(1, 2)); + left = side("WlPairLeft"); + right = side("WlPairRight"); + final TextField comfort = number(left, "Comfort", "comfortMultiplier", + pricing.comfortMultiplier()); + final TextField xl = number(right, "XL", "xlMultiplier", pricing.xlMultiplier()); + pair.add(left).add(right); + page.add(pair); + + final SpanLabel error = Ui.text("", "WlError"); + error.setName("error"); + final Button save = Ui.primary("Save prices", "submit", null); + save.addActionListener(e -> { + PricingDto wanted = new PricingDto(pricing.currency(), cents(base), cents(perKm), + cents(perMinute), cents(minimum), value(fee), value(commission), value(surge), + value(comfort), value(xl)); + save.setEnabled(false); + error.setText(""); + Api.admin().savePricing(wanted, Net.to(saved -> { + Telemetry.pricingChanged(); + Ui.say("The new prices are in force"); + fill(panes, page, saved); + }, (status, message) -> { + save.setEnabled(true); + error.setText(message); + panes.refresh(); + })); + }); + page.add(error).add(save); + panes.refresh(); + } + + private static Container side(String uiid) { + Container side = new Container(BoxLayout.y()); + side.setUIID(uiid); + return side; + } + + private static TextField money(Container to, String caption, String name, long cents, + String hint) { + TextField field = Ui.field(to, caption, name, hint == null ? "0.00" : hint, + TextArea.DECIMAL); + long part = cents % 100; + field.setText((cents / 100) + "." + (part < 10 ? "0" : "") + part); + return field; + } + + private static TextField number(Container to, String caption, String name, double value) { + TextField field = Ui.field(to, caption, name, "1.0", TextArea.DECIMAL); + // Two decimal places are all a percentage or a multiplier needs. + long hundredths = Math.round(value * 100d); + long part = hundredths % 100; + field.setText((hundredths / 100) + "." + (part < 10 ? "0" : "") + part); + return field; + } + + private static long cents(TextField field) { + return Math.round(value(field) * 100d); + } + + /// The number typed, or -1 for anything that is not one, which the server + /// then refuses by name. + private static double value(TextField field) { + String text = field.getText().trim().replace(',', '.'); + boolean point = false; + if (text.length() == 0 || text.length() > 12) { + return -1; + } + for (int iter = 0; iter < text.length(); iter++) { + char letter = text.charAt(iter); + if (letter == '.' && !point) { + point = true; + } else if (letter < '0' || letter > '9') { + return -1; + } + } + return ".".equals(text) ? -1 : Double.parseDouble(text); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/UsersForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/UsersForm.java new file mode 100644 index 00000000000..049c043dfb9 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/admin/UsersForm.java @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.admin; + +import com.codename1.components.SpanLabel; +import com.codename1.components.Switch; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.FlagDto; +import com.codenameone.examples.wayline.api.ModerationEventDto; +import com.codenameone.examples.wayline.api.RoleChangeDto; +import com.codenameone.examples.wayline.api.SuspendDto; +import com.codenameone.examples.wayline.api.UserDetailDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.StatRow; +import com.codenameone.examples.wayline.ui.Ui; +import java.util.List; + +/// The accounts, and what an admin decides about each: its roles, whether it +/// is flagged for attention, and whether it may be used at all. +/// +/// All of it takes effect at once. The server reads an account's roles and its +/// block on every request, not from the token the app is holding, so a driver +/// made an admin has the admin screens the next time their app asks who they +/// are, and a blocked account's next call is refused. +/// +/// A flag and a block are both kept with a reason and with who gave it; the +/// account's screen shows that history, newest first. +public final class UsersForm { + static final String ALL = "all"; + static final String FLAGGED = "flagged"; + private static final String[] FILTERS = {ALL, "riders", "drivers", "admins", FLAGGED, + "blocked"}; + private static final String[] FILTER_NAMES = {"Everyone", "Riders", "Drivers", "Admins", + "Flagged", "Blocked"}; + + private final Panes panes; + private final Container list = Ui.list("users"); + private final TextField search; + private List users; + private String filter; + + private UsersForm(Panes panes, String filter) { + this.panes = panes; + this.filter = filter == null || filter.length() == 0 ? ALL : filter; + Container top = new Container(BoxLayout.y()); + top.setUIID("WlPage"); + search = new TextField("", Lang.tr("Search by name or e-mail"), 24, TextField.ANY); + search.setUIID("WlField"); + search.getHintLabel().setUIID("WlFieldHint"); + search.setName("search"); + search.addDataChangedListener((type, index) -> fill()); + top.add(search); + top.add(Ui.choice("filter", FILTER_NAMES, FILTERS, this.filter, value -> { + this.filter = value; + fill(); + }, 3)); + panes.list("People", "Users", Panes.listed(top, list), + this::load); + } + + private void load() { + Api.admin().users(Net.to(found -> { + users = found; + fill(); + }, Ui.failed(list, this::load))); + } + + /// @param filter one of the filters to open on, or empty for everyone + static void show(Form previous, String filter) { + show(Panes.phone(previous), filter); + } + + static void show(Panes panes, String filter) { + new UsersForm(panes, filter); + } + + private void fill() { + list.removeAll(); + String wanted = search.getText().trim(); + int shown = 0; + for (int iter = 0; users != null && iter < users.size(); iter++) { + final UserDto user = users.get(iter); + if (!passes(user) || !matches(user, wanted)) { + continue; + } + shown++; + Container marks = new Container(new FlowLayout(Label.RIGHT, Label.CENTER)); + if (user.flagged()) { + marks.add(Ui.badge("Flagged", "WlBadgeWarn")); + } + marks.add(Ui.badge(roles(user), user.suspended() ? "WlBadgeBad" : "WlBadge")); + Container row = Ui.row((char) 0, user.displayName(), user.username(), marks); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle( + Ui.avatar(user.displayName()))); + final Container picked = row; + list.add(Ui.tap(row, "user-" + user.username(), e -> { + panes.picked(picked); + open(user.username()); + })); + } + if (shown == 0 && users != null) { + list.add(Ui.empty(FontImage.MATERIAL_PERSON_SEARCH, "Nobody matches", null)); + } + panes.refresh(); + } + + private boolean passes(UserDto user) { + if (FLAGGED.equals(filter)) { + return user.flagged(); + } + if ("blocked".equals(filter)) { + return user.suspended(); + } + if ("admins".equals(filter)) { + return user.admin(); + } + if ("drivers".equals(filter)) { + return user.driver(); + } + if ("riders".equals(filter)) { + return !user.driver() && !user.admin(); + } + return true; + } + + /// Whether `wanted` is in the name or the address, whatever the case. Both + /// are compared a letter at a time: a name is not a protocol token, but + /// folding it by the device's language would still miss an `I` in Turkish. + private static boolean matches(UserDto user, String wanted) { + return wanted.length() == 0 || contains(user.displayName(), wanted) + || contains(user.username(), wanted); + } + + private static boolean contains(String text, String wanted) { + if (text == null) { + return false; + } + for (int at = 0; at + wanted.length() <= text.length(); at++) { + if (text.regionMatches(true, at, wanted, 0, wanted.length())) { + return true; + } + } + return false; + } + + static String roles(UserDto user) { + if (user.suspended()) { + return "Blocked"; + } + return user.admin() ? "Admin" : user.driver() ? "Driver" : "Rider"; + } + + /// One account in full, fetched each time it is shown. + private void open(final String username) { + Api.admin().user(username, Net.to(detail -> show(detail), Ui::fail)); + } + + private void show(final UserDetailDto detail) { + final UserDto user = detail.user(); + Container page = Ui.page(); + + Label standing = Ui.badge(user.suspended() ? "Blocked" : user.flagged() ? "Flagged" + : "In good standing", user.suspended() ? "WlBadgeBad" : user.flagged() + ? "WlBadgeWarn" : "WlBadgeGood"); + standing.setName("standing"); + page.add(Ui.person(user.displayName(), user.username(), standing)); + if (user.flagged() && user.flagReason() != null && user.flagReason().length() > 0) { + SpanLabel why = Ui.text(user.flagReason(), "WlNotice"); + why.setName("flagReason"); + page.add(why); + } + + StatRow numbers = Ui.stats(3); + numbers.stat(String.valueOf(detail.rides()), "Rides", "userRides"); + numbers.stat(String.valueOf(detail.cancellations()), "Cancelled", + "userCancellations"); + numbers.stat(detail.rating() > 0 ? Ui.tenths(detail.rating()) : "-", "Rating", + "userRating"); + page.add(numbers); + StatRow money = Ui.stats(2); + money.stat(Ui.money(detail.spent(), null), "Spent riding", "userSpent"); + money.stat(Ui.money(detail.earned(), null), "Earned driving", "userEarned"); + page.add(money); + + Ui.section(page, "Contact"); + page.add(Ui.row(FontImage.MATERIAL_PHONE, user.phone(), + user.phoneVerified() ? "Verified" : "Not verified", null)); + if (user.driver()) { + page.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, user.vehicle(), user.plate(), null)); + } + + Ui.section(page, "Access"); + final Switch driver = toggle("driver", user.driver()); + final Switch admin = toggle("admin", user.admin()); + page.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, "Driver", + "May go online and take rides", driver)); + page.add(Ui.row(FontImage.MATERIAL_INSIGHTS, "Admin", + "May see and change everything here", admin)); + // A change the server refuses -- an admin removing their own role, say + // -- is put back, so the switches always show what is true. Putting + // one back is itself a change, which `settling` keeps from being sent. + final boolean[] settling = new boolean[1]; + // The roles as the server last confirmed them: driver, admin. + final boolean[] held = {user.driver(), user.admin()}; + final Runnable roles = () -> { + if (settling[0]) { + return; + } + RoleChangeDto change = new RoleChangeDto(driver.isValue(), admin.isValue()); + Api.admin().setRoles(user.username(), change, Net.to(saved -> { + held[0] = saved.driver(); + held[1] = saved.admin(); + }, (status, message) -> { + Ui.fail(status, message); + settling[0] = true; + driver.setValue(held[0]); + admin.setValue(held[1]); + settling[0] = false; + })); + }; + driver.addChangeListener(e -> roles.run()); + admin.addChangeListener(e -> roles.run()); + + Ui.section(page, "Moderation"); + page.add(Ui.text("A flag marks the account for the admins and changes nothing for " + + "its owner. A block signs it out and refuses it until lifted.", "WlMuted")); + Container actions = new Container(new GridLayout(1, 2)); + actions.add(pair("WlPairLeft", user.flagged() + ? Ui.secondary("Remove flag", "unflag", e -> flag(user, false, "")) + : Ui.secondary("Flag", "flag", e -> panes.reason("Flag account", + Lang.tr("Why does {0} need the admins' attention?", user.displayName()), + "Flag", why -> flag(user, true, why))))); + actions.add(pair("WlPairRight", user.suspended() + ? Ui.secondary("Unblock", "unblock", e -> block(user, false, "")) + : Ui.danger("Block", "block", e -> panes.reason("Block account", + Lang.tr("{0} is signed out everywhere and cannot sign in again " + + "until the block is lifted.", user.displayName()), "Block", + why -> block(user, true, why))))); + page.add(actions); + + Ui.section(page, "History"); + List events = detail.events(); + if (events == null || events.isEmpty()) { + page.add(Ui.label("Nothing has been done to this account.", "WlMuted")); + } + for (int iter = 0; events != null && iter < events.size(); iter++) { + ModerationEventDto event = events.get(iter); + Container row = Ui.row(icon(event.action()), action(event.action()), + event.reason() == null || event.reason().length() == 0 ? null : event.reason(), + null); + row.setName("event-" + iter); + Container when = new Container(BoxLayout.y()); + when.add(Ui.plain(Ui.when(event.at()), "WlRowDetail")); + when.add(Ui.plain(event.by(), "WlRowDetail")); + row.add(BorderLayout.EAST, when); + page.add(row); + } + panes.detail(user.displayName(), "User", page); + } + + private void flag(UserDto user, boolean flagged, String why) { + FlagDto change = new FlagDto(flagged, why); + Api.admin().flag(user.username(), change, Net.to(saved -> { + Telemetry.userFlagged(flagged); + open(user.username()); + }, Ui::fail)); + } + + private void block(UserDto user, boolean blocked, String why) { + SuspendDto change = new SuspendDto(blocked, why); + Api.admin().suspend(user.username(), change, Net.to(saved -> { + Telemetry.userBlocked(blocked); + open(user.username()); + }, Ui::fail)); + } + + private static Container pair(String uiid, Button button) { + Container side = new Container(new BorderLayout()); + side.setUIID(uiid); + side.add(BorderLayout.CENTER, button); + return side; + } + + private static char icon(String action) { + return "block".equals(action) ? FontImage.MATERIAL_BLOCK + : "unblock".equals(action) ? FontImage.MATERIAL_LOCK_OPEN + : "flag".equals(action) ? FontImage.MATERIAL_FLAG + : "unflag".equals(action) ? FontImage.MATERIAL_OUTLINED_FLAG + : "approve".equals(action) ? FontImage.MATERIAL_CHECK_CIRCLE + : "reject".equals(action) ? FontImage.MATERIAL_CANCEL + : FontImage.MATERIAL_MANAGE_ACCOUNTS; + } + + private static String action(String action) { + return "block".equals(action) ? "Blocked" : "unblock".equals(action) ? "Unblocked" + : "flag".equals(action) ? "Flagged" : "unflag".equals(action) ? "Flag removed" + : "approve".equals(action) ? "Approved to drive" : "reject".equals(action) + ? "Application sent back" : "Roles changed"; + } + + private static Switch toggle(String name, boolean value) { + Switch toggle = new Switch(); + toggle.setName(name); + toggle.setValue(value); + return toggle; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/ApplicationForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/ApplicationForm.java new file mode 100644 index 00000000000..b533235843b --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/ApplicationForm.java @@ -0,0 +1,477 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.driver; + +import com.codename1.components.SpanLabel; +import com.codename1.l10n.ParseException; +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.spinner.Picker; +import com.codename1.util.Base64; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.DocumentDto; +import com.codenameone.examples.wayline.api.DocumentUploadDto; +import com.codenameone.examples.wayline.api.DriverApplicationDto; +import com.codenameone.examples.wayline.api.ProfileDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.net.Account; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.ui.Chart; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Photos; +import com.codenameone.examples.wayline.ui.Ui; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/// The application to drive, a step at a time: you, your licence, your car, +/// your documents, and a last look before it is handed in. +/// +/// Each step is saved as it is left, so an application can be put down half +/// way and picked up on another day or another phone. The server keeps the +/// draft and decides what "complete" means: handing in an application that is +/// missing something comes back with what, in words, and that is shown as it +/// is rather than worked out again here. +public final class ApplicationForm { + /// The documents asked for, in the order the server lists them. + public static final String[] KINDS = {"photo_id", "licence", "selfie", + "vehicle_registration", "insurance"}; + private static final String[] KIND_NAMES = {"Photo ID", "Driving licence", "A photo of you", + "Vehicle registration", "Insurance"}; + private static final String[] KIND_HINTS = {"A passport or an identity card", + "The side with your photo on it", "Your face, in good light, no sunglasses", + "The paper that says the car is yours", "The page with the policy's dates on it"}; + private static final char[] KIND_ICONS = {FontImage.MATERIAL_BADGE, + FontImage.MATERIAL_CREDIT_CARD, FontImage.MATERIAL_FACE, + FontImage.MATERIAL_DESCRIPTION, FontImage.MATERIAL_VERIFIED_USER}; + private static final String[] STEPS = {"About you", "Your licence", "Your car", + "Your documents", "Review and submit"}; + private static final String[] GENDERS = {"unspecified", "female", "male", "nonbinary"}; + private static final String[] GENDER_NAMES = {"Rather not say", "Woman", "Man", + "Non-binary"}; + private static final String ISO = "yyyy-MM-dd"; + + private final Form form = Ui.form("Drive with Wayline", "Application"); + private final Runnable done; + private final Container body = Ui.page(); + private final Label stepLabel = Ui.plain("", "WlStepLabel"); + private final Label stepTitle = Ui.label("", "WlTitle"); + private final Container progress = new Container(new BorderLayout()); + private final SpanLabel error = Ui.text("", "WlError"); + private final Button next = Ui.primary("Continue", "next", e -> forward()); + private final Button back = Ui.link("Back", "backStep", e -> backward()); + /// The pictures taken on this visit, to show beside their documents. + private final Map thumbs = new HashMap(); + private DriverApplicationDto application; + private String gender; + private int step; + /// Reads what the step on screen has in its fields into [#application]. + private Runnable collect = () -> { }; + + private ApplicationForm(DriverApplicationDto application, Runnable done) { + this.application = application; + this.done = done; + Ui.back(form, () -> done.run()); + UserDto user = Session.user(); + ProfileDto profile = Account.profile(); + gender = profile != null && profile.gender() != null && profile.gender().length() > 0 + ? profile.gender() : user.gender() == null || user.gender().length() == 0 + ? GENDERS[0] : user.gender(); + if (application.legalName() == null || application.legalName().length() == 0) { + this.application = filled(user.displayName(), application.dateOfBirth(), + application.licenceNumber(), application.licenceExpiry()); + } + stepLabel.setName("step"); + error.setName("error"); + Container head = new Container(BoxLayout.y()); + head.setUIID("WlPage"); + head.add(stepLabel).add(stepTitle).add(progress); + Container foot = new Container(BoxLayout.y()); + foot.setUIID("WlFooter"); + foot.setSafeArea(true); + foot.add(error).add(next).add(back); + form.add(BorderLayout.NORTH, head); + form.add(BorderLayout.CENTER, body); + form.add(BorderLayout.SOUTH, foot); + } + + /// @param done run when the user leaves, with the application handed in or + /// not; it shows the screen to go back to + public static void show(Form previous, DriverApplicationDto application, Runnable done) { + ApplicationForm wizard = new ApplicationForm(application, done); + wizard.display(); + wizard.form.show(); + } + + // -------------------------------------------------------------- the steps + + private void display() { + stepLabel.setText(Lang.tr("Step {0} of {1}", String.valueOf(step + 1), + String.valueOf(STEPS.length))); + stepTitle.setText(STEPS[step]); + progress.removeAll(); + progress.add(BorderLayout.CENTER, new Chart.Meter((step + 1) / (double) STEPS.length)); + body.removeAll(); + // A new step starts at its top, wherever the last one was left. + body.scrollRectToVisible(0, 0, 1, 1, body); + error.setText(""); + collect = () -> { }; + if (step == 0) { + you(); + } else if (step == 1) { + licence(); + } else if (step == 2) { + car(); + } else if (step == 3) { + documents(); + } else { + review(); + } + next.setText(step == STEPS.length - 1 ? "Submit application" : "Continue"); + next.setName(step == STEPS.length - 1 ? "submitApplication" : "next"); + back.setHidden(step == 0); + Ui.refresh(form); + } + + private void you() { + body.add(Ui.text("As it is written on your licence. Riders see only your first name.", + "WlMuted")); + final TextField name = Ui.field(body, "Legal name", "legalName", ""); + name.setText(application.legalName()); + final Picker born = date(body, "Date of birth", "dateOfBirth", application.dateOfBirth()); + body.add(Ui.label("Gender", "WlLabel")); + body.add(Ui.choice("gender", GENDER_NAMES, GENDERS, gender, value -> gender = value, 2)); + body.add(Ui.text("Riders who ask for a woman at the wheel are offered to drivers " + + "who say they are one.", "WlMuted")); + collect = () -> application = filled(name.getText().trim(), iso(born), + application.licenceNumber(), application.licenceExpiry()); + } + + private void licence() { + body.add(Ui.text("The number is on the front of the card.", "WlMuted")); + final TextField number = Ui.field(body, "Licence number", "licenceNumber", ""); + number.setText(application.licenceNumber()); + final Picker expiry = date(body, "Expires", "licenceExpiry", application.licenceExpiry()); + collect = () -> application = filled(application.legalName(), + application.dateOfBirth(), number.getText().trim(), iso(expiry)); + } + + private void car() { + Container pair = new Container(new GridLayout(1, 2)); + Container left = side("WlPairLeft"); + Container right = side("WlPairRight"); + final TextField make = Ui.field(left, "Make", "vehicleMake", "Toyota"); + final TextField model = Ui.field(right, "Model", "vehicleModel", "Prius"); + pair.add(left).add(right); + body.add(pair); + Container second = new Container(new GridLayout(1, 2)); + left = side("WlPairLeft"); + right = side("WlPairRight"); + final TextField year = Ui.field(left, "Year", "vehicleYear", "2021", TextArea.NUMERIC); + final TextField colour = Ui.field(right, "Colour", "vehicleColor", "Blue"); + second.add(left).add(right); + body.add(second); + Container third = new Container(new GridLayout(1, 2)); + left = side("WlPairLeft"); + right = side("WlPairRight"); + final TextField plate = Ui.field(left, "Licence plate", "vehiclePlate", "7ABC123"); + final TextField seats = Ui.field(right, "Seats for riders", "vehicleSeats", "4", + TextArea.NUMERIC); + third.add(left).add(right); + body.add(third); + make.setText(application.vehicleMake()); + model.setText(application.vehicleModel()); + year.setText(application.vehicleYear() > 0 + ? String.valueOf(application.vehicleYear()) : ""); + colour.setText(application.vehicleColor()); + plate.setText(application.vehiclePlate()); + seats.setText(String.valueOf(application.vehicleSeats() > 0 + ? application.vehicleSeats() : 4)); + final String[] product = {application.product() == null + || application.product().length() == 0 ? "standard" : application.product()}; + body.add(Ui.label("Kind of ride", "WlLabel")); + body.add(Ui.choice("product", new String[] {"Standard", "Comfort", "XL"}, + new String[] {"standard", "comfort", "xl"}, product[0], + value -> product[0] = value)); + body.add(Ui.text("Comfort is a newer, roomier car. XL seats six or more.", "WlMuted")); + final boolean[] accessible = {application.accessible()}; + body.add(Ui.row(FontImage.MATERIAL_ACCESSIBLE, "Takes a wheelchair", + "A ramp or a lift, and room to secure the chair", + Ui.toggle("accessible", accessible[0], on -> accessible[0] = on))); + final boolean[] pets = {application.petFriendly()}; + body.add(Ui.row(FontImage.MATERIAL_PETS, "Takes pets", + "Riders travelling with a pet are offered to you", + Ui.toggle("petFriendly", pets[0], on -> pets[0] = on))); + collect = () -> { + DriverApplicationDto a = application; + application = new DriverApplicationDto(a.username(), a.name(), a.status(), + a.legalName(), a.dateOfBirth(), a.licenceNumber(), a.licenceExpiry(), + make.getText().trim(), model.getText().trim(), number(year.getText()), + colour.getText().trim(), plate.getText().trim(), number(seats.getText()), + product[0], accessible[0], pets[0], a.documents(), a.rejectionReason(), + a.submittedAt(), a.reviewedAt(), a.reviewedBy()); + }; + } + + /// The application as it stands, with the applicant's and the licence's + /// details as given. + private DriverApplicationDto filled(String legalName, String dateOfBirth, + String licenceNumber, String licenceExpiry) { + DriverApplicationDto a = application; + return new DriverApplicationDto(a.username(), a.name(), a.status(), legalName, + dateOfBirth, licenceNumber, licenceExpiry, a.vehicleMake(), a.vehicleModel(), + a.vehicleYear(), a.vehicleColor(), a.vehiclePlate(), a.vehicleSeats(), + a.product(), a.accessible(), a.petFriendly(), a.documents(), + a.rejectionReason(), a.submittedAt(), a.reviewedAt(), a.reviewedBy()); + } + + private void documents() { + body.add(Ui.text("Photograph each one flat, with all four corners in the picture.", + "WlMuted")); + for (int iter = 0; iter < KINDS.length; iter++) { + body.add(document(iter)); + } + } + + /// One document: what it is, whether we have it, and the two ways to give + /// it to us. + private Container document(final int index) { + final String kind = KINDS[index]; + String status = status(kind); + boolean have = !"missing".equals(status); + Container box = new Container(BoxLayout.y()); + box.setUIID("WlOption"); + Label picture = new Label("", "WlThumb"); + Image thumb = thumbs.get(kind); + if (thumb != null) { + int side = CN.convertToPixels(9f); + picture.setIcon(thumb.fill(side, side)); + } else { + Ui.icon(picture, KIND_ICONS[index], 4f); + } + Label state = Ui.badge(have ? "rejected".equals(status) ? "Rejected" + : "accepted".equals(status) ? "Accepted" : "Added" : "Needed", + have ? "rejected".equals(status) ? "WlBadgeBad" : "WlBadgeGood" : "WlBadgeWarn"); + state.setName("status-" + kind); + // The state stands over the name and not beside it: the name is long + // in some languages and has to keep the whole line. + state.getAllStyles().setMargin(0, 0, 0, 0); + Container lines = new Container(BoxLayout.y()); + lines.add(FlowLayout.encloseIn(state)); + lines.add(Ui.label(KIND_NAMES[index], "WlRowTitle")); + lines.add(Ui.text(KIND_HINTS[index], "WlRowDetail")); + Container top = new Container(new BorderLayout()); + top.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(picture)); + top.add(BorderLayout.CENTER, lines); + box.add(top); + Container ways = new Container(new GridLayout(1, 2)); + Button camera = Ui.link(have ? "Retake" : "Camera", "camera-" + kind, + e -> Photos.camera((jpeg, taken) -> upload(kind, jpeg, taken))); + Ui.icon(camera, FontImage.MATERIAL_PHOTO_CAMERA, 3f); + Button gallery = Ui.link("Gallery", "gallery-" + kind, + e -> Photos.gallery((jpeg, taken) -> upload(kind, jpeg, taken))); + Ui.icon(gallery, FontImage.MATERIAL_PHOTO_LIBRARY, 3f); + ways.add(camera).add(gallery); + box.add(ways); + return box; + } + + private void upload(final String kind, byte[] jpeg, final Image picture) { + DocumentUploadDto document = new DocumentUploadDto(kind, "image/jpeg", + Base64.encodeNoNewline(jpeg)); + Api.driving().upload(document, Net.to(saved -> { + application = saved; + thumbs.put(kind, picture); + if (step == 3) { + display(); + } + }, this::failed)); + } + + private void review() { + body.add(Ui.text("Have a last look. You can change anything until it is handed in.", + "WlMuted")); + Ui.section(body, "You"); + body.add(Ui.fact("Legal name", application.legalName())); + body.add(Ui.fact("Date of birth", application.dateOfBirth())); + body.add(Ui.fact("Licence", application.licenceNumber())); + body.add(Ui.fact("Licence expires", application.licenceExpiry())); + Ui.section(body, "Your car"); + body.add(Ui.fact("Car", ApplyForm.car(application))); + body.add(Ui.fact("Licence plate", application.vehiclePlate())); + body.add(Ui.fact("Seats for riders", String.valueOf(application.vehicleSeats()))); + body.add(Ui.fact("Kind of ride", Lang.tr(Ui.product(application.product())))); + body.add(Ui.fact("Takes a wheelchair", Lang.tr(application.accessible() ? "Yes" : "No"))); + body.add(Ui.fact("Takes pets", Lang.tr(application.petFriendly() ? "Yes" : "No"))); + Ui.section(body, "Documents"); + int have = KINDS.length - ApplyForm.missing(application); + Container documents = Ui.fact("Photos added", have + " / " + KINDS.length); + documents.getComponentAt(1).setName("documentsAdded"); + body.add(documents); + body.add(Ui.text("By submitting you confirm that all of this is true and that the " + + "documents are your own.", "WlMuted")); + } + + // ----------------------------------------------------------- moving along + + private void forward() { + collect.run(); + // What the step asks for, asked for here: the server would take a + // half-empty draft, and say what is missing only at the very end. + if (incomplete()) { + error.setText(Lang.tr("Fill in everything on this step to continue.")); + Ui.refresh(form); + return; + } + next.setEnabled(false); + error.setText(""); + if (step == STEPS.length - 1) { + Api.driving().submit(Net.to(submitted -> { + Telemetry.applicationSubmitted(); + next.setEnabled(true); + Ui.say("Application sent. We will be in touch."); + done.run(); + }, this::failed)); + return; + } + if (step == 3) { + // Nothing typed on this step: each picture was sent as it was taken. + next.setEnabled(true); + step++; + display(); + return; + } + final boolean named = step == 0; + Api.driving().save(application, Net.to(saved -> { + application = saved; + next.setEnabled(true); + if (named) { + // The gender is the account's, not the application's. + ProfileDto profile = Account.profile(); + Account.saveProfile(profile == null ? Session.user().displayName() : profile.name(), + gender, () -> { }, (status, message) -> { }); + } + step++; + display(); + }, this::failed)); + } + + private boolean incomplete() { + if (step == 0) { + return empty(application.legalName()) || empty(application.dateOfBirth()); + } + if (step == 1) { + return empty(application.licenceNumber()) || empty(application.licenceExpiry()); + } + if (step == 2) { + return empty(application.vehicleMake()) || empty(application.vehicleModel()) + || empty(application.vehicleColor()) || empty(application.vehiclePlate()) + || application.vehicleYear() <= 0 || application.vehicleSeats() <= 0; + } + return false; + } + + private static boolean empty(String text) { + return text == null || text.length() == 0; + } + + private void backward() { + collect.run(); + if (step > 0) { + step--; + display(); + } + } + + private void failed(int status, String message) { + next.setEnabled(true); + // The server's own sentence: what is missing, or what is wrong with it. + error.setText(Lang.tr(message)); + Ui.refresh(form); + } + + private String status(String kind) { + for (int iter = 0; application.documents() != null && iter < application.documents().size(); + iter++) { + DocumentDto document = application.documents().get(iter); + if (kind.equals(document.kind())) { + return document.status(); + } + } + return "missing"; + } + + private static Container side(String uiid) { + Container side = new Container(BoxLayout.y()); + side.setUIID(uiid); + return side; + } + + /// A field for a date, which opens the platform's own way of choosing one. + private static Picker date(Container to, String caption, String name, String value) { + Picker picker = new Picker(); + picker.setType(Display.PICKER_TYPE_DATE); + picker.setUIID("WlField"); + picker.setName(name); + picker.setFormatter(new SimpleDateFormat("d MMM yyyy")); + if (value != null && value.length() > 0) { + try { + picker.setDate(new SimpleDateFormat(ISO).parse(value)); + } catch (ParseException unreadable) { + // Left on today: the server would not have stored such a date. + picker.setDate(new Date()); + } + } + to.add(Ui.label(caption, "WlLabel")).add(picker); + return picker; + } + + private static String iso(Picker picker) { + Date date = picker.getDate(); + return date == null ? "" : new SimpleDateFormat(ISO).format(date); + } + + private static int number(String text) { + String trimmed = text == null ? "" : text.trim(); + if (trimmed.length() == 0 || trimmed.length() > 6) { + return 0; + } + for (int iter = 0; iter < trimmed.length(); iter++) { + if (trimmed.charAt(iter) < '0' || trimmed.charAt(iter) > '9') { + return 0; + } + } + return Integer.parseInt(trimmed); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/ApplyForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/ApplyForm.java new file mode 100644 index 00000000000..5f841d98ce2 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/ApplyForm.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.driver; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.DocumentDto; +import com.codenameone.examples.wayline.api.DriverApplicationDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Menu; +import com.codenameone.examples.wayline.ui.Nav; +import com.codenameone.examples.wayline.ui.Ui; + +/// The driver's side of the app for someone who is not a driver yet: where +/// their application stands, and the way on from there. +/// +/// Whether anyone drives is the server's decision and an admin's: an +/// application is filled in, handed in, and approved or sent back with a +/// reason. This screen shows which of those it is. There is no way from here +/// to going online -- the screen that has that button is not built for an +/// account the server does not call a driver, and the server refuses the call +/// from one anyway. +public class ApplyForm implements Nav.Home { + private static final int POLL_MILLIS = 5000; + + private final Form form = Ui.home("Apply"); + private final Menu menu = new Menu(form, Nav.DRIVER); + private final Container page = Ui.page(); + private String showing = ""; + + public ApplyForm() { + menu.item(FontImage.MATERIAL_ASSIGNMENT, "Your application", "home", () -> { }); + Container bar = new Container(new BorderLayout()); + bar.setUIID("WlHomeBar"); + bar.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(menu.flatButton())); + bar.add(BorderLayout.CENTER, FlowLayout.encloseLeftMiddle( + Ui.label("Drive with Wayline", "WlHeading"))); + page.setSafeArea(false); + Container screen = new Container(new BorderLayout()); + // The strip the status bar is drawn in is the bar's colour, not + // whatever is behind the screen. + screen.setUIID("WlForm"); + screen.setSafeArea(true); + screen.add(BorderLayout.NORTH, bar); + screen.add(BorderLayout.CENTER, page); + form.add(screen); + menu.install(); + form.addShowListener(e -> load()); + // An answer can come at any moment, and is worth showing when it does. + UITimer.timer(POLL_MILLIS, true, form, this::load); + } + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + load(); + } + + private void load() { + Api.driving().application(Net.to(this::fill, (status, message) -> { })); + } + + private void fill(final DriverApplicationDto application) { + String status = application.status() == null ? "none" : application.status(); + if ("approved".equals(status)) { + // The account is a driver's now: say so to the app, and let it + // build the screen a driver gets. + if (!"approved".equals(showing)) { + showing = "approved"; + Session.refreshUser(Nav::home, Ui::fail); + } + return; + } + String now = status + ":" + application.submittedAt() + ":" + missing(application); + if (now.equals(showing)) { + return; + } + showing = now; + page.removeAll(); + if ("pending".equals(status)) { + pending(application); + } else if ("rejected".equals(status)) { + rejected(application); + } else { + invite(application, "draft".equals(status) && started(application)); + } + Ui.refresh(form); + } + + /// Not applied yet, or part of the way through. + private void invite(final DriverApplicationDto application, boolean started) { + Container banner = Ui.banner(FontImage.MATERIAL_DIRECTIONS_CAR, + started ? "Finish your application" : "Earn on your own schedule", + started ? Lang.tr("{0} of 5 documents added. Pick up where you left off.", + String.valueOf(5 - missing(application))) + : "Tell us about you and your car, add five photos, and we take it " + + "from there.", "WlBannerIcon"); + page.add(banner); + Label state = Ui.label(started ? "Draft" : "Not started", "WlBadge"); + state.setName("applicationStatus"); + page.add(FlowLayout.encloseCenter(state)); + Ui.section(page, "What you will need"); + page.add(Ui.row(FontImage.MATERIAL_BADGE, "A driving licence", + "Its number, its expiry date and a photo of it", null)); + page.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, "A car with four doors", + "Registered and insured, with the papers to show it", null)); + page.add(Ui.row(FontImage.MATERIAL_PHOTO_CAMERA, "About ten minutes", + "Most of it is taking five photos", null)); + final boolean begun = started; + page.add(Ui.primary(started ? "Continue application" : "Start application", "apply", + e -> { + if (!begun) { + Telemetry.applicationStarted(false); + } + ApplicationForm.show(form, application, this::refreshed); + })); + } + + private void pending(DriverApplicationDto application) { + page.add(Ui.banner(FontImage.MATERIAL_HOURGLASS_TOP, "Application under review", + "We have everything we asked for. Someone on our team is reading it.", + "WlBannerIconWarn")); + Label state = Ui.label("Under review", "WlBadgeWarn"); + state.setName("applicationStatus"); + page.add(FlowLayout.encloseCenter(state)); + Ui.section(page, "What happens next"); + page.add(Ui.row(FontImage.MATERIAL_CHECK_CIRCLE, "Application received", + application.submittedAt() > 0 ? Ui.when(application.submittedAt()) : null, null)); + page.add(Ui.row(FontImage.MATERIAL_FACT_CHECK, "Documents checked", + "Usually within two working days", null)); + page.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, "You go online", + "This screen becomes your driver's home the moment you are approved", null)); + Ui.section(page, "Your car"); + page.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, car(application), + application.vehiclePlate(), null)); + } + + private void rejected(final DriverApplicationDto application) { + page.add(Ui.banner(FontImage.MATERIAL_ERROR_OUTLINE, "We could not approve this yet", + "Read what was missing, put it right, and send the application again.", + "WlBannerIconBad")); + Label state = Ui.label("Changes needed", "WlBadgeBad"); + state.setName("applicationStatus"); + page.add(FlowLayout.encloseCenter(state)); + Ui.section(page, "What the reviewer said"); + Container said = new Container(BoxLayout.y()); + said.setUIID("WlPanel"); + com.codename1.components.SpanLabel reason = Ui.text("", "WlText"); + // The reviewer's own words, in whatever language they wrote them. + reason.setShouldLocalize(false); + reason.setText(application.rejectionReason()); + reason.setName("rejectionReason"); + said.add(reason); + page.add(said); + page.add(Ui.primary("Fix and resubmit", "apply", e -> { + Telemetry.applicationStarted(true); + ApplicationForm.show(form, application, this::refreshed); + })); + } + + /// Back from the form: whatever was done there, show how things stand now. + private void refreshed() { + showing = ""; + form.showBack(); + } + + private static boolean started(DriverApplicationDto application) { + return application.legalName() != null && application.legalName().length() > 0 + || missing(application) < 5; + } + + static int missing(DriverApplicationDto application) { + int missing = 0; + for (int iter = 0; application.documents() != null && iter < application.documents().size(); + iter++) { + DocumentDto document = application.documents().get(iter); + if ("missing".equals(document.status())) { + missing++; + } + } + return application.documents() == null ? 5 : missing; + } + + static String car(DriverApplicationDto application) { + StringBuilder car = new StringBuilder(); + append(car, application.vehicleColor()); + append(car, application.vehicleMake()); + append(car, application.vehicleModel()); + if (application.vehicleYear() > 0) { + append(car, String.valueOf(application.vehicleYear())); + } + return car.toString(); + } + + private static void append(StringBuilder to, String word) { + if (word != null && word.length() > 0) { + to.append(to.length() > 0 ? " " : "").append(word); + } + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/DriverForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/DriverForm.java new file mode 100644 index 00000000000..8284ca0d4bb --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/DriverForm.java @@ -0,0 +1,560 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.driver; + +import com.codename1.maps.LatLng; +import com.codename1.maps.Marker; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.DriverStatusDto; +import com.codenameone.examples.wayline.api.EarningsDto; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.live.LiveChannel; +import com.codenameone.examples.wayline.map.CarMarker; +import com.codenameone.examples.wayline.map.Locator; +import com.codenameone.examples.wayline.map.MapStage; +import com.codenameone.examples.wayline.map.Maps; +import com.codenameone.examples.wayline.map.Routes; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.BottomSheet; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Menu; +import com.codenameone.examples.wayline.ui.Nav; +import com.codenameone.examples.wayline.ui.Pulse; +import com.codenameone.examples.wayline.ui.RideListForm; +import com.codenameone.examples.wayline.ui.Ring; +import com.codenameone.examples.wayline.ui.StatRow; +import com.codenameone.examples.wayline.ui.Ui; +import java.io.IOException; + +/// Driving: go online, answer the offer, drive the ride. +/// +/// While online the app reports the car's position every few seconds. That +/// report is three things at once to the server: where to draw the car on the +/// rider's map, how far this driver is from the next request, and the sign the +/// driver is still there -- a driver who stops reporting stops being offered +/// rides. +/// +/// An offer is the server's to make and to withdraw. It arrives as a ride in +/// the `OFFERED` state with a number of seconds to answer in; when they run out +/// the server offers the ride to the next driver, and this screen finds out +/// the way it finds out everything, by asking what the driver's ride now is. +/// +/// Each step of a ride has one thing to do next, and the sheet shows that one +/// thing as its only large button. +public class DriverForm extends LiveChannel.Adapter implements Nav.Home { + private static final int TICK_MILLIS = 3000; + /// How fast the made-up car drives where there is no real position: + /// brisk, so a demonstration does not take the time a real ride would. + private static final double SIMULATED_SPEED = 25; + /// Meters a second in town, for a time of arrival worked out from a + /// distance: 30 km/h. + private static final double CITY_SPEED = 8.3; + private static final Net.Failed QUIET = (status, message) -> { }; + + private final Form form = Ui.home("Driver"); + private final Menu menu = new Menu(form, Nav.DRIVER); + private final MapStage stage; + private final BottomSheet sheet; + private final CarMarker car; + + private LatLng here = Locator.here(); + private double heading; + private boolean online; + private RideDto ride; + private EarningsDto earnings; + /// What the last finished trip paid, to say so until the next one starts. + private String lastFare; + private String showing = ""; + /// Where the car is being driven to, as `rideId:state`, and the way there. + private String target = ""; + private Routes.Way way; + private double travelled; + private Marker pickupMarker; + private Marker dropoffMarker; + private Maps.Line line; + private Ring countdown; + private int ticks; + + public DriverForm() { + menu.item(FontImage.MATERIAL_DIRECTIONS_CAR, "Drive", "home", () -> { }); + menu.item(FontImage.MATERIAL_HISTORY, "Your trips", "history", + () -> RideListForm.show(form, "Your trips", cb -> Api.driver().history(cb), false)); + menu.item(FontImage.MATERIAL_PAYMENTS, "Earnings", "earnings", + () -> EarningsForm.show(form)); + stage = new MapStage(here, Maps.STREET_ZOOM, menu.button()); + sheet = stage.sheet(); + car = new CarMarker(stage.map, form, here, heading); + stage.addRound(FontImage.MATERIAL_MY_LOCATION, "recentre", this::recentre); + form.add(stage.layers()); + menu.install(); + form.addShowListener(e -> { + LiveChannel.listen(this); + refresh(); + }); + UITimer.timer(TICK_MILLIS, true, form, this::tick); + UITimer.timer(1000, true, form, this::second); + } + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + display(); + // Picks up where the driver left off: still online, perhaps mid-ride. + Api.driver().me(Net.to(me -> { + // Too late: the user has left for another mode or signed out, and + // Shift was ended then. Starting it again here would report a + // driver with no driver's screen. + if (!Nav.isHome(form)) { + return; + } + online = me.online(); + shift(); + showing = ""; + display(); + refresh(); + }, QUIET)); + loadEarnings(); + } + + public void show() { + start(); + form.show(); + } + + // ------------------------------------------------------------ the server + + private void tick() { + ticks++; + if (!online) { + return; + } + move(); + report(true, QUIET); + if (!LiveChannel.connected() || ticks % 3 == 0) { + refresh(); + } + } + + /// Tells the server where the car is, and whether it is taking rides. + private void report(final boolean wanted, Net.Failed failed) { + final DriverStatusDto status = new DriverStatusDto(wanted, here.getLatitude(), + here.getLongitude(), heading); + Api.driver().status(status, Net.to(me -> { + if (online != me.online()) { + online = me.online(); + shift(); + Telemetry.driverOnline(online); + showing = ""; + display(); + } + }, failed)); + } + + private void refresh() { + Api.driver().active(Net.to(this::update, QUIET)); + } + + /// Keeps the phone reporting in the background exactly while the server + /// has this driver on line. + private void shift() { + if (online) { + Shift.begin(); + } else { + Shift.end(); + } + } + + @Override + public void rideChanged(String id, String state) { + refresh(); + } + + private void update(RideDto latest) { + boolean none = latest == null || RideState.NONE == latest.state(); + if (none && ride != null && RideState.OFFERED == ride.state()) { + Ui.say("The request went to another driver"); + } else if (none && ride != null) { + Ui.say("The rider cancelled"); + } + ride = none ? null : latest; + if (ride != null) { + lastFare = null; + } + aim(); + display(); + } + + private void act(Net.Call call) { + act(call, () -> { }); + } + + /// The same, with something to do first when the server agrees. + private void act(Net.Call call, final Runnable agreed) { + Net.send(call, changed -> { + agreed.run(); + update(changed); + }, (status, message) -> { + Ui.fail(status, message); + refresh(); + }); + } + + private void loadEarnings() { + Api.driver().earnings(Net.to(earned -> { + earnings = earned; + showing = ""; + display(); + }, QUIET)); + } + + // --------------------------------------------------------------- the map + + /// Points the car at wherever the ride needs it next: the pickup once the + /// ride is accepted, the destination once the rider is aboard. + private void aim() { + String wanted = ride == null ? "" : ride.id() + ":" + ride.state(); + if (wanted.equals(target)) { + return; + } + target = wanted; + way = null; + travelled = 0; + if (pickupMarker != null) { + stage.map.removeMarker(pickupMarker); + pickupMarker = null; + } + if (dropoffMarker != null) { + stage.map.removeMarker(dropoffMarker); + dropoffMarker = null; + } + if (line != null) { + line.remove(stage.map); + line = null; + } + if (ride == null) { + stage.center(here, Maps.STREET_ZOOM); + return; + } + final LatLng pickup = new LatLng(ride.pickupLat(), ride.pickupLng()); + final LatLng dropoff = new LatLng(ride.dropoffLat(), ride.dropoffLng()); + pickupMarker = Maps.pickup(stage.map, pickup); + dropoffMarker = Maps.dropoff(stage.map, dropoff); + stage.frame(here, pickup, dropoff); + if (RideState.ARRIVED == ride.state()) { + return; + } + final LatLng to = RideState.IN_PROGRESS == ride.state() ? dropoff : pickup; + final String aimed = target; + Routes.find(here, to, route -> { + if (!aimed.equals(target) || ride == null) { + return; + } + line = Maps.route(stage.map, route.points); + // An offer shows the way; only an accepted ride is driven. + if (RideState.OFFERED != ride.state()) { + way = route; + } + }); + } + + /// Moves the car. On a phone the phone has moved it already; where there + /// is no real position, this drives it along the route. + private void move() { + LatLng now; + if (!Locator.simulated()) { + now = Locator.here(); + } else if (way != null) { + travelled += SIMULATED_SPEED * TICK_MILLIS / 1000d; + now = Routes.along(way.points, travelled); + } else { + return; + } + if (Maps.meters(here, now) > 2) { + heading = Maps.heading(here, now); + } + here = now; + car.moveTo(here, heading); + chip(); + } + + private void recentre() { + if (ride == null) { + stage.center(here, Maps.STREET_ZOOM); + } else { + stage.frame(here, new LatLng(ride.pickupLat(), ride.pickupLng()), + new LatLng(ride.dropoffLat(), ride.dropoffLng())); + } + } + + /// How far, and about how long, to where the ride needs the car next. + private void chip() { + if (ride == null || RideState.OFFERED == ride.state() + || RideState.ARRIVED == ride.state()) { + stage.chip(null, (char) 0); + return; + } + double meters = Maps.meters(here, next()); + stage.chip(Ui.minutes(meters / CITY_SPEED) + Ui.DOT + Ui.distance(meters), + FontImage.MATERIAL_NAVIGATION); + } + + private LatLng next() { + return RideState.IN_PROGRESS == ride.state() + ? new LatLng(ride.dropoffLat(), ride.dropoffLng()) + : new LatLng(ride.pickupLat(), ride.pickupLng()); + } + + // ------------------------------------------------------------- the sheet + + private void second() { + // The offer has run out: the server has moved on, so ask what is left. + if (countdown != null && countdown.secondsLeft() == 0) { + countdown = null; + refresh(); + } + } + + private void display() { + String now = (online ? "on" : "off") + ":" + (ride == null ? "" : ride.id() + ride.state()); + if (now.equals(showing)) { + return; + } + showing = now; + sheet.reset(); + countdown = null; + header(); + chip(); + if (ride == null) { + waiting(); + } else if (RideState.OFFERED == ride.state()) { + offer(); + } else { + trip(); + } + sheet.ready(false); + } + + /// The card at the top: online or off, and what today has made. + private void header() { + Container card = new Container(new BorderLayout()); + card.setUIID("WlStatusCard"); + card.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle( + Ui.badge(online ? "Online" : "Offline", online ? "WlBadgeGood" : "WlBadge"))); + if (earnings != null) { + Container today = new Container(BoxLayout.y()); + today.add(Ui.label(Ui.money(earnings.todayCents(), earnings.currency()), + "WlStatusValue")); + today.add(Ui.label(Lang.tr("Today, {0} trips", String.valueOf(earnings.todayRides())), + "WlStatusDetail")); + card.add(BorderLayout.EAST, today); + } + stage.header(card); + } + + private void waiting() { + Container peek = sheet.peek(); + Label title = Ui.label(online ? "Looking for rides" : "You are offline", "WlHeading"); + title.setName("driverState"); + peek.add(title); + if (lastFare != null) { + peek.add(Ui.text(Lang.tr("Trip complete: {0}", lastFare), "WlNotice")); + } + if (online) { + peek.add(Ui.label("Stay close to busy streets; requests come to the nearest car.", + "WlMuted")); + peek.add(FlowLayout.encloseCenter(new Pulse())); + } else { + peek.add(Ui.label("Go online to start taking rides.", "WlMuted")); + Button go = new Button("GO", "WlGo"); + go.setName("goOnline"); + go.addActionListener(e -> report(true, Ui::fail)); + peek.add(FlowLayout.encloseCenter(go)); + } + if (earnings != null) { + StatRow numbers = Ui.stats(3); + numbers.stat(Ui.money(earnings.todayCents(), earnings.currency()), "Today", + "earnedToday"); + numbers.stat(Ui.money(earnings.weekCents(), earnings.currency()), "This week", + "earnedWeek"); + numbers.stat(String.valueOf(earnings.rides()), "Trips", "trips"); + peek.add(numbers); + } + if (online) { + peek.add(Ui.secondary("Go offline", "goOffline", e -> report(false, Ui::fail))); + } + } + + private void offer() { + final String id = ride.id(); + Container peek = sheet.peek(); + Label title = Ui.label("Ride request", "WlHeading"); + title.setName("driverState"); + countdown = new Ring(); + countdown.setName("countdown"); + countdown.start(ride.offerExpiresInSeconds(), ride.offerExpiresInSeconds()); + Label fare = Ui.label(Ui.money(ride.fareCents(), ride.currency()), "WlFare"); + fare.setName("fare"); + Container words = new Container(BoxLayout.y()); + words.add(title); + words.add(fare); + Container top = new Container(new BorderLayout()); + top.add(BorderLayout.CENTER, words); + top.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(countdown)); + peek.add(top); + double away = Maps.meters(here, new LatLng(ride.pickupLat(), ride.pickupLng())); + peek.add(Ui.row(FontImage.MATERIAL_TRIP_ORIGIN, ride.pickupAddress(), + Lang.tr("{0} away, {1}", Ui.distance(away), Ui.minutes(away / CITY_SPEED)), null)); + peek.add(Ui.row(FontImage.MATERIAL_PLACE, ride.dropoffAddress(), + Ui.distance(ride.distanceMeters()) + Ui.DOT + Ui.minutes(ride.durationSeconds()), + null)); + peek.add(Ui.primary("Accept", "accept", e -> act(cb -> Api.driver().accept(id, cb), + () -> Telemetry.offerAnswered(true)))); + peek.add(Ui.link("Decline", "decline", e -> act(cb -> Api.driver().decline(id, cb), + () -> Telemetry.offerAnswered(false)))); + sheet.more().add(Ui.row(FontImage.MATERIAL_PERSON, ride.riderName(), asked(), null)); + sheet.more().add(Ui.row(ride.cash() ? FontImage.MATERIAL_PAYMENTS + : FontImage.MATERIAL_CREDIT_CARD, ride.cash() ? "Cash" : "Card", + Lang.tr(Ui.product(ride.product())), null)); + } + + private void trip() { + final String id = ride.id(); + RideState state = ride.state(); + Container peek = sheet.peek(); + Label title = Ui.label(RideState.ACCEPTED == state + ? Lang.tr("Pick up {0}", ride.riderName()) + : RideState.ARRIVED == state ? Lang.tr("Waiting for {0}", ride.riderName()) + : Lang.tr("On the trip"), "WlHeading"); + title.setName("driverState"); + peek.add(title); + boolean aboard = RideState.IN_PROGRESS == state; + peek.add(Ui.row(aboard ? FontImage.MATERIAL_PLACE : FontImage.MATERIAL_TRIP_ORIGIN, + aboard ? ride.dropoffAddress() : ride.pickupAddress(), + aboard ? "Drop-off" : "Pickup", null)); + if (ride.cash()) { + // Said before the trip ends, so that it is not a surprise at the door. + Label cash = Ui.label(Lang.tr("Collect {0} in cash", Ui.money( + ride.total() > 0 ? ride.total() : ride.fareCents(), ride.currency())), + "WlNotice"); + cash.setName("collectCash"); + Ui.icon(cash, FontImage.MATERIAL_PAYMENTS, 3f); + peek.add(cash); + } + if (!aboard) { + Container reach = new Container(new GridLayout(1, 2)); + reach.setUIID("WlActions"); + reach.add(Ui.action(FontImage.MATERIAL_CALL, "Call", "call", e -> call())); + reach.add(Ui.action(FontImage.MATERIAL_SMS, "Message", "message", e -> message())); + peek.add(reach); + } + if (RideState.ACCEPTED == state) { + peek.add(Ui.primary("I have arrived", "arrived", + e -> act(cb -> Api.driver().arrived(id, cb)))); + } else if (RideState.ARRIVED == state) { + peek.add(Ui.primary("Start trip", "start", + e -> act(cb -> Api.driver().start(id, cb)))); + } else { + peek.add(Ui.primary("Complete trip", "complete", + e -> Api.driver().complete(id, Net.to(finished -> { + Telemetry.rideCompleted("driver", finished.fareCents(), finished.cash()); + lastFare = Ui.money(finished.fareCents(), finished.currency()); + Ui.say(finished.cash() ? Lang.tr("Collect {0} in cash", Ui.money( + finished.total() > 0 ? finished.total() : finished.fareCents(), + finished.currency())) : Lang.tr("Trip complete: {0}", lastFare)); + ride = null; + aim(); + display(); + loadEarnings(); + }, Ui::fail)))); + } + Container more = sheet.more(); + more.add(Ui.row(FontImage.MATERIAL_PERSON, ride.riderName(), asked(), null)); + more.add(Ui.row(FontImage.MATERIAL_PLACE, ride.dropoffAddress(), + Ui.distance(ride.distanceMeters()) + Ui.DOT + Ui.minutes(ride.durationSeconds()), + Ui.label(Ui.money(ride.fareCents(), ride.currency()), "WlRowValue"))); + // A rider who is aboard is taken where they are going: there is no + // cancelling from here, only finishing. + if (!aboard) { + more.add(Ui.link("Cancel ride", "cancel", + e -> Api.driver().cancel(id, Net.to(cancelled -> { + Telemetry.rideCancelled("driver", state); + ride = null; + aim(); + display(); + }, Ui::fail)))); + } + } + + private void call() { + String number = ride == null ? null : ride.riderPhone(); + if (number == null || number.length() == 0 || !CN.canDial()) { + Ui.say("Calling is not available on this device"); + return; + } + CN.dial(number); + } + + private void message() { + String number = ride == null ? null : ride.riderPhone(); + if (number == null || number.length() == 0) { + Ui.say("Your rider cannot be messaged right now"); + return; + } + try { + CN.sendSMS(number, Lang.tr("Hi, this is your Wayline driver.")); + } catch (IOException failed) { + Ui.say("Messages are not available on this device"); + } + } + + /// What the rider asked for with the ride, in a line. + private String asked() { + StringBuilder asked = new StringBuilder(); + if (ride.quietRide()) { + asked.append(Lang.tr("Quiet ride")); + } + if (ride.accessibleVehicle()) { + asked.append(asked.length() > 0 ? ", " : "").append(Lang.tr("Accessible vehicle")); + } + if (ride.petFriendly()) { + asked.append(asked.length() > 0 ? ", " : "").append(Lang.tr("Pet friendly")); + } + if (ride.note() != null && ride.note().length() > 0) { + asked.append(asked.length() > 0 ? ", " : "").append(ride.note()); + } + return asked.toString(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/EarningsForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/EarningsForm.java new file mode 100644 index 00000000000..105e3c9e3d6 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/EarningsForm.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.driver; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.EarningsSummaryDto; +import com.codenameone.examples.wayline.api.PayoutAccountDto; +import com.codenameone.examples.wayline.api.PayoutDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.pay.Cards; +import com.codenameone.examples.wayline.ui.Chart; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.StatRow; +import com.codenameone.examples.wayline.ui.Ui; +import java.util.List; + +/// What driving has paid: what is waiting to be cashed out, the last +/// fortnight a day at a time, and the payouts already sent. +/// +/// The sums are the server's. It works them out from the rides it recorded, +/// less its commission, so this screen can show them and cannot change them. +public final class EarningsForm { + private final Form form = Ui.form("Earnings", "Earnings"); + private final Container page = Ui.page(); + private PayoutAccountDto account; + + private EarningsForm(Form previous) { + Ui.back(form, previous); + form.add(BorderLayout.CENTER, page); + } + + public static void show(Form previous) { + EarningsForm earnings = new EarningsForm(previous); + earnings.form.show(); + earnings.load(); + } + + private void load() { + Api.driver().payoutAccount(Net.to(found -> { + account = found; + Api.driver().earningsSummary(Net.to(summary -> Api.driver().payouts(Net.to( + payouts -> fill(summary, payouts), Ui::fail)), Ui::fail)); + }, Ui::fail)); + } + + private void fill(final EarningsSummaryDto summary, List payouts) { + page.removeAll(); + final String currency = summary.currency(); + Container balance = new Container(BoxLayout.y()); + balance.setUIID("WlPanel"); + balance.add(Ui.label("Ready to cash out", "WlStatLabel")); + Label amount = Ui.plain(Ui.money(summary.balance(), currency), "WlChartValue"); + amount.setName("balance"); + balance.add(amount); + final boolean banked = account != null && account.accountLast4() != null + && account.accountLast4().length() > 0; + Button cash = Ui.primary("Cash out", "cashOut", e -> { + if (!banked) { + account(); + return; + } + Api.driver().cashOut(Net.to(sent -> { + Telemetry.payoutRequested(sent.amount()); + Ui.say(Lang.tr("{0} is on its way to your bank", Ui.money(sent.amount(), + currency))); + load(); + }, Ui::fail)); + }); + cash.setEnabled(summary.balance() > 0); + balance.add(cash); + page.add(balance); + + StatRow periods = Ui.stats(3); + periods.stat(Ui.money(summary.today(), currency), "Today", "earnedToday"); + periods.stat(Ui.money(summary.week(), currency), "This week", "earnedWeek"); + periods.stat(Ui.money(summary.month(), currency), "This month", "earnedMonth"); + page.add(periods); + + Ui.section(page, "The last two weeks"); + int days = summary.daily() == null ? 0 : summary.daily().size(); + double[] amounts = new double[days]; + for (int iter = 0; iter < days; iter++) { + amounts[iter] = summary.daily().get(iter).amount(); + } + page.add(Chart.of("daily", amounts, Lang.tr("{0} days ago", String.valueOf( + Math.max(1, days - 1))), Lang.tr("Today"))); + + StatRow numbers = Ui.stats(2); + numbers.stat(String.valueOf(summary.trips()), "Trips", "trips"); + numbers.stat(Ui.money(summary.tips(), currency), "Tips", "tips"); + numbers.stat(summary.rating() > 0 ? Ui.tenths(summary.rating()) : "-", "Rating", + "rating"); + numbers.stat(Math.round(summary.acceptanceRate() * 100d) + "%", "Requests accepted", + "acceptance"); + page.add(numbers); + + Ui.section(page, "Payouts"); + page.add(Ui.tap(Ui.row(FontImage.MATERIAL_ACCOUNT_BALANCE, + banked ? account.bankName() : Lang.tr("Add a bank account"), + banked ? Lang.tr("Account ending {0}", account.accountLast4()) + : Lang.tr("Where your earnings are sent"), null), "payoutAccount", + e -> account())); + for (int iter = 0; payouts != null && iter < payouts.size(); iter++) { + PayoutDto payout = payouts.get(iter); + Container row = Ui.row(FontImage.MATERIAL_NORTH_EAST, Ui.when(payout.createdAt()), + payout.destination(), Ui.plain(Ui.money(payout.amount(), currency), + "WlRowValue")); + row.setName("payout-" + iter); + page.add(row); + } + if (payouts == null || payouts.isEmpty()) { + page.add(Ui.label("Nothing has been paid out yet.", "WlMuted")); + } + Ui.refresh(form); + } + + /// Where payouts go. The account number is typed in full and only its + /// last four digits leave the phone: the rest is the bank's business, and + /// a sample server has no bank. + private void account() { + final Form edit = Ui.form("Bank account", "PayoutAccount"); + Ui.back(edit, form); + Container fields = Ui.page(); + final TextField holder = Ui.field(fields, "Account holder", "holder", ""); + final TextField bank = Ui.field(fields, "Bank", "bankName", ""); + final TextField number = Ui.field(fields, "Account number", "accountNumber", "", + TextArea.NUMERIC); + if (account != null) { + holder.setText(account.holder()); + bank.setText(account.bankName()); + } + final SpanLabel error = Ui.text("", "WlError"); + fields.add(error); + fields.add(Ui.primary("Save", "submit", e -> { + String digits = Cards.digits(number.getText()); + if (digits.length() < 4) { + error.setText(Lang.tr("Type the account number.")); + edit.revalidate(); + return; + } + PayoutAccountDto wanted = new PayoutAccountDto(holder.getText().trim(), + bank.getText().trim(), digits.substring(digits.length() - 4)); + Api.driver().savePayoutAccount(wanted, Net.to(saved -> { + form.showBack(); + load(); + }, (status, message) -> { + error.setText(message); + edit.revalidate(); + })); + })); + edit.add(BorderLayout.CENTER, fields); + edit.show(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/Shift.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/Shift.java new file mode 100644 index 00000000000..2c5efd27c4a --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/driver/Shift.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.driver; + +import com.codename1.background.ForegroundService; +import com.codename1.maps.LatLng; +import com.codename1.ui.CN; +import com.codenameone.examples.wayline.api.DriverStatusDto; +import com.codenameone.examples.wayline.map.Locator; +import com.codenameone.examples.wayline.map.Maps; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; + +import java.util.concurrent.atomic.AtomicBoolean; + +// tag::wayline-shift[] +/// A driver's time on line, as far as the phone is concerned: the position +/// keeps going to the server while the app is in the background. +/// +/// In the foreground the driver screen reports every few seconds itself. Put +/// away -- the driver switched to a navigation app, or locked the phone -- that +/// screen stops, and this takes over: it follows the position in the +/// background and reports it when the car has moved [#MIN_METERS], and at +/// least every [#KEEP_ALIVE_MILLIS] even when it has not, because a driver who +/// goes quiet for a minute stops being offered rides. +/// +/// - On iOS the `location` background mode keeps the app running while it +/// tracks, and each new position is the moment to report. +/// - On Android a foreground service keeps it running, with the notification +/// the system requires, and wakes it for the reports a parked car would not +/// send. +/// - The simulator and the browser stand still and report nothing here. +public final class Shift { + /// How far the car moves before the server hears of it again. + static final double MIN_METERS = 25d; + /// The longest the server goes without hearing, well inside the minute + /// after which it stops offering this driver rides. + static final long KEEP_ALIVE_MILLIS = 20000L; + private static final String CHANNEL = "wayline-driving"; + private static final Net.Failed QUIET = (status, message) -> { }; + + private static boolean on; + private static boolean background; + private static long sentAt; + private static LatLng sentFrom; + private static ForegroundService service; + /// Read by the service's own thread, so not a plain field. + private static final AtomicBoolean RUNNING = new AtomicBoolean(); + private static final Locator.Watcher WATCHER = (state, where) -> { + if (state == Locator.FOUND && background) { + report(where); + } + }; + + private Shift() { + } + + /// The driver went on line. + public static void begin() { + if (on) { + return; + } + on = true; + sentFrom = null; + Locator.track(true); + Locator.watch(WATCHER); + if (ForegroundService.isSupported() && "and".equals(CN.getPlatformName()) + && !CN.isSimulator()) { + RUNNING.set(true); + service = ForegroundService.start(CHANNEL, "You're online", + "Wayline shares your location with riders while you drive.", null, + Shift::keepAlive); + } + } + + /// The driver went off line, signed out or left the driver's side of the + /// app. Nothing goes on following where the phone is. + public static void end() { + if (!on) { + return; + } + on = false; + Locator.unwatch(WATCHER); + Locator.track(false); + RUNNING.set(false); + if (service != null) { + service.stop(); + service = null; + } + } + + /// Leaving the driver's side of the app, with the session still there: + /// the server is told this driver is off line before the phone stops + /// reporting. Otherwise it would go on offering rides to a phone that is + /// no longer listening until the reports went stale, a minute, and each + /// rider would wait out an offer nobody sees. One request, not waited + /// for; if it is lost the minute is the fallback. + public static void leave() { + if (on) { + LatLng where = Locator.here(); + Api.driver().status(new DriverStatusDto(false, where.getLatitude(), + where.getLongitude(), Locator.heading()), Net.to(me -> { }, QUIET)); + } + end(); + } + + public static boolean isOn() { + return on; + } + + /// The app went to the background, and the reports are this class's. + public static void paused() { + background = true; + } + + /// The app is back, and the driver screen reports again. + public static void resumed() { + background = false; + } + // end::wayline-shift[] + + /// The foreground service's task, on a thread of its own: wakes now and + /// then for the car that is not moving, which brings no new positions. + private static void keepAlive(ForegroundService running) { + while (RUNNING.get() && running.isRunning()) { + try { + Thread.sleep(KEEP_ALIVE_MILLIS); + } catch (InterruptedException stop) { + return; + } + if (RUNNING.get()) { + CN.callSerially(() -> { + if (on && background) { + report(Locator.here()); + } + }); + } + } + } + + /// Reports `where` if the car has moved far enough or long enough has + /// passed. The answer says whether the server still has this driver on + /// line; one that does not ends the shift here too. + private static void report(LatLng where) { + long now = System.currentTimeMillis(); + boolean moved = sentFrom == null || Maps.meters(sentFrom, where) >= MIN_METERS; + if (!moved && now - sentAt < KEEP_ALIVE_MILLIS) { + return; + } + sentAt = now; + sentFrom = where; + Api.driver().status(new DriverStatusDto(true, where.getLatitude(), where.getLongitude(), + Locator.heading()), Net.to(me -> { + if (!me.online()) { + end(); + } + }, QUIET)); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/live/LiveChannel.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/live/LiveChannel.java new file mode 100644 index 00000000000..8eab97f5fe1 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/live/LiveChannel.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.live; + +import com.codename1.io.CharArrayReader; +import com.codename1.io.JSONParser; +import com.codename1.io.WebSocket; +import com.codename1.io.WebSocketState; +import com.codename1.ui.CN; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.net.Session; + +import java.io.IOException; +import java.util.Map; +import java.util.Timer; + +/// The WebSocket the server pushes changes down: a ride changed state, the +/// driver moved, a car went on or offline. +/// +/// It only ever says *that* something changed. What the ride now is, the app +/// asks for over the REST API, which stays the one source of truth -- so a +/// frame that is lost, or a socket that is down, costs a few seconds and +/// nothing else. Every screen that listens here also polls, slowly while the +/// socket is up and faster while it is not ([#connected] says which). +/// +/// A WebSocket handshake cannot carry the bearer token on every platform, so +/// the connection is let in by a ticket: the app asks for one over the +/// authenticated API, and the server accepts it once, within half a minute. +public final class LiveChannel { + /// What a screen wants to hear about. All of it arrives on the EDT. + public interface Listener { + void rideChanged(String id, String state); + + void driverMoved(String rideId, double lat, double lng, double heading); + + void fleetChanged(String username, boolean online, double lat, double lng, + double heading); + } + + /// A listener that ignores everything, to override the part a screen wants. + public static class Adapter implements Listener { + @Override + public void rideChanged(String id, String state) { + } + + @Override + public void driverMoved(String rideId, double lat, double lng, double heading) { + } + + @Override + public void fleetChanged(String username, boolean online, double lat, double lng, + double heading) { + } + } + + private static final int PING_MILLIS = 25000; + + private static WebSocket socket; + private static Listener listener; + private static boolean wanted; + private static boolean connected; + private static int attempts; + /// Counts the connections made, so that what a closed or replaced one + /// still has to say -- its `onClose`, a frame already on its way -- is + /// recognised as stale and dropped. + private static int generation; + private static Timer pinger; + + private LiveChannel() { + } + + /// Connects, and keeps reconnecting until [#stop]. + public static void start() { + if (wanted) { + return; + } + wanted = true; + attempts = 0; + connect(); + } + + public static void stop() { + wanted = false; + connected = false; + listener = null; + generation++; + if (pinger != null) { + pinger.cancel(); + pinger = null; + } + if (socket != null) { + socket.close(); + socket = null; + } + } + + /// The one screen that is listening: the one on display. + public static void listen(Listener current) { + listener = current; + } + + public static boolean connected() { + return connected; + } + + private static void connect() { + if (!wanted || !WebSocket.isSupported()) { + return; + } + final int mine = ++generation; + Api.account().liveTicket(Net.to(ticket -> { + if (mine == generation && wanted) { + open(ticket.ticket(), mine); + } + }, (status, message) -> lost(mine))); + } + + private static void open(String ticket, final int mine) { + String base = Session.serverUrl(); + // http -> ws and https -> wss: the scheme keeps its "s". + String url = "ws" + base.substring(4) + "/ws/live?ticket=" + ticket; + // The handlers run on the socket's thread, so each hands its news to + // the EDT, where the rest of the app lives. + socket = WebSocket.build(url) + .onConnect(ws -> CN.callSerially(() -> opened(mine))) + .onTextMessage((ws, text) -> CN.callSerially(() -> frame(text, mine))) + .onClose((ws, code, reason) -> CN.callSerially(() -> lost(mine))) + .onError((ws, err) -> CN.callSerially(() -> lost(mine))) + .connect(10000); + } + + private static void opened(int mine) { + if (mine != generation) { + return; + } + connected = true; + attempts = 0; + if (pinger != null) { + pinger.cancel(); + } + // Something has to cross an idle connection now and then, or a proxy + // on the way decides it is dead. + pinger = CN.setInterval(PING_MILLIS, () -> { + if (mine == generation && socket != null + && socket.getReadyState() == WebSocketState.OPEN) { + socket.send("{\"type\":\"ping\"}"); + } + }); + } + + private static void lost(int mine) { + if (mine != generation || !wanted) { + return; + } + connected = false; + final int waiting = ++generation; + if (socket != null) { + socket.close(); + socket = null; + } + // 1, 2, 4 ... 32 seconds, and then every 32. + int delay = 1000 << Math.min(attempts++, 5); + // Only if nothing has happened to the channel in the meantime. Stopped + // and started again during the wait, it has a connection of its own + // by now, and connecting here as well would take that one's place: + // its callbacks would go unheard and its socket would be left open. + CN.setTimeout(delay, () -> CN.callSerially(() -> { + if (waiting == generation) { + connect(); + } + })); + } + + private static void frame(String text, int mine) { + if (mine != generation || listener == null) { + return; + } + Map json; + try { + json = new JSONParser().parseJSON(new CharArrayReader(text.toCharArray())); + } catch (IOException malformed) { + return; + } + Object type = json.get("type"); + if ("ride".equals(type)) { + listener.rideChanged(text(json, "id"), text(json, "state")); + } else if ("driver".equals(type)) { + listener.driverMoved(text(json, "rideId"), number(json, "lat"), number(json, "lng"), + number(json, "heading")); + } else if ("fleet".equals(type)) { + Object online = json.get("online"); + listener.fleetChanged(text(json, "username"), + Boolean.TRUE.equals(online) || "true".equals(online), + number(json, "lat"), number(json, "lng"), number(json, "heading")); + } + } + + // Tested, never cast and caught: a failed cast does not throw on iOS. + private static String text(Map json, String key) { + Object value = json.get(key); + return value instanceof String ? (String) value : ""; + } + + private static double number(Map json, String key) { + Object value = json.get(key); + return value instanceof Number ? ((Number) value).doubleValue() : 0; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/CarMarker.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/CarMarker.java new file mode 100644 index 00000000000..00daef47003 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/CarMarker.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.map; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapView; +import com.codename1.maps.Marker; +import com.codename1.maps.MarkerOptions; +import com.codename1.ui.EncodedImage; +import com.codename1.ui.Form; +import com.codename1.ui.util.UITimer; + +/// A car on a map, that drives from one reported position to the next. +/// +/// Positions arrive every few seconds. Put where each one says, the car would +/// jump; so it is moved there a little at a time over about the time until +/// the next report, and turned to face the way it is going. +public final class CarMarker { + /// How long a move takes: the interval drivers report at. + private static final int GLIDE_MILLIS = 3000; + private static final int FRAME_MILLIS = 50; + /// The turn is done in this much of the move, as a car turns at a corner + /// and not all along the street. + private static final double TURN_PART = 0.3; + + private final MapView map; + private final Form form; + private Marker marker; + private EncodedImage icon; + private LatLng at; + private double heading; + private LatLng from; + private LatLng to; + private double fromHeading; + private double toHeading; + private long startedAt; + private UITimer timer; + + /// @param form the screen the map is on; the car moves while it is showing + /// @param heading degrees clockwise from north + public CarMarker(MapView map, Form form, LatLng at, double heading) { + this.map = map; + this.form = form; + place(at, heading); + } + + public LatLng position() { + return at; + } + + public double heading() { + return heading; + } + + /// Drives the car to `target`. + /// + /// @param facing the heading reported with the position, or a negative + /// number when none was, and the way the car moved says which way it + /// faces + public void moveTo(LatLng target, double facing) { + double moved = Maps.meters(at, target); + double wanted = facing >= 0 ? facing : moved > 2 ? Maps.heading(at, target) : heading; + // Too far to have been driven in one interval: the car was somewhere + // else, and is shown where it is. + if (moved > 1500) { + stop(); + place(target, wanted); + map.repaint(); + return; + } + from = at; + to = target; + fromHeading = heading; + toHeading = wanted; + startedAt = System.currentTimeMillis(); + if (timer == null) { + timer = UITimer.timer(FRAME_MILLIS, true, form, this::step); + } + } + + public void remove() { + stop(); + if (marker != null) { + map.removeMarker(marker); + marker = null; + } + } + + private void stop() { + if (timer != null) { + timer.cancel(); + timer = null; + } + } + + private void step() { + double part = Math.min(1d, (System.currentTimeMillis() - startedAt) + / (double) GLIDE_MILLIS); + // The shorter way round from one heading to the other. + double turn = toHeading - fromHeading; + while (turn > 180) { + turn -= 360; + } + while (turn < -180) { + turn += 360; + } + place(new LatLng(from.getLatitude() + (to.getLatitude() - from.getLatitude()) * part, + from.getLongitude() + (to.getLongitude() - from.getLongitude()) * part), + fromHeading + turn * Math.min(1d, part / TURN_PART)); + map.repaint(); + if (part >= 1d) { + stop(); + } + } + + private void place(LatLng position, double facing) { + at = position; + heading = facing; + EncodedImage wanted = Maps.car(facing); + if (marker != null && wanted == icon) { //NOPMD CompareObjectsWithEquals + marker.setPosition(position); + return; + } + // A marker's picture cannot be changed, so a car that has turned is a + // new marker. + if (marker != null) { + map.removeMarker(marker); + } + icon = wanted; + marker = map.addMarker(new MarkerOptions(position).icon(wanted).anchor(0.5f, 0.5f)); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/Locator.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/Locator.java new file mode 100644 index 00000000000..00f98edf1b0 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/Locator.java @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.map; + +import com.codename1.location.Location; +import com.codename1.location.LocationListener; +import com.codename1.location.LocationManager; +import com.codename1.location.LocationRequest; +import com.codename1.maps.LatLng; +import com.codename1.ui.CN; +import com.codenameone.examples.wayline.AppConfig; + +import java.util.ArrayList; +import java.util.List; + +/// Where the device is. +/// +/// On a phone, and in a browser, that is the position the platform reports. In +/// the simulator there is no position worth the name, so the app stands at a +/// fixed point instead -- and since a simulated driver would then never move, +/// the driver screen drives the car along the route itself when [#simulated] +/// says so. +/// +/// Nothing here waits. The platform answers when it answers -- after the user +/// has dealt with a permission prompt, which may be never -- so [#here] is +/// always the best answer there is at that moment, and a screen that wants to +/// follow the search [#watch]es it: the position when it comes, or that it was +/// refused, or that there is none to be had, or that nothing has been heard +/// yet. The screen stays usable through all of them, on the middle of the demo +/// city until something better is known. +public final class Locator { + /// Positions are made up; nothing is being looked for. + public static final int SIMULATED = 0; + /// Asked, and not answered yet. + public static final int SEARCHING = 1; + /// [#here] is where the device is. + public static final int FOUND = 2; + /// The user, or a setting, refused. + public static final int DENIED = 3; + /// The platform has no position to give. + public static final int UNAVAILABLE = 4; + /// Still not answered after [#PATIENCE]: a prompt nobody has dealt with, + /// or a fix that is slow to come. An answer is still taken when it does. + public static final int SLOW = 5; + + /// How long a search may go unanswered before a screen says so. + private static final int PATIENCE = 8000; + /// How often a tracking search asks for a position. + private static final long TRACK_MILLIS = 5000L; + + /// Told every time the search gets somewhere, on the event thread. + public interface Watcher { + /// @param state one of the constants of [Locator] + /// @param where the position for [#FOUND]; otherwise where the app + /// stands in the meantime + void located(int state, LatLng where); + } + + private static LatLng pinned; + private static LatLng found; + private static int state = SIMULATED; + private static boolean started; + /// Whether the position is wanted precisely and in the background: a + /// driver on line. See [#track]. + private static boolean tracking; + /// Whether the search was put down because the app went to the + /// background, to be taken up again when it returns. + private static boolean paused; + private static double heading; + /// Counts the searches, so that the patience of one that has been + /// restarted does not run out on the next. + private static int search; + private static final List WATCHERS = new ArrayList(); + + private Locator() { + } + + /// Stands the device at `point`, whatever it reports. For the tests. + public static void pin(LatLng point) { + pinned = point; + } + + /// Whether positions are made up, because there is no real one to read. + public static boolean simulated() { + return pinned != null || CN.isSimulator(); + } + + /// The device's position as far as it is known: the last one reported, + /// and until there is one the middle of the demo city. Never waits. + public static LatLng here() { + if (pinned != null) { + return pinned; + } + if (found != null && !CN.isSimulator()) { + return found; + } + return new LatLng(AppConfig.DEFAULT_LAT, AppConfig.DEFAULT_LNG); + } + + /// The direction the device last moved in, in degrees from north. + public static double heading() { + return heading; + } + + // tag::wayline-locator-track[] + /// Asks for the position precisely, often, and on in the background, or + /// goes back to the ordinary search. A driver on line tracks: riders follow + /// the car on a map, and the server offers rides by distance. + /// + /// The platform decides what "in the background" allows. With the + /// `location` background mode an iPhone goes on reporting after the app is + /// put away; Android does while the app runs a foreground service, which is + /// what [com.codenameone.examples.wayline.driver.Shift] starts. + public static void track(boolean on) { + if (tracking == on) { + return; + } + tracking = on; + if (started && !simulated()) { + LocationManager.getLocationManager().setLocationListener(null); + start(true); + } + } + + /// The app went to the background. Unless a driver is tracking, the + /// platform is told to stop looking: an app with a background location + /// mode is otherwise kept running, and draining the battery, for a map + /// nobody is looking at. + public static void paused() { + if (!started || tracking || simulated()) { + return; + } + paused = true; + search++; + LocationManager.getLocationManager().setLocationListener(null); + } + + /// The app is back: whatever [#paused] put down is taken up again. + public static void resumed() { + if (!paused) { + return; + } + paused = false; + if (started && !simulated()) { + start(true); + } + } + // end::wayline-locator-track[] + + /// How the search stands: one of the constants above. + public static int state() { + return simulated() ? SIMULATED : state; + } + + /// Follows the search, starting it if nothing has yet. Asking is what + /// makes the platform ask the user for permission. `watcher` is told + /// where things stand now unless that is nothing worth telling. + public static void watch(Watcher watcher) { + if (!WATCHERS.contains(watcher)) { + WATCHERS.add(watcher); + } + if (simulated()) { + return; + } + if (!started) { + start(true); + } else if (state != SEARCHING) { + watcher.located(state, here()); + } + } + + /// Stops following. When nobody is left the platform is told to stop + /// too: a location listener left in place keeps the receiver running, and + /// the battery paying for it, on every screen without a map and after + /// signing out. The position it found is kept for [#here], and the next + /// [#watch] starts the search again. + public static void unwatch(Watcher watcher) { + WATCHERS.remove(watcher); + if (started && WATCHERS.isEmpty()) { + started = false; + paused = false; + // Whatever the listener or the patience of this search still has + // to say is about a search nobody is following. + search++; + LocationManager.getLocationManager().setLocationListener(null); + } + } + + /// Asks again, after a refusal or a failure. + public static void retry() { + if (simulated()) { + return; + } + LocationManager.getLocationManager().setLocationListener(null); + start(false); + } + + private static void start(final boolean resumed) { + started = true; + final int mine = ++search; + // A search taken up again -- one screen with a map left, the next + // arrived -- already knows where the device is, and says so, where + // a first search has only that it is looking. + set(resumed && found != null ? FOUND : SEARCHING); + LocationListener listener = new LocationListener() { + @Override + public void locationUpdated(Location location) { + if (mine != search || location == null) { + return; + } + found = new LatLng(location.getLatitude(), location.getLongitude()); + if (location.getDirection() >= 0f) { + heading = location.getDirection(); + } + set(FOUND); + } + + @Override + public void providerStateChanged(int newState) { + if (mine != search || newState == LocationManager.AVAILABLE) { + return; + } + if (newState == LocationManager.OUT_OF_SERVICE) { + set(DENIED); + } else if (found == null) { + // With a position in hand, losing the signal for a while + // is not worth a word. + set(UNAVAILABLE); + } + } + }; + if (tracking) { + // Every few seconds, as precisely as the device can: what a car on + // a rider's map needs. The ordinary search takes the platform's + // default, which on an iPhone waits for 100 meters of movement. + LocationManager.getLocationManager().setLocationListener(listener, + new LocationRequest(LocationRequest.PRIORITY_HIGH_ACCUARCY, TRACK_MILLIS)); + } else { + LocationManager.getLocationManager().setLocationListener(listener); + } + CN.setTimeout(PATIENCE, new Runnable() { + @Override + public void run() { + if (mine == search && state == SEARCHING) { + set(SLOW); + } + } + }); + } + + private static void set(int next) { + state = next; + LatLng where = here(); + // A copy: a watcher may stop watching when it hears. + List all = new ArrayList(WATCHERS); + for (Watcher watcher : all) { + watcher.located(next, where); + } + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/MapStage.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/MapStage.java new file mode 100644 index 00000000000..9dd8c5bfe34 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/MapStage.java @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.map; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapView; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codenameone.examples.wayline.ui.BottomSheet; +import com.codenameone.examples.wayline.ui.Ui; + +/// A map that fills the screen, and what floats over it. +/// +/// From the top: a shade under the status bar, so the clock stays readable +/// over any map; a bar with a round button at its start and whatever the +/// screen puts beside it -- a search card, a status; a chip for one short +/// fact. At the bottom: round buttons at the end of the screen, and under +/// them the [BottomSheet]. +/// +/// Under the chip, when there is something to say about where the device is: +/// that its position was refused, cannot be had, or is slow to come. It is a +/// button, and pressing it asks again; the map and everything else stay as +/// usable as they were, standing on the demo city in the meantime. See +/// [Locator]. +/// +/// The map is one layer and all of that is another. The parts of the upper +/// layer with nothing in them take no touches, so the map under them is +/// dragged and pinched as if nothing were there. +public final class MapStage { + public final MapView map; + private final Container layers; + private final Container north = new Container(BoxLayout.y()); + private final Container south = new Container(BoxLayout.y()); + private final Container header = new Container(new BorderLayout()); + private final Container chips = new Container(new FlowLayout(Component.CENTER)); + private final Container side = new Container(new FlowLayout(Component.RIGHT)); + private final Label chip = Ui.chip("", (char) 0); + private final Button notice = new Button("", "WlChip"); + private final Container notices = new Container(new FlowLayout(Component.CENTER)); + private final Locator.Watcher watcher = new Locator.Watcher() { + @Override + public void located(int state, LatLng where) { + locationChanged(state, where); + } + }; + private Located located; + private boolean everLocated; + /// Whether the search under way was asked for with the notice, and so + /// deserves to be seen to be under way. + private boolean asked; + private final BottomSheet sheet = new BottomSheet(); + /// Counts the times the camera was sent somewhere, so that a framing put + /// off until after a layout can tell it has been overtaken. + private int framed; + + /// Told where the device is, when that becomes known and when it changes. + public interface Located { + /// @param first whether this is the first position this map has had: + /// the moment to move the camera, which later ones must not, since + /// by then the user may be looking at somewhere else + void at(LatLng where, boolean first); + } + + /// @param lead the round button at the start of the bar: the menu on a + /// home screen, the way back on any other + public MapStage(LatLng center, int zoom, Button lead) { + map = Maps.create(center, zoom); + + Container shade = new Container(BoxLayout.y()); + shade.setUIID("WlStatusShade"); + // All of its height is the inset the device asks to be kept clear, so + // it is exactly as tall as the status bar and absent where there is + // none to cover. + shade.setSafeArea(true); + Container bar = new Container(new BorderLayout()); + bar.setUIID("WlMapBar"); + bar.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(lead)); + bar.add(BorderLayout.CENTER, header); + chip.setName("chip"); + chip.setHidden(true); + chips.add(chip); + notice.setName("locationNotice"); + FontImage.setMaterialIcon(notice, FontImage.MATERIAL_LOCATION_OFF, 3.2f); + notice.addActionListener(e -> { + asked = true; + Locator.retry(); + }); + notices.setHidden(true); + notices.add(notice); + north.add(shade).add(bar).add(chips).add(notices); + south.add(side).add(sheet); + + Container floating = new Container(new BorderLayout()); + floating.add(BorderLayout.NORTH, north); + floating.add(BorderLayout.SOUTH, south); + layers = new Container(new LayeredLayout()) { + @Override + protected void initComponent() { + super.initComponent(); + // After this screen is up: an answer that is already known is + // told at once, and telling it lays the screen out. + CN.callSerially(new Runnable() { + @Override + public void run() { + if (isInitialized()) { + Locator.watch(watcher); + } + } + }); + } + + @Override + protected void deinitialize() { + Locator.unwatch(watcher); + super.deinitialize(); + } + }; + layers.add(map).add(floating); + } + + /// Has `listener` told where the device is. Until it is, the screen has + /// [Locator#here], which is the demo city when nothing better is known. + public void whenLocated(Located listener) { + located = listener; + } + + private void locationChanged(int state, LatLng where) { + String text = null; + if (state == Locator.FOUND) { + boolean first = !everLocated; + everLocated = true; + if (located != null) { + located.at(where, first); + } + } else if (state == Locator.DENIED) { + text = "Location is turned off. Try again"; + } else if (state == Locator.UNAVAILABLE) { + text = "Can't find your location. Try again"; + } else if (state == Locator.SLOW) { + text = "Still looking for your location. Try again"; + } else if (state == Locator.SEARCHING && asked) { + text = "Looking for your location..."; + } + if (state != Locator.SEARCHING) { + asked = false; + } + boolean none = text == null; + if (!none) { + notice.setText(text); + } + if (notices.isHidden() != none || !none) { + notices.setHidden(none); + north.revalidate(); + } + } + + /// The map with everything over it, to add to a screen. + public Container layers() { + return layers; + } + + public BottomSheet sheet() { + return sheet; + } + + /// Puts `content` beside the round button at the top, in place of what + /// was there; null leaves the place empty. + public void header(Component content) { + header.removeAll(); + if (content != null) { + header.add(BorderLayout.CENTER, content); + } + north.revalidate(); + } + + /// Adds a round button above the sheet: recentre, and the like. + public Button addRound(char icon, String name, final Runnable onClick) { + Button button = Ui.round(icon, name, e -> onClick.run()); + side.add(button); + return button; + } + + /// Shows `text` in the chip under the bar; null or empty takes the chip + /// away. + public void chip(String text, char icon) { + boolean none = text == null || text.length() == 0; + if (!none) { + chip.setText(text); + if (icon != 0) { + FontImage.setMaterialIcon(chip, icon, 3.2f); + } + } + if (chip.isHidden() != none || !none) { + chip.setHidden(none); + north.revalidate(); + } + } + + /// Moves the camera so that `points` are all in the part of the map + /// nothing covers. + /// + /// Twice, the second time after the screen has been laid out again: this + /// is asked for while the sheet is being filled, when the sheet is still + /// as tall as what it showed before. + public void frame(final LatLng... points) { + final int asked = ++framed; + fit(points); + CN.callSerially(new Runnable() { + @Override + public void run() { + // Not if the camera has been sent somewhere else since. + if (asked == framed) { + layers.revalidate(); + fit(points); + } + } + }); + } + + /// Puts `center` in the middle of the map, in place of whatever was + /// being framed. + public void center(LatLng center, int zoom) { + framed++; + map.moveCamera(center, zoom); + } + + private void fit(LatLng... points) { + // The chip counts whether it is showing or not: it comes and goes + // with the driver's car, and the pins must not end up under it. + int top = north.getHeight() + (chip.isHidden() ? CN.convertToPixels(9f) : 0); + int bottom = Math.max(south.getHeight(), south.getPreferredH()); + if (map.getHeight() > 0 && bottom == 0) { + // Asked before the first layout: the sheet will take about this. + bottom = map.getHeight() * 2 / 5; + } + // A little over, so a pin at the edge is not half under the sheet. + int margin = CN.convertToPixels(4f); + Maps.frame(map, top + margin, bottom + margin, points); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/Maps.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/Maps.java new file mode 100644 index 00000000000..7c8bf8cc6a1 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/Maps.java @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.map; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapBounds; +import com.codename1.maps.MapView; +import com.codename1.maps.Marker; +import com.codename1.maps.MarkerOptions; +import com.codename1.maps.Polyline; +import com.codename1.maps.vector.MapStyle; +import com.codename1.maps.vector.MvtTileSource; +import com.codename1.maps.vector.TileSource; +import com.codename1.ui.CN; +import com.codename1.ui.EncodedImage; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import com.codename1.util.MathUtil; +import com.codenameone.examples.wayline.ui.Look; + +import java.util.ArrayList; +import java.util.List; + +/// The map, as the screens use it. +/// +/// `MapView` draws OpenStreetMap vector tiles with no key and no account, in a +/// light or a dark style to match the app. Everything put on it is drawn here, +/// in code, in colours from `theme.css`: the pickup and drop-off pins +/// (`WlMarkerPickup`, `WlMarkerDropoff`), the car (`WlMarkerCar`) and the route +/// (`WlRoute`). In each of those styles the text colour is the shape and the +/// background colour is its edge. +public final class Maps { + public static final int CITY_ZOOM = 13; + public static final int STREET_ZOOM = 15; + /// The car is drawn once for each of this many directions, and the + /// drawings kept. + private static final int HEADINGS = 36; + + private static TileSource tiles; + private static final EncodedImage[] CARS = new EncodedImage[HEADINGS]; + /// Whether [#CARS] were drawn for the dark look. + private static boolean carsDark; + + private Maps() { + } + + /// Draws every map from these tiles from now on. The tests use the handful + /// bundled with them, so a screenshot does not depend on a tile server. + public static void useTiles(TileSource source) { + tiles = source; + } + + public static MapView create(LatLng center, int zoom) { + MapView map = new MapView(tiles == null ? MvtTileSource.openFreeMap() : tiles, + Look.dark() ? MapStyle.dark() : MapStyle.light()); + map.setName("map"); + // The map is the screen here, not an item on a page that scrolls, so + // the wheel is for zooming it. + map.setWheelZoom(true); + map.moveCamera(center, zoom); + return map; + } + + /// Where the ride starts: a disc with a dot in it. + public static Marker pickup(MapView map, LatLng at) { + return marker(map, at, pin("WlMarkerPickup", true)); + } + + /// Where the ride ends: the same, square. + public static Marker dropoff(MapView map, LatLng at) { + return marker(map, at, pin("WlMarkerDropoff", false)); + } + + private static Marker marker(MapView map, LatLng at, EncodedImage icon) { + return map.addMarker(new MarkerOptions(at).icon(icon).anchor(0.5f, 0.5f)); + } + + private static EncodedImage pin(String uiid, boolean round) { + Style style = UIManager.getInstance().getComponentStyle(uiid); + int side = CN.convertToPixels(5.5f); + int edge = Math.max(2, side / 7); + int dot = Math.max(2, side / 4); + Image image = Image.createImage(side, side, 0); + Graphics g = image.getGraphics(); + g.setAntiAliased(true); + g.setColor(style.getBgColor()); + fill(g, round, 0, side); + g.setColor(style.getFgColor()); + fill(g, round, edge, side - 2 * edge); + g.setColor(style.getBgColor()); + fill(g, round, (side - dot) / 2, dot); + return EncodedImage.createFromImage(image, false); + } + + private static void fill(Graphics g, boolean round, int at, int side) { + if (round) { + g.fillArc(at, at, side, side, 0, 360); + } else { + g.fillRoundRect(at, at, side, side, side / 4, side / 4); + } + } + + /// A car seen from above, pointing along `heading` -- degrees clockwise + /// from north. A map marker cannot be turned, so the car is drawn turned. + static EncodedImage car(double heading) { + boolean dark = Look.dark(); + if (dark != carsDark) { + carsDark = dark; + for (int iter = 0; iter < HEADINGS; iter++) { + CARS[iter] = null; + } + } + int step = (int) Math.round(heading * HEADINGS / 360d) % HEADINGS; + if (step < 0) { + step += HEADINGS; + } + if (CARS[step] == null) { + CARS[step] = drawCar(step * 360d / HEADINGS); + } + return CARS[step]; + } + + private static EncodedImage drawCar(double heading) { + Style style = UIManager.getInstance().getComponentStyle("WlMarkerCar"); + // Square, and wide enough for the car's length at any angle. + int side = CN.convertToPixels(9f); + double length = side * 0.78; + double width = side * 0.40; + Image image = Image.createImage(side, side, 0); + Graphics g = image.getGraphics(); + g.setAntiAliased(true); + double angle = Math.toRadians(heading); + // The body with its corners cut, as across (x) and along (y), in + // halves of the width and the length; first the edge, then the body + // inside it, then the two windows. + double[] bodyX = {-0.62, 0.62, 1, 1, 0.7, -0.7, -1, -1}; + double[] bodyY = {1, 1, 0.72, -0.8, -1, -1, -0.8, 0.72}; + g.setColor(style.getBgColor()); + shape(g, bodyX, bodyY, width / 2 + side * 0.035, length / 2 + side * 0.035, angle, side); + g.setColor(style.getFgColor()); + shape(g, bodyX, bodyY, width / 2, length / 2, angle, side); + g.setColor(style.getBgColor()); + shape(g, new double[] {-0.62, 0.62, 0.74, -0.74}, new double[] {0.5, 0.5, 0.14, 0.14}, + width / 2, length / 2, angle, side); + shape(g, new double[] {-0.7, 0.7, 0.6, -0.6}, new double[] {-0.42, -0.42, -0.7, -0.7}, + width / 2, length / 2, angle, side); + return EncodedImage.createFromImage(image, false); + } + + /// Fills the outline `(x, y)`, scaled and turned about the middle of a + /// square image `side` across. + private static void shape(Graphics g, double[] x, double[] y, double scaleX, double scaleY, + double angle, int side) { + int[] screenX = new int[x.length]; + int[] screenY = new int[x.length]; + double sin = Math.sin(angle); + double cos = Math.cos(angle); + for (int iter = 0; iter < x.length; iter++) { + double across = x[iter] * scaleX; + double along = y[iter] * scaleY; + // Along is up the screen when the heading is north. + screenX[iter] = (int) Math.round(side / 2d + across * cos + along * sin); + screenY[iter] = (int) Math.round(side / 2d + across * sin - along * cos); + } + g.fillPolygon(screenX, screenY, x.length); + } + + /// A route on the map: the line and the edge drawn under it. + public static final class Line { + private final Polyline edge; + private final Polyline line; + + Line(Polyline edge, Polyline line) { + this.edge = edge; + this.line = line; + } + + public void remove(MapView map) { + map.removePolyline(edge); + map.removePolyline(line); + } + } + + /// Draws `points` as the route and returns it, to remove it by. + public static Line route(MapView map, List points) { + Style style = UIManager.getInstance().getComponentStyle("WlRoute"); + int width = Math.max(4, CN.convertToPixels(1.1f)); + // The edge first, so that the line is drawn over it. + Polyline edge = stroke(points, style.getBgColor(), width + Math.max(2, width / 2)); + Polyline line = stroke(points, style.getFgColor(), width); + map.addPolyline(edge); + map.addPolyline(line); + return new Line(edge, line); + } + + private static Polyline stroke(List points, int color, int width) { + LatLng[] line = new LatLng[points.size()]; + points.toArray(line); + return new Polyline(line).setStrokeColor(color).setStrokeWidth(width); + } + + /// Moves the camera so that all of `points` are in view. + public static void show(MapView map, LatLng... points) { + frame(map, 0, 0, points); + } + + /// Moves the camera so that all of `points` are in the part of the map + /// that is not covered: `top` pixels of it are under something at the top, + /// and `bottom` pixels under the sheet. + public static void frame(MapView map, int top, int bottom, LatLng... points) { + List all = new ArrayList(); + for (LatLng point : points) { + if (point != null) { + all.add(point); + } + } + if (all.isEmpty()) { + return; + } + int height = map.getHeight(); + if (height > 0 && top + bottom > 0) { + // The camera fits what it is given to the whole map. So it is given + // more: the points, and a point far enough above and below them + // that the points themselves land in the clear part. + double north = all.get(0).getLatitude(); + double south = north; + for (LatLng point : all) { + north = Math.max(north, point.getLatitude()); + south = Math.min(south, point.getLatitude()); + } + double clear = Math.max(0.3, 1d - (top + bottom) / (double) height); + double span = Math.max(north - south, 0.002) / clear; + double middle = all.get(0).getLongitude(); + all.add(new LatLng(north + span * top / height, middle)); + all.add(new LatLng(south - span * bottom / height, middle)); + } + if (all.size() == 1) { + map.moveCamera(all.get(0), STREET_ZOOM); + } else { + // Room for a marker's own size at the edge, on a screen of any density. + map.fitBounds(MapBounds.fromCoordinates(all), CN.convertToPixels(10, true)); + } + } + + /// The distance between two points in meters, along the surface. + public static double meters(LatLng a, LatLng b) { + double lat1 = Math.toRadians(a.getLatitude()); + double lat2 = Math.toRadians(b.getLatitude()); + double dLat = lat2 - lat1; + double dLng = Math.toRadians(b.getLongitude() - a.getLongitude()); + double h = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2); + return 2 * 6371000d * MathUtil.asin(Math.min(1d, Math.sqrt(h))); + } + + /// The compass heading from `a` to `b`, in degrees. + public static double heading(LatLng a, LatLng b) { + double lat1 = Math.toRadians(a.getLatitude()); + double lat2 = Math.toRadians(b.getLatitude()); + double dLng = Math.toRadians(b.getLongitude() - a.getLongitude()); + double y = Math.sin(dLng) * Math.cos(lat2); + double x = Math.cos(lat1) * Math.sin(lat2) + - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng); + double degrees = Math.toDegrees(MathUtil.atan2(y, x)); + return degrees < 0 ? degrees + 360 : degrees; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/Routes.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/Routes.java new file mode 100644 index 00000000000..74fff9ebe19 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/map/Routes.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.map; + +import com.codename1.maps.LatLng; +import com.codename1.maps.routing.Route; +import com.codename1.maps.routing.RouteCallback; +import com.codename1.maps.routing.RouteRequest; +import com.codename1.maps.routing.Routing; +import com.codename1.maps.routing.TravelMode; + +import java.util.ArrayList; +import java.util.List; + +/// The way from one point to another by road. +/// +/// The roads come from `Routing`, whose default service is the public OSRM +/// demo server: free, keyless, and with no promise of being there. So a route +/// that cannot be found is not an error here. The answer is then the straight +/// line, with the time it would take at a city's pace -- the map still shows +/// where the car is going, and the fare is still quoted, from a length that is +/// a little short. For an app you ship, give `Routing.setService` a routing +/// server of your own. +public final class Routes { + /// A route, reduced to what the screens use. + public static final class Way { + public final List points; + public final double meters; + public final double seconds; + + Way(List points, double meters, double seconds) { + this.points = points; + this.meters = meters; + this.seconds = seconds; + } + } + + public interface Found { + void found(Way way); + } + + /// Meters a second in town: 30 km/h. + private static final double CITY_SPEED = 8.3; + + private static boolean straight; + + private Routes() { + } + + /// Answers every request with the straight line and asks no server. The + /// tests turn this on, so that what they see does not depend on one. + public static void useStraightLines(boolean on) { + straight = on; + } + + public static void find(final LatLng from, final LatLng to, final Found found) { + if (straight) { + found.found(line(from, to)); + return; + } + Routing.findRoute(new RouteRequest(from, to).setTravelMode(TravelMode.DRIVING), + new RouteCallback() { + @Override + public void routesFound(List routes) { + Object first = routes == null || routes.isEmpty() ? null : routes.get(0); + if (!(first instanceof Route)) { + found.found(line(from, to)); + return; + } + Route route = (Route) first; + List points = new ArrayList(); + for (Object point : route.getPoints()) { + if (point instanceof LatLng) { + points.add((LatLng) point); + } + } + if (points.size() < 2) { + found.found(line(from, to)); + return; + } + found.found(new Way(points, route.getDistanceMeters(), + route.getDurationSeconds())); + } + + @Override + public void routeFailed(String message, Throwable err) { + found.found(line(from, to)); + } + }); + } + + private static Way line(LatLng from, LatLng to) { + List points = new ArrayList(); + points.add(from); + points.add(to); + double meters = Maps.meters(from, to); + return new Way(points, meters, meters / CITY_SPEED); + } + + /// The point `meters` along `points` from its start; the end when the line + /// is shorter than that. + public static LatLng along(List points, double meters) { + double left = meters; + for (int iter = 1; iter < points.size(); iter++) { + LatLng a = points.get(iter - 1); + LatLng b = points.get(iter); + double leg = Maps.meters(a, b); + if (left <= leg && leg > 0) { + double part = left / leg; + return new LatLng(a.getLatitude() + (b.getLatitude() - a.getLatitude()) * part, + a.getLongitude() + (b.getLongitude() - a.getLongitude()) * part); + } + left -= leg; + } + return points.get(points.size() - 1); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Account.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Account.java new file mode 100644 index 00000000000..bb248e1ec25 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Account.java @@ -0,0 +1,426 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.net; + +import com.codename1.io.Preferences; +import com.codename1.ui.CN; +import com.codenameone.examples.wayline.Prefs; +import com.codenameone.examples.wayline.api.PlaceDto; +import com.codenameone.examples.wayline.api.PreferencesDto; +import com.codenameone.examples.wayline.api.ProfileDto; + +/// Keeps what the user chose in Settings with their account. +/// +/// [Prefs] is still what every screen reads and writes, and still on the +/// device: that copy is what the app starts from before the server has +/// answered, and all there is for someone who is signed out. This class is the +/// other half. When someone signs in it fetches the account's values and puts +/// them into `Prefs`, so a phone they have never used looks like the one they +/// left; and from then on it is told of every change and sends it back. +/// +/// A change is not sent the moment it is made. A name typed into a field +/// changes with every letter, so changes are gathered for a moment and sent as +/// one request with everything in it. +/// +/// A change is not forgotten when its request fails, either. The device's +/// values stay marked as unsent, on the device, until the server has taken +/// them: they are sent again a little later, and an account load that finds +/// them unsent -- the app restarted before the network came back -- sends them +/// rather than overwriting them with what the server still holds. +public final class Account { + /// How long after the last change the values are sent. + private static final int SETTLE_MILLIS = 700; + /// How long after a request failed it is sent again. + private static final int RETRY_MILLIS = 30000; + /// The keys under which each half is marked unsent; the value is the + /// account it was not sent for. + private static final String UNSENT_PREFERENCES = "wayline.account.unsent.preferences"; + private static final String UNSENT_PROFILE = "wayline.account.unsent.profile"; + + /// True while the account's values are being put into `Prefs`, which + /// reports each of them as a change like any other. + private static boolean applying; + private static boolean preferencesChanged; + private static boolean profileChanged; + private static boolean waiting; + /// Whether each half of the account's values has arrived since it signed + /// in. Until one has, the device's copy of it is not the account's -- it + /// is the defaults, or what an earlier session left -- and is not sent. + private static boolean preferencesLoaded; + private static boolean profileLoaded; + /// The profile as the server last gave it: the name and the gender are + /// part of it, and are sent back as they were with the rest. + private static ProfileDto profile; + /// What a request carries beyond the settings, for a test to look at. + private static PreferencesDto sent; + /// How many requests of each half were sent. An answer clears the unsent + /// mark only if no later request of its half went out after it. + private static int preferencesSends; + private static int profileSends; + + private Account() { + } + + /// Starts listening to `Prefs`. Called once, when the app starts. + public static void start() { + Prefs.onChange(Account::changed); + } + + /// Fetches the account's settings and profile into `Prefs`, then runs + /// `done` -- also when the server did not answer, with what the device + /// had. Each half is asked for whether or not the other came, and one that + /// did not come is asked for again before anything of it is saved. + public static void load(final Runnable done) { + if (Session.user() == null) { + done.run(); + return; + } + profile = null; + preferencesLoaded = false; + profileLoaded = false; + // What another account left unsent is not this one's to send. + String owner = Session.owner(); + if (!isUnsent(UNSENT_PREFERENCES, owner)) { + Preferences.delete(UNSENT_PREFERENCES); + } + if (!isUnsent(UNSENT_PROFILE, owner)) { + Preferences.delete(UNSENT_PROFILE); + } + fetch(done); + } + + private static void fetch(final Runnable done) { + final String owner = Session.owner(); + // An answer from a session that has since ended still runs `done`. + // Signing out and in while the account loads is rare, and the worst + // of it is the home screen shown a moment before its settings, which + // the new session's own load applies when it ends. The data itself is + // only taken for the account signed in now, which is checked below. + final Runnable thenProfile = () -> { + if (profileLoaded || !signedInAs(owner)) { + done.run(); + return; + } + Api.account().profile(Net.to(described -> { + if (signedInAs(owner)) { + if (described != null && isUnsent(UNSENT_PROFILE, owner)) { + // The device has a change the server never took: + // the server's name and gender are kept, the rest is + // the device's, and is sent. + profile = described; + profileLoaded = true; + profileChanged = true; + schedule(SETTLE_MILLIS); + } else { + apply(described); + profileLoaded = described != null; + } + } + done.run(); + }, (status, message) -> done.run())); + }; + if (preferencesLoaded) { + thenProfile.run(); + return; + } + Api.account().preferences(Net.to(preferences -> { + if (signedInAs(owner)) { + if (preferences != null && isUnsent(UNSENT_PREFERENCES, owner)) { + // As for the profile: the device's are newer. + preferencesLoaded = true; + preferencesChanged = true; + schedule(SETTLE_MILLIS); + } else { + apply(preferences); + preferencesLoaded = preferences != null; + } + } + thenProfile.run(); + }, (status, message) -> thenProfile.run())); + } + + /// The profile last fetched or saved, or null before there is one. + public static ProfileDto profile() { + return profile; + } + + /// Saves the name and the gender, which only the profile screen changes. + public static void saveProfile(final String name, final String gender, final Runnable done, + final Net.Failed failed) { + if (!profileLoaded) { + // The rest of the profile goes out with the name, and it has not + // been seen yet; see send(). + fetch(() -> { + if (profileLoaded) { + saveProfile(name, gender, done, failed); + } else { + failed.failed(0, "Could not reach the server. Check your connection."); + } + }); + return; + } + ProfileDto wanted = described(name, gender); + final String owner = Session.owner(); + Api.account().saveProfile(wanted, Net.to(user -> { + // The answer is about the account that asked; see send(). + if (signedInAs(owner)) { + profile = wanted; + Session.setUser(user); + } + done.run(); + }, failed)); + } + + /// The preferences sent last, or null when none were. + public static PreferencesDto lastSent() { + return sent; + } + + /// Sends what is waiting now, for a screen that is about to end the session. + public static void flush() { + waiting = false; + send(); + } + + private static void changed(String name) { + if (applying || Session.user() == null || Prefs.RECENTS.equals(name)) { + return; + } + if (Prefs.EMERGENCY_NAME.equals(name) || Prefs.EMERGENCY_PHONE.equals(name) + || Prefs.HOME.equals(name) || Prefs.WORK.equals(name)) { + profileChanged = true; + } + // The contact's number is in both: it is who a trip is shared with. + if (!Prefs.EMERGENCY_NAME.equals(name) && !Prefs.HOME.equals(name) + && !Prefs.WORK.equals(name)) { + preferencesChanged = true; + } + schedule(SETTLE_MILLIS); + } + + /// Sends what has changed `millis` from now, unless a send is already + /// waiting. + private static void schedule(int millis) { + if (!waiting) { + waiting = true; + CN.setTimeout(millis, () -> CN.callSerially(() -> { + if (waiting) { + waiting = false; + send(); + } + })); + } + } + + private static void send() { + if (Session.user() == null) { + preferencesChanged = false; + profileChanged = false; + return; + } + if (preferencesChanged && !preferencesLoaded || profileChanged && !profileLoaded) { + // The account's values never arrived, so what the device holds in + // their place is not known to be this account's: sent, it would + // replace on the server what was never seen here. They are asked + // for again instead, and the change that brought this about gives + // way to them. A later change is saved as usual once they are in. + preferencesChanged = preferencesChanged && preferencesLoaded; + profileChanged = profileChanged && profileLoaded; + fetch(() -> { }); + } + final String owner = Session.owner(); + if (preferencesChanged) { + preferencesChanged = false; + sent = preferences(); + // Marked before it goes, and cleared once the server has it: until + // then the device's values are the account's newest. + Preferences.set(UNSENT_PREFERENCES, owner); + final int send = ++preferencesSends; + Api.account().savePreferences(sent, Net.to(saved -> { + if (send == preferencesSends) { + sentFor(UNSENT_PREFERENCES, owner); + } + }, (status, message) -> { + if (refused(status)) { + // Refused: sent again, it would be refused again. + if (send == preferencesSends) { + sentFor(UNSENT_PREFERENCES, owner); + } + } else if (signedInAs(owner)) { + preferencesChanged = true; + schedule(RETRY_MILLIS); + } + })); + } + if (profileChanged) { + profileChanged = false; + final ProfileDto wanted = described(); + Preferences.set(UNSENT_PROFILE, owner); + final int send = ++profileSends; + Api.account().saveProfile(wanted, Net.to(user -> { + if (send == profileSends) { + sentFor(UNSENT_PROFILE, owner); + } + // Signing out sends what is waiting and does not wait for the + // answer. If somebody else has signed in by the time it comes, + // this profile is not theirs: kept, its name and gender would + // go out with their next change and overwrite their own. + if (signedInAs(owner)) { + profile = wanted; + } + }, (status, message) -> { + if (refused(status)) { + if (send == profileSends) { + sentFor(UNSENT_PROFILE, owner); + } + } else if (signedInAs(owner)) { + profileChanged = true; + schedule(RETRY_MILLIS); + } + })); + } + } + + /// Whether the server refused a request for what it carried, so that sent + /// again it would be refused again. Anything else -- the server not + /// reached (0), busy, failing, or the session needing a new token -- may + /// succeed later, and the change is kept for then. + private static boolean refused(int status) { + return status >= 400 && status < 500 && status != 401 && status != 408 + && status != 429; + } + + /// Whether `key` marks a change that `owner`'s account has not taken. + private static boolean isUnsent(String key, String owner) { + return owner != null && owner.equals(Preferences.get(key, "")); + } + + /// The server has what `owner` left unsent under `key`. The mark is one + /// key for every account, so an answer that comes after somebody else + /// signed in leaves theirs alone: it may be a change of their own that + /// never went through, and cleared it would be lost to the server's copy. + private static void sentFor(String key, String owner) { + if (isUnsent(key, owner)) { + Preferences.delete(key); + } + } + + /// Whether the account signed in now is `owner` ([Session#owner()]): an + /// answer that comes back after the session it was asked in has ended is + /// not acted on. + private static boolean signedInAs(String owner) { + return owner != null && owner.equals(Session.owner()); + } + + private static PreferencesDto preferences() { + String language = Prefs.language(); + return new PreferencesDto( + language.length() == 0 ? com.codenameone.examples.wayline.ui.Lang.current() + : language, + Prefs.theme(), + Prefs.units(), + Prefs.notifyRideUpdates(), + Prefs.notifyReceipts(), + Prefs.notifyPromotions(), + Prefs.driverGender(), + Prefs.quietRide(), + Prefs.accessibleVehicle(), + Prefs.petFriendly(), + Prefs.paymentMethod(), + Prefs.tipPercent(), + Prefs.shareTrips() ? Prefs.emergencyContactPhone() : ""); + } + + /// The profile to send: the one the server gave, with the device's values + /// for what `Prefs` holds. + private static ProfileDto described() { + return described(profile == null ? Session.user().displayName() : profile.name(), + profile == null ? Session.user().gender() : profile.gender()); + } + + /// The same, with `name` and `gender` in place of the ones held. + private static ProfileDto described(String name, String gender) { + PlaceDto home = Prefs.home(); + PlaceDto work = Prefs.work(); + return new ProfileDto(name, gender, Prefs.emergencyContactName(), + Prefs.emergencyContactPhone(), home == null ? "" : home.name(), + work == null ? "" : work.name()); + } + + private static void apply(PreferencesDto from) { + if (from == null) { + return; + } + applying = true; + try { + // An account that has never chosen a language leaves the device's. + if (from.language() != null && from.language().length() > 0) { + Prefs.setLanguage(from.language()); + } + Prefs.setTheme(from.theme()); + Prefs.setUnits(from.units()); + Prefs.setNotifyRideUpdates(from.notifyRideUpdates()); + Prefs.setNotifyReceipts(from.notifyReceipts()); + Prefs.setNotifyPromotions(from.notifyPromotions()); + Prefs.setDriverGender(from.driverGender()); + Prefs.setQuietRide(from.quietRide()); + Prefs.setAccessibleVehicle(from.accessibleVehicle()); + Prefs.setPetFriendly(from.petFriendly()); + String method = from.defaultPaymentMethodId(); + if (method != null && method.length() > 0) { + Prefs.setPaymentMethod(method); + } + Prefs.setTipPercent(from.defaultTipPercent()); + String sharedWith = from.shareTripsWith(); + Prefs.setShareTrips(sharedWith != null && sharedWith.length() > 0); + } finally { + applying = false; + } + } + + private static void apply(ProfileDto from) { + if (from == null) { + return; + } + profile = from; + applying = true; + try { + Prefs.setEmergencyContactName(from.emergencyContactName()); + Prefs.setEmergencyContactPhone(from.emergencyContactPhone()); + Prefs.setHome(place(Prefs.home(), from.homeAddress())); + Prefs.setWork(place(Prefs.work(), from.workAddress())); + } finally { + applying = false; + } + } + + /// The place for an address the account has. The server keeps the words + /// and not the position, so the place this device already has is kept when + /// it is the same one; another is left without a position, for the screen + /// that goes there to look up. + private static PlaceDto place(PlaceDto known, String address) { + if (address == null || address.length() == 0) { + return null; + } + if (known != null && address.equals(known.name())) { + return known; + } + return new PlaceDto(address, "", 0d, 0d); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Api.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Api.java new file mode 100644 index 00000000000..b5b0b2c0c33 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Api.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.net; + +import com.codename1.io.rest.RestClients; +import com.codenameone.examples.wayline.api.AccountApi; +import com.codenameone.examples.wayline.api.AdminApi; +import com.codenameone.examples.wayline.api.DriverApi; +import com.codenameone.examples.wayline.api.DrivingApi; +import com.codenameone.examples.wayline.api.GeoApi; +import com.codenameone.examples.wayline.api.PaymentApi; +import com.codenameone.examples.wayline.api.RiderApi; + +/// The server, as the app calls it. +/// +/// Each of these is an implementation the build generated from an interface in +/// the `shared` module. The server implements the other half of the same +/// interface, so a path or a field that changes on one side stops the other +/// from compiling. +public final class Api { + private static AccountApi account; + private static RiderApi rider; + private static DriverApi driver; + private static GeoApi geo; + private static AdminApi admin; + private static PaymentApi payments; + private static DrivingApi driving; + + private Api() { + } + + static void connect(String baseUrl) { + account = RestClients.create(AccountApi.class, baseUrl); + rider = RestClients.create(RiderApi.class, baseUrl); + driver = RestClients.create(DriverApi.class, baseUrl); + geo = RestClients.create(GeoApi.class, baseUrl); + admin = RestClients.create(AdminApi.class, baseUrl); + payments = RestClients.create(PaymentApi.class, baseUrl); + driving = RestClients.create(DrivingApi.class, baseUrl); + } + + public static AccountApi account() { + return account; + } + + public static RiderApi rider() { + return rider; + } + + public static DriverApi driver() { + return driver; + } + + public static GeoApi geo() { + return geo; + } + + public static AdminApi admin() { + return admin; + } + + public static PaymentApi payments() { + return payments; + } + + /// Applying to drive, which is open to everyone; [#driver] is for those + /// whose application was approved. + public static DrivingApi driving() { + return driving; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Net.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Net.java new file mode 100644 index 00000000000..b0f9c8d1423 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Net.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.net; + +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; +import com.codenameone.examples.wayline.ui.Lang; + +/// Makes a call through a generated client and sorts out what came back. +/// +/// A generated method answers with a `Response` whatever happened: null when +/// the server could not be reached, and otherwise a status with either the +/// typed result or, for a refusal, the server's reason as text. This turns that +/// into one of two callbacks, so a screen writes what to do with the result and +/// what to do with a failure, and nothing else. +/// +/// ```java +/// Api.rider().active(Net.to(ride -> show(ride), Ui::fail)); +/// ``` +public final class Net { + /// A call to a generated client method, waiting for its callback. + public interface Call { + void run(OnComplete> callback); + } + + public interface Ok { + void got(T value); + } + + public interface Failed { + /// @param status the HTTP status, or 0 when the server was not reached + /// @param message something to show the user + void failed(int status, String message); + } + + private Net() { + } + + /// The callback to hand a generated method: `ok` gets the result, `failed` + /// everything else. + public static OnComplete> to(final Ok ok, final Failed failed) { + return new OnComplete>() { + @Override + public void completed(Response response) { + if (response == null) { + failed.failed(0, "Could not reach the server. Check your connection."); + return; + } + int status = response.getResponseCode(); + if (status >= 200 && status < 300) { + ok.got(response.getResponseData()); + return; + } + failed.failed(status, reason(response, status)); + } + }; + } + + /// Makes a call that was handed over as a value, which is how one screen + /// serves several calls of the same shape. + public static void send(Call call, Ok ok, Failed failed) { + call.run(to(ok, failed)); + } + + /// What the server said when it refused. Read as an `Object` and tested, + /// never cast to the type the call was declared with: on a refusal the body + /// is text whatever that type is. + private static String reason(Response response, int status) { + Object body = response.getResponseData(); + if (body instanceof String && ((String) body).length() > 0 + && ((String) body).length() < 200 && ((String) body).indexOf('<') < 0 + && ((String) body).indexOf('{') < 0) { + return (String) body; + } + if (status == 401) { + return "Please sign in again."; + } + if (status == 403) { + return "Your account is not allowed to do that."; + } + if (status == 429) { + return "Too many attempts. Wait a moment and try again."; + } + return Lang.tr("Something went wrong ({0}). Try again.", String.valueOf(status)); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Session.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Session.java new file mode 100644 index 00000000000..e476b145a24 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/net/Session.java @@ -0,0 +1,517 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.net; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.RequestAuthorizer; +import com.codename1.io.Util; +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcConfiguration; +import com.codename1.io.oidc.OidcRequestAuthorizer; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.PkceChallenge; +import com.codename1.io.oidc.SecureStorageTokenStore; +import com.codename1.io.oidc.TokenStore; +import com.codename1.ui.CN; +import com.codename1.util.SuccessCallback; +import com.codenameone.examples.wayline.AppConfig; +import com.codenameone.examples.wayline.Prefs; +import com.codenameone.examples.wayline.api.UserDto; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Map; + +/// Who is signed in, and the tokens that say so. +/// +/// Signing in is OAuth's authorization-code flow with PKCE against the app's +/// own server, made without a browser: the app shows its own sign-in screen, +/// posts the password to the server for a short-lived session, exchanges the +/// session for an authorization code and the code for tokens. The password is +/// sent once, to the server that owns it, and is never stored; what is stored +/// is the token pair, in the device's secure storage. +/// +/// From then on [OidcRequestAuthorizer] does the work: it puts the access token +/// on every request under `/api`, renews it shortly before it expires, and +/// renews and retries when the server refuses one. When renewing fails -- the +/// refresh token ran out, or the account was suspended -- it says so, and the +/// app goes back to the welcome screen. +public final class Session { + private static final String STORE_PREFIX = "wayline"; + private static final String SESSION_COOKIE = "CN1SESSION"; + + private static String base; + private static OidcClient client; + private static OidcRequestAuthorizer authorizer; + private static TokenStore store; + private static UserDto user; + private static Runnable onSignedOut; + /// Counts the sessions: moved on whenever the server changes, an account + /// signs in, or the session ends. An answer to a request made in an + /// earlier session describes somebody who is no longer signed in here -- + /// another server's account, or the account before a sign-out on this + /// one -- and is dropped rather than installed for whoever is now. + private static int session; + + private Session() { + } + + /// Points the app at a server. Called at start-up, and again when the + /// server address is changed. + public static void start(String baseUrl) { + if (store == null) { + // Falls back to ordinary storage where the platform has no keystore + // to offer, which is the simulator. + store = new SecureStorageTokenStore().allowPlainStorageFallback(true); + } + if (base != null) { + NetworkManager.getInstance().setAuthorizer(base + "/api", null); + if (!base.equals(baseUrl)) { + // Changing the server ends the session with the old one, and + // its tokens go with it. + store.clear(storeKey(base)); + } + } + base = baseUrl; + user = null; + session++; + client = OidcClient.create(OidcConfiguration.newBuilder() + .issuer(base) + .authorizationEndpoint(base + "/oauth2/authorize") + .tokenEndpoint(base + "/oauth2/token") + .revocationEndpoint(base + "/oauth2/revoke") + .jwksUri(base + "/oauth2/jwks") + .build()) + .setClientId(AppConfig.CLIENT_ID) + .setRedirectUri(AppConfig.redirectUri()) + .setScopes("openid", "profile") + .setTokenStore(store) + .setStoreKey(storeKey(base)); + authorizer = new OidcRequestAuthorizer(client); + authorizer.install(base + "/api"); + // Not tied to this start(). A request to an earlier server still in + // flight when the app switched, answered 401 with its refresh refused, + // signs the new server out too: one more sign-in, after a server + // switch that already asked for one, and nothing of either account is + // exposed by it. + authorizer.addSignInRequiredListener((source, reason) -> { + user = null; + session++; + if (onSignedOut != null) { + onSignedOut.run(); + } + }); + Api.connect(base); + } + + /// Where the tokens issued by one server are kept: under a name made from + /// that server's address. + /// + /// One name for every server would hand the tokens of the last one to + /// whichever address is typed next -- the app would restore them and send + /// them, refresh token included, to a server that never issued them. + /// Every character that is not a letter or a digit is written as its code, + /// so two addresses never share a name and the name is one any platform's + /// storage accepts. + static String storeKey(String server) { + StringBuilder key = new StringBuilder(STORE_PREFIX).append('-'); + for (int iter = 0; iter < server.length(); iter++) { + char c = server.charAt(iter); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + key.append(c); + } else { + key.append('_').append(Integer.toHexString(c)).append('_'); + } + } + return key.toString(); + } + + /// Keeps the tokens somewhere else. The tests keep them in memory, so one + /// run leaves nothing behind for the next. + public static void useStore(TokenStore tokens) { + store = tokens; + } + + /// What to do when the session ends without the user asking: go back to + /// the welcome screen. + public static void onSignedOut(Runnable handler) { + onSignedOut = handler; + } + + public static String serverUrl() { + return base; + } + + /// The signed-in user as the server last described them, or null. + public static UserDto user() { + return user; + } + + public static void setUser(UserDto described) { + user = described; + if (described != null && described.username() != null) { + // What the device keeps about one person is not the next one's. + Prefs.ownedBy(owner(described.username())); + } + } + + /// Who what the device keeps belongs to: the signed-in account, or null. + /// See [#owner(String)]. + public static String owner() { + return user == null || user.username() == null ? null : owner(user.username()); + } + + /// The device's name for the account `username` on the server in use. A + /// name is unique on one server only: the same e-mail address signed in + /// to two servers is two accounts, and what one left -- recent places, + /// a change it has not sent yet -- is not the other's to see or send. + public static String owner(String username) { + return storeKey(base == null ? "" : base) + "/" + username; + } + + /// Picks up the session a previous run left, if it is still good. + /// + /// @param signedIn there is a session, and [#user] is set + /// @param signedOut there is none + /// @param unreachable there is one, but the server did not answer + public static void restore(final Runnable signedIn, final Runnable signedOut, + final Runnable unreachable) { + // Loading the tokens can outlast the session it was asked in -- the + // server is changed while it reads -- and then neither answer is this + // session's to act on: see [#session]. + final int asked = session; + authorizer.load().ready(new SuccessCallback() { + @Override + public void onSucess(OidcTokens tokens) { + if (asked != session) { + return; + } + if (tokens == null || tokens.getAccessToken() == null) { + signedOut.run(); + return; + } + refreshUser(signedIn, (status, message) -> { + if (status == 0) { + unreachable.run(); + } else { + signedOut.run(); + } + }); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + if (asked == session) { + signedOut.run(); + } + } + }); + } + + /// Asks the server who this is. The roles in the answer decide which modes + /// the app offers, so it is asked again whenever that could have changed. + public static void refreshUser(final Runnable done, Net.Failed failed) { + // An answer from a session that has since ended -- the server was + // changed while an approval poll waited, or the account signed out + // and another signed in -- describes that account, not this one; + // it is dropped. See [#session]. + final int asked = session; + Api.account().me(Net.to(described -> { + if (asked != session) { + return; + } + setUser(described); + done.run(); + }, (status, message) -> { + if (asked == session) { + failed.failed(status, message); + } + })); + } + + /// Signs in with an e-mail address and password. + public static void signIn(String email, String password, final Runnable done, + final Net.Failed failed) { + // The server can be changed while the sign-in is under way, and + // start() then puts a new authorizer in place. Tokens the old server + // issued are dropped rather than installed in the new one, which would + // send them -- the refresh token too -- to a server that never issued + // them. See [#session]. + final int asked = session; + authenticate(email, password, tokens -> { + if (asked != session) { + return; + } + session++; + authorizer.setTokens(tokens); + store.save(storeKey(base), tokens); + refreshUser(done, failed); + }, failed); + } + + /// Exchanges an e-mail address and password for tokens, and does nothing + /// with them: [#signIn] is this, and then keeping what comes back. + public static void authenticate(final String email, final String password, + final Net.Ok got, final Net.Failed failed) { + final PkceChallenge pkce = PkceChallenge.generate(); + // Any unguessable value does for the state; this is one. + final String state = PkceChallenge.generate().getVerifier(); + // In a browser the app sees neither the session cookie nor a redirect: + // the browser keeps the first and follows the second. So there the + // password's answer is its status, and the code is read from the page + // the redirect ends on -- see AppConfig.redirectUri(). + final boolean web = AppConfig.pageOrigin() != null; + final String redirect = AppConfig.redirectUri(); + + // The steps read `base` as they go rather than pinning it. Changing + // the server in the middle of a sign-in hands the next step to the new + // server, which can do nothing with what it gets: a session cookie and + // a code that only the first server honours, single use and good for + // minutes. A server the user chose is trusted with more than that -- + // the password in step 1 goes to it -- and the last check in + // signIn drops the tokens of a sign-in that outlived its server. + // 1. The password, for a session. + final Step login = new Step("POST", base + "/login"); + login.addArgument("username", email); + login.addArgument("password", password); + login.send(() -> { + if (login.status == 0) { + failed.failed(0, "Could not reach the server. Check your connection."); + return; + } + if (login.status == 429) { + failed.failed(429, "Too many attempts. Wait a minute and try again."); + return; + } + if (web ? login.status != 200 : login.session == null) { + failed.failed(401, "That e-mail and password do not match an account."); + return; + } + // 2. The session, for an authorization code. + final Step authorize = new Step("GET", base + "/oauth2/authorize"); + if (web) { + // Or a session that did not take is answered with a page. + authorize.addRequestHeader("Accept", "application/json"); + } else { + authorize.addRequestHeader("Cookie", login.session); + } + authorize.addArgument("response_type", "code"); + authorize.addArgument("client_id", AppConfig.CLIENT_ID); + authorize.addArgument("redirect_uri", redirect); + authorize.addArgument("scope", "openid profile"); + authorize.addArgument("state", state); + authorize.addArgument("code_challenge", pkce.getChallenge()); + authorize.addArgument("code_challenge_method", pkce.getMethod()); + authorize.send(() -> { + String code = web ? authorize.answer("code") + : parameter(authorize.location, "code"); + String returned = web ? authorize.answer("state") + : parameter(authorize.location, "state"); + // The state has to come back as it went: a code that arrives + // with another was not issued for this sign-in. + if (code == null || !state.equals(returned)) { + failed.failed(authorize.status, "Signing in failed. Try again."); + return; + } + // 3. The code, for tokens. + final Step token = new Step("POST", base + "/oauth2/token"); + token.addArgument("grant_type", "authorization_code"); + token.addArgument("code", code); + token.addArgument("redirect_uri", redirect); + token.addArgument("code_verifier", pkce.getVerifier()); + token.addArgument("client_id", AppConfig.CLIENT_ID); + token.send(() -> { + OidcTokens tokens = token.status == 200 ? token.tokens() : null; + if (tokens == null || tokens.getAccessToken() == null) { + failed.failed(token.status, "Signing in failed. Try again."); + return; + } + // 4. The session has done its work, and is ended rather + // than left to run out: in a browser it is a cookie that + // would go on signing this user in to whoever sat down + // next. Nothing waits on the answer; the tokens stand + // without it. + Step end = new Step("POST", base + "/logout"); + if (!web) { + end.addRequestHeader("Cookie", login.session); + } + end.send(() -> { }); + got.got(tokens); + }); + }); + }); + } + + /// Signs out: the refresh token is revoked at the server, so the session + /// is over there too and not only forgotten here. + public static void signOut(final Runnable done) { + OidcTokens tokens = authorizer.getTokens(); + if (tokens != null && tokens.getRefreshToken() != null) { + // Sent and not waited for. Signing out is something the user does + // to this phone, and it is done here whether or not the server + // answers: the tokens are gone from the device either way. A + // revocation that is lost leaves a refresh token only someone who + // already copied it off the phone could use, and it expires on + // its own; holding the sign-out until the network allows would + // trap the user signed in on a phone with no connection. + client.revoke(tokens.getRefreshToken()); + } + user = null; + session++; + authorizer.signOut().ready(new SuccessCallback() { + @Override + public void onSucess(Boolean cleared) { + done.run(); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + done.run(); + } + }); + } + + /// A parameter of the query string of `url`, or null. + static String parameter(String url, String name) { + int query = url == null ? -1 : url.indexOf('?'); + if (query < 0) { + return null; + } + int at = query + 1; + while (at < url.length()) { + int end = url.indexOf('&', at); + if (end < 0) { + end = url.length(); + } + if (url.startsWith(name + "=", at)) { + return Util.decode(url.substring(at + name.length() + 1, end), "UTF-8", false); + } + at = end + 1; + } + return null; + } + + /// One request of the sign-in. It follows no redirect and keeps no cookie + /// jar, because the redirect and the cookie are the answers it is after; + /// and it carries no bearer token, because there is not one yet. + private static final class Step extends ConnectionRequest { + int status; + String location; + String session; + private byte[] body; + private Runnable then; + private boolean reported; + + Step(String method, String url) { + setUrl(url); + setHttpMethod(method); + setPost(!"GET".equals(method)); + setFollowRedirects(false); + setCookiesEnabled(false); + setReadResponseForErrors(true); + setDuplicateSupported(true); + setFailSilently(true); + setAuthorizer(RequestAuthorizer.NONE); + } + + void send(Runnable next) { + then = next; + NetworkManager.getInstance().addToQueue(this); + } + + /// A text member of the JSON this request was answered with, or null. + String answer(String name) { + if (status != 200 || body == null) { + return null; + } + try { + Map json = new JSONParser().parseJSON(new InputStreamReader( + new ByteArrayInputStream(body), "UTF-8")); + Object value = json == null ? null : json.get(name); + return value instanceof String ? (String) value : null; + } catch (IOException malformed) { + return null; + } catch (RuntimeException malformed) { + return null; + } + } + + OidcTokens tokens() { + try { + Map json = new JSONParser().parseJSON(new InputStreamReader( + new ByteArrayInputStream(body == null ? new byte[0] : body), "UTF-8")); + return OidcTokens.fromTokenResponse(json, null); + } catch (IOException malformed) { + return null; + } catch (RuntimeException malformed) { + return null; + } + } + + @Override + protected void readHeaders(Object connection) throws IOException { + location = getHeader(connection, "Location"); + String[] cookies = getHeaders(connection, "Set-Cookie"); + for (int iter = 0; cookies != null && iter < cookies.length; iter++) { + if (cookies[iter] != null && cookies[iter].startsWith(SESSION_COOKIE + "=")) { + int end = cookies[iter].indexOf(';'); + session = end < 0 ? cookies[iter] : cookies[iter].substring(0, end); + } + } + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + status = code; + } + + @Override + protected void readResponse(InputStream input) throws IOException { + if (status == 0) { + status = getResponseCode(); + } + body = Util.readInputStream(input); + } + + @Override + protected void postResponse() { + if (status == 0) { + status = getResponseCode(); + } + report(); + } + + @Override + protected void handleException(Exception err) { + status = 0; + report(); + } + + private void report() { + if (!reported) { + reported = true; + CN.callSerially(then); + } + } + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/notify/Notifications.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/notify/Notifications.java new file mode 100644 index 00000000000..dbaa14ba35e --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/notify/Notifications.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.notify; + +import com.codename1.push.PushClient; +import com.codename1.push.PushError; +import com.codename1.push.PushListener; +import com.codename1.push.PushMessage; +import com.codename1.push.PushSubscription; +import com.codename1.ui.CN; +import com.codenameone.examples.wayline.AppConfig; +import com.codenameone.examples.wayline.api.PushDeviceDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.ui.Nav; + +/// Push notifications: the ride news that reaches a phone in a pocket. +/// +/// The phone registers with the platform's push service once, and the token +/// it gets is handed to the Wayline server for whoever is signed in. Signing +/// out takes it back, so the next person to use the phone is not told about +/// the last one's rides. The server decides what to send and when; see the +/// server's `RidePushes`. +/// +/// Only on a phone (Android and iOS) and only with [AppConfig#PUSH_APP_KEY] +/// set. The simulator has no push service to register with, and a browser +/// build is told everything over the live channel while its page is open. +public final class Notifications { + private static final Net.Failed QUIET = (status, message) -> { }; + private static PushClient client; + private static PushSubscription subscription; + + private Notifications() { + } + + // tag::wayline-push-register[] + /// Asks the platform for a push token. Called every time the app comes to + /// the foreground; registering is idempotent, and the platform answers + /// with the token it already gave when nothing has changed. + public static void start() { + if (!available()) { + return; + } + if (client == null) { + client = PushClient.builder(AppConfig.PUSH_APP_KEY) + .listener(new Listener()) + .build(); + } + client.register(); + } + + /// Someone has signed in: the server learns where to reach them. + public static void signedIn() { + PushDeviceDto device = device(); + if (device != null && Session.user() != null) { + Api.account().registerPush(device, Net.to(saved -> { }, QUIET)); + } + } + + /// Someone is signing out: the server forgets this phone for them, and + /// then `then` runs. It runs whether or not the server could be reached; + /// signing out never waits on a notification. + public static void signingOut(Runnable then) { + PushDeviceDto device = device(); + if (device == null || Session.user() == null) { + then.run(); + return; + } + // A failure is not kept for retrying. The row it would remove names + // this phone's token, and the next sign-in on the phone takes that row + // over for whoever signs in, as the server keeps a phone on the + // account that signed in on it last. What is lost to an outage is + // the window until then, on the phone of the account that signed out + // -- noted in the implementor's guide among the hardening a + // production deployment adds, not worth a durable queue in a sample. + Api.account().unregisterPush(device, Net.to(gone -> then.run(), + (status, message) -> then.run())); + } + // end::wayline-push-register[] + + /// Whether this build can be sent notifications at all. + public static boolean available() { + if (AppConfig.PUSH_APP_KEY.length() == 0 || CN.isSimulator()) { + return false; + } + String platform = CN.getPlatformName(); + return "and".equals(platform) || "ios".equals(platform); + } + + private static PushDeviceDto device() { + if (subscription == null) { + return null; + } + return new PushDeviceDto(subscription.getTransportId(), subscription.getToken(), + CN.getPlatformName()); + } + + // tag::wayline-push-listener[] + /// Called on the event thread. A message is about a ride: the app opens + /// the screen that shows it, which reads the ride from the server. The + /// message's own words are for the notification shade, not for the app. + private static final class Listener implements PushListener { + @Override + public void onRegistration(PushSubscription registered) { + subscription = registered; + signedIn(); + } + + @Override + public void onMessage(PushMessage message) { + Object ride = message.getData().get("rideId"); + Object role = message.getData().get("role"); + // A message with no title is a silent one: the Live Activity + // moving on, which the push client has already applied. + String title = message.getTitle(); + if (ride != null && title != null && title.length() > 0 + && Session.user() != null) { + Nav.showRide(role == null ? Nav.RIDER : String.valueOf(role)); + } + } + + @Override + public void onError(PushError error) { + // Nothing to tell the user: the app works without notifications, + // and asks again the next time it comes to the foreground. + System.out.println("Push registration failed: " + error.getCode() + " " + + error.getMessage()); + } + } + // end::wayline-push-listener[] +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/CardForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/CardForm.java new file mode 100644 index 00000000000..12ac15ef296 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/CardForm.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.CardDto; +import com.codenameone.examples.wayline.api.PaymentSetupDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Ui; + +/// Adds a card. +/// +/// How depends on who takes the payments, and the server says which when the +/// setup starts. A real provider has a page of its own for typing a card into: +/// the app opens it in the browser, never sees the number, and asks the server +/// what was saved when the user comes back. The simulated provider a +/// development server uses has no such page, so the card is typed here -- which +/// is also what lets the whole flow be tried, and tested, with no account +/// anywhere. +public final class CardForm { + /// The setup the browser was opened for, to finish when the app is back. + private static CardForm waiting; + /// What a hosted setup is finished with: the card went to the processor's + /// own page, and nothing of it is here to send. + private static final CardDto NO_CARD = new CardDto(null, 0, 0, null, null); + + private final Form form = Ui.form("Add a card", "Card"); + private final Form previous; + private final Runnable added; + private final Container page = Ui.page(); + private final SpanLabel error = Ui.text("", "WlError"); + private String setupId; + + private CardForm(Form previous, Runnable added) { + this.previous = previous; + this.added = added; + Ui.back(form, previous); + error.setName("error"); + form.add(BorderLayout.CENTER, page); + } + + /// @param added run once the card is in the wallet; it is what shows the + /// next screen + public static void show(Form previous, Runnable added) { + final CardForm card = new CardForm(previous, added); + card.form.show(); + Api.payments().startSetup(Net.to(card::begin, (status, message) -> { + Ui.fail(status, message); + previous.showBack(); + })); + } + + /// The app is in front again. If it left to add a card, see what came of it. + public static void resumed() { + if (waiting != null) { + waiting.finish(NO_CARD); + } + } + + private void begin(PaymentSetupDto setup) { + setupId = setup.id(); + if (setup.hosted()) { + hosted(setup.url()); + } else { + typed(); + } + Ui.refresh(form); + } + + private void hosted(final String url) { + page.add(Ui.banner(FontImage.MATERIAL_LOCK, "Your card goes straight to the bank", + "The payment provider's own page opens in your browser. Wayline never sees " + + "the number. Come back here when you have finished.", "WlBannerIcon")); + page.add(error); + page.add(Ui.primary("Open the secure page", "openHosted", e -> { + waiting = this; + CN.execute(url); + })); + page.add(Ui.secondary("I have finished", "finishHosted", e -> finish(NO_CARD))); + } + + private void typed() { + final Label number = Ui.plain(mask(""), "WlCardNumber"); + final Label holder = Ui.plain("", "WlCardSmall"); + final Label expires = Ui.plain("MM/YY", "WlCardSmall"); + final Label brand = Ui.plain("", "WlCardBrand"); + Container face = new Container(new BorderLayout()); + face.setUIID("WlCardFace"); + face.add(BorderLayout.NORTH, brand); + face.add(BorderLayout.CENTER, number); + Container foot = new Container(new BorderLayout()); + foot.add(BorderLayout.CENTER, holder); + foot.add(BorderLayout.EAST, expires); + face.add(BorderLayout.SOUTH, foot); + page.add(face); + + final TextField digits = Ui.field(page, "Card number", "cardNumber", + "4242 4242 4242 4242", TextArea.NUMERIC); + Container pair = new Container(new GridLayout(1, 2)); + Container left = new Container(BoxLayout.y()); + Container right = new Container(BoxLayout.y()); + left.setUIID("WlPairLeft"); + right.setUIID("WlPairRight"); + final TextField expiry = Ui.field(left, "Expires", "cardExpiry", "MM/YY", + TextArea.NUMERIC); + final TextField cvc = Ui.field(right, "Security code", "cardCvc", "123", + TextArea.NUMERIC); + pair.add(left).add(right); + page.add(pair); + final TextField name = Ui.field(page, "Name on the card", "cardHolder", ""); + // Each field is written back in the shape a card has it in. Writing it + // back is itself a change, so the listener stops when there is nothing + // left to correct. + digits.addDataChangedListener((type, index) -> { + String shaped = Cards.group(digits.getText()); + if (!shaped.equals(digits.getText())) { + digits.setText(shaped); + } + number.setText(mask(Cards.digits(shaped))); + brand.setText(Cards.brand(Cards.digits(shaped))); + face.revalidate(); + }); + expiry.addDataChangedListener((type, index) -> { + String shaped = Cards.expiry(expiry.getText()); + if (!shaped.equals(expiry.getText())) { + expiry.setText(shaped); + } + expires.setText(shaped.length() == 0 ? "MM/YY" : shaped); + face.revalidate(); + }); + name.addDataChangedListener((type, index) -> { + holder.setText(name.getText()); + face.revalidate(); + }); + page.add(Ui.text("This server takes no real payments. Use the test card " + + "4242 4242 4242 4242 with any date ahead and any three digits.", "WlNotice")); + page.add(error); + page.add(Ui.primary("Save card", "saveCard", e -> { + String wrong = Cards.check(digits.getText(), expiry.getText(), cvc.getText()); + if (wrong != null) { + error.setText(Lang.tr(wrong)); + Ui.refresh(form); + return; + } + CardDto card = new CardDto(Cards.digits(digits.getText()), + Cards.month(expiry.getText()), Cards.year(expiry.getText()), + cvc.getText().trim(), name.getText().trim()); + finish(card); + })); + } + + private void finish(CardDto card) { + waiting = null; + error.setText(""); + Api.payments().completeSetup(setupId, card, Net.to(method -> { + Telemetry.cardAdded(); + added.run(); + }, + (status, message) -> { + error.setText(message); + Ui.refresh(form); + })); + } + + /// The number as the card shows it: what is typed, and dots for the rest. + private static String mask(String digits) { + StringBuilder shown = new StringBuilder(); + for (int iter = 0; iter < 16; iter++) { + if (iter > 0 && iter % 4 == 0) { + shown.append(" "); + } + shown.append(iter < digits.length() ? digits.charAt(iter) : '*'); + } + return shown.toString(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/Cards.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/Cards.java new file mode 100644 index 00000000000..d22aea4f324 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/Cards.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +/// What a card number, an expiry date and a security code look like. +/// +/// Checking them here is a courtesy: it says "that is not a card number" before +/// a request is made. The decision is the provider's, which checks again and +/// is the only one who knows whether the card is real. +public final class Cards { + private Cards() { + } + + /// Only the digits of `text`. + public static String digits(String text) { + StringBuilder digits = new StringBuilder(); + for (int iter = 0; text != null && iter < text.length(); iter++) { + char letter = text.charAt(iter); + if (letter >= '0' && letter <= '9') { + digits.append(letter); + } + } + return digits.toString(); + } + + /// The digits of `text` in fours, as a card has them: `4242 4242`. + public static String group(String text) { + String digits = digits(text); + if (digits.length() > 19) { + digits = digits.substring(0, 19); + } + StringBuilder grouped = new StringBuilder(); + for (int iter = 0; iter < digits.length(); iter++) { + if (iter > 0 && iter % 4 == 0) { + grouped.append(' '); + } + grouped.append(digits.charAt(iter)); + } + return grouped.toString(); + } + + /// The digits of `text` as a month and a year: `0428` is `04/28`. + public static String expiry(String text) { + String digits = digits(text); + if (digits.length() > 4) { + digits = digits.substring(0, 4); + } + return digits.length() <= 2 ? digits : digits.substring(0, 2) + "/" + digits.substring(2); + } + + public static int month(String expiry) { + String digits = digits(expiry); + return digits.length() < 2 ? 0 : Integer.parseInt(digits.substring(0, 2)); + } + + /// The year in full: a card says `28` and means 2028. + public static int year(String expiry) { + String digits = digits(expiry); + return digits.length() < 4 ? 0 : 2000 + Integer.parseInt(digits.substring(2, 4)); + } + + /// The network a number belongs to, from how it starts; empty while that + /// cannot be told. + public static String brand(String digits) { + if (digits.startsWith("4")) { + return "Visa"; + } + if (digits.startsWith("34") || digits.startsWith("37")) { + return "Amex"; + } + if (digits.startsWith("6011") || digits.startsWith("65")) { + return "Discover"; + } + if (digits.length() >= 2) { + int two = Integer.parseInt(digits.substring(0, 2)); + if (two >= 51 && two <= 55 || two >= 22 && two <= 27) { + return "Mastercard"; + } + } + return ""; + } + + /// What is wrong with what was typed, in English, or null when nothing is. + public static String check(String number, String expiry, String cvc) { + String digits = digits(number); + if (digits.length() < 12 || digits.length() > 19 || !luhn(digits)) { + return "That is not a card number. Check the digits."; + } + int month = month(expiry); + if (month < 1 || month > 12 || year(expiry) == 0) { + return "Write the expiry date as month and year: 04/28."; + } + String code = cvc == null ? "" : cvc.trim(); + if (code.length() < 3 || code.length() > 4 || digits(code).length() != code.length()) { + return "The security code is the three or four digits on the card."; + } + return null; + } + + /// The check digit every card number ends in, which catches a mistyped one. + public static boolean luhn(String digits) { + int sum = 0; + boolean twice = false; + for (int iter = digits.length() - 1; iter >= 0; iter--) { + int digit = digits.charAt(iter) - '0'; + if (twice) { + digit *= 2; + if (digit > 9) { + digit -= 9; + } + } + sum += digit; + twice = !twice; + } + return digits.length() > 0 && sum % 10 == 0; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/ReceiptForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/ReceiptForm.java new file mode 100644 index 00000000000..3048e9721c9 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/ReceiptForm.java @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.ReasonDto; +import com.codenameone.examples.wayline.api.ReceiptDto; +import com.codenameone.examples.wayline.api.TipDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Ui; + +/// What a ride cost, how it was paid, and -- for the rider -- the chance to +/// leave a tip. +/// +/// Every number here is the server's, in cents, and is shown as it came. The +/// one sum the app does is the tip a percentage stands for, which it then sends +/// as an amount: what "15%" means is decided where the rider can see it. +public final class ReceiptForm { + private static final int[] PERCENTS = {10, 15, 20}; + + private ReceiptForm() { + } + + /// Fetches the rider's receipt for `rideId` and shows it. + public static void open(final Form previous, String rideId) { + Api.payments().receipt(rideId, Net.to(receipt -> show(previous, receipt, false), + Ui::fail)); + } + + /// @param admin true for an admin, who may return the money and may not tip + public static void show(final Form previous, final ReceiptDto receipt, final boolean admin) { + final Form form = Ui.form("Receipt", "Receipt"); + Ui.back(form, previous); + Container page = page(receipt, admin); + actions(form, previous, page, receipt, admin); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + /// What a receipt says, without what can be done about it: for a caller + /// with its own place to show one, which the admin console is. + /// + /// @param admin true to name the rider, which an admin needs and a rider does not + public static Container page(final ReceiptDto receipt, final boolean admin) { + Container page = Ui.page(); + final String currency = receipt.currency(); + + Label total = Ui.plain(Ui.money(receipt.total(), currency), "WlAmount"); + total.setName("total"); + page.add(total); + Label status = Ui.badge(status(receipt.status()), badge(receipt.status())); + status.setName("paymentStatus"); + page.add(FlowLayout.encloseCenter(status)); + page.add(FlowLayout.encloseCenter(Ui.plain( + receipt.paidAt() > 0 ? Ui.when(receipt.paidAt()) : "", "WlMuted"))); + + Ui.section(page, "Trip"); + Container trip = new Container(BoxLayout.y()); + trip.add(Ui.row(FontImage.MATERIAL_TRIP_ORIGIN, receipt.pickupName(), null, null)); + trip.add(Ui.row(FontImage.MATERIAL_PLACE, receipt.dropoffName(), + Ui.distance(receipt.distanceMeters()) + Ui.DOT + + Ui.minutes(receipt.durationSeconds()), null)); + if (receipt.driverName() != null && receipt.driverName().length() > 0) { + trip.add(Ui.row(FontImage.MATERIAL_PERSON, receipt.driverName(), receipt.vehicle(), + null)); + } + if (admin && receipt.riderUsername() != null && receipt.riderUsername().length() > 0) { + trip.add(Ui.row(FontImage.MATERIAL_HAIL, receipt.riderUsername(), "Rider", null)); + } + page.add(trip); + + Ui.section(page, "Fare"); + Container fare = new Container(BoxLayout.y()); + fare.setUIID("WlPanel"); + fare.add(Ui.fact("Base fare", Ui.money(receipt.baseFare(), currency))); + fare.add(Ui.fact("Distance", Ui.money(receipt.distanceFare(), currency))); + fare.add(Ui.fact("Time", Ui.money(receipt.timeFare(), currency))); + fare.add(Ui.fact("Service fee", Ui.money(receipt.serviceFee(), currency))); + if (receipt.discount() > 0) { + fare.add(Ui.fact("Discount", "-" + Ui.money(receipt.discount(), currency))); + } + if (receipt.tip() > 0) { + Container tipped = Ui.fact("Tip", Ui.money(receipt.tip(), currency)); + tipped.getComponentAt(1).setName("tipPaid"); + fare.add(tipped); + } + fare.add(Ui.fact("Total", Ui.money(receipt.total(), currency), "WlFactTotal")); + fare.add(Ui.fact("Paid with", "cash".equals(receipt.status()) ? Lang.tr("Cash") + : receipt.methodLabel())); + page.add(fare); + return page; + } + + private static void actions(final Form form, final Form previous, Container page, + final ReceiptDto receipt, final boolean admin) { + final String currency = receipt.currency(); + boolean settled = "paid".equals(receipt.status()) || "cash".equals(receipt.status()); + if (!admin && settled && receipt.tip() == 0) { + tip(form, previous, page, receipt); + } + if (admin && "paid".equals(receipt.status())) { + page.add(Ui.danger("Refund this ride", "refund", e -> Ui.reason(form, + "Refund", Lang.tr("{0} goes back to the card it came from.", + Ui.money(receipt.total(), currency)), "Refund", + why -> { + ReasonDto reason = new ReasonDto(why); + Api.admin().refundRide(receipt.rideId(), reason, Net.to(refunded -> { + Telemetry.rideRefunded("receipt"); + show(previous, refunded, true); + }, Ui::fail)); + }))); + } + } + + /// The tip: three shares of the fare to tap, or an amount to type. + private static void tip(final Form form, final Form previous, Container page, + final ReceiptDto receipt) { + Ui.section(page, "Add a tip"); + page.add(Ui.text(receipt.driverName() == null || receipt.driverName().length() == 0 + ? Lang.tr("All of it goes to your driver.") + : Lang.tr("All of it goes to {0}.", receipt.driverName()), "WlMuted")); + final long fare = receipt.baseFare() + receipt.distanceFare() + receipt.timeFare(); + String[] labels = new String[PERCENTS.length]; + String[] values = new String[PERCENTS.length]; + for (int iter = 0; iter < PERCENTS.length; iter++) { + long cents = Math.max(100L, Math.round(fare * PERCENTS[iter] / 100d)); + values[iter] = String.valueOf(cents); + labels[iter] = PERCENTS[iter] + "% " + Ui.money(cents, receipt.currency()); + } + final TextField custom = new TextField("", Lang.tr("Another amount"), 8, TextArea.DECIMAL); + custom.setUIID("WlField"); + custom.getHintLabel().setUIID("WlFieldHint"); + custom.setName("tipCustom"); + final long[] chosen = {0}; + Container presets = Ui.choice("tip", labels, values, "", value -> { + chosen[0] = Long.parseLong(value); + custom.setText(""); + }); + final String[] amounts = values; + // The amounts are numbers; nothing in them is a word to look up. + for (int iter = 0; iter < presets.getComponentCount(); iter++) { + ((Button) presets.getComponentAt(iter)).setShouldLocalize(false); + } + page.add(presets); + page.add(custom); + page.add(Ui.primary("Send tip", "sendTip", e -> { + long cents = custom.getText().trim().length() > 0 ? cents(custom.getText()) + : chosen[0]; + if (cents <= 0) { + Ui.say("Choose an amount for the tip"); + return; + } + TipDto tip = new TipDto(cents); + // Which of the shares it was, when it was one of them. + int share = 0; + for (int iter = 0; iter < amounts.length; iter++) { + if (custom.getText().trim().length() == 0 + && amounts[iter].equals(String.valueOf(cents))) { + share = PERCENTS[iter]; + } + } + final int reported = share; + Api.payments().tip(receipt.rideId(), tip, Net.to(tipped -> { + Telemetry.tipped("receipt", reported); + Ui.say("Thank you. Your tip was sent."); + show(previous, tipped, false); + }, Ui::fail)); + })); + } + + /// An amount typed with or without a decimal point, in cents; 0 for + /// anything that is not an amount. + static long cents(String typed) { + String text = typed.trim().replace(',', '.'); + int point = text.indexOf('.'); + String whole = point < 0 ? text : text.substring(0, point); + String part = point < 0 ? "" : text.substring(point + 1); + if (part.length() > 2) { + part = part.substring(0, 2); + } + while (part.length() < 2) { + part += "0"; + } + if (Cards.digits(whole).length() != whole.length() + || Cards.digits(part).length() != part.length() || whole.length() > 6) { + return 0; + } + return (whole.length() == 0 ? 0 : Long.parseLong(whole)) * 100 + Long.parseLong(part); + } + + /// A payment's state, in words. + public static String status(String status) { + return "paid".equals(status) ? "Paid" : "cash".equals(status) ? "Paid in cash" + : "refunded".equals(status) ? "Refunded" + : "refund_pending".equals(status) ? "Refund pending" + : "refund_failed".equals(status) ? "Refund failed" + : "failed".equals(status) ? "Payment failed" : "Payment pending"; + } + + public static String badge(String status) { + return "paid".equals(status) || "cash".equals(status) ? "WlBadgeGood" + : "failed".equals(status) || "refund_failed".equals(status) ? "WlBadgeBad" + : "WlBadgeWarn"; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/WalletForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/WalletForm.java new file mode 100644 index 00000000000..ae64dcba63d --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/pay/WalletForm.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.pay; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.wayline.Prefs; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.PaymentMethodDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Ui; +import java.util.List; + +/// The ways the rider can pay: their cards, and cash, which is always there. +/// +/// The list is the server's. A card is added by the payment provider and what +/// comes back is a brand and four digits; the number itself is never kept by +/// this app, and by the server only long enough to hand it over. +/// +/// The same screen answers "which of these, for this ride?": opened with +/// [#pick], a tap on a method chooses it and goes back. +public final class WalletForm { + public interface Picked { + void picked(PaymentMethodDto method); + } + + private final Form form; + private final Form previous; + private final Picked picked; + private final String chosen; + private final Container list = new Container(BoxLayout.y()); + + private WalletForm(Form previous, String chosen, Picked picked) { + this.previous = previous; + this.picked = picked; + this.chosen = chosen; + form = Ui.form(picked == null ? "Wallet" : "Pay with", "Wallet"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Ui.text(picked == null + ? "Tap a card to make it the one that pays unless you say otherwise." + : "Choose how to pay for this ride.", "WlMuted")); + list.setName("methods"); + page.add(list); + page.add(Ui.secondary("Add a card", "addCard", + e -> CardForm.show(form, () -> { + form.showBack(); + load(); + }))); + form.add(BorderLayout.CENTER, page); + } + + /// The wallet, to look after. + public static void show(Form previous) { + WalletForm wallet = new WalletForm(previous, null, null); + wallet.form.show(); + wallet.load(); + } + + /// The wallet, to choose from for one ride. + /// + /// @param chosen the id of the method the ride has now + public static void pick(Form previous, String chosen, Picked picked) { + WalletForm wallet = new WalletForm(previous, chosen, picked); + wallet.form.show(); + wallet.load(); + } + + /// The name of a method, as a rider would say it. + public static String label(PaymentMethodDto method) { + if (method == null || "cash".equals(method.kind())) { + return Lang.tr("Cash"); + } + return method.label() == null || method.label().length() == 0 + ? method.brand() + " " + method.last4() : method.label(); + } + + private void load() { + Api.payments().methods(Net.to(this::fill, Ui::fail)); + } + + private void fill(List methods) { + list.removeAll(); + for (int iter = 0; methods != null && iter < methods.size(); iter++) { + final PaymentMethodDto method = methods.get(iter); + boolean cash = "cash".equals(method.kind()); + boolean marked = picked == null ? method.isDefault() : method.id().equals(chosen); + Container row = new Container(new BorderLayout()); + row.setUIID(marked ? "WlOptionPicked" : "WlOption"); + Label icon = new Label("", "WlOptionIcon"); + Ui.icon(icon, cash ? FontImage.MATERIAL_PAYMENTS + : FontImage.MATERIAL_CREDIT_CARD, 4f); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(icon)); + Container lines = new Container(BoxLayout.y()); + lines.add(Ui.plain(label(method), "WlRowTitle")); + lines.add(Ui.plain(cash ? Lang.tr("Pay the driver directly") + : Lang.tr("Expires {0}", expiry(method)), "WlRowDetail")); + row.add(BorderLayout.CENTER, lines); + Container end = new Container(new FlowLayout(Label.RIGHT, Label.CENTER)); + if (method.isDefault()) { + end.add(Ui.badge("Default", "WlBadgeGood")); + } + row.add(BorderLayout.EAST, end); + Ui.tap(row, cash ? "method-cash" : "method-" + iter, e -> choose(method)); + list.add(row); + if (!cash && picked == null) { + // Its own line under the card, so that a tap meant for the card + // is never a tap on Remove. + Button remove = Ui.link("Remove this card", "remove-" + iter, e -> + Api.payments().remove(method.id(), Net.to(gone -> { + Telemetry.cardRemoved(); + if (method.id().equals(Prefs.paymentMethod())) { + Prefs.setPaymentMethod(Prefs.PAYMENT_CASH); + } + load(); + }, Ui::fail))); + list.add(FlowLayout.encloseRight(remove)); + } + } + Ui.refresh(form); + } + + private void choose(final PaymentMethodDto method) { + if (picked != null) { + previous.showBack(); + picked.picked(method); + return; + } + Api.payments().makeDefault(method.id(), Net.to(made -> { + Prefs.setPaymentMethod(method.id()); + load(); + }, Ui::fail)); + } + + private static String expiry(PaymentMethodDto method) { + int year = method.expYear() % 100; + return (method.expMonth() < 10 ? "0" : "") + method.expMonth() + "/" + + (year < 10 ? "0" : "") + year; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/PlaceSearchForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/PlaceSearchForm.java new file mode 100644 index 00000000000..5e35b86f277 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/PlaceSearchForm.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.rider; + +import com.codename1.maps.LatLng; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.wayline.Prefs; +import com.codenameone.examples.wayline.api.PlaceDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.ui.Ui; + +import java.util.List; + +/// Finds a place by name. +/// +/// The search goes to the app's own server, which asks a geocoder on the app's +/// behalf. So the app holds no geocoder key, the server can swap one provider +/// for another without a release, and the provider sees the server and never a +/// user's device. +public final class PlaceSearchForm { + public interface Picked { + void picked(PlaceDto place); + } + + private static final int PAUSE_MILLIS = 350; + + private final Form form = Ui.form("Where to?", "PlaceSearch"); + private final Container results = new Container(BoxLayout.y()); + private final TextField query; + private final LatLng near; + private final Form previous; + private final Picked picked; + private int typed; + + private PlaceSearchForm(Form previous, LatLng near, Picked picked) { + this.previous = previous; + this.near = near; + this.picked = picked; + Ui.back(form, previous); + Container top = Ui.page(); + top.setScrollableY(false); + query = Ui.field(top, "Destination", "query", "An address or a place"); + // Searches when the typing pauses, not at every letter. + query.addDataChangedListener((type, index) -> { + final int mine = ++typed; + UITimer.timer(PAUSE_MILLIS, false, form, () -> { + if (mine == typed) { + search(); + } + }); + }); + query.setDoneListener(e -> search()); + results.setName("results"); + results.setScrollableY(true); + form.add(BorderLayout.NORTH, top); + form.add(BorderLayout.CENTER, results); + suggest(); + } + + /// Before anything is typed: the places already known, which are most of + /// where anybody goes. + private void suggest() { + results.removeAll(); + int count = 0; + count = known(FontImage.MATERIAL_HOME, Prefs.home(), count); + count = known(FontImage.MATERIAL_WORK, Prefs.work(), count); + List recents = Prefs.recents(); + for (int iter = 0; iter < recents.size(); iter++) { + count = known(FontImage.MATERIAL_SCHEDULE, recents.get(iter), count); + } + Ui.refresh(form); + } + + private int known(char icon, final PlaceDto place, int count) { + if (place == null) { + return count; + } + results.add(Ui.tap(Ui.row(icon, place.name(), place.address(), null), "known-" + count, + e -> { + previous.showBack(); + picked.picked(place); + })); + return count + 1; + } + + public static void show(Form previous, LatLng near, Picked picked) { + PlaceSearchForm search = new PlaceSearchForm(previous, near, picked); + search.form.show(); + search.query.startEditingAsync(); + } + + private void search() { + final String text = query.getText().trim(); + if (text.length() < 2) { + suggest(); + return; + } + Api.geo().search(text, near.getLatitude(), near.getLongitude(), Net.to(places -> { + // An answer to what is no longer in the field is dropped. + if (text.equals(query.getText().trim())) { + fill(places); + } + }, Ui::fail)); + } + + private void fill(List places) { + results.removeAll(); + if (places == null || places.isEmpty()) { + Container none = Ui.page(); + none.add(Ui.label("Nothing found", "WlMuted")); + results.add(none); + } + for (int iter = 0; places != null && iter < places.size(); iter++) { + final PlaceDto place = places.get(iter); + results.add(Ui.tap(Ui.row(FontImage.MATERIAL_PLACE, place.name(), place.address(), + null), "place-" + iter, e -> { + previous.showBack(); + picked.picked(place); + })); + } + Ui.refresh(form); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/RideCard.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/RideCard.java new file mode 100644 index 00000000000..70b637f3a8b --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/RideCard.java @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.rider; + +import com.codename1.surfaces.LiveActivity; +import com.codename1.surfaces.LiveActivityDescriptor; +import com.codename1.surfaces.SurfaceColor; +import com.codename1.surfaces.SurfaceColumn; +import com.codename1.surfaces.SurfaceFontWeight; +import com.codename1.surfaces.SurfaceNode; +import com.codename1.surfaces.SurfaceProgress; +import com.codename1.surfaces.SurfaceRow; +import com.codename1.surfaces.SurfaceText; +import com.codename1.ui.CN; +import com.codenameone.examples.wayline.api.RideActivityDto; +import com.codenameone.examples.wayline.api.RideActivityKeys; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; + +import java.util.HashMap; +import java.util.Map; + +/// The ride on the lock screen: a Live Activity that says the driver is on +/// the way, who they are, the car to look for and how long until it is there. +/// +/// It starts when a driver takes the ride and ends with the ride. While the +/// app is open the rider screen moves it on with every change it sees; once +/// the app is put away the server does, with push notifications addressed to +/// the activity's id, which is why the id is sent to the server here. +/// +/// Where it shows: the lock screen and the Dynamic Island on an iPhone, an +/// ongoing notification on Android, and a small window on the desktop and in +/// the simulator. Elsewhere [LiveActivity#start] answers a handle that does +/// nothing, and this class has nothing to check. +final class RideCard { + private static final Net.Failed QUIET = (status, message) -> { }; + private static final int ACCENT = 0xff1f6feb; + + private static LiveActivity activity; + private static String rideId; + /// What the activity last showed, so that a poll that changes nothing + /// sends nothing. + private static String shown = ""; + /// A start or an update is under way on another thread. + private static boolean busy; + /// The ride a start was last tried for. A platform with nothing to show + /// answers an inactive handle, and asking again on every poll would not + /// change that. + private static String tried; + + private RideCard() { + } + + // tag::wayline-ride-card-follow[] + /// The rider screen has `ride` as it now is. Called on the event thread. + static void follow(final RideDto ride) { + if (ride == null || busy) { + return; + } + boolean meeting = RideState.ACCEPTED == ride.state() || RideState.ARRIVED == ride.state() + || RideState.IN_PROGRESS == ride.state(); + if (activity != null && !ride.id().equals(rideId)) { + // A ride this screen no longer shows: its activity goes. + final LiveActivity stale = activity; + activity = null; + rideId = null; + shown = ""; + CN.scheduleBackgroundTask(() -> stale.end(null, true)); + } + final boolean ours = activity != null; + if (!meeting) { + if (ours) { + final LiveActivity ending = activity; + final boolean done = RideState.COMPLETED == ride.state(); + activity = null; + rideId = null; + shown = ""; + // A finished ride stays on the lock screen a little while, as + // the platform decides; a cancelled one goes at once. + CN.scheduleBackgroundTask(() -> ending.end(done ? state(ride) : null, !done)); + } + return; + } + final Map now = state(ride); + String key = ride.id() + ":" + now; + if (ours ? key.equals(shown) : ride.id().equals(tried)) { + return; + } + if (!ours) { + tried = ride.id(); + } + shown = key; + busy = true; + final LiveActivity current = ours ? activity : null; + // Off the event thread: on a phone starting and updating an activity + // is a round trip to the system. + CN.scheduleBackgroundTask(() -> { + if (current != null) { + current.update(now); + CN.callSerially(() -> busy = false); + return; + } + final LiveActivity started = LiveActivity.start(descriptor(), now); + CN.callSerially(() -> { + busy = false; + if (!started.isActive()) { + return; + } + activity = started; + rideId = ride.id(); + Api.rider().activity(ride.id(), new RideActivityDto(started.getId()), + Net.to(saved -> { }, QUIET)); + }); + }); + } + // end::wayline-ride-card-follow[] + + /// The activity's state, in the keys the server sends too. + static Map state(RideDto ride) { + Map out = new HashMap(); + String status; + double progress; + if (RideState.ARRIVED == ride.state()) { + status = "Your driver is here"; + progress = RideActivityKeys.ARRIVED; + } else if (RideState.IN_PROGRESS == ride.state()) { + status = "On the trip"; + progress = RideActivityKeys.IN_TRIP; + } else if (RideState.COMPLETED == ride.state()) { + status = "You've arrived"; + progress = RideActivityKeys.DONE; + } else { + status = "On the way"; + progress = RideActivityKeys.ON_THE_WAY; + } + out.put(RideActivityKeys.STATUS, status); + out.put(RideActivityKeys.PROGRESS, Double.valueOf(progress)); + out.put(RideActivityKeys.DRIVER, text(ride.driverName())); + out.put(RideActivityKeys.CAR, text(ride.vehicle())); + out.put(RideActivityKeys.PLATE, text(ride.plate())); + out.put(RideActivityKeys.ETA, ride.etaMinutes() > 0 + && RideState.ARRIVED != ride.state() ? ride.etaMinutes() + " min" : ""); + return out; + } + + // tag::wayline-ride-card-layout[] + /// The layout, filled in from the state on the device: an update sends the + /// state alone. + private static LiveActivityDescriptor descriptor() { + return new LiveActivityDescriptor(RideActivityKeys.KIND) + .setContent(content()) + .setCompactLeading(new SurfaceText("${driver}").setFontSize(13) + .setMaxLines(1)) + .setCompactTrailing(new SurfaceText("${eta}").setFontSize(13) + .setColor(SurfaceColor.ACCENT)) + .setMinimal(new SurfaceText("${eta}").setFontSize(11)) + .setExpandedCenter(new SurfaceText("${status}").setFontSize(16) + .setFontWeight(SurfaceFontWeight.SEMIBOLD)) + .setExpandedBottom(content()) + .setTint(SurfaceColor.rgb(ACCENT)); + } + + private static SurfaceNode content() { + return new SurfaceColumn().setSpacing(6).setPadding(12) + .add(new SurfaceRow().setSpacing(10) + .add(new SurfaceColumn().setSpacing(2).setWeight(1) + .add(new SurfaceText("${status}").setFontSize(15) + .setFontWeight(SurfaceFontWeight.SEMIBOLD) + .setMaxLines(1)) + .add(new SurfaceText("${driver} - ${car}").setFontSize(13) + .setColor(SurfaceColor.SECONDARY_LABEL) + .setMaxLines(1)) + .add(new SurfaceText("${plate}").setFontSize(13) + .setFontWeight(SurfaceFontWeight.BOLD) + .setMaxLines(1))) + .add(new SurfaceText("${eta}").setFontSize(22) + .setFontWeight(SurfaceFontWeight.BOLD) + .setColor(SurfaceColor.ACCENT))) + .add(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR) + .setValueState(RideActivityKeys.PROGRESS) + .setColor(SurfaceColor.rgb(ACCENT))); + } + // end::wayline-ride-card-layout[] + + private static String text(String value) { + return value == null ? "" : value; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/RiderForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/RiderForm.java new file mode 100644 index 00000000000..fae4aef7b23 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/rider/RiderForm.java @@ -0,0 +1,906 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.rider; + +import com.codename1.maps.LatLng; +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.wayline.Prefs; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.FareOptionDto; +import com.codenameone.examples.wayline.api.FareQuoteDto; +import com.codenameone.examples.wayline.api.NearbyDriverDto; +import com.codenameone.examples.wayline.api.PaymentMethodDto; +import com.codenameone.examples.wayline.api.PlaceDto; +import com.codenameone.examples.wayline.api.RatingDto; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.api.RideRequestDto; +import com.codenameone.examples.wayline.api.RideState; +import com.codenameone.examples.wayline.api.TipDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.live.LiveChannel; +import com.codenameone.examples.wayline.map.CarMarker; +import com.codenameone.examples.wayline.map.Locator; +import com.codenameone.examples.wayline.map.MapStage; +import com.codenameone.examples.wayline.map.Maps; +import com.codenameone.examples.wayline.map.Routes; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.pay.ReceiptForm; +import com.codenameone.examples.wayline.pay.WalletForm; +import com.codenameone.examples.wayline.ui.BottomSheet; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Menu; +import com.codenameone.examples.wayline.ui.Nav; +import com.codenameone.examples.wayline.ui.Pulse; +import com.codenameone.examples.wayline.ui.RideListForm; +import com.codenameone.examples.wayline.ui.Ui; +import com.codenameone.examples.wayline.ui.VerifyPhoneForm; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Riding: the map, a search card over the top of it, and a sheet at the +/// bottom that is whatever the ride needs next -- where to, what it costs, who +/// is coming, how it was. +/// +/// The sheet follows the ride's state, and the state is the server's. Nothing +/// here moves a ride along; it asks (request, cancel, rate) and then shows what +/// the server says the ride now is, whether that came back from the call, from +/// the next poll, or because the live channel said to look. +public class RiderForm extends LiveChannel.Adapter implements Nav.Home { + private static final int POLL_MILLIS = 3000; + /// Meters a second in town, for a time of arrival worked out from a + /// distance: 30 km/h. + private static final double CITY_SPEED = 8.3; + private static final Net.Failed QUIET = (status, message) -> { }; + + private final Form form = Ui.home("Rider"); + private final Menu menu = new Menu(form, Nav.RIDER); + private final MapStage stage; + private final BottomSheet sheet; + + private LatLng here = Locator.here(); + private String hereAddress = "Current location"; + private PlaceDto destination; + private Routes.Way way; + private RideRequestDto request; + private FareQuoteDto fare; + /// The ride on screen: under way, or just finished and not yet dismissed. + private RideDto ride; + /// Where the ride's car is: as the ride last said, or as the live channel + /// said since, which is more recent than any poll. + private double carLat; + private double carLng; + private double carHeading; + /// What the sheet is showing, so that a poll that changes nothing leaves + /// it alone. + private String showing = ""; + private com.codename1.maps.Marker pickupMarker; + private com.codename1.maps.Marker dropoffMarker; + private CarMarker car; + private Maps.Line line; + private int polls; + /// The cars online near here, drawn while there is no ride to look at. + private final List nearby = new ArrayList(); + /// What the ride being offered is asked for with: the kind of car and the + /// way to pay, each of them changed on the offer itself. + private String product = "standard"; + private PaymentMethodDto paying; + private List methods; + + public RiderForm() { + menu.item(FontImage.MATERIAL_HAIL, "Ride", "home", () -> { }); + menu.item(FontImage.MATERIAL_HISTORY, "Your rides", "history", + () -> RideListForm.show(form, "Your rides", cb -> Api.rider().history(cb), false, + (list, picked) -> { + if (RideState.COMPLETED == picked.state()) { + ReceiptForm.open(list, picked.id()); + } + })); + menu.item(FontImage.MATERIAL_ACCOUNT_BALANCE_WALLET, "Wallet", "wallet", + () -> WalletForm.show(form)); + UserDto user = Session.user(); + if (!user.driver()) { + menu.item(FontImage.MATERIAL_DIRECTIONS_CAR, "Drive with Wayline", "drive", + () -> Nav.switchTo(Nav.DRIVER)); + } + stage = new MapStage(here, Maps.STREET_ZOOM, menu.button()); + sheet = stage.sheet(); + stage.addRound(FontImage.MATERIAL_MY_LOCATION, "recentre", this::recentre); + // The position comes when the platform has it, which is after this + // screen is up: until then the screen stands on the demo city. + stage.whenLocated((where, first) -> { + here = where; + if (first && ride == null && destination == null && "idle".equals(showing)) { + start(); + loadNearby(); + } + }); + form.add(stage.layers()); + menu.install(); + form.addShowListener(e -> { + LiveChannel.listen(this); + refresh(); + loadMethods(); + }); + UITimer.timer(POLL_MILLIS, true, form, this::poll); + } + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + idle(); + Api.geo().reverse(here.getLatitude(), here.getLongitude(), Net.to(place -> { + if (place != null && place.name() != null && place.name().length() > 0) { + hereAddress = place.name(); + } + }, QUIET)); + } + + public void show() { + start(); + form.show(); + } + + // ------------------------------------------------------------ the server + + private void poll() { + polls++; + if (ride == null || !ride.state().isActive()) { + // Nothing to follow, so the map shows who is about: often enough + // to see them move, seldom enough to cost nothing. + if (ride == null && polls % 2 == 0) { + loadNearby(); + } + return; + } + // The live channel says when to look; the poll is for when it cannot. + if (!LiveChannel.connected() || polls % 4 == 0) { + refresh(); + } + } + + private void refresh() { + if (ride != null) { + final String id = ride.id(); + Api.rider().ride(id, Net.to(this::update, QUIET)); + } else { + Api.rider().active(Net.to(this::update, QUIET)); + } + } + + @Override + public void rideChanged(String id, String state) { + refresh(); + } + + @Override + public void driverMoved(String rideId, double lat, double lng, double heading) { + if (ride != null && ride.id().equals(rideId)) { + carLat = lat; + carLng = lng; + carHeading = heading; + car(); + } + } + + private void update(RideDto latest) { + if (latest == null || RideState.NONE == latest.state()) { + return; + } + boolean first = ride == null || !ride.id().equals(latest.id()); + // Seen to end here, and not found already over when the app started. + if (!first && RideState.COMPLETED != ride.state() + && RideState.COMPLETED == latest.state()) { + Telemetry.rideCompleted("rider", latest.fareCents(), latest.cash()); + } + ride = latest; + carLat = latest.driverLat(); + carLng = latest.driverLng(); + carHeading = latest.driverHeading(); + if (first) { + clearNearby(); + draw(); + } + car(); + RideCard.follow(latest); + String now = latest.id() + ":" + latest.state(); + if (!now.equals(showing)) { + showing = now; + rideDisplay(); + } + } + + /// The rider's ways to pay, for the offer to name the one in use. + private void loadMethods() { + Api.payments().methods(Net.to(found -> { + methods = found; + paying = null; + if ("offer".equals(showing) && fare != null) { + offer(); + } + }, QUIET)); + } + + /// The method this ride is paid with: the one picked for it, else the + /// rider's default. + private PaymentMethodDto method() { + if (paying != null) { + return paying; + } + for (int iter = 0; methods != null && iter < methods.size(); iter++) { + if (methods.get(iter).isDefault()) { + return methods.get(iter); + } + } + return null; + } + + private void loadNearby() { + Api.rider().nearby(here.getLatitude(), here.getLongitude(), Net.to(cars -> { + if (ride == null) { + showNearby(cars); + } + }, QUIET)); + } + + /// Moves the cars already drawn to where the cars are now. The server does + /// not say which car is which -- a rider has no business knowing -- so each + /// position goes to the nearest marker not yet used, which is the same car + /// unless two have crossed, and then nobody can tell. + private void showNearby(List cars) { + List free = new ArrayList(nearby); + nearby.clear(); + for (int iter = 0; cars != null && iter < cars.size(); iter++) { + NearbyDriverDto found = cars.get(iter); + LatLng at = new LatLng(found.lat(), found.lng()); + int closest = -1; + double least = 0; + for (int other = 0; other < free.size(); other++) { + double meters = Maps.meters(free.get(other).position(), at); + if (closest < 0 || meters < least) { + closest = other; + least = meters; + } + } + if (closest >= 0) { + CarMarker moved = free.remove(closest); + moved.moveTo(at, found.heading()); + nearby.add(moved); + } else { + nearby.add(new CarMarker(stage.map, form, at, found.heading())); + } + } + for (int iter = 0; iter < free.size(); iter++) { + free.get(iter).remove(); + } + stage.map.repaint(); + } + + private void clearNearby() { + for (int iter = 0; iter < nearby.size(); iter++) { + nearby.get(iter).remove(); + } + nearby.clear(); + } + + // --------------------------------------------------------------- the map + + private void clearMap() { + if (pickupMarker != null) { + stage.map.removeMarker(pickupMarker); + pickupMarker = null; + } + if (dropoffMarker != null) { + stage.map.removeMarker(dropoffMarker); + dropoffMarker = null; + } + if (car != null) { + car.remove(); + car = null; + } + if (line != null) { + line.remove(stage.map); + line = null; + } + } + + private void draw(LatLng pickup, LatLng dropoff, Routes.Way route) { + clearMap(); + if (route != null) { + line = Maps.route(stage.map, route.points); + } + pickupMarker = Maps.pickup(stage.map, pickup); + dropoffMarker = Maps.dropoff(stage.map, dropoff); + stage.frame(pickup, dropoff); + } + + /// Draws the ride's two ends, and its route once that is known. + private void draw() { + final String id = ride.id(); + final LatLng pickup = new LatLng(ride.pickupLat(), ride.pickupLng()); + final LatLng dropoff = new LatLng(ride.dropoffLat(), ride.dropoffLng()); + draw(pickup, dropoff, null); + Routes.find(pickup, dropoff, route -> { + if (ride != null && ride.id().equals(id)) { + draw(pickup, dropoff, route); + car(); + } + }); + } + + /// Puts the driver's car where the ride says it is, and says in the chip + /// how long until it gets where it is going. + private void car() { + // By the driver's name: the server keeps the driver's sign-in address + // to the driver. + boolean assigned = ride != null && ride.driverName() != null + && ride.driverName().length() > 0 && ride.state().isActive() + && RideState.OFFERED != ride.state() + && (carLat != 0 || carLng != 0); + if (!assigned) { + if (car != null) { + car.remove(); + car = null; + } + if (ride != null) { + stage.chip(null, (char) 0); + } + return; + } + LatLng at = new LatLng(carLat, carLng); + boolean first = false; + if (car == null) { + car = new CarMarker(stage.map, form, at, carHeading); + first = true; + } else { + car.moveTo(at, carHeading); + } + stage.map.repaint(); + if (RideState.ARRIVED == ride.state()) { + stage.chip(Lang.tr("Your driver is here"), FontImage.MATERIAL_PLACE); + return; + } + boolean aboard = RideState.IN_PROGRESS == ride.state(); + double meters = Maps.meters(at, aboard ? new LatLng(ride.dropoffLat(), ride.dropoffLng()) + : new LatLng(ride.pickupLat(), ride.pickupLng())); + stage.chip(Lang.tr(aboard ? "{0} to destination" : "Pickup in {0}", + Ui.minutes(meters / CITY_SPEED)) + Ui.DOT + Ui.distance(meters), + FontImage.MATERIAL_SCHEDULE); + if (first) { + // The car has just appeared: the map is fitted again with it in. + recentre(); + } + } + + private void recentre() { + if (ride != null && ride.state().isActive()) { + stage.frame(new LatLng(ride.pickupLat(), ride.pickupLng()), + new LatLng(ride.dropoffLat(), ride.dropoffLng()), car == null ? null + : car.position()); + } else if (destination != null) { + stage.frame(here, new LatLng(destination.lat(), destination.lng())); + } else { + stage.center(here, Maps.STREET_ZOOM); + } + } + + // ------------------------------------------------------------- the sheet + + private void display(String key) { + showing = key; + sheet.reset(); + } + + /// The card at the top: the search when there is nowhere to go yet, and + /// from where to where once there is. + private void header(String to) { + if (to == null) { + Button where = new Button("Where to?", "WlSearchCard"); + where.setName("whereTo"); + Ui.icon(where, FontImage.MATERIAL_SEARCH, 3.8f); + where.addActionListener(e -> PlaceSearchForm.show(form, here, this::go)); + stage.header(where); + return; + } + Container trip = new Container(BoxLayout.y()); + trip.setUIID("WlTripCard"); + trip.add(stop(FontImage.MATERIAL_TRIP_ORIGIN, hereAddress)); + trip.add(stop(FontImage.MATERIAL_PLACE, to)); + stage.header(trip); + } + + private static Label stop(char icon, String text) { + Label stop = Ui.label(text, "WlTripStop"); + Ui.icon(stop, icon, 2.8f); + return stop; + } + + /// Nothing under way: where to? + private void idle() { + display("idle"); + ride = null; + destination = null; + way = null; + fare = null; + request = null; + paying = null; + product = "standard"; + clearMap(); + stage.chip(null, (char) 0); + stage.center(here, Maps.STREET_ZOOM); + header(null); + UserDto user = Session.user(); + Container peek = sheet.peek(); + peek.add(Ui.label(Lang.tr("Hello, {0}", firstName(user.displayName())), "WlHeading")); + peek.add(Ui.label("Where are you going?", "WlMuted")); + if (!user.phoneVerified()) { + peek.add(Ui.text("Verify your phone number to request a ride.", "WlNotice")); + peek.add(Ui.link("Verify now", "verify", + e -> VerifyPhoneForm.show(() -> new RiderForm().show()))); + } + // Home and work, always offered: with a place, they go there; without + // one, they ask for it. + Container saved = new Container(new GridLayout(1, 2)); + saved.add(shortcut(FontImage.MATERIAL_HOME, "Home", "home", Prefs.home())); + saved.add(shortcut(FontImage.MATERIAL_WORK, "Work", "work", Prefs.work())); + peek.add(saved); + List recents = Prefs.recents(); + for (int iter = 0; iter < recents.size(); iter++) { + final PlaceDto place = recents.get(iter); + sheet.more().add(Ui.tap(Ui.row(FontImage.MATERIAL_SCHEDULE, place.name(), + place.address(), null), "recent-" + iter, e -> go(place))); + } + sheet.ready(false); + loadNearby(); + } + + private Button shortcut(char icon, String title, final String which, final PlaceDto place) { + Button shortcut = new Button(title, "WlShortcut"); + shortcut.setName("shortcut-" + which); + Ui.icon(shortcut, icon, 3.4f); + shortcut.addActionListener(e -> { + if (place != null) { + go(place); + return; + } + PlaceSearchForm.show(form, here, picked -> { + if ("home".equals(which)) { + Prefs.setHome(picked); + } else { + Prefs.setWork(picked); + } + idle(); + }); + }); + return shortcut; + } + + /// A destination is chosen. + private void go(final PlaceDto place) { + if (place.lat() == 0 && place.lng() == 0) { + // A place that came with the account from another device: the + // server keeps what it is called, and where that is is looked up. + double lat = here.getLatitude(); + double lng = here.getLongitude(); + Api.geo().search(place.name(), lat, lng, Net.to(found -> { + if (found == null || found.isEmpty()) { + Ui.say("That place could not be found. Choose it again."); + return; + } + go(new PlaceDto(place.name(), place.address(), found.get(0).lat(), + found.get(0).lng())); + }, Ui::fail)); + return; + } + destination = place; + Prefs.addRecent(place); + quote(); + } + + /// The route to the destination, and what it would cost. + private void quote() { + display("quote"); + header(destination.name()); + sheet.peek().add(Ui.label(destination.name(), "WlHeading")); + sheet.peek().add(Ui.label("Working out the fare", "WlMuted")); + sheet.peek().add(FlowLayout.encloseCenter(new Pulse())); + sheet.ready(false); + final PlaceDto chosen = destination; + final LatLng to = new LatLng(chosen.lat(), chosen.lng()); + Routes.find(here, to, route -> { + if (destination != chosen) { //NOPMD CompareObjectsWithEquals + return; + } + way = route; + draw(here, to, route); + request = request(); + Api.rider().quote(request, Net.to(quoted -> { + if (destination == chosen) { //NOPMD CompareObjectsWithEquals + fare = quoted; + Telemetry.rideQuoted(quoted.options() == null ? 0 : quoted.options().size(), + quoted.distanceMeters()); + offer(); + } + }, (status, message) -> { + Ui.fail(status, message); + idle(); + })); + }); + } + + /// The request, with what the rider asked for in Settings. + private RideRequestDto request() { + return new RideRequestDto(here.getLatitude(), here.getLongitude(), hereAddress, + destination.lat(), destination.lng(), destination.name(), way.meters, way.seconds, + Prefs.paymentMethod(), null, Prefs.driverGender(), Prefs.quietRide(), + Prefs.accessibleVehicle(), Prefs.petFriendly(), null); + } + + /// The offer: the kinds of car there are and what each costs, how it is + /// paid, and the button that asks for it. + private void offer() { + display("offer"); + clearNearby(); + // How far and how long is said in the sheet here, not in the chip: + // the sheet leaves a strip of map just tall enough for the route, and + // a chip in it would sit on the pins. + stage.chip(null, (char) 0); + Container peek = sheet.peek(); + Label length = Ui.plain(Ui.distance(fare.distanceMeters()) + Ui.DOT + + Ui.minutes(fare.durationSeconds()), "WlMuted"); + length.setName("tripLength"); + Container headed = new Container(new BorderLayout()); + headed.add(BorderLayout.CENTER, Ui.label("Choose a ride", "WlHeading")); + headed.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(length)); + peek.add(headed); + FareOptionDto picked = null; + List options = fare.options(); + for (int iter = 0; options != null && iter < options.size(); iter++) { + final FareOptionDto option = options.get(iter); + boolean mine = option.product().equals(product); + if (mine) { + picked = option; + } + peek.add(option(option, mine)); + } + long price = picked == null ? fare.amountCents() : picked.total(); + Label total = Ui.plain(Ui.money(price, fare.currency()), "WlRowValue"); + total.setName("fare"); + final PaymentMethodDto method = method(); + Container pay = Ui.row(method == null || "cash".equals(method.kind()) + ? FontImage.MATERIAL_PAYMENTS : FontImage.MATERIAL_CREDIT_CARD, + WalletForm.label(method), asked(), total); + ((Label) ((Container) pay.getComponentAt(1)).getComponentAt(0)).setName("payWith"); + peek.add(Ui.tap(pay, "payment", e -> WalletForm.pick(form, + method == null ? Prefs.PAYMENT_CASH : method.id(), chosen -> { + paying = chosen; + offer(); + }))); + final Button go = Ui.primary(picked == null ? Lang.tr("Request ride") + : Lang.tr("Request {0}", Lang.tr(picked.name())), "request", null); + go.addActionListener(e -> { + go.setEnabled(false); + PaymentMethodDto with = method(); + // The trip that was quoted, as the kind of car and the payment + // chosen on this sheet. + RideRequestDto q = request; + RideRequestDto asking = new RideRequestDto(q.pickupLat(), q.pickupLng(), + q.pickupAddress(), q.dropoffLat(), q.dropoffLng(), q.dropoffAddress(), + q.distanceMeters(), q.durationSeconds(), + with == null ? Prefs.PAYMENT_CASH : with.id(), product, q.driverGender(), + q.quietRide(), q.accessibleVehicle(), q.petFriendly(), q.note()); + request = asking; + final boolean cash = with == null || "cash".equals(with.kind()); + Api.rider().request(asking, Net.to(asked -> { + Telemetry.rideRequested(product, cash); + update(asked); + }, (status, message) -> { + go.setEnabled(true); + Ui.fail(status, message); + })); + }); + peek.add(go); + peek.add(Ui.link("Cancel", "back", e -> idle())); + sheet.ready(false); + stage.frame(here, new LatLng(destination.lat(), destination.lng())); + } + + /// One kind of car: what it is, how long until one comes, what it costs. + private Container option(final FareOptionDto option, boolean picked) { + Container row = new Container(new BorderLayout()); + row.setUIID(picked ? "WlOptionPicked" : "WlOption"); + Label icon = new Label("", "WlOptionIcon"); + Ui.icon(icon, "xl".equals(option.product()) + ? FontImage.MATERIAL_AIRPORT_SHUTTLE : "comfort".equals(option.product()) + ? FontImage.MATERIAL_LOCAL_TAXI : FontImage.MATERIAL_DIRECTIONS_CAR, 4.4f); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(icon)); + Container lines = new Container(BoxLayout.y()); + // How many it seats sits beside the name as a figure, which leaves + // the line under it for how far off a car is, whole in any language. + Label seats = Ui.plain(String.valueOf(option.seats()), "WlRowDetail"); + Ui.icon(seats, FontImage.MATERIAL_PERSON, 2.5f); + Container named = new Container(new BorderLayout()); + named.add(BorderLayout.WEST, Ui.label(option.name(), "WlRowTitle")); + named.add(BorderLayout.CENTER, seats); + lines.add(named); + lines.add(Ui.plain(option.driversNearby() == 0 ? Lang.tr("None nearby") + : Lang.tr("{0} away", Ui.minutes(option.etaMinutes() * 60d)), "WlRowDetail")); + row.add(BorderLayout.CENTER, lines); + Label price = Ui.plain(Ui.money(option.total(), fare.currency()), "WlRowValue"); + price.setName("price-" + option.product()); + row.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(price)); + return Ui.tap(row, "product-" + option.product(), e -> { + product = option.product(); + offer(); + }); + } + + /// What the rider's settings ask of every ride, in a line. + private static String asked() { + StringBuilder asked = new StringBuilder(); + if (Prefs.GENDER_WOMEN.equals(Prefs.driverGender())) { + asked.append(Lang.tr("Women drivers only")); + } + if (Prefs.quietRide()) { + asked.append(asked.length() > 0 ? ", " : "").append(Lang.tr("Quiet ride")); + } + if (Prefs.accessibleVehicle()) { + asked.append(asked.length() > 0 ? ", " : "").append(Lang.tr("Accessible vehicle")); + } + if (Prefs.petFriendly()) { + asked.append(asked.length() > 0 ? ", " : "").append(Lang.tr("Pet friendly")); + } + return asked.toString(); + } + + /// A ride exists: the sheet is its state. + private void rideDisplay() { + sheet.reset(); + RideState state = ride.state(); + Label title = Ui.label(Ui.state(state), "WlHeading"); + title.setName("rideState"); + Container peek = sheet.peek(); + header(ride.dropoffAddress()); + if (RideState.REQUESTED == state || RideState.OFFERED == state) { + stage.chip(null, (char) 0); + peek.add(title); + peek.add(Ui.label(Lang.tr("To {0}", ride.dropoffAddress()), "WlMuted")); + peek.add(FlowLayout.encloseCenter(new Pulse())); + peek.add(cancelButton()); + } else if (state.isActive()) { + peek.add(title); + peek.add(driverCard()); + peek.add(actions(RideState.IN_PROGRESS != state)); + Container more = sheet.more(); + more.add(Ui.row(FontImage.MATERIAL_TRIP_ORIGIN, ride.pickupAddress(), null, null)); + more.add(Ui.row(FontImage.MATERIAL_PLACE, ride.dropoffAddress(), null, + Ui.label(Ui.money(ride.fareCents(), ride.currency()), "WlRowValue"))); + more.add(Ui.row(FontImage.MATERIAL_PAYMENTS, "Payment", paid(), null)); + } else if (RideState.COMPLETED == state) { + stage.chip(null, (char) 0); + receipt(); + } else { + stage.chip(null, (char) 0); + peek.add(title); + peek.add(Ui.text(RideState.NO_DRIVERS == state + ? "Nobody could take the ride just now. You have not been charged." + : RideState.CANCELLED_BY_DRIVER == state + ? "Your driver had to cancel. You have not been charged." + : "The ride was cancelled.", "WlText")); + peek.add(Ui.primary("OK", "dismiss", e -> idle())); + } + sheet.ready(false); + } + + /// Who is coming, and in what. + private Container driverCard() { + Container card = new Container(new BorderLayout()); + card.setUIID("WlDriverCard"); + String initials = ride.driverPhotoInitials(); + Label face = Ui.avatar(ride.driverName()); + if (initials != null && initials.length() > 0) { + face.setText(initials); + } + Container lines = new Container(BoxLayout.y()); + Label name = Ui.plain(ride.driverName(), "WlRowTitle"); + Ui.shrink(name, 2.8f); + lines.add(name); + // The rating leads the line about the car, with its star. + Label car = Ui.plain(ride.driverRating() > 0 + ? Ui.tenths(ride.driverRating()) + Ui.DOT + ride.vehicle() : ride.vehicle(), + "WlRowDetail"); + if (ride.driverRating() > 0) { + Ui.icon(car, FontImage.MATERIAL_STAR, 2.5f); + } + Ui.shrink(car, 2.2f); + lines.add(car); + if (ride.plate() != null && ride.plate().length() > 0) { + // The plate has a line of its own: beside the name it cut the + // name short on a narrow phone. + Label plate = new Label("", "WlPlate"); + // A plate is a plate in any language. + plate.setShouldLocalize(false); + plate.setText(ride.plate()); + lines.add(FlowLayout.encloseIn(plate)); + } + card.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(face)); + card.add(BorderLayout.CENTER, lines); + return card; + } + + /// What can be done about a driver who is on the way. + private Container actions(boolean cancellable) { + Container actions = new Container(new GridLayout(1, cancellable ? 4 : 3)); + actions.setUIID("WlActions"); + // The driver's number comes with the ride while the driver has it, + // and is gone from it when the ride is over. + actions.add(Ui.action(FontImage.MATERIAL_CALL, "Call", "call", e -> call())); + actions.add(Ui.action(FontImage.MATERIAL_SMS, "Message", "message", e -> message())); + actions.add(Ui.action(FontImage.MATERIAL_SHARE, "Share", "share", e -> CN.share( + Lang.tr("I am on my way to {0} with {1} in a {2} ({3}).", ride.dropoffAddress(), + ride.driverName(), ride.vehicle(), ride.plate()), null, null))); + if (cancellable) { + final String id = ride.id(); + final RideState state = ride.state(); + Button cancel = Ui.action(FontImage.MATERIAL_CLOSE, "Cancel", "cancel", + e -> Api.rider().cancel(id, Net.to(cancelled -> { + Telemetry.rideCancelled("rider", state); + idle(); + }, Ui::fail))); + cancel.setUIID("WlActionDanger"); + actions.add(cancel); + } + return actions; + } + + private void call() { + String number = ride == null ? null : ride.driverPhone(); + if (number == null || number.length() == 0 || !CN.canDial()) { + Ui.say("Calling is not available on this device"); + return; + } + CN.dial(number); + } + + private void message() { + String number = ride == null ? null : ride.driverPhone(); + if (number == null || number.length() == 0) { + Ui.say("Your driver cannot be messaged right now"); + return; + } + try { + CN.sendSMS(number, Lang.tr("Hi, this is your Wayline rider.")); + } catch (IOException failed) { + Ui.say("Messages are not available on this device"); + } + } + + private String paid() { + String label = ride.paymentMethodLabel(); + return ride.cash() || label == null || label.length() == 0 ? Lang.tr("Cash") : label; + } + + private Button cancelButton() { + final String id = ride.id(); + final RideState state = ride.state(); + return Ui.danger("Cancel ride", "cancel", + e -> Api.rider().cancel(id, Net.to(cancelled -> { + Telemetry.rideCancelled("rider", state); + idle(); + }, Ui::fail))); + } + + /// The ride is over: what it cost, how it was, and a tip if the rider + /// wants to leave one. The stars are sent as they are tapped; the tip goes + /// with Done, so that a slip of the thumb is not a charge. + private void receipt() { + Container peek = sheet.peek(); + Label title = Ui.label("You have arrived", "WlHeading"); + title.setName("rideState"); + Label price = Ui.plain(Ui.money(ride.total() > 0 ? ride.total() : ride.fareCents(), + ride.currency()), "WlPrice"); + price.setName("fare"); + Container top = new Container(new BorderLayout()); + top.add(BorderLayout.CENTER, title); + top.add(BorderLayout.EAST, price); + peek.add(top); + Label how = Ui.plain(Ui.distance(ride.distanceMeters()) + Ui.DOT + + Ui.minutes(ride.durationSeconds()) + Ui.DOT + paid(), "WlMuted"); + how.setName("paidWith"); + peek.add(how); + if ("FAILED".equals(ride.paymentStatus())) { + peek.add(Ui.text("The card was declined. Choose another way to pay in your " + + "wallet.", "WlNotice")); + } + SpanLabel asked = Ui.text("", "WlText"); + asked.setShouldLocalize(false); + asked.setText(Lang.tr("How was the ride with {0}?", ride.driverName())); + peek.add(asked); + final Container stars = new Container(new FlowLayout(CN.CENTER)); + final String id = ride.id(); + for (int star = 1; star <= 5; star++) { + final int given = star; + Button button = new Button("", "WlStar"); + button.setName("star-" + star); + Ui.icon(button, FontImage.MATERIAL_STAR_BORDER, 5.6f); + button.addActionListener(e -> { + for (int iter = 0; iter < 5; iter++) { + Ui.icon((Button) stars.getComponentAt(iter), + iter < given ? FontImage.MATERIAL_STAR + : FontImage.MATERIAL_STAR_BORDER, 5.6f); + } + stars.revalidate(); + RatingDto rating = new RatingDto(given); + Api.rider().rate(id, rating, Net.to(rated -> Telemetry.rated(given), + Ui::fail)); + }); + stars.add(button); + } + peek.add(stars); + final int[] percent = {Prefs.tipPercent()}; + boolean tippable = "PAID".equals(ride.paymentStatus()) && ride.tip() == 0; + if (tippable) { + peek.add(Ui.label("Tip", "WlLabel")); + peek.add(Ui.choice("tip", new String[] {"None", "10%", "15%", "20%"}, + new String[] {"0", "10", "15", "20"}, String.valueOf(percent[0]), + value -> percent[0] = Integer.parseInt(value))); + } + final long fareCents = ride.fareCents(); + final boolean tipping = tippable; + peek.add(Ui.primary("Done", "done", e -> { + if (tipping && percent[0] > 0) { + TipDto tip = new TipDto(Math.max(100L, Math.round(fareCents * percent[0] / 100d))); + final int share = percent[0]; + Api.payments().tip(id, tip, Net.to(tipped -> { + Telemetry.tipped("ride", share); + Ui.say("Thank you. Your tip was sent."); + }, Ui::fail)); + } + idle(); + })); + peek.add(Ui.link("View receipt", "receipt", e -> ReceiptForm.open(form, id))); + Container more = sheet.more(); + more.add(Ui.row(FontImage.MATERIAL_TRIP_ORIGIN, ride.pickupAddress(), null, null)); + more.add(Ui.row(FontImage.MATERIAL_PLACE, ride.dropoffAddress(), null, null)); + more.add(Ui.row(FontImage.MATERIAL_PERSON, ride.driverName(), ride.vehicle(), null)); + } + + private static String firstName(String name) { + String text = name == null ? "" : name.trim(); + int space = text.indexOf(' '); + return space < 0 ? text : text.substring(0, space); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/BottomSheet.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/BottomSheet.java new file mode 100644 index 00000000000..4e2b4ecfe0a --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/BottomSheet.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; + +/// The sheet that rises from the bottom of a map. +/// +/// It has two parts. [#peek()] is what is always there: the headline and the +/// one thing to do next. [#more()] is the rest -- the details, the lesser +/// actions -- and is shown when the sheet is pulled up and put away when it is +/// pushed down, so the map keeps most of the screen until the user asks for +/// the sheet. The handle at the top is what is pulled; a tap on it does the +/// same. A sheet with nothing in `more` has no handle. +/// +/// A screen fills it the same way each time: [#reset()], add to the two parts, +/// [#ready()]. +public final class BottomSheet extends Container { + private final Grip grip = new Grip(); + private final Container peek = new Container(BoxLayout.y()); + private final Container more = new Container(BoxLayout.y()); + private boolean expanded; + + public BottomSheet() { + super(BoxLayout.y()); + setUIID("WlSheet"); + setName("panel"); + setSafeArea(true); + // A touch on the sheet is the sheet's, and does not reach through to + // drag the map under it. + setGrabsPointerEvents(true); + add(grip).add(peek).add(more); + more.setHidden(true); + } + + /// The padding under the sheet's content as the theme has it, and as it + /// was last made here. + private int styledUnder = -1; + private int madeUnder = -1; + + /// Keeps the content clear of what the device draws over the bottom of + /// the screen, by making the sheet's own padding at least that deep. + /// + /// Marking the sheet a safe area is not enough. A safe area is padded + /// while it is laid out, from where it then is; asked how tall it wants + /// to be before that, it answers without the padding, is given that + /// height, and then spends some of it on the padding after all -- which + /// came out of the last thing in the sheet. Padding that is there both + /// times cannot disagree with itself. The mark stays for the sides, which + /// do not change how tall the sheet is. + private void clearOfTheDevice() { + Style style = getUnselectedStyle(); + int now = style.getPaddingBottom(); + if (now != madeUnder) { + // Styled again since: a change of theme, or of name. + styledUnder = now; + } + Rectangle safe = Display.getInstance().getDisplaySafeArea(new Rectangle()); + int covered = safe.getHeight() <= 0 ? 0 + : Math.max(0, CN.getDisplayHeight() - safe.getY() - safe.getHeight()); + int wanted = Math.max(styledUnder, covered); + if (wanted != now) { + getAllStyles().setPaddingUnitBottom(Style.UNIT_TYPE_PIXELS); + getAllStyles().setPaddingBottom(wanted); + } + madeUnder = wanted; + } + + @Override + protected Dimension calcPreferredSize() { + clearOfTheDevice(); + return super.calcPreferredSize(); + } + + @Override + public void layoutContainer() { + clearOfTheDevice(); + super.layoutContainer(); + } + + public Container peek() { + return peek; + } + + public Container more() { + return more; + } + + /// Empties the sheet, for the next thing it will show. + public void reset() { + peek.removeAll(); + more.removeAll(); + } + + /// The sheet has been filled: lays it out. + /// + /// @param open whether to show `more` straight away + public void ready(boolean open) { + boolean extra = more.getComponentCount() > 0; + grip.handle(extra); + expanded = extra && open; + more.setHidden(!expanded); + Container parent = getParent(); + if (parent != null) { + parent.revalidate(); + } + } + + public boolean isExpanded() { + return expanded; + } + + public void setExpanded(boolean wanted) { + if (wanted == expanded || more.getComponentCount() == 0) { + return; + } + expanded = wanted; + more.setHidden(!wanted); + Container parent = getParent(); + if (parent != null) { + parent.animateLayout(180); + } + } + + /// The handle: a short bar, pulled up or down, or tapped. + private final class Grip extends Component { + private int pressedY; + private boolean pulled; + /// Whether there is anything to pull the sheet up for. With nothing, + /// the handle is not drawn and still takes its room: without it the + /// first line of the sheet sat against the sheet's top edge. + private boolean handle; + + void handle(boolean wanted) { + if (handle != wanted) { + handle = wanted; + setShouldCalcPreferredSize(true); + } + } + + Grip() { + setUIID("WlGrip"); + setGrabsPointerEvents(true); + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(12f), handle + ? CN.convertToPixels(1.2f) + style.getVerticalPadding() + : CN.convertToPixels(2.6f)); + } + + @Override + public void paint(Graphics g) { + if (!handle) { + return; + } + Style style = getStyle(); + int width = CN.convertToPixels(10f); + int height = CN.convertToPixels(1.2f); + g.setColor(style.getFgColor()); + g.setAntiAliased(true); + g.fillRoundRect(getX() + (getWidth() - width) / 2, + getY() + (getHeight() - height) / 2, width, height, height, height); + } + + @Override + public void pointerPressed(int x, int y) { + pressedY = y; + pulled = false; + } + + @Override + public void pointerDragged(int x, int y) { + // Far enough to be meant, and once for each pull. + if (!pulled && Math.abs(y - pressedY) > CN.convertToPixels(2f)) { + pulled = true; + setExpanded(y < pressedY); + } + } + + @Override + public void pointerReleased(int x, int y) { + if (!pulled) { + setExpanded(!expanded); + } + } + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Chart.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Chart.java new file mode 100644 index 00000000000..cbbca167d92 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Chart.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +/// A row of bars: rides a day, takings a day, requests by the hour. +/// +/// Drawn here, in a few lines, because that is all a bar chart is and because +/// it then takes its colours from `theme.css` like everything else: the bars +/// are the `WlChart` style's colour, the highest of them `WlChartPeak`'s, and +/// the lines behind them `WlChartGrid`'s. Nothing here names a colour. +public final class Chart extends Component { + private static final float HEIGHT_MM = 24f; + + private final double[] values; + + private Chart(double[] values) { + this.values = values; + setUIID("WlChart"); + setFocusable(false); + } + + /// The chart, with what its two ends are written under it. + /// + /// @param name what a test finds the chart by + /// @param from the caption under the first bar, already translated + /// @param to the caption under the last + public static Container of(String name, double[] values, String from, String to) { + Chart chart = new Chart(values); + chart.setName(name); + Container box = new Container(new BorderLayout()); + box.add(BorderLayout.CENTER, chart); + Container axis = new Container(new BorderLayout()); + axis.add(BorderLayout.WEST, Ui.plain(from, "WlChartAxis")); + axis.add(BorderLayout.EAST, Ui.plain(to, "WlChartAxis")); + box.add(BorderLayout.SOUTH, axis); + return box; + } + + /// How many bars there are, for a test. + public int bars() { + return values.length; + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(40f), + CN.convertToPixels(HEIGHT_MM) + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int left = getX() + style.getPaddingLeftNoRTL(); + int top = getY() + style.getPaddingTop(); + int width = getWidth() - style.getHorizontalPadding(); + int height = getHeight() - style.getVerticalPadding(); + if (width <= 0 || height <= 0) { + return; + } + UIManager styles = getUIManager(); + g.setColor(styles.getComponentStyle("WlChartGrid").getFgColor()); + // Three lines to read the heights against: the foot, half, the top. + for (int line = 0; line <= 2; line++) { + int y = top + (height - 1) * line / 2; + g.drawLine(left, y, left + width, y); + } + int count = values.length; + if (count == 0) { + return; + } + double most = 0; + int peak = -1; + for (int iter = 0; iter < count; iter++) { + if (values[iter] > most) { + most = values[iter]; + peak = iter; + } + } + if (most <= 0) { + return; + } + // The space is shared out in whole pixels, so every bar is as wide as + // every other and what is left over goes to the two ends. + int slot = Math.max(1, width / count); + int gap = Math.max(1, slot / 4); + int bar = Math.max(1, slot - gap); + int start = left + (width - slot * count + gap) / 2; + int round = Math.min(bar, CN.convertToPixels(1.2f)); + int normal = style.getFgColor(); + int highest = styles.getComponentStyle("WlChartPeak").getFgColor(); + boolean rtl = isRTL(); + g.setAntiAliased(true); + for (int iter = 0; iter < count; iter++) { + int tall = (int) Math.round(values[iter] / most * (height - 2)); + if (values[iter] > 0 && tall < round) { + tall = round; + } + if (tall <= 0) { + continue; + } + // Time runs the way the language is read. + int at = rtl ? count - 1 - iter : iter; + g.setColor(iter == peak ? highest : normal); + g.fillRoundRect(start + at * slot, top + height - tall, bar, tall, round, round); + } + } + + /// A share of something, as a bar part of the way along a track: the bar + /// is the `WlMeter` style's colour and the track a faint wash of it. + public static final class Meter extends Component { + private static final int TRACK_ALPHA = 36; + private final double share; + + /// @param share from 0 to 1 + public Meter(double share) { + this.share = share < 0 ? 0 : share > 1 ? 1 : share; + setUIID("WlMeter"); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(20f), + CN.convertToPixels(1.6f) + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int left = getX() + style.getPaddingLeftNoRTL(); + int top = getY() + style.getPaddingTop(); + int width = getWidth() - style.getHorizontalPadding(); + int height = getHeight() - style.getVerticalPadding(); + if (width <= 0 || height <= 0) { + return; + } + g.setAntiAliased(true); + // The track is the bar's own colour, faint: a background colour + // would be painted square behind the rounded ends. + int alpha = g.getAlpha(); + g.setColor(style.getFgColor()); + g.setAlpha(TRACK_ALPHA); + g.fillRoundRect(left, top, width, height, height, height); + g.setAlpha(alpha); + int filled = (int) Math.round(width * share); + if (filled > 0) { + filled = Math.max(filled, height); + g.setColor(style.getFgColor()); + g.fillRoundRect(isRTL() ? left + width - filled : left, top, filled, height, + height, height); + } + } + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/ConsentForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/ConsentForm.java new file mode 100644 index 00000000000..fdee91bcd94 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/ConsentForm.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.wayline.Telemetry; + +/// Asks, once on a device, whether the app may report how it is used. +/// +/// It is shown after the first sign-in and before anything is counted, and it +/// has no way out but an answer: nothing is reported while there is none, so +/// leaving the question open would be a no that is asked again at every +/// start. Either answer can be changed in Settings, which is said here. +public final class ConsentForm { + private ConsentForm() { + } + + /// @param then where to go once there is an answer + public static void show(final Runnable then) { + Form form = Ui.form("Usage statistics", "Consent"); + Container page = Ui.page(); + Container banner = Ui.banner(FontImage.MATERIAL_INSIGHTS, "Help improve Wayline", + "Wayline can count which screens are opened and which features are used, " + + "so that we can see what works and fix what does not.", "WlBannerIcon"); + // The page has its own top padding, and the banner's on top of it + // pushed the last line of the promise below under the answers. + banner.getAllStyles().setPaddingTop(0); + page.add(banner); + page.add(Ui.row(FontImage.MATERIAL_VISIBILITY_OFF, "No personal details", + "Never your name, e-mail, phone number, places or card details", null)); + // The two answers stay in view however much there is to read. + Container answers = new Container(BoxLayout.y()); + answers.setUIID("WlPage"); + answers.setSafeArea(true); + answers.add(Ui.primary("Share usage statistics", "consentYes", e -> answer(true, then))); + answers.add(Ui.secondary("Not now", "consentNo", e -> answer(false, then))); + answers.add(Ui.text("Turn it on or off at any time in Settings", "WlMuted")); + form.add(BorderLayout.SOUTH, answers); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + private static void answer(boolean yes, Runnable then) { + Telemetry.allow(yes); + then.run(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Lang.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Lang.java new file mode 100644 index 00000000000..42e407d1dc5 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Lang.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.l10n.L10NManager; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import com.codenameone.examples.wayline.Prefs; +import java.util.HashMap; +import java.util.Map; + +/// The language the app speaks. +/// +/// The translations are the `Bundle_.properties` files in +/// `src/main/l10n`, which the build packs into the theme. One of them at a +/// time is installed as the `UIManager`'s bundle, and from then on every +/// label, button, title and hint looks its text up there as it is set: the +/// English text in the source is the key, and a text with no entry is shown as +/// it is. So a screen written in English is translated without saying so. +/// +/// What a component cannot do by itself is a sentence with something put into +/// it -- "Pick up Riley". Those go through [#tr(String, Object...)], with the +/// whole sentence as the key and `{0}` where the name goes, because where it +/// goes is the translator's decision. +/// +/// To add a string: write it in English in the code, and add a line for it to +/// each of the four translated files. To add a language: add its file, and its +/// code to [#CODES]. +public final class Lang { + /// The languages there is a translation for. English is the source. + public static final String[] CODES = {"en", "es", "fr", "de", "he"}; + + private static final String BUNDLE = "Bundle"; + /// The entry each file names its own language in, in that language. + private static final String NAME_KEY = "language.name"; + + private Lang() { + } + + /// The language in use: the one chosen in Settings, else the device's when + /// there is a translation for it, else English. + public static String current() { + String chosen = known(Prefs.language()); + if (chosen != null) { + return chosen; + } + String device = known(L10NManager.getInstance().getLanguage()); + return device == null ? CODES[0] : device; + } + + /// Installs the translation for [#current()]. A screen built after this is + /// in the new language, and laid out right to left when the language is + /// written that way; one already built keeps the words it was built with. + public static void apply() { + Map bundle = new HashMap(); + // Always said, one way or the other: a bundle that is silent about the + // direction leaves the one before it in force. + bundle.put("@rtl", "false"); + Map translated = table(current()); + if (translated != null) { + bundle.putAll(translated); + } + UIManager.getInstance().setBundle(bundle); + } + + /// What `code`'s language calls itself. + public static String name(String code) { + Map translated = table(code); + String name = translated == null ? null : translated.get(NAME_KEY); + return name == null || name.length() == 0 ? code : name; + } + + public static String tr(String text) { + return UIManager.getInstance().localize(text, text); + } + + /// Translates `pattern` and puts `values` where it says `{0}`, `{1}`... + public static String tr(String pattern, Object... values) { + String text = tr(pattern); + for (int iter = 0; iter < values.length; iter++) { + String mark = "{" + iter + "}"; + String value = String.valueOf(values[iter]); + int at = text.indexOf(mark); + while (at >= 0) { + text = text.substring(0, at) + value + text.substring(at + mark.length()); + at = text.indexOf(mark, at + value.length()); + } + } + return text; + } + + /// The code among [#CODES] that `language` is, or null. A device reports + /// its language with or without a country (`es`, `es_MX`) and Hebrew under + /// either of two codes; this is compared as the ASCII it is, since folding + /// the case would follow the very language being worked out. + private static String known(String language) { + if (language == null || language.length() < 2) { + return null; + } + if (language.regionMatches(true, 0, "iw", 0, 2)) { + return "he"; + } + for (String code : CODES) { + if (language.regionMatches(true, 0, code, 0, 2)) { + return code; + } + } + return null; + } + + private static Map table(String code) { + Resources theme = Resources.getGlobalResources(); + return theme == null ? null : theme.getL10N(BUNDLE, code); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Layouts.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Layouts.java new file mode 100644 index 00000000000..a44f5ee1c51 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Layouts.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import java.util.HashMap; +import java.util.Map; + +/// How much room there is, and whose look the app wears. +/// +/// The app is laid out for a phone. Two things change that, and both are +/// asked here so that no screen works them out for itself: +/// +/// - [#wide()]: the window is as wide as a tablet on its side, a desktop +/// window or a browser. The admin console then shows its navigation and a +/// list with its detail side by side; every other screen keeps its column +/// and stops it growing past [#column()]. +/// - [#desktopLook()]: the platform's own desktop theme is installed -- macOS +/// Aqua, Windows Fluent or GNOME Adwaita, chosen by the `nativeTheme=native` +/// build hint. The admin console then dresses its controls in that theme's +/// buttons, fields and lists with [#dress(Container)], and keeps the app's +/// own styles only for what a desktop has no control for: the charts and +/// the numbers. +/// +/// Both are read when a screen is built. A window that is resized across the +/// line, or a device that is turned, rebuilds the screen: see +/// [#onChange(Form, Runnable)]. +public final class Layouts { + /// A window at least this wide, in millimetres, is laid out side by side. + /// A phone on its side is narrower; a tablet on its side is not. + private static final float WIDE_MM = 165f; + /// The widest a column of text and controls is left to grow. + private static final float COLUMN_MM = 150f; + + /// The app's own style names, and the name each goes by under a desktop + /// theme. Those are declared at the end of `theme.css`, each deriving from + /// the native control it stands for. + private static final Map DESKTOP = new HashMap(); + + static { + String[] dressed = {"WlForm", "WlPage", "WlList", "WlPrimary", "WlSecondary", "WlDanger", + "WlLink", "WlField", "WlFieldHint", "WlRow", "WlRowPicked", "WlRowIcon", "WlRowTitle", + "WlRowDetail", "WlRowValue", "WlSection", "WlHeading", "WlText", "WlMuted", "WlLabel", + "WlTitle", "WlError", "WlNotice", "WlPanel", "WlFact", "WlFactLabel", "WlFactValue", + "WlFactTotal", "WlSegments", "WlSegment", "WlTile", "WlStat", "WlStatValue", + "WlStatLabel", "WlChartValue", "WlChartAxis", "WlAmount", "WlBannerIcon", + "WlBannerTitle", "WlBannerText", "WlAlert", "WlSide", "WlSideItem", "WlSidePicked", + "WlSideLabel", "WlSideName", "WlSideDetail", "WlPaneBar", "WlPaneTitle", "WlMaster", + "WlBarButton", "WlHomeBar"}; + for (int iter = 0; iter < dressed.length; iter++) { + DESKTOP.put(dressed[iter], "WlDesk" + dressed[iter].substring(2)); + } + } + + private Layouts() { + } + + /// Whether there is room to lay a screen out side by side. + public static boolean wide() { + return CN.getDisplayWidth() >= CN.convertToPixels(WIDE_MM); + } + + /// The widest a single column of content is drawn, in pixels. + public static int column() { + return CN.convertToPixels(COLUMN_MM); + } + + /// How many columns of `millimetres` each fit in `width` pixels: at least + /// one, and no more than `most`. + public static int columns(int width, float millimetres, int most) { + int fit = width / Math.max(1, CN.convertToPixels(millimetres)); + return Math.max(1, Math.min(most, fit)); + } + + /// Whether the platform's desktop theme is the one under the app's own. + /// + /// The three desktop themes are the only ones installed on a desktop that + /// mark themselves as a platform's defaults; a desktop build that kept the + /// legacy theme, a phone and a tablet all answer false and keep the app's + /// look everywhere. + public static boolean desktopLook() { + return CN.isDesktop() + && UIManager.getInstance().isThemeConstant("nativeThemeDefaultsBool", false); + } + + /// Dresses everything under `root` in the desktop theme, when there is one: + /// each component styled by the app takes the twin of its style that + /// derives from the platform's control, and whatever scrolls shows the + /// platform's scrollbar. Does nothing anywhere else. + /// + /// It is done to a finished tree rather than where each piece is made, so + /// that the screens are written once, for a phone, and the console alone + /// decides what a desktop makes of them. + public static void dress(Container root) { + if (!desktopLook()) { + return; + } + dressed(root); + } + + private static void dressed(Component component) { + String twin = DESKTOP.get(component.getUIID()); + if (twin != null) { + component.setUIID(twin); + if (component instanceof Label) { + redraw((Label) component); + } + } + if (component instanceof SpanLabel) { + SpanLabel span = (SpanLabel) component; + String text = DESKTOP.get(span.getTextUIID()); + if (text != null) { + span.setTextUIID(text); + } + return; + } + if (component instanceof Container) { + Container holder = (Container) component; + if (holder.isScrollableY()) { + holder.setScrollVisible(true); + } + // A row that is one button is still a container of labels. + for (int iter = 0; iter < holder.getComponentCount(); iter++) { + dressed(holder.getComponentAt(iter)); + } + } + } + + /// Draws the icon of `label` again in the colours of the style it now has: + /// an icon is an image, made once from the style the label had then. + private static void redraw(Label label) { + if (!(label.getIcon() instanceof FontImage)) { + return; + } + FontImage icon = (FontImage) label.getIcon(); + String glyph = icon.getText(); + float pixels = icon.getFont() == null ? 0 : icon.getFont().getPixelSize(); + if (glyph == null || glyph.length() != 1 || pixels <= 0) { + return; + } + FontImage.setMaterialIcon(label, glyph.charAt(0), + pixels * 100f / CN.convertToPixels(100f)); + } + + /// Keeps the content of `page` in a column no wider than [#column()], in + /// the middle of however much room there is: called by the page as it is + /// laid out, with the padding its style gave it. + /// + /// @param margin the side padding of the page's own style, in pixels + static void centre(Container page, int margin) { + int side = Math.max(margin, (page.getWidth() - column()) / 2); + Style style = page.getAllStyles(); + Style now = page.getUnselectedStyle(); + if (now.getPaddingLeftNoRTL() != side || now.getPaddingRightNoRTL() != side) { + style.setPaddingUnitLeft(Style.UNIT_TYPE_PIXELS); + style.setPaddingUnitRight(Style.UNIT_TYPE_PIXELS); + style.setPaddingLeft(side); + style.setPaddingRight(side); + } + } + + /// Calls `rebuild` when `form` stops being wide or starts to be, which is + /// a window dragged across the line or a tablet turned. The screen was + /// built for the room there was; it is built again for the room there is. + public static void onChange(final Form form, final Runnable rebuild) { + final boolean[] was = {wide()}; + form.addSizeChangedListener(e -> { + boolean now = wide(); + if (now != was[0]) { + was[0] = now; + // After the event: the form is in the middle of being resized. + CN.callSerially(rebuild); + } + }); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Look.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Look.java new file mode 100644 index 00000000000..74b5601bfcb --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Look.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.plaf.UIManager; +import com.codenameone.examples.wayline.Prefs; + +/// Light or dark. +/// +/// `theme.css` has both: every rule has its dark twin in the block at the end +/// of the file. Which of the two a component gets is decided when its style is +/// resolved, so a screen built after [#apply()] is in the new look and one +/// already built is not -- the caller rebuilds what is on screen. +public final class Look { + private Look() { + } + + /// Makes the look the one chosen in Settings: light, dark, or whichever + /// the device is in. + public static void apply() { + String theme = Prefs.theme(); + CN.setDarkMode(Prefs.THEME_DARK.equals(theme) ? Boolean.TRUE + : Prefs.THEME_LIGHT.equals(theme) ? Boolean.FALSE : null); + if (CN.isDesktop()) { + // On a desktop the platform's theme takes the colours and the font + // the user chose for the whole machine -- the accent among them. + // It reaches only what that theme styles, which is what the admin + // console wears; see Layouts. + UIManager.getInstance().setUseNativeColors(true); + UIManager.getInstance().setUseNativeFonts(true); + } + UIManager.getInstance().refreshTheme(); + } + + /// Whether the app is drawn dark right now. A platform that cannot say is + /// taken to be light. + public static boolean dark() { + Boolean dark = CN.isDarkMode(); + return dark != null && dark.booleanValue(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Menu.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Menu.java new file mode 100644 index 00000000000..89c41c7db23 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Menu.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.net.Session; + +/// The menu of a home screen: who is signed in, which of the app's three modes +/// this is, the places that mode has, and the way to another mode. +/// +/// Each mode fills in its own places with [#item]; nothing here knows what a +/// rider's or a driver's are. What is the same for all of them is the top -- +/// the person and the mode -- and the bottom: the other modes the account's +/// roles open, Settings, and signing out. An account with one role is offered +/// no other mode, because it has none. +/// +/// It slides in over the screen as a layer of the screen's own, not as the +/// title bar's side menu: the home screens have no title bar, and on a desktop +/// the title bar's menu is handed to the window's. +public final class Menu { + private final Form form; + private final String mode; + private final Container layer = new Container(new BorderLayout()); + private final Container places = new Container(BoxLayout.y()); + private final Container panel; + + /// @param mode the mode `form` is the home of: `Nav.RIDER` and the others + public Menu(Form form, String mode) { + this.form = form; + this.mode = mode; + panel = new Container(new BorderLayout()) { + @Override + protected Dimension calcPreferredSize() { + // Most of a phone's width, and no wider than a menu wants to + // be on a tablet or a desktop. + Dimension size = super.calcPreferredSize(); + size.setWidth(Math.min(CN.getDisplayWidth() * 5 / 6, CN.convertToPixels(78f))); + return size; + } + }; + panel.setUIID("WlMenu"); + panel.setSafeArea(true); + panel.setGrabsPointerEvents(true); + panel.setName("menu"); + build(); + // What is left of the screen beside the menu: dimmed, and a tap on it + // puts the menu away. + Button dim = new Button("", "WlScrim"); + dim.setName("menu-dim"); + dim.addActionListener(e -> close()); + layer.add(BorderLayout.WEST, panel); + layer.add(BorderLayout.CENTER, dim); + layer.setHidden(true); + layer.setVisible(false); + } + + /// Adds the menu to the screen, closed, as its top layer. Call it after + /// the screen's own content has been added. + public void install() { + form.add(layer); + } + + /// The round button that opens the menu, for the screen to place. + public Button button() { + return Ui.round(FontImage.MATERIAL_MENU, "menu-open", e -> open()); + } + + /// The button that opens the menu, for a screen with a bar and no map. + public Button flatButton() { + return Ui.flat(FontImage.MATERIAL_MENU, "menu-open", e -> open()); + } + + /// Adds one of the mode's own places. + /// + /// @param name the button's name is `menu-` and this + public Menu item(char icon, String text, String name, final Runnable go) { + places.add(entry(icon, text, "menu-" + name, go)); + return this; + } + + public void open() { + if (!layer.isHidden()) { + return; + } + layer.setHidden(false); + layer.setVisible(true); + form.getContentPane().revalidate(); + // Laid out where it belongs; started from off the edge it comes from, + // which is the right-hand one in a right-to-left language. + panel.setX(panel.isRTL() ? layer.getWidth() : -panel.getWidth()); + layer.animateLayout(200); + } + + public void close() { + if (layer.isHidden()) { + return; + } + layer.setHidden(true); + layer.setVisible(false); + form.getContentPane().revalidate(); + } + + private void build() { + UserDto user = Session.user(); + Container top = new Container(BoxLayout.y()); + top.setUIID("WlMenuTop"); + Container who = new Container(new BorderLayout()); + Container names = new Container(BoxLayout.y()); + names.add(Ui.label(user.displayName(), "WlMenuName")); + names.add(Ui.label(user.username(), "WlMenuDetail")); + who.add(BorderLayout.WEST, Ui.avatar(user.displayName())); + who.add(BorderLayout.CENTER, names); + top.add(who); + // Which of the three apps this is, said in words at the top of each. + top.add(FlowLayout.encloseIn(Ui.badge(Nav.ADMIN.equals(mode) ? "Admin mode" + : Nav.DRIVER.equals(mode) ? "Driver mode" : "Rider mode", "WlModeBadge"))); + + Container list = new Container(BoxLayout.y()); + list.setScrollableY(true); + list.setScrollVisible(false); + list.add(places); + // The other modes, for an account whose roles open more than one. + // Everyone may ride; driving and administering are granted. + if (user.driver() || user.admin() || !Nav.RIDER.equals(mode)) { + list.add(Ui.label("Switch to", "WlMenuLabel")); + if (!Nav.RIDER.equals(mode)) { + list.add(other(FontImage.MATERIAL_HAIL, "Riding", Nav.RIDER)); + } + if (user.driver() && !Nav.DRIVER.equals(mode)) { + list.add(other(FontImage.MATERIAL_DIRECTIONS_CAR, "Driving", Nav.DRIVER)); + } + if (user.admin() && !Nav.ADMIN.equals(mode)) { + list.add(other(FontImage.MATERIAL_INSIGHTS, "Administration", Nav.ADMIN)); + } + } + + Container bottom = new Container(BoxLayout.y()); + bottom.setUIID("WlMenuBottom"); + bottom.add(entry(FontImage.MATERIAL_SETTINGS, "Settings", "menu-settings", + () -> SettingsForm.show(form))); + bottom.add(entry(FontImage.MATERIAL_LOGOUT, "Sign out", "menu-signOut", Nav::signOut)); + + panel.add(BorderLayout.NORTH, top); + panel.add(BorderLayout.CENTER, list); + panel.add(BorderLayout.SOUTH, bottom); + } + + private Button other(char icon, String text, final String to) { + return entry(icon, text, "switch-" + to, () -> Nav.switchTo(to)); + } + + private Button entry(char icon, String text, String name, final Runnable go) { + Button entry = new Button(text, "WlMenuItem"); + entry.setName(name); + Ui.icon(entry, icon, 3.8f); + entry.addActionListener(e -> { + close(); + go.run(); + }); + return entry; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Nav.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Nav.java new file mode 100644 index 00000000000..6fbe06b04a7 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Nav.java @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.wayline.AppConfig; +import com.codenameone.examples.wayline.Prefs; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.admin.AdminForm; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.driver.ApplyForm; +import com.codenameone.examples.wayline.driver.DriverForm; +import com.codenameone.examples.wayline.driver.Shift; +import com.codenameone.examples.wayline.live.LiveChannel; +import com.codenameone.examples.wayline.net.Account; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.notify.Notifications; +import com.codenameone.examples.wayline.rider.RiderForm; + +/// Decides which screen the app is on. +/// +/// The app is three apps in one -- for riding, for driving and for +/// administering -- and which of them a user may enter is the server's +/// decision: it is the roles on their account, as `/api/me` reports them. Each +/// has a home screen of its own, with its own menu, and the user is in one of +/// them at a time. Hiding a mode here is a courtesy and not the protection; the +/// server refuses a driver's or an admin's call from anyone without the role, +/// whatever the app shows. +public final class Nav { + public static final String RIDER = "rider"; + public static final String DRIVER = "driver"; + public static final String ADMIN = "admin"; + + /// The home screen of a mode. + public interface Home { + /// Fills the screen in and starts it talking to the server. Called + /// once, before the screen is first shown. + void start(); + + Form form(); + } + + /// The home screen last built, and the mode it is the home of. + private static Form homeForm; + private static String homeMode = ""; + + private Nav() { + } + + /// Whether `form` is still the home screen, for an answer that arrives + /// after the user has gone to another mode or signed out and should then + /// change nothing. + public static boolean isHome(Form form) { + return form != null && form == homeForm; + } + + /// The first screen: wherever the stored session leads. + public static void launch() { + Session.onSignedOut(() -> { + Shift.end(); + LiveChannel.stop(); + Telemetry.signedOut("expired"); + WelcomeForm.show(); + }); + Form starting = Ui.form("", "Starting"); + starting.getToolbar().hideToolbar(); + starting.show(); + Session.restore(Nav::enter, WelcomeForm::show, Nav::unreachable); + } + + /// Someone has just signed in, or been found still signed in: their + /// account's settings are fetched, applied, and then their home is shown in + /// the language and the colours they chose. + public static void enter() { + // Where this phone is to be sent notifications, now that there is + // someone to send them to. + Notifications.signedIn(); + Account.load(() -> { + Look.apply(); + Lang.apply(); + if (Telemetry.asked()) { + home(); + } else { + // Once on a device, before the first screen that would be + // counted: whether it may be. + ConsentForm.show(Nav::home); + } + }); + } + + /// Shows the home screen of the mode the user is in. + public static void home() { + Form home = build(); + if (home != null) { + home.show(); + } + } + + /// Builds the home screen of the mode the user is in, without showing it: + /// for a caller that is about to show something on top of it. Null, and + /// the welcome screen showing, when nobody is signed in. + public static Form build() { + UserDto user = Session.user(); + if (user == null) { + WelcomeForm.show(); + return null; + } + LiveChannel.start(); + String mode = mode(user); + Telemetry.role(mode); + // Driving is for the approved. Anyone else who chooses it gets the + // way there: the application, and what has become of it. + if (!DRIVER.equals(mode)) { + // Out of the driver's side of the app is off line as far as the + // phone is concerned: nothing goes on reporting where it is. + Shift.leave(); + } + Home home = ADMIN.equals(mode) ? new AdminForm() + : !DRIVER.equals(mode) ? new RiderForm() + : user.driver() ? new DriverForm() : new ApplyForm(); + home.start(); + homeForm = home.form(); + homeMode = mode; + return homeForm; + } + + /// The mode to open for `user`. + /// + /// The one they chose last, while their account still allows it. With no + /// choice made, the one their account is for: an admin administers, a + /// driver drives, and everyone else rides. A choice is remembered for the + /// account that made it, so the next person to sign in on the same device + /// starts where their own account does. + public static String mode(UserDto user) { + String saved = AppConfig.mode(Session.owner(user.username())); + if (ADMIN.equals(saved) && user.admin()) { + return ADMIN; + } + // Not only for a driver: someone who is applying to be one is in the + // driver's side of the app too, on the screen that says how it stands. + if (DRIVER.equals(saved)) { + return DRIVER; + } + if (RIDER.equals(saved)) { + return RIDER; + } + return user.admin() ? ADMIN : user.driver() ? DRIVER : RIDER; + } + + /// The user chose `mode` from the menu. + public static void switchTo(String mode) { + UserDto user = Session.user(); + AppConfig.setMode(mode, user == null ? "" : Session.owner(user.username())); + home(); + } + + /// A notification about a ride was opened: the home that shows that ride, + /// as the rider or as the driver the notification was for. Nothing + /// changes when that home is already on screen; it hears of the change + /// over the live channel too. + public static void showRide(String role) { + UserDto user = Session.user(); + if (user == null) { + return; + } + String wanted = DRIVER.equals(role) && user.driver() ? DRIVER : RIDER; + if (wanted.equals(homeMode) && homeForm != null && CN.getCurrentForm() == homeForm) { + return; + } + AppConfig.setMode(wanted, Session.owner(user.username())); + home(); + } + + public static void signOut() { + leave("user", true); + } + + /// The account was closed: signed out, and what the device kept for the + /// usage reports forgotten with it. + public static void deleted() { + // Closing the account already made the server forget its devices. + leave("deleted", false); + Telemetry.forget(); + // Nobody is coming back for where that account went. + Prefs.ownedBy(""); + } + + private static void leave(final String reason, boolean forgetDevice) { + Account.flush(); + Shift.leave(); + LiveChannel.stop(); + homeForm = null; + homeMode = ""; + // The server forgets this phone for the account first, while there is + // still a session to ask with. + Runnable out = () -> { + Telemetry.signedOut(reason); + Session.signOut(WelcomeForm::show); + }; + // The ride's Live Activity is left to the system. Signing out does not + // end the ride, which goes on for the rider who signed out, and the + // system removes an activity on its own within hours. Ending it here + // would hide a ride that is still happening. + if (forgetDevice) { + Notifications.signingOut(out); + } else { + out.run(); + } + } + + /// Points the app at another server, which ends the session with this one. + public static void changeServer(final String url) { + Runnable switchOver = () -> { + AppConfig.setServerUrl(url); + Session.start(AppConfig.serverUrl()); + launch(); + }; + if (AppConfig.sameServer(url, Session.serverUrl()) || Session.user() == null) { + LiveChannel.stop(); + switchOver.run(); + return; + } + // Signed in: sign out the way the Sign out button does, against the + // server still in use, before pointing anywhere else. Session.start + // only forgets the tokens on this phone, so the old server would keep + // a live refresh token and this phone's push registration, and go on + // notifying it about that account's rides. + Account.flush(); + Shift.leave(); + LiveChannel.stop(); + homeForm = null; + homeMode = ""; + Notifications.signingOut(() -> { + Telemetry.signedOut("server"); + Session.signOut(switchOver); + }); + } + + /// There is a session, and no server to use it with. + private static void unreachable() { + Form form = Ui.form("Wayline", "Unreachable"); + Container page = Ui.page(); + page.add(Ui.label("No connection", "WlTitle")); + page.add(Ui.text(Lang.tr("The server at {0} did not answer.", Session.serverUrl()), + "WlText")); + page.add(Ui.primary("Try again", "retry", e -> launch())); + if (AppConfig.serverSwitchable()) { + page.add(Ui.secondary("Server address", "server", e -> ServerForm.show(form))); + } + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Photos.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Photos.java new file mode 100644 index 00000000000..c1b53b7be60 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Photos.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.capture.Capture; +import com.codename1.io.FileSystemStorage; +import com.codename1.io.Log; +import com.codename1.io.Util; +import com.codename1.ui.CN; +import com.codename1.ui.Image; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.util.ImageIO; +import com.codenameone.examples.wayline.Telemetry; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/// A picture from the camera or from the gallery, made small enough to send. +/// +/// A phone's camera makes pictures of several megabytes, and the server takes +/// documents of up to one and a half. So whatever comes back is scaled to fit +/// in [#LONGEST_SIDE] and written as a JPEG, which leaves a page of a licence +/// perfectly readable in a fraction of that. +public final class Photos { + /// Where the pictures come from, for a test that has no camera. + public interface Source { + /// @param camera true when the camera was asked for, false the gallery + /// @return the picture, or null as when the user backs out + Image take(boolean camera); + } + + public interface Taken { + /// @param jpeg the picture, encoded + /// @param picture the same picture, to show + void taken(byte[] jpeg, Image picture); + } + + private static final int LONGEST_SIDE = 1280; + private static final float QUALITY = 0.8f; + + private static Source source; + + private Photos() { + } + + /// Takes every picture from `from` from now on, and opens neither the + /// camera nor the gallery. The tests use it to hand in a picture of their + /// own. + public static void useSource(Source from) { + source = from; + } + + public static void camera(Taken taken) { + if (source != null) { + deliver(source.take(true), taken); + return; + } + Capture.capturePhoto(e -> picked(e, taken)); + } + + public static void gallery(Taken taken) { + if (source != null) { + deliver(source.take(false), taken); + return; + } + CN.openGallery(e -> picked(e, taken), CN.GALLERY_IMAGE); + } + + /// The camera and the gallery both answer with the path of a file, or + /// with nothing when the user backed out. + private static void picked(ActionEvent event, Taken taken) { + Object path = event == null ? null : event.getSource(); + if (!(path instanceof String)) { + return; + } + InputStream in = null; + try { + in = FileSystemStorage.getInstance().openInputStream((String) path); + deliver(Image.createImage(in), taken); + } catch (IOException failed) { + Log.e(failed); + Telemetry.error(failed); + Ui.say("That picture could not be read"); + } finally { + Util.cleanup(in); + } + } + + private static void deliver(Image picture, Taken taken) { + if (picture == null) { + return; + } + Image small = picture; + if (picture.getWidth() > LONGEST_SIDE || picture.getHeight() > LONGEST_SIDE) { + small = picture.scaledSmallerRatio(LONGEST_SIDE, LONGEST_SIDE); + } + ImageIO encoder = ImageIO.getImageIO(); + if (encoder == null) { + Ui.say("This device cannot prepare a picture to send"); + return; + } + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + encoder.save(small, out, ImageIO.FORMAT_JPEG, QUALITY); + taken.taken(out.toByteArray(), small); + } catch (IOException failed) { + Log.e(failed); + Telemetry.error(failed); + Ui.say("That picture could not be read"); + } + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/ProfileForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/ProfileForm.java new file mode 100644 index 00000000000..0b769a95210 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/ProfileForm.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.wayline.Prefs; +import com.codenameone.examples.wayline.api.PlaceDto; +import com.codenameone.examples.wayline.api.ProfileDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.map.Locator; +import com.codenameone.examples.wayline.net.Account; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.rider.PlaceSearchForm; + +/// Who the user is to the service: their name, who to tell if something goes +/// wrong, and the two places they go most. +public final class ProfileForm { + private static final String[] GENDERS = {"unspecified", "female", "male", "nonbinary"}; + private static final String[] GENDER_NAMES = {"Rather not say", "Woman", "Man", + "Non-binary"}; + + private ProfileForm() { + } + + /// @param saved where to go once the profile is saved: a screen that shows + /// the name is built again there + public static void show(final Form previous, final Runnable saved) { + final Form form = Ui.form("Profile", "Profile"); + Ui.back(form, previous); + UserDto user = Session.user(); + ProfileDto profile = Account.profile(); + Container page = Ui.page(); + + Ui.section(page, "About you"); + final TextField name = Ui.field(page, "Name", "name", "What drivers will call you"); + name.setText(profile == null ? user.displayName() : profile.name()); + final String[] gender = {profile == null || profile.gender() == null + || profile.gender().length() == 0 ? GENDERS[0] : profile.gender()}; + page.add(Ui.label("Gender", "WlLabel")); + // Two rows of two: four of these do not fit side by side in German. + page.add(Ui.choice("gender", GENDER_NAMES, GENDERS, gender[0], + value -> gender[0] = value, 2)); + page.add(Ui.text("Riders who ask for a woman at the wheel are offered to drivers " + + "who say they are one.", "WlMuted")); + + Ui.section(page, "Emergency contact"); + final TextField contact = Ui.field(page, "Their name", "emergencyName", ""); + contact.setText(Prefs.emergencyContactName()); + final TextField number = Ui.field(page, "Their phone number", "emergencyPhone", + "+1 555 0100", TextArea.PHONENUMBER); + number.setText(Prefs.emergencyContactPhone()); + + Ui.section(page, "Saved places"); + Container places = SettingsForm.group(); + places.add(place(form, previous, saved, FontImage.MATERIAL_HOME, "Home", "home", + Prefs.home())); + places.add(place(form, previous, saved, FontImage.MATERIAL_WORK, "Work", "work", + Prefs.work())); + page.add(places); + + final SpanLabel error = Ui.text("", "WlError"); + error.setName("error"); + final Button save = Ui.primary("Save", "submit", null); + save.addActionListener(e -> { + save.setEnabled(false); + error.setText(""); + Prefs.setEmergencyContactName(contact.getText()); + Prefs.setEmergencyContactPhone(number.getText()); + Account.saveProfile(name.getText().trim(), gender[0], saved, (status, message) -> { + save.setEnabled(true); + error.setText(message); + Ui.refresh(form); + }); + }); + page.add(error).add(save); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + private static Container place(final Form form, final Form previous, final Runnable saved, + char icon, String title, final String which, PlaceDto place) { + return Ui.tap(Ui.row(icon, title, place == null ? Lang.tr("Not set") : place.name(), + SettingsForm.chevron()), "place-" + which, + e -> PlaceSearchForm.show(form, Locator.here(), picked -> { + if ("home".equals(which)) { + Prefs.setHome(picked); + } else { + Prefs.setWork(picked); + } + show(previous, saved); + })); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Pulse.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Pulse.java new file mode 100644 index 00000000000..05e6d25cc3f --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Pulse.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; + +/// "Still looking": a dot that sends out rings, the way a search does on a +/// map. It runs for as long as it is on screen. +/// +/// The colour is the `WlPulse` style's text colour. +public final class Pulse extends Component { + /// How long one ring takes from the dot to the edge. + private static final int PERIOD_MILLIS = 1800; + private static final int RINGS = 3; + + public Pulse() { + setUIID("WlPulse"); + } + + @Override + protected void initComponent() { + super.initComponent(); + Form form = getComponentForm(); + if (form != null) { + form.registerAnimated(this); + } + } + + @Override + protected void deinitialize() { + Form form = getComponentForm(); + if (form != null) { + form.deregisterAnimated(this); + } + super.deinitialize(); + } + + @Override + public boolean animate() { + return true; + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + int side = CN.convertToPixels(13f); + return new Dimension(side + style.getHorizontalPadding(), + side + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int side = Math.min(getWidth() - style.getHorizontalPadding(), + getHeight() - style.getVerticalPadding()); + if (side <= 0) { + return; + } + int centerX = getX() + getWidth() / 2; + int centerY = getY() + getHeight() / 2; + int dot = Math.max(4, CN.convertToPixels(2f)); + int alpha = g.getAlpha(); + g.setAntiAliased(true); + g.setColor(style.getFgColor()); + long now = System.currentTimeMillis() % PERIOD_MILLIS; + for (int ring = 0; ring < RINGS; ring++) { + // Each ring is a third of the way behind the one before it, and + // fades as it grows. + double part = ((now + (long) ring * PERIOD_MILLIS / RINGS) % PERIOD_MILLIS) + / (double) PERIOD_MILLIS; + int size = dot + (int) ((side - dot) * part); + g.setAlpha((int) (90 * (1d - part))); + g.fillArc(centerX - size / 2, centerY - size / 2, size, size, 0, 360); + } + g.setAlpha(alpha); + g.fillArc(centerX - dot / 2, centerY - dot / 2, dot, dot, 0, 360); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/RegisterForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/RegisterForm.java new file mode 100644 index 00000000000..e19e043ff6f --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/RegisterForm.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.RegisterDto; +import com.codenameone.examples.wayline.AppConfig; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.net.Session; + +/// Creates an account, signs in with it and goes on to verify the phone. +/// +/// Everyone who registers can ride. Someone who wants to drive says so here +/// and is taken on to the application; the driver role comes when an admin +/// approves it. Nobody registers as an admin either: that role is granted by +/// another admin, from the admin screens. +public final class RegisterForm { + private RegisterForm() { + } + + public static void show(Form previous) { + final Form form = Ui.form("Create an account", "Register"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField name = Ui.field(page, "Name", "name", "What drivers will call you"); + final TextField email = Ui.field(page, "E-mail", "email", "you@example.com", + TextArea.EMAILADDR); + final TextField phone = Ui.field(page, "Mobile number", "phone", "+1 555 010 0000", + TextArea.PHONENUMBER); + final TextField password = Ui.field(page, "Password", "password", + "At least 8 characters", TextArea.PASSWORD); + + // Riding is open to everyone the moment they have an account. Driving + // is asked for here and granted later, by an admin who has read the + // application this choice leads to. + final String[] wants = {Nav.RIDER}; + page.add(Ui.label("I want to", "WlLabel")); + page.add(Ui.choice("want", new String[] {"Ride", "Drive"}, + new String[] {Nav.RIDER, Nav.DRIVER}, wants[0], value -> wants[0] = value)); + page.add(Ui.text("Drivers apply after signing up: a few details about you and your " + + "car, and five photos. You can ride in the meantime.", "WlMuted")); + + final SpanLabel error = Ui.text("", "WlError"); + error.setName("error"); + final Button create = Ui.primary("Create account", "submit", null); + create.addActionListener(e -> { + final boolean driving = Nav.DRIVER.equals(wants[0]); + final RegisterDto request = new RegisterDto(email.getText().trim(), password.getText(), + name.getText().trim(), phone.getText().trim(), false, null, null, driving, + null); + final Net.Failed failed = (status, message) -> { + create.setEnabled(true); + error.setText(message); + Ui.refresh(form); + }; + create.setEnabled(false); + error.setText(""); + // The server checks every field and says what is wrong with the + // first that fails, so nothing is validated twice. + Api.account().register(request, Net.to(user -> Session.signIn(request.email(), + request.password(), () -> { + // Someone who came to drive starts on the driver's side, + // which for them is the application. + AppConfig.setMode(driving ? Nav.DRIVER : Nav.RIDER, Session.owner(request.email())); + Telemetry.registered(driving); + VerifyPhoneForm.show(Nav::enter); + }, failed), failed)); + }); + page.add(error).add(create); + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/RideListForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/RideListForm.java new file mode 100644 index 00000000000..e92b5aa91e0 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/RideListForm.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.wayline.api.RideDto; +import com.codenameone.examples.wayline.net.Net; + +import java.util.List; + +/// A list of rides: a rider's, a driver's, or for an admin everyone's. Which +/// it is depends only on the call it is given -- and on the server, which +/// answers each of the three with the rides that caller may see. +public final class RideListForm { + private RideListForm() { + } + + /// @param people whether to name the rider and driver on each row, which + /// is for the admin's list + public static void show(Form previous, String title, Net.Call> call, + final boolean people) { + show(previous, title, call, people, null); + } + + /// What a tap on a ride does, given the list to come back to. + public interface Opened { + void opened(Form list, RideDto ride); + } + + public static void show(Form previous, String title, Net.Call> call, + final boolean people, final Opened opened) { + final Form form = Ui.form(title, "Rides"); + Ui.back(form, previous); + final Container list = Ui.list("rides"); + form.add(BorderLayout.CENTER, list); + form.show(); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Net.send(call, rides -> { + list.removeAll(); + if (rides == null || rides.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_HISTORY, "No rides yet", + "A ride shows up here as soon as it is asked for.")); + } + for (int iter = 0; rides != null && iter < rides.size(); iter++) { + final RideDto ride = rides.get(iter); + String detail = Ui.when(ride.requestedAt()); + if (people) { + detail += " - " + (ride.driverName() == null + || ride.driverName().length() == 0 ? ride.riderName() + : Lang.tr("{0} with {1}", ride.riderName(), ride.driverName())); + } + Container value = new Container(BoxLayout.y()); + value.add(Ui.plain(Ui.money(ride.total() > 0 ? ride.total() : ride.fareCents(), + ride.currency()), "WlRowValue")); + value.add(Ui.badge(Ui.state(ride.state()), Ui.stateBadge(ride.state()))); + Container row = Ui.row(FontImage.MATERIAL_PLACE, ride.dropoffAddress(), detail, + value); + if (opened == null) { + row.setName("ride-" + iter); + } else { + Ui.tap(row, "ride-" + iter, e -> opened.opened(form, ride)); + } + list.add(row); + } + Ui.refresh(form); + }, Ui.failed(list, () -> load[0].run())); + Ui.pull(list, load[0]); + load[0].run(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Ring.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Ring.java new file mode 100644 index 00000000000..b8b458659fc --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Ring.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +/// A countdown drawn as a ring that empties, with the seconds left inside it. +/// +/// The colours are two styles' text colours: `WlRing` for the part of the +/// ring still left and `WlRingTrack` for the part used up. Neither has a +/// background, so whatever the ring sits on shows through its middle. +public final class Ring extends Component { + private long endsAt; + private int total = 1; + private int shown = -1; + + public Ring() { + setUIID("WlRing"); + } + + /// Starts counting down from `seconds`, of a count that began at `of`. + public void start(int seconds, int of) { + total = Math.max(1, Math.max(seconds, of)); + endsAt = System.currentTimeMillis() + seconds * 1000L; + repaint(); + } + + /// The whole seconds left, never less than none. + public int secondsLeft() { + long left = endsAt - System.currentTimeMillis(); + return left <= 0 ? 0 : (int) ((left + 999) / 1000); + } + + @Override + protected void initComponent() { + super.initComponent(); + Form form = getComponentForm(); + if (form != null) { + form.registerAnimated(this); + } + } + + @Override + protected void deinitialize() { + Form form = getComponentForm(); + if (form != null) { + form.deregisterAnimated(this); + } + super.deinitialize(); + } + + /// Repaints while the count is running, which is what makes the ring + /// shrink smoothly and not a second at a time. + @Override + public boolean animate() { + boolean running = endsAt > System.currentTimeMillis() || shown != 0; + shown = secondsLeft(); + return running; + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + int side = CN.convertToPixels(10.5f); + return new Dimension(side + style.getHorizontalPadding(), + side + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int track = UIManager.getInstance().getComponentStyle("WlRingTrack").getFgColor(); + int side = Math.min(getWidth() - style.getHorizontalPadding(), + getHeight() - style.getVerticalPadding()); + if (side <= 0) { + return; + } + int centerX = getX() + getWidth() / 2; + int centerY = getY() + getHeight() / 2; + int stroke = Math.max(2, CN.convertToPixels(0.9f)); + int radius = (side - stroke) / 2; + long left = Math.max(0L, endsAt - System.currentTimeMillis()); + double part = Math.min(1d, left / (total * 1000d)); + + // The ring as dots around the circle, the first at the top and going + // clockwise: enough of them that they join up. + g.setAntiAliased(true); + int dots = Math.max(60, (int) (2 * Math.PI * radius / Math.max(1, stroke / 2))); + int lit = (int) Math.round(dots * part); + for (int dot = 0; dot < dots; dot++) { + double angle = 2 * Math.PI * dot / dots; + int x = centerX + (int) Math.round(Math.sin(angle) * radius); + int y = centerY - (int) Math.round(Math.cos(angle) * radius); + g.setColor(dot < lit ? style.getFgColor() : track); + g.fillArc(x - stroke / 2, y - stroke / 2, stroke, stroke, 0, 360); + } + + String text = String.valueOf(secondsLeft()); + Font font = style.getFont(); + g.setFont(font); + g.setColor(style.getFgColor()); + g.drawString(text, centerX - font.stringWidth(text) / 2, centerY - font.getHeight() / 2); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/ServerForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/ServerForm.java new file mode 100644 index 00000000000..3c86ec852f0 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/ServerForm.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.wayline.net.Session; + +/// Where the server is. A released app has one answer and no use for this +/// screen; while you are developing, it is how a phone is pointed at the server +/// running on your computer -- `http://` and the computer's address on the +/// network, since `localhost` on a phone is the phone. +public final class ServerForm { + private ServerForm() { + } + + public static void show(Form previous) { + Form form = Ui.form("Server address", "Server"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Ui.text("The address of the Wayline server this app talks to. " + + "Changing it signs you out.", "WlText")); + final TextField url = Ui.field(page, "Address", "url", "http://192.168.1.20:8080", + TextArea.URL); + url.setText(Session.serverUrl()); + page.add(Ui.primary("Save", "submit", e -> Nav.changeServer(url.getText()))); + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/SettingsForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/SettingsForm.java new file mode 100644 index 00000000000..7fb28e40ce0 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/SettingsForm.java @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.wayline.AppConfig; +import com.codenameone.examples.wayline.Prefs; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.AccountClosureDto; +import com.codenameone.examples.wayline.api.PasswordChangeDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.pay.WalletForm; + +/// Settings: the account, and everything about the app a person may want +/// their own way. +/// +/// Every control here reads and writes [Prefs] and nothing else, so this +/// screen does not know where a setting is kept: `Prefs` keeps it on the +/// device, and `net.Account` hears of the change and keeps it with the account. +/// The three under Appearance change what is already on screen -- the +/// language, light or dark, miles or kilometres -- and take effect at once: the +/// look and the words are applied, and the screens are built again in them, +/// this one included. +public final class SettingsForm { + private SettingsForm() { + } + + public static void show(final Form previous) { + final Form form = Ui.form("Settings", "Settings"); + Ui.back(form, previous); + final UserDto user = Session.user(); + Container page = Ui.page(); + + page.add(Ui.person(user.displayName(), user.username(), null)); + + Ui.section(page, "Account"); + Container account = group(); + account.add(Ui.tap(Ui.row(FontImage.MATERIAL_PERSON, "Profile", + "Name, emergency contact, home and work", chevron()), "profile", + e -> ProfileForm.show(form, () -> show(previous)))); + account.add(Ui.row(FontImage.MATERIAL_PHONE, user.phone(), + user.phoneVerified() ? null : "Needed before your first ride", + user.phoneVerified() ? Ui.badge("Verified", "WlBadgeGood") + : Ui.link("Verify", "verify", + e -> VerifyPhoneForm.show(() -> show(previous))))); + if (user.driver()) { + account.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, user.vehicle(), user.plate(), + null)); + } + account.add(Ui.tap(Ui.row(FontImage.MATERIAL_LOCK, "Password", + "Change the password you sign in with", chevron()), "password", + e -> password(form))); + page.add(account); + + Ui.section(page, "Ride preferences"); + Container ride = group(); + ride.add(Ui.label("Driver", "WlLabel")); + ride.add(Ui.choice("driverGender", new String[] {"Any driver", "Women only"}, + new String[] {Prefs.GENDER_ANY, Prefs.GENDER_WOMEN}, Prefs.driverGender(), + Prefs::setDriverGender)); + ride.add(Ui.row(FontImage.MATERIAL_VOLUME_OFF, "Quiet ride", + "Ask the driver to keep conversation short", + Ui.toggle("quietRide", Prefs.quietRide(), Prefs::setQuietRide))); + ride.add(Ui.row(FontImage.MATERIAL_ACCESSIBLE, "Accessible vehicle", + "Only cars that take a wheelchair", + Ui.toggle("accessibleVehicle", Prefs.accessibleVehicle(), + Prefs::setAccessibleVehicle))); + ride.add(Ui.row(FontImage.MATERIAL_PETS, "Pet friendly", + "Tell the driver an animal is coming", + Ui.toggle("petFriendly", Prefs.petFriendly(), Prefs::setPetFriendly))); + page.add(ride); + + Ui.section(page, "Payment"); + Container pay = group(); + pay.add(Ui.tap(Ui.row(FontImage.MATERIAL_ACCOUNT_BALANCE_WALLET, "Wallet", + "Your cards, and which of them pays by default", chevron()), "wallet", + e -> WalletForm.show(form))); + pay.add(Ui.label("Tip offered first", "WlLabel")); + pay.add(Ui.choice("tip", new String[] {"None", "10%", "15%", "20%"}, + new String[] {"0", "10", "15", "20"}, String.valueOf(Prefs.tipPercent()), + value -> Prefs.setTipPercent(Integer.parseInt(value)))); + page.add(pay); + + Ui.section(page, "Notifications"); + Container notify = group(); + notify.add(Ui.row(FontImage.MATERIAL_NOTIFICATIONS, "Ride updates", + "Driver found, arriving, at the door", + Ui.toggle("notifyRideUpdates", Prefs.notifyRideUpdates(), + Prefs::setNotifyRideUpdates))); + notify.add(Ui.row(FontImage.MATERIAL_RECEIPT, "Receipts", "After every ride", + Ui.toggle("notifyReceipts", Prefs.notifyReceipts(), Prefs::setNotifyReceipts))); + notify.add(Ui.row(FontImage.MATERIAL_LOCAL_OFFER, "Offers", "Discounts and news", + Ui.toggle("notifyPromotions", Prefs.notifyPromotions(), + Prefs::setNotifyPromotions))); + page.add(notify); + + Ui.section(page, "Appearance"); + Container look = group(); + look.add(Ui.label("Language", "WlLabel")); + String speaking = Lang.current(); + for (int iter = 0; iter < Lang.CODES.length; iter++) { + final String code = Lang.CODES[iter]; + Label tick = new Label("", "WlRowIcon"); + if (code.equals(speaking)) { + Ui.icon(tick, FontImage.MATERIAL_CHECK, 3.4f); + } + Container row = new Container(new BorderLayout()); + row.setUIID("WlRow"); + // A language is called what its speakers call it, whatever + // language the rest of the screen is in. + row.add(BorderLayout.CENTER, Ui.plain(Lang.name(code), "WlRowTitle")); + row.add(BorderLayout.EAST, tick); + look.add(Ui.tap(row, "language-" + code, e -> { + boolean other = !code.equals(Lang.current()); + Prefs.setLanguage(code); + if (other) { + Telemetry.languageChanged(code); + } + again(previous); + })); + } + look.add(Ui.label("Theme", "WlLabel")); + look.add(Ui.choice("theme", new String[] {"System", "Light", "Dark"}, + new String[] {Prefs.THEME_SYSTEM, Prefs.THEME_LIGHT, Prefs.THEME_DARK}, + Prefs.theme(), value -> { + if (!value.equals(Prefs.theme())) { + Prefs.setTheme(value); + Telemetry.themeChanged(value); + } + again(previous); + })); + look.add(Ui.label("Distances", "WlLabel")); + look.add(Ui.choice("units", new String[] {"Kilometres", "Miles"}, + new String[] {Prefs.UNITS_KM, Prefs.UNITS_MILES}, Prefs.units(), value -> { + Prefs.setUnits(value); + again(previous); + })); + page.add(look); + + Ui.section(page, "Privacy and safety"); + Container safe = group(); + String contact = Prefs.emergencyContactName(); + safe.add(Ui.row(FontImage.MATERIAL_SHARE_LOCATION, "Share my trips", + contact.length() == 0 + ? Lang.tr("Name an emergency contact in your profile first") + : Lang.tr("Saves {0}'s number with your account as your trip contact. " + + "No message is sent.", contact), + Ui.toggle("shareTrips", Prefs.shareTrips(), Prefs::setShareTrips))); + // Kept on this device and not with the account: it is this + // installation that reports or does not. + safe.add(Ui.row(FontImage.MATERIAL_INSIGHTS, "Usage statistics", + "Share which screens and features are used. Never your name, places or " + + "payment details.", + Ui.toggle("usageStatistics", Telemetry.allowed(), Telemetry::allow))); + page.add(safe); + + Ui.section(page, "About"); + Container about = group(); + about.add(Ui.row(FontImage.MATERIAL_INFO, "Wayline", + "A sample ride service built with Codename One", null)); + about.add(Ui.tap(Ui.row(FontImage.MATERIAL_DESCRIPTION, "Terms of service", null, + chevron()), "terms", e -> legal(form, "Terms of service", + "Wayline is a sample. No real rides are booked, no real drivers are " + + "sent and no money changes hands.", null))); + about.add(Ui.tap(Ui.row(FontImage.MATERIAL_POLICY, "Privacy policy", null, chevron()), + "privacy", e -> legal(form, "Privacy policy", + "Your position is sent to the server you signed in to while you " + + "ride or drive, and to nobody else. Your settings are kept with " + + "your account on that server.", + "With usage statistics turned on, the app also counts which screens " + + "are opened and which features are used, and reports that to " + + "Codename One's analytics service under a made-up id for this " + + "device. Your name, e-mail, phone number, places and card details " + + "are never part of it, and turning it off in Settings stops it."))); + if (AppConfig.serverSwitchable()) { + about.add(Ui.tap(Ui.row(FontImage.MATERIAL_DNS, "Server address", null, chevron()), + "server", e -> ServerForm.show(form))); + } + page.add(about); + + page.add(Ui.secondary("Sign out", "signOut", e -> Nav.signOut())); + page.add(Ui.link("Delete my account", "deleteAccount", e -> delete(form))); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + /// Applies the look and the language, and shows everything again in them. + /// + /// A screen that is already built keeps the words and colours it was built + /// with, so the mode's first screen is built anew to go back to, and this + /// one over it. + private static void again(Form previous) { + Look.apply(); + Lang.apply(); + Form home = Nav.build(); + show(home == null ? previous : home); + } + + static Container group() { + Container group = new Container(BoxLayout.y()); + group.setUIID("WlGroup"); + return group; + } + + /// The mark at the end of a row that leads somewhere. + static Label chevron() { + Label chevron = new Label("", "WlRowIcon"); + Ui.icon(chevron, FontImage.MATERIAL_CHEVRON_RIGHT, 3.4f); + return chevron; + } + + /// Changing the password. The server wants the current one as well as the + /// new: being signed in is what a borrowed phone is, too. + private static void password(final Form previous) { + final Form form = Ui.form("Password", "Password"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField current = Ui.field(page, "Current password", "current", "", + TextArea.PASSWORD); + final TextField replacement = Ui.field(page, "New password", "replacement", + "At least 8 characters", TextArea.PASSWORD); + final TextField again = Ui.field(page, "New password, again", "again", "", + TextArea.PASSWORD); + final SpanLabel error = Ui.text("", "WlError"); + error.setName("error"); + final Button save = Ui.primary("Change password", "submit", null); + save.addActionListener(e -> { + if (!replacement.getText().equals(again.getText())) { + error.setText(Lang.tr("The two new passwords are not the same.")); + Ui.refresh(form); + return; + } + PasswordChangeDto change = new PasswordChangeDto(current.getText(), + replacement.getText()); + save.setEnabled(false); + error.setText(""); + Api.account().changePassword(change, Net.to(user -> { + Ui.say("Your password was changed"); + previous.showBack(); + }, (status, message) -> { + save.setEnabled(true); + error.setText(message); + Ui.refresh(form); + })); + }); + page.add(error).add(save); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + /// Closing the account, which cannot be taken back, so it is its own + /// screen that says what goes and has one red button. + private static void delete(final Form previous) { + final Form form = Ui.form("Delete account", "DeleteAccount"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Ui.banner(FontImage.MATERIAL_DELETE_FOREVER, "Delete your account?", + "Your sign-in, your profile, your saved cards and your settings are removed " + + "for good. Rides you took stay in the service's records without your name.", + "WlBannerIconBad")); + // Asked for again, as changing the password asks: being signed in is + // what a borrowed phone is, too, and the server refuses without it. + final TextField password = Ui.field(page, "Current password", "password", "", + TextArea.PASSWORD); + final SpanLabel error = Ui.text("", "WlError"); + error.setName("error"); + page.add(error); + final Button go = Ui.danger("Delete my account", "confirmDelete", null); + go.addActionListener(e -> { + go.setEnabled(false); + AccountClosureDto closure = new AccountClosureDto(password.getText()); + Api.account().deleteAccount(closure, Net.to(gone -> Nav.deleted(), (status, message) -> { + go.setEnabled(true); + error.setText(message); + Ui.refresh(form); + })); + }); + page.add(go); + page.add(Ui.link("Keep my account", "keep", e -> previous.showBack())); + form.add(BorderLayout.CENTER, page); + form.show(); + } + + private static void legal(Form previous, String title, String text, String more) { + Form form = Ui.form(title, "Legal"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Ui.text(text, "WlText")); + if (more != null) { + page.add(Ui.text(more, "WlText")); + } + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/SignInForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/SignInForm.java new file mode 100644 index 00000000000..1e1cdfaf757 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/SignInForm.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.net.Session; + +/// E-mail and password. The form is the app's own; what happens when it is +/// submitted is in [Session#signIn]. +public final class SignInForm { + private SignInForm() { + } + + public static void show(Form previous) { + final Form form = Ui.form("Sign in", "SignIn"); + Ui.back(form, previous); + Container page = Ui.page(); + final TextField email = Ui.field(page, "E-mail", "email", "you@example.com", + TextArea.EMAILADDR); + final TextField password = Ui.field(page, "Password", "password", "", + TextArea.PASSWORD); + final SpanLabel error = Ui.text("", "WlError"); + error.setName("error"); + final Button signIn = Ui.primary("Sign in", "submit", null); + signIn.addActionListener(e -> { + if (email.getText().trim().length() == 0 || password.getText().length() == 0) { + error.setText("Enter your e-mail and password."); + Ui.refresh(form); + return; + } + signIn.setEnabled(false); + error.setText(""); + Session.signIn(email.getText().trim(), password.getText(), () -> { + Telemetry.signedIn(); + Nav.enter(); + }, (status, message) -> { + signIn.setEnabled(true); + error.setText(message); + Ui.refresh(form); + }); + }); + page.add(error).add(signIn); + form.add(BorderLayout.CENTER, page); + form.show(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Skeleton.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Skeleton.java new file mode 100644 index 00000000000..cbad926df7b --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Skeleton.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; + +/// What a list shows while what is in it is on its way: the outline of a few +/// rows, drawn as bars where the icon, the two lines and the value will be. +/// +/// It says "this is loading" without a word and without moving, and it keeps +/// the screen from jumping when the rows arrive, because they land where the +/// outline was. The colour is the `WlSkeleton` style's. +final class Skeleton extends Component { + /// How tall one row is drawn, in millimetres: a row of two lines. + private static final float ROW_MM = 9.6f; + + private final int rows; + + Skeleton(int rows) { + this.rows = rows; + setUIID("WlSkeleton"); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + Style style = getStyle(); + return new Dimension(CN.convertToPixels(40f) + style.getHorizontalPadding(), + rows * CN.convertToPixels(ROW_MM) + style.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style style = getStyle(); + int left = getX() + style.getPaddingLeftNoRTL(); + int width = getWidth() - style.getHorizontalPadding(); + int row = CN.convertToPixels(ROW_MM); + int dot = CN.convertToPixels(4.4f); + int tall = CN.convertToPixels(1.9f); + int thin = CN.convertToPixels(1.5f); + int gap = CN.convertToPixels(2.6f); + if (width <= dot + gap) { + return; + } + boolean rtl = isRTL(); + g.setAntiAliased(true); + g.setColor(style.getFgColor()); + int lines = width - dot - gap; + for (int iter = 0; iter < rows; iter++) { + int top = getY() + style.getPaddingTop() + iter * row; + int middle = top + row / 2; + // Each row a little shorter than the one before: a list of + // identical bars reads as a pattern, not as a list. + int first = lines * (48 - iter % 3 * 7) / 100; + int second = lines * (30 + iter % 2 * 9) / 100; + int value = lines * 14 / 100; + bar(g, rtl, left, width, 0, middle - dot / 2, dot, dot); + bar(g, rtl, left, width, dot + gap, middle - tall - thin / 3, first, tall); + bar(g, rtl, left, width, dot + gap, middle + thin * 2 / 3, second, thin); + bar(g, rtl, left, width, width - value, middle - tall / 2, value, tall); + } + } + + /// One bar, `from` pixels in from the side a line of text starts at. + private static void bar(Graphics g, boolean rtl, int left, int width, int from, int y, + int w, int h) { + int x = rtl ? left + width - from - w : left + from; + g.fillRoundRect(x, y, w, h, h, h); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/StatRow.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/StatRow.java new file mode 100644 index 00000000000..017dc1dedc4 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/StatRow.java @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; + +import java.util.ArrayList; +import java.util.List; + +/// A row of dashboard numbers, each over its caption, sized as one thing. +/// +/// A label asked to fit its own text decides alone and after the fact: it +/// changes its font once it has been given a width, by which time the tile +/// around it has its height from the font it had before, and its neighbour +/// has decided something else. The result was a row whose numbers were three +/// sizes and whose captions were cut off, or pushed out of the tile. +/// +/// So the row decides, before it lays its tiles out and from the width it +/// really has: one size for every number, the largest at which the longest +/// fits, and one for every caption, which goes onto a second line before it +/// is made any smaller. +public final class StatRow extends Container { + /// The smallest a number and a caption are drawn, in millimetres. + private static final float SMALLEST_VALUE = 2.6f; + private static final float SMALLEST_CAPTION = 1.9f; + + private final int columns; + private final List tiles = new ArrayList(); + private int laidOutAt; + private String fitted = ""; + private String planned = ""; + private boolean correcting; + + StatRow(int columns) { + super(new GridLayout(1, columns)); + this.columns = columns; + } + + /// Adds a number: `value` as it is, over `caption` in the user's language. + /// The number's label is called `name`. + public StatRow stat(String value, String caption, String name) { + Tile tile = new Tile(value, Lang.tr(caption), name); + tiles.add(tile); + add(tile); + return this; + } + + /// Takes every number out, for a row that is filled again. + public void clear() { + tiles.clear(); + removeAll(); + } + + @Override + protected Dimension calcPreferredSize() { + // Asked how tall it is before it knows how wide: answered for the + // width it last had, or the room its nearest sized ancestor offers. + fit(laidOutAt > 0 ? laidOutAt : roomOffered()); + planned = fitted; + return super.calcPreferredSize(); + } + + @Override + public void layoutContainer() { + int width = getWidth(); + if (width > 0) { + laidOutAt = width; + fit(width); + if (!fitted.equals(planned) && !correcting) { + // The height was answered for another width, and the tiles + // are a different height at this one. + correcting = true; + CN.callSerially(() -> { + correcting = false; + setShouldCalcPreferredSize(true); + Form form = getComponentForm(); + if (form != null) { + form.revalidate(); + } + }); + } + } + super.layoutContainer(); + } + + private int roomOffered() { + Container parent = getParent(); + while (parent != null) { + if (parent.getWidth() > 0) { + return parent.getWidth() - parent.getStyle().getHorizontalPadding(); + } + parent = parent.getParent(); + } + return CN.getDisplayWidth(); + } + + private void fit(int width) { + if (tiles.isEmpty() || width <= 0) { + return; + } + int across = Math.min(columns, tiles.size()); + Tile first = tiles.get(0); + int room = (width - getStyle().getHorizontalPadding()) / across + - first.getStyle().getHorizontalPadding() + - first.getStyle().getHorizontalMargins(); + if (room <= 0) { + return; + } + float value = 1f; + float caption = 1f; + for (Tile tile : tiles) { + value = Math.min(value, share(room - tile.value.sides(), tile.value.width(tile.number))); + tile.split(room - tile.upper.sides()); + caption = Math.min(caption, share(room - tile.upper.sides(), tile.widest())); + } + StringBuilder made = new StringBuilder(); + for (Tile tile : tiles) { + made.append(tile.value.scale(value, SMALLEST_VALUE)).append('/'); + made.append(tile.upper.scale(caption, SMALLEST_CAPTION)).append('/'); + tile.lower.scale(caption, SMALLEST_CAPTION); + made.append(tile.show()).append(' '); + } + fitted = made.toString(); + } + + /// The part of its full size at which text `needed` pixels wide fits + /// `room`. + private static float share(int room, int needed) { + return needed <= room || needed <= 0 ? 1f : Math.max(0f, room) / (float) needed; + } + + /// One number over its caption. + private static final class Tile extends Container { + private final String number; + private final String caption; + private final Sized value; + private final Sized upper; + private final Sized lower; + private String top; + private String bottom; + + Tile(String number, String caption, String name) { + super(BoxLayout.y()); + setUIID("WlStat"); + this.number = number == null ? "" : number; + this.caption = caption == null ? "" : caption; + value = new Sized(Ui.plain(this.number, "WlStatValue")); + value.label.setName(name); + upper = new Sized(Ui.plain(this.caption, "WlStatLabel")); + lower = new Sized(Ui.plain("", "WlStatLabel")); + top = this.caption; + bottom = ""; + add(value.label).add(upper.label).add(lower.label); + show(); + } + + /// Puts the caption on one line if `room` holds it, and otherwise on + /// two, broken at the space that leaves the longer line shortest. + void split(int room) { + top = caption; + bottom = ""; + int whole = upper.width(caption); + if (whole <= room) { + return; + } + int best = whole; + for (int at = caption.indexOf(' '); at > 0; at = caption.indexOf(' ', at + 1)) { + String before = caption.substring(0, at); + String after = caption.substring(at + 1); + int longer = Math.max(upper.width(before), upper.width(after)); + if (longer < best) { + best = longer; + top = before; + bottom = after; + } + } + } + + int widest() { + return Math.max(upper.width(top), upper.width(bottom)); + } + + /// Writes the lines decided by [#split]; answers how many there are. + int show() { + if (!top.equals(upper.label.getText())) { + upper.label.setText(top); + } + if (!bottom.equals(lower.label.getText())) { + lower.label.setText(bottom); + } + boolean second = bottom.length() > 0; + if (lower.label.isHidden() == second) { + lower.label.setHidden(!second); + lower.label.setVisible(second); + } + return second ? 2 : 1; + } + } + + /// A label and the font its style gave it, which is the size it is drawn + /// at when there is room. The style is asked again every time, because a + /// label is restyled under us: the desktop console gives it another name, + /// and a change of theme gives it another font. + private static final class Sized { + private final Label label; + private Font styled; + private Font applied; + private int appliedPixels; + + Sized(Label label) { + this.label = label; + } + + private Font styled() { + Font now = label.getUnselectedStyle().getFont(); + if (now != applied || styled == null) { + styled = now; + applied = now; + appliedPixels = Math.round(now.getPixelSize()); + } + return styled; + } + + int sides() { + return label.getUnselectedStyle().getHorizontalPadding() + + label.getUnselectedStyle().getHorizontalMargins(); + } + + /// How wide `text` is at the full size. + int width(String text) { + return text.length() == 0 ? 0 : styled().stringWidth(text); + } + + /// Draws the label at `share` of its full size, and never smaller + /// than `smallest` millimetres; answers the size in pixels. + int scale(float share, float smallest) { + Font full = styled(); + int most = Math.round(full.getPixelSize()); + if (most <= 0) { + // Not a font that can be drawn at another size. + return 0; + } + int least = Math.min(most, CN.convertToPixels(smallest)); + int pixels = share >= 1f ? most : Math.max(least, (int) Math.floor(most * share)); + if (pixels != appliedPixels) { + applied = pixels == most ? full : full.derive(pixels, full.getStyle()); + appliedPixels = pixels; + label.getAllStyles().setFont(applied); + } + return pixels; + } + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Ui.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Ui.java new file mode 100644 index 00000000000..5a709f747c7 --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/Ui.java @@ -0,0 +1,732 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.components.Switch; +import com.codename1.components.ToastBar; +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.ButtonGroup; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.RadioButton; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.Prefs; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.RideState; + +import java.util.Date; + +/// The pieces every screen is made of. Each one is a component with a style +/// name from `theme.css`; nothing here, or in any screen, sets a colour or a +/// font in code. +public final class Ui { + /// What goes between two short facts on one line: a raised dot. + public static final String DOT = " \u00b7 "; + + private Ui() { + } + + /// A screen with a title bar. + /// + /// @param name what a test waits for; the title is for people and changes + public static Form form(String title, String name) { + Form form = new Form(title, new BorderLayout()); + form.setUIID("WlForm"); + form.setName(name); + watch(form); + clearOfTheStatusBar(form); + form.addSizeChangedListener(e -> clearOfTheStatusBar(form)); + return form; + } + + /// Keeps the title bar of `form` under what the device draws over the top + /// of the screen -- the clock, a notch -- the same way the map screens + /// keep their floating bar under it: by the inset the device reports. The + /// strip a form reserves for the status bar is as tall as the theme makes + /// it, which says nothing of a notch; the title bar is padded by whatever + /// that strip leaves uncovered. + private static void clearOfTheStatusBar(Form form) { + Container bar = form.getToolbar(); + if (bar == null) { + return; + } + Rectangle safe = Display.getInstance().getDisplaySafeArea(new Rectangle()); + int covered = Math.max(0, safe.getY()); + Container area = bar.getParent(); + int kept = 0; + if (area != null && area.getLayout() instanceof BorderLayout) { + Component strip = ((BorderLayout) area.getLayout()).getNorth(); + if (strip != null && strip != bar) { + kept = strip.getPreferredH(); + } + } + int styled = UIManager.getInstance().getComponentStyle("Toolbar").getPaddingTop(); + int wanted = styled + Math.max(0, covered - kept); + if (bar.getUnselectedStyle().getPaddingTop() != wanted) { + bar.getAllStyles().setPaddingUnitTop(Style.UNIT_TYPE_PIXELS); + bar.getAllStyles().setPaddingTop(wanted); + } + } + + /// Gives `label` a material icon `size` millimetres tall, and the space + /// between it and the label's words: without one the two touch. + public static void icon(Label label, char icon, float size) { + FontImage.setMaterialIcon(label, icon, size); + label.setGap(CN.convertToPixels(ICON_GAP)); + } + + /// The space between an icon and the words beside it, in millimetres. + private static final float ICON_GAP = 1.4f; + + /// Has `form` counted as a screen each time it is shown, under its name. + /// Every screen of the app is made by this class or handed to this method, + /// so this is the one place a screen view is reported from. + public static void watch(final Form form) { + form.addShowListener(e -> viewed(form.getName())); + } + + /// Counts something that takes the place of a screen without being a + /// form: a pane of the admin's console on a wide window. + public static void viewed(String name) { + Telemetry.screen(name); + } + + /// The first screen of a mode: no title bar, and layers, so that a map can + /// fill the screen with the controls floating over it and the menu sliding + /// in over both. + public static Form home(String name) { + Form form = new Form("", new LayeredLayout()); + form.setUIID("WlForm"); + form.setName(name); + watch(form); + form.getToolbar().hideToolbar(); + // What is on the screen scrolls, where it needs to. The screen itself + // does not, or it would offer to scroll the map away. + form.setScrollable(false); + return form; + } + + /// Gives `form` a back arrow that returns to `previous`. + public static void back(Form form, final Form previous) { + back(form, () -> previous.showBack()); + } + + /// Gives `form` a back arrow that runs `leave`. + /// + /// On a desktop whose window owns the title bar there is no title bar in + /// the app to carry an arrow. The command is then named, so that it is in + /// the window's menu, and the screen gets a line of its own to go back + /// from. A screen that has put something of its own at its top keeps it, + /// under that line: a screen with no way back is one nobody can leave + /// without a device's back button, and a desktop has none. + public static void back(final Form form, final Runnable leave) { + boolean window = CN.isDesktop() && form.getToolbar().getParent() == null; + Command back = form.getToolbar().setBackCommand(window ? Lang.tr("Back") : "", + e -> leave.run()); + if (!window) { + return; + } + back.setDesktopMenu(Command.DESKTOP_MENU_VIEW); + back.setDesktopShortcut('['); + final boolean[] added = new boolean[1]; + form.addShowListener(e -> { + if (added[0] || form.getToolbar().getParent() != null + || !(form.getContentPane().getLayout() instanceof BorderLayout)) { + return; + } + added[0] = true; + Container bar = new Container(new BorderLayout()); + bar.setUIID("WlPaneBar"); + Button arrow = button("Back", "back-bar", "WlLink", e2 -> leave.run()); + icon(arrow, FontImage.MATERIAL_ARROW_BACK, 3.4f); + bar.add(BorderLayout.WEST, arrow); + Component top = ((BorderLayout) form.getContentPane().getLayout()).getNorth(); + if (top == null) { + form.add(BorderLayout.NORTH, bar); + } else { + top.remove(); + Container both = new Container(BoxLayout.y()); + both.add(bar).add(top); + form.add(BorderLayout.NORTH, both); + } + Layouts.dress(bar); + form.revalidate(); + }); + } + + /// A column of content with the page's margins, that scrolls when it has to. + public static Container page() { + Container page = new Column(); + page.setUIID("WlPage"); + page.setScrollableY(true); + page.setScrollVisible(false); + page.setSafeArea(true); + return page; + } + + public static Container card() { + Container card = new Container(BoxLayout.y()); + card.setUIID("WlCard"); + return card; + } + + public static Label label(String text, String uiid) { + return new Label(text == null ? "" : text, uiid); + } + + /// Text that wraps. + public static SpanLabel text(String text, String uiid) { + SpanLabel label = new SpanLabel(text == null ? "" : text); + label.setUIID("Container"); + label.setTextUIID(uiid); + return label; + } + + public static Button primary(String text, String name, ActionListener onClick) { + return button(text, name, "WlPrimary", onClick); + } + + public static Button secondary(String text, String name, ActionListener onClick) { + return button(text, name, "WlSecondary", onClick); + } + + public static Button danger(String text, String name, ActionListener onClick) { + return button(text, name, "WlDanger", onClick); + } + + public static Button link(String text, String name, ActionListener onClick) { + return button(text, name, "WlLink", onClick); + } + + /// A round button that floats over the map. + public static Button round(char icon, String name, ActionListener onClick) { + Button button = button("", name, "WlMapButton", onClick); + icon(button, icon, 4f); + return button; + } + + /// The same button for a bar that is a surface of its own: flat, since a + /// shadow says "this floats", and on a bar nothing does. + public static Button flat(char icon, String name, ActionListener onClick) { + Button button = button("", name, "WlBarButton", onClick); + icon(button, icon, 4f); + return button; + } + + /// One of a row of things to do: an icon with a word under it. + public static Button action(char icon, String text, String name, + ActionListener onClick) { + Button button = button(text, name, "WlAction", onClick); + icon(button, icon, 4f); + button.setTextPosition(Component.BOTTOM); + button.setGap(CN.convertToPixels(0.6f)); + return button; + } + + /// A short fact in a pill: how far, how long. The icon may be 0. + public static Label chip(String text, char icon) { + Label chip = new Label(text == null ? "" : text, "WlChip"); + if (icon != 0) { + icon(chip, icon, 2.7f); + } + return chip; + } + + /// A person, as the initials of their name in a circle. + public static Label avatar(String name) { + Label avatar = new Label("", "WlAvatar"); + // Initials are not words: looking them up would translate "OK". + avatar.setShouldLocalize(false); + avatar.setText(initials(name)); + return avatar; + } + + /// The first letters of the first and last words of `name`. + public static String initials(String name) { + String text = name == null ? "" : name.trim(); + if (text.length() == 0) { + return "?"; + } + int space = text.lastIndexOf(' '); + String first = text.substring(0, 1); + return space < 0 || space + 1 >= text.length() ? first + : first + text.substring(space + 1, space + 2); + } + + public interface Flipped { + void flipped(boolean on); + } + + /// A switch that reports each change. + public static Switch toggle(String name, boolean value, final Flipped flipped) { + final Switch toggle = new Switch(); + toggle.setName(name); + toggle.setValue(value); + toggle.addChangeListener(e -> flipped.flipped(toggle.isValue())); + return toggle; + } + + public interface Chosen { + void chosen(String value); + } + + /// A few options side by side, one of them selected. Each button is named + /// `name-value`. + /// + /// @param labels what each option is called, for people + /// @param values what each option is, for the code + public static Container choice(String name, String[] labels, final String[] values, + String current, final Chosen chosen) { + return choice(name, labels, values, current, chosen, values.length); + } + + /// The same, in rows of `columns`, for options that do not fit in one. + public static Container choice(String name, String[] labels, final String[] values, + String current, final Chosen chosen, int columns) { + Container row = new Container(new GridLayout((values.length + columns - 1) / columns, + columns)); + row.setUIID("WlSegments"); + ButtonGroup group = new ButtonGroup(); + for (int iter = 0; iter < values.length; iter++) { + final String value = values[iter]; + RadioButton option = RadioButton.createToggle(labels[iter], group); + option.setUIID("WlSegment"); + option.setName(name + "-" + value); + option.setSelected(value.equals(current)); + option.addActionListener(e -> chosen.chosen(value)); + row.add(option); + } + return row; + } + + /// The longest address that is still shown on one line. + private static final int LONG_ADDRESS = 26; + + /// Who a screen is about: their initials, their name, the address they sign + /// in with, and under those `value` when there is something to say about + /// them. The address wraps rather than being cut off; it is what tells + /// two people with the same name apart. + public static Container person(String name, String username, Component value) { + Container who = new Container(new BorderLayout()); + who.setUIID("WlPanel"); + Container names = new Container(BoxLayout.y()); + Label called = plain(name, "WlHeading"); + shrink(called, 3f); + String signsIn = username == null ? "" : username; + Component address; + if (signsIn.length() > LONG_ADDRESS) { + // An address has no spaces to break at: a long one is broken + // where the line ends, which keeps all of it readable. + SpanLabel wrapped = text("", "WlMuted"); + wrapped.setShouldLocalize(false); + wrapped.setText(signsIn); + address = wrapped; + } else { + Label line = plain(signsIn, "WlMuted"); + shrink(line, 2f); + address = line; + } + names.add(called).add(address); + if (value != null) { + names.add(FlowLayout.encloseIn(value)); + } + who.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(avatar(name))); + who.add(BorderLayout.CENTER, names); + return who; + } + + /// Lets `label` draw its text smaller, down to `smallest` millimetres, + /// when it would otherwise be cut off -- and never larger than its style + /// says, which is what fitting text to its space does when left alone. + public static void shrink(Label label, float smallest) { + float styled = label.getUnselectedStyle().getFont().getPixelSize() + / (float) CN.convertToPixels(1f); + label.setAutoSizeMode(true); + label.setMaxAutoSize(styled); + label.setMinAutoSize(Math.min(smallest, styled)); + } + + /// Lays `form` out again after its content changed while it was showing. + /// + /// Twice, the second time after the first has been drawn: text that wraps + /// learns its width from the first pass, and only then knows how many + /// lines it is. + public static void refresh(final Form form) { + form.revalidate(); + CN.callSerially(() -> form.revalidate()); + } + + /// The heading over a group of settings or facts. + public static void section(Container page, String title) { + page.add(label(title, "WlSection")); + } + + /// A label for text that is a name or a number, not a word to translate. + public static Label plain(String text, String uiid) { + Label label = new Label("", uiid); + label.setShouldLocalize(false); + label.setText(text == null ? "" : text); + return label; + } + + /// One line of a receipt or a record: what it is on one side, and its + /// value, which is never translated, on the other. + public static Container fact(String caption, String value) { + return fact(caption, value, "WlFactValue"); + } + + public static Container fact(String caption, String value, String valueUiid) { + Container fact = new Container(new BorderLayout()); + fact.setUIID("WlFact"); + fact.add(BorderLayout.CENTER, label(caption, "WlFactLabel")); + fact.add(BorderLayout.EAST, plain(value, valueUiid)); + return fact; + } + + /// What a screen shows instead of a list with nothing in it, and at the top + /// of a screen that is about one thing: a large icon, a line, and a few + /// words under it. + public static Container banner(char icon, String title, String text, String iconUiid) { + Container banner = new Container(BoxLayout.y()); + banner.setUIID("WlBanner"); + Label image = new Label("", iconUiid); + icon(image, icon, 6f); + banner.add(FlowLayout.encloseCenter(image)); + SpanLabel heading = text(title, "WlBannerTitle"); + heading.getTextComponent().setAlignment(Component.CENTER); + banner.add(heading); + if (text != null && text.length() > 0) { + SpanLabel words = text(text, "WlBannerText"); + words.getTextComponent().setAlignment(Component.CENTER); + banner.add(words); + } + return banner; + } + + public static Container empty(char icon, String title, String text) { + return banner(icon, title, text, "WlBannerIcon"); + } + + /// A list that fills a screen: rows from edge to edge, scrolling. + public static Container list(String name) { + Container list = new Column(); + list.setUIID("WlList"); + list.setScrollableY(true); + list.setScrollVisible(false); + list.setName(name); + // Until it is filled: every list is emptied by what fills it. + list.add(new Skeleton(5)); + return list; + } + + /// Has a pull down on `list` fetch what is in it again, with `load`. A + /// window on a desktop is not pulled, and gets nothing. + public static void pull(Container list, final Runnable load) { + if (!CN.isDesktop()) { + list.addPullToRefresh(load); + } + } + + /// A column that stays a column: on a window wider than a column of text + /// should be, it keeps its content in the middle rather than stretching + /// every row and every button from one edge to the other. + private static final class Column extends Container { + private String styled; + private int margin; + + Column() { + super(BoxLayout.y()); + } + + @Override + public void layoutContainer() { + // The margin is the style's own, read once for each style the + // column is given: what is set below replaces it. + if (!getUIID().equals(styled)) { + styled = getUIID(); + margin = getUnselectedStyle().getPaddingLeftNoRTL(); + } + Layouts.centre(this, margin); + super.layoutContainer(); + } + } + + public interface Reason { + void given(String reason); + } + + /// Asks why, before something an admin does to someone else's account or + /// ride. The reason is kept with what was done, so it is not optional. + /// + /// @param action what the button says, which is what will be done + public static void reason(final Form previous, String title, String text, String action, + final Reason reason) { + final Form form = form(title, "Reason"); + back(form, previous); + form.add(BorderLayout.CENTER, reasonPage(text, action, reason)); + form.show(); + } + + /// What [#reason] asks with, for a caller that has its own place to put + /// it: the admin console shows it beside the list on a wide screen. + public static Container reasonPage(String text, String action, final Reason reason) { + final Container page = page(); + page.add(text(text, "WlText")); + final TextField why = field(page, "Reason", "reason", "A sentence the record will keep"); + final SpanLabel error = text("", "WlError"); + error.setName("error"); + page.add(error); + page.add(danger(action, "confirm", e -> { + String given = why.getText().trim(); + if (given.length() == 0) { + error.setText(Lang.tr("Say why, in a few words.")); + page.getComponentForm().revalidate(); + return; + } + reason.given(given); + })); + return page; + } + + private static Button button(String text, String name, String uiid, + ActionListener onClick) { + Button button = new Button(text, uiid); + // The name is what a test finds the button by. + button.setName(name); + if (onClick != null) { + button.addActionListener(onClick); + } + return button; + } + + /// Adds a caption and a text field under it to `to`, and returns the field. + public static TextField field(Container to, String caption, String name, String hint, + int constraint) { + TextField field = new TextField("", hint, 24, constraint); + field.setUIID("WlField"); + field.getHintLabel().setUIID("WlFieldHint"); + field.setName(name); + to.add(label(caption, "WlLabel")).add(field); + return field; + } + + public static TextField field(Container to, String caption, String name, String hint) { + return field(to, caption, name, hint, TextArea.ANY); + } + + /// A row of a list: an icon, two lines of text, and something at the end. + /// The icon may be 0, and the last two null. + public static Container row(char icon, String title, String detail, Component value) { + Container row = new Container(new BorderLayout()); + row.setUIID("WlRow"); + if (icon != 0) { + Label image = new Label("", "WlRowIcon"); + icon(image, icon, 4f); + row.add(BorderLayout.WEST, image); + } + Container lines = new Container(BoxLayout.y()); + lines.add(label(title, "WlRowTitle")); + if (detail != null && detail.length() > 0) { + lines.add(text(detail, "WlRowDetail")); + } + row.add(BorderLayout.CENTER, lines); + if (value != null) { + // Its own height, in the middle of the row: straight into EAST it + // would be stretched to the height of the row. + row.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(value)); + } + return row; + } + + /// Makes the whole of `row` one button. The name is the button's, for a + /// test to click. + public static Container tap(Container row, String name, ActionListener onClick) { + Button lead = new Button(); + lead.setName(name); + lead.setHidden(true); + lead.addActionListener(onClick); + row.add(BorderLayout.SOUTH, lead); + row.setLeadComponent(lead); + return row; + } + + /// A day, without the time. + public static String day(long millis) { + return new SimpleDateFormat("MMM d, yyyy").format(new Date(millis)); + } + + /// The product a ride was asked for as, in words. + public static String product(String product) { + return "comfort".equals(product) ? "Comfort" : "xl".equals(product) ? "XL" : "Standard"; + } + + /// A moment, as a short date and time. + public static String when(long millis) { + return new SimpleDateFormat("MMM d, HH:mm").format(new Date(millis)); + } + + public static Label badge(String text, String uiid) { + return new Label(text, uiid); + } + + /// A row of numbers, each with a caption under it, `columns` across: + /// add them with [StatRow#stat]. More than `columns` of them run onto a + /// further row. + public static StatRow stats(int columns) { + return new StatRow(columns); + } + + /// Tells the user something failed. The signature is `Net.Failed`'s, so a + /// call can end `Ui::fail`. + public static void fail(int status, String message) { + // What the server says is the server's wording; what the app says of + // its own is translated. Looking both up costs nothing. + ToastBar.showErrorMessage(Lang.tr(message)); + } + + /// What a list's load ends with when it fails: the user is told, as with + /// [#fail], and a list still showing the outline of rows that are not + /// coming shows what went wrong instead, with a way to ask again. A list + /// that already has rows keeps them. + /// + /// @param load fetches the list again, or null when there is no asking again + public static Net.Failed failed(final Container list, final Runnable load) { + return (status, message) -> { + fail(status, message); + if (list.getComponentCount() != 1 || !(list.getComponentAt(0) instanceof Skeleton)) { + return; + } + list.removeAll(); + list.add(empty(FontImage.MATERIAL_CLOUD_OFF, message, null)); + if (load != null) { + list.add(FlowLayout.encloseCenter(link("Try again", "retry", e -> { + list.removeAll(); + list.add(new Skeleton(5)); + list.revalidate(); + load.run(); + }))); + } + list.revalidate(); + }; + } + + public static void say(String message) { + ToastBar.showInfoMessage(Lang.tr(message)); + } + + /// An amount of money. Whole cents in and text out: `$12.40` for dollars, + /// and the amount followed by the currency's code for anything else. + public static String money(long cents, String currency) { + long whole = cents / 100; + long part = Math.abs(cents % 100); + String amount = whole + "." + (part < 10 ? "0" : "") + part; + if (currency == null || currency.length() == 0 || "USD".equals(currency)) { + return "$" + amount; + } + return amount + " " + currency; + } + + /// A distance, in the units chosen in Settings. + public static String distance(double meters) { + if (Prefs.UNITS_MILES.equals(Prefs.units())) { + double feet = meters * 3.28084; + if (feet < 900) { + return Lang.tr("{0} ft", String.valueOf(Math.round(feet / 50d) * 50)); + } + return Lang.tr("{0} mi", tenths(meters / 1609.344)); + } + if (meters < 950) { + return Lang.tr("{0} m", String.valueOf(Math.round(meters / 50d) * 50)); + } + return Lang.tr("{0} km", tenths(meters / 1000d)); + } + + /// `value` with one digit after the point. + public static String tenths(double value) { + long tenths = Math.round(value * 10d); + return (tenths / 10) + "." + (tenths % 10); + } + + public static String minutes(double seconds) { + return Lang.tr("{0} min", String.valueOf(Math.max(1L, Math.round(seconds / 60d)))); + } + + /// A ride's state in words. + public static String state(RideState state) { + if (RideState.REQUESTED == state || RideState.OFFERED == state) { + return "Finding a driver"; + } + if (RideState.ACCEPTED == state) { + return "Driver on the way"; + } + if (RideState.ARRIVED == state) { + return "Driver at pickup"; + } + if (RideState.IN_PROGRESS == state) { + return "On the trip"; + } + if (RideState.COMPLETED == state) { + return "Completed"; + } + if (RideState.CANCELLED_BY_RIDER == state) { + return "Cancelled by rider"; + } + if (RideState.CANCELLED_BY_DRIVER == state) { + return "Cancelled by driver"; + } + if (RideState.CANCELLED_BY_ADMIN == state) { + return "Cancelled by Wayline"; + } + if (RideState.NO_DRIVERS == state) { + return "No driver found"; + } + return ""; + } + + /// The style of the badge a state is shown in. + public static String stateBadge(RideState state) { + if (RideState.COMPLETED == state) { + return "WlBadgeGood"; + } + if (state != null && state.isActive()) { + return "WlBadgeWarn"; + } + return "WlBadgeBad"; + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/VerifyPhoneForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/VerifyPhoneForm.java new file mode 100644 index 00000000000..bc76c391f3c --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/VerifyPhoneForm.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.wayline.Telemetry; +import com.codenameone.examples.wayline.api.PhoneCodeDto; +import com.codenameone.examples.wayline.api.UserDto; +import com.codenameone.examples.wayline.net.Api; +import com.codenameone.examples.wayline.net.Net; +import com.codenameone.examples.wayline.net.Session; + +/// Proves the phone number is the user's, which the server asks for before the +/// first ride. +/// +/// The code is the server's: it makes it up, texts it, and is the only one who +/// checks it. The app never sees the code unless the server is a development +/// one with no SMS provider configured, which sends it back so the flow can be +/// tried without a phone. +public final class VerifyPhoneForm { + private VerifyPhoneForm() { + } + + /// @param after where to go once the number is verified, or the user + /// decides to leave it for later + public static void show(final Runnable after) { + final Form form = Ui.form("Verify your number", "VerifyPhone"); + UserDto user = Session.user(); + Container page = Ui.page(); + page.add(Ui.text(user == null || user.phone() == null + ? Lang.tr("We sent a six-digit code to your phone.") + : Lang.tr("We sent a six-digit code to {0}.", user.phone()), "WlText")); + final SpanLabel demo = Ui.text("", "WlNotice"); + demo.setName("demoCode"); + demo.setHidden(true); + page.add(demo); + final TextField code = Ui.field(page, "Code", "code", "000000", TextArea.NUMERIC); + final SpanLabel error = Ui.text("", "WlError"); + error.setName("error"); + final Button verify = Ui.primary("Verify", "submit", null); + verify.addActionListener(e -> { + PhoneCodeDto entered = new PhoneCodeDto(code.getText().trim()); + verify.setEnabled(false); + error.setText(""); + Api.account().verifyPhone(entered, Net.to(verified -> { + Session.setUser(verified); + Telemetry.phoneVerified(); + after.run(); + }, (status, message) -> { + verify.setEnabled(true); + error.setText(message); + Ui.refresh(form); + })); + }); + final Runnable send = () -> Api.account().startPhoneVerification(Net.to(challenge -> { + if (challenge.demoCode() != null && challenge.demoCode().length() > 0) { + demo.setText(Lang.tr("This is a development server, so no text was " + + "sent. Your code is {0}.", challenge.demoCode())); + demo.setHidden(false); + Ui.refresh(form); + } + }, (status, message) -> { + error.setText(message); + Ui.refresh(form); + })); + page.add(error).add(verify); + page.add(Ui.link("Send a new code", "resend", e -> send.run())); + page.add(Ui.link("Not now", "skip", e -> after.run())); + form.add(BorderLayout.CENTER, page); + form.show(); + send.run(); + } +} diff --git a/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/WelcomeForm.java b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/WelcomeForm.java new file mode 100644 index 00000000000..29f968314cc --- /dev/null +++ b/scripts/wayline/common/src/main/java/com/codenameone/examples/wayline/ui/WelcomeForm.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.wayline.AppConfig; + +/// What a signed-out user sees. +public final class WelcomeForm { + private WelcomeForm() { + } + + public static void show() { + final Form form = Ui.form("", "Welcome"); + form.getToolbar().hideToolbar(); + + Container hero = new Container(BoxLayout.y()); + hero.setUIID("WlHero"); + hero.setSafeArea(true); + Label icon = new Label("", "WlHeroIcon"); + Ui.icon(icon, FontImage.MATERIAL_NEAR_ME, 10f); + hero.add(icon); + hero.add(Ui.label("Wayline", "WlHeroTitle")); + hero.add(Ui.text("A ride when you need one, and a fair way to drive.", "WlHeroText")); + + Container actions = Ui.page(); + actions.setScrollableY(false); + actions.add(Ui.primary("Sign in", "signIn", e -> SignInForm.show(form))); + actions.add(Ui.secondary("Create an account", "register", e -> RegisterForm.show(form))); + if (AppConfig.serverSwitchable()) { + actions.add(Ui.link("Server address", "server", e -> ServerForm.show(form))); + } + + form.add(BorderLayout.CENTER, hero); + form.add(BorderLayout.SOUTH, actions); + form.show(); + } +} diff --git a/scripts/wayline/common/src/main/l10n/Bundle.properties b/scripts/wayline/common/src/main/l10n/Bundle.properties new file mode 100644 index 00000000000..c07a695976e --- /dev/null +++ b/scripts/wayline/common/src/main/l10n/Bundle.properties @@ -0,0 +1,3 @@ +# Workaround for the simulator AutoLocalizationBundle @im fabrication crash +# in Codename One <= 7.0.236. Once the framework fix ships, this file can be removed. +@im= diff --git a/scripts/wayline/common/src/main/l10n/Bundle_de.properties b/scripts/wayline/common/src/main/l10n/Bundle_de.properties new file mode 100644 index 00000000000..ab6d68ad7a6 --- /dev/null +++ b/scripts/wayline/common/src/main/l10n/Bundle_de.properties @@ -0,0 +1,532 @@ +# Wayline in German. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +@rtl=false +language.name=Deutsch + +Admin=Admin +Dashboard=\u00dcbersicht +Live\ map=Live-Karte +Rides=Fahrten +People=Personen +Operations=Betrieb +Wayline\ administration=Wayline-Verwaltung +Today=Heute +Manage=Verwalten +Where\ every\ car\ online\ is=Wo jedes Auto gerade ist +The\ most\ recent,\ in\ every\ state=Die neuesten, in jedem Status +Roles,\ and\ suspending\ an\ account=Rollen und Sperren von Konten +Rides\ today=Fahrten heute +Under\ way=Unterwegs +Online=Online +Offline=Offline +Fares=Einnahmen +Cars\ move\ as\ their\ drivers\ report\ in.=Die Autos bewegen sich, sobald die Fahrer sich melden. +No\ cars\ online=Keine Autos online +1\ car\ online=1 Auto online +{0}\ cars\ online={0} Autos online +Suspended=Gesperrt +Driver=Fahrer +Rider=Fahrgast +Verified=Best\u00e4tigt +Not\ verified=Nicht best\u00e4tigt +May\ go\ online\ and\ take\ rides=Darf online gehen und Fahrten annehmen +May\ see\ and\ change\ everything\ here=Darf hier alles sehen und \u00e4ndern +Signed\ out,\ and\ refused\ until\ lifted=Abgemeldet und abgewiesen, bis die Sperre aufgehoben wird +Access=Zugriff +Drive=Fahren +Your\ trips=Deine Fahrten +Earnings=Verdienst +The\ request\ went\ to\ another\ driver=Die Anfrage ging an einen anderen Fahrer +The\ rider\ cancelled=Der Fahrgast hat storniert +Today,\ {0}\ trips=Heute, {0} Fahrten +Looking\ for\ rides=Suche nach Fahrten +You\ are\ offline=Du bist offline +Trip\ complete\:\ {0}=Fahrt abgeschlossen: {0} +Stay\ close\ to\ busy\ streets;\ requests\ come\ to\ the\ nearest\ car.=Bleib in der N\u00e4he belebter Stra\u00dfen; Anfragen gehen an das n\u00e4chste Auto. +Go\ online\ to\ start\ taking\ rides.=Geh online, um Fahrten anzunehmen. +GO=LOS +This\ week=Diese Woche +Trips=Fahrten +All\ time=Insgesamt +Go\ offline=Offline gehen +Go\ online=Online gehen +Ride\ request=Fahrtanfrage +{0}\ away,\ {1}={0} entfernt, {1} +Accept=Annehmen +Decline=Ablehnen +Pick\ up\ {0}={0} abholen +Waiting\ for\ {0}=Warten auf {0} +On\ the\ trip=Auf der Fahrt +Drop-off=Ziel +Pickup=Abholung +I\ have\ arrived=Ich bin da +Start\ trip=Fahrt starten +Complete\ trip=Fahrt abschlie\u00dfen +Cancel\ ride=Fahrt stornieren +Quiet\ ride=Ruhige Fahrt +Accessible\ vehicle=Barrierefreies Fahrzeug +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=Der Server ist nicht erreichbar. Pr\u00fcfe deine Verbindung. +Please\ sign\ in\ again.=Bitte melde dich erneut an. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Dein Konto darf das nicht. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Zu viele Versuche. Warte kurz und versuche es erneut. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Zu viele Versuche. Warte eine Minute und versuche es erneut. +Something\ went\ wrong\ ({0}).\ Try\ again.=Etwas ist schiefgelaufen ({0}). Versuche es erneut. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=Diese E-Mail und dieses Passwort passen zu keinem Konto. +Signing\ in\ failed.\ Try\ again.=Anmeldung fehlgeschlagen. Versuche es erneut. +Where\ to?=Wohin? +Destination=Ziel +An\ address\ or\ a\ place=Eine Adresse oder ein Ort +Nothing\ found=Nichts gefunden +Current\ location=Aktueller Standort +Ride=Fahren lassen +Your\ rides=Deine Fahrten +Your\ driver\ is\ here=Dein Fahrer ist da +{0}\ to\ destination={0} bis zum Ziel +Pickup\ in\ {0}=Abholung in {0} +Hello,\ {0}=Hallo, {0} +Where\ are\ you\ going?=Wohin soll es gehen? +Verify\ your\ phone\ number\ to\ request\ a\ ride.=Best\u00e4tige deine Telefonnummer, um eine Fahrt anzufragen. +Verify\ now=Jetzt best\u00e4tigen +Home=Zuhause +Work=Arbeit +Working\ out\ the\ fare=Fahrpreis wird berechnet +No\ drivers\ nearby\ right\ now=Gerade keine Fahrer in der N\u00e4he +1\ driver\ nearby,\ {0}\ away=1 Fahrer in der N\u00e4he, {0} entfernt +{0}\ drivers\ nearby,\ the\ closest\ {1}\ away={0} Fahrer in der N\u00e4he, der n\u00e4chste {1} entfernt +Request\ ride=Fahrt anfragen +Cancel=Abbrechen +Payment=Zahlung +Ride\ preferences=Fahrtw\u00fcnsche +Cash=Bar +Card=Karte +Women\ drivers\ only=Nur Fahrerinnen +Pet\ friendly=Haustiere willkommen +To\ {0}=Nach {0} +Nobody\ could\ take\ the\ ride\ just\ now.\ You\ have\ not\ been\ charged.=Gerade konnte niemand die Fahrt \u00fcbernehmen. Dir wurde nichts berechnet. +Your\ driver\ had\ to\ cancel.\ You\ have\ not\ been\ charged.=Dein Fahrer musste stornieren. Dir wurde nichts berechnet. +The\ ride\ was\ cancelled.=Die Fahrt wurde storniert. +OK=OK +Call=Anrufen +Calls\ to\ your\ driver\ are\ not\ available\ on\ this\ server=Anrufe beim Fahrer sind auf diesem Server nicht verf\u00fcgbar +Message=Nachricht +Messages\ to\ your\ driver\ are\ not\ available\ on\ this\ server=Nachrichten an den Fahrer sind auf diesem Server nicht verf\u00fcgbar +Share=Teilen +I\ am\ on\ my\ way\ to\ {0}\ with\ {1}\ in\ a\ {2}\ ({3}).=Ich bin unterwegs nach {0} mit {1} in einem {2} ({3}). +You\ have\ arrived=Du bist angekommen +How\ was\ the\ ride\ with\ {0}?=Wie war die Fahrt mit {0}? +Thanks\ for\ the\ rating=Danke f\u00fcr die Bewertung +Not\ now=Nicht jetzt +Admin\ mode=Admin-Modus +Driver\ mode=Fahrermodus +Rider\ mode=Fahrgastmodus +Switch\ to=Wechseln zu +Riding=Mitfahren +Driving=Fahren +Administration=Verwaltung +Settings=Einstellungen +Sign\ out=Abmelden +No\ connection=Keine Verbindung +The\ server\ at\ {0}\ did\ not\ answer.=Der Server unter {0} hat nicht geantwortet. +Try\ again=Erneut versuchen +Server\ address=Serveradresse +Create\ an\ account=Konto erstellen +Name=Name +What\ drivers\ will\ call\ you=Wie Fahrer dich nennen +E-mail=E-Mail +Mobile\ number=Handynummer +Password=Passwort +At\ least\ 8\ characters=Mindestens 8 Zeichen +I\ want\ to\ drive=Ich m\u00f6chte fahren +Take\ ride\ requests\ and\ earn=Fahrten annehmen und verdienen +Car=Auto +Blue\ Toyota\ Prius=Blauer Toyota Prius +Licence\ plate=Kennzeichen +Create\ account=Konto erstellen +No\ rides\ yet=Noch keine Fahrten +{0}\ with\ {1}={0} mit {1} +Server=Server +The\ address\ of\ the\ Wayline\ server\ this\ app\ talks\ to.\ Changing\ it\ signs\ you\ out.=Die Adresse des Wayline-Servers, mit dem diese App spricht. Eine \u00c4nderung meldet dich ab. +Address=Adresse +Save=Speichern +Account=Konto +Needed\ before\ your\ first\ ride=Vor deiner ersten Fahrt erforderlich +Verify=Best\u00e4tigen +Any\ driver=Beliebiger Fahrer +Women\ only=Nur Frauen +Ask\ the\ driver\ to\ keep\ conversation\ short=Den Fahrer bitten, wenig zu reden +Only\ cars\ that\ take\ a\ wheelchair=Nur Autos, die einen Rollstuhl mitnehmen +Tell\ the\ driver\ an\ animal\ is\ coming=Dem Fahrer sagen, dass ein Tier mitkommt +Pay\ with=Bezahlen mit +Tip=Trinkgeld +None=Keins +Notifications=Benachrichtigungen +Ride\ updates=Fahrt-Updates +Driver\ found,\ arriving,\ at\ the\ door=Fahrer gefunden, unterwegs, vor der T\u00fcr +Receipts=Belege +After\ every\ ride=Nach jeder Fahrt +Offers=Angebote +Discounts\ and\ news=Rabatte und Neuigkeiten +Appearance=Darstellung +Language=Sprache +Theme=Design +System=System +Light=Hell +Dark=Dunkel +Distances=Entfernungen +Kilometres=Kilometer +Miles=Meilen +Privacy\ and\ safety=Datenschutz und Sicherheit +Share\ my\ trips=Meine Fahrten teilen +Emergency\ contact=Notfallkontakt +Their\ name=Name +Their\ phone\ number=Telefonnummer +About=Info +A\ sample\ ride\ service\ built\ with\ Codename\ One=Ein Beispiel-Fahrdienst, gebaut mit Codename One +Terms\ of\ service=Nutzungsbedingungen +Wayline\ is\ a\ sample.\ No\ real\ rides\ are\ booked,\ no\ real\ drivers\ are\ sent\ and\ no\ money\ changes\ hands.=Wayline ist ein Beispiel. Es werden keine echten Fahrten gebucht, keine echten Fahrer geschickt und es flie\u00dft kein Geld. +Privacy\ policy=Datenschutzerkl\u00e4rung +Your\ position\ is\ sent\ to\ the\ server\ you\ signed\ in\ to\ while\ you\ ride\ or\ drive,\ and\ to\ nobody\ else.\ Settings\ are\ kept\ on\ this\ device.=Dein Standort wird w\u00e4hrend der Fahrt an den Server gesendet, bei dem du angemeldet bist, und an niemanden sonst. Einstellungen bleiben auf diesem Ger\u00e4t. +Sign\ in=Anmelden +Enter\ your\ e-mail\ and\ password.=Gib deine E-Mail und dein Passwort ein. +{0}\ ft={0} ft +{0}\ mi={0} mi +{0}\ m={0} m +{0}\ km={0} km +{0}\ min={0} Min. +Finding\ a\ driver=Fahrer wird gesucht +Driver\ on\ the\ way=Fahrer unterwegs +Driver\ at\ pickup=Fahrer am Abholort +Completed=Abgeschlossen +Cancelled\ by\ rider=Vom Fahrgast storniert +Cancelled\ by\ driver=Vom Fahrer storniert +No\ driver\ found=Kein Fahrer gefunden +Verify\ your\ number=Nummer best\u00e4tigen +We\ sent\ a\ six-digit\ code\ to\ your\ phone.=Wir haben einen sechsstelligen Code an dein Telefon gesendet. +We\ sent\ a\ six-digit\ code\ to\ {0}.=Wir haben einen sechsstelligen Code an {0} gesendet. +Code=Code +This\ is\ a\ development\ server,\ so\ no\ text\ was\ sent.\ Your\ code\ is\ {0}.=Dies ist ein Entwicklungsserver, daher wurde keine SMS gesendet. Dein Code ist {0}. +Send\ a\ new\ code=Neuen Code senden +A\ ride\ when\ you\ need\ one,\ and\ a\ fair\ way\ to\ drive.=Eine Fahrt, wenn du sie brauchst, und eine faire Art zu fahren. + +# Driving applications, payments, the admin console and the account. +1\ application\ to\ review=1 Bewerbung zu pr\u00fcfen +{0}\ applications\ to\ review={0} Bewerbungen zu pr\u00fcfen +1\ flagged\ account=1 markiertes Konto +{0}\ flagged\ accounts={0} markierte Konten +7\ days=7 Tage +14\ days=14 Tage +30\ days=30 Tage +A\ car\ with\ four\ doors=Ein Auto mit vier T\u00fcren +A\ driving\ licence=Ein F\u00fchrerschein +A\ flag\ marks\ the\ account\ for\ the\ admins\ and\ changes\ nothing\ for\ its\ owner.\ A\ block\ signs\ it\ out\ and\ refuses\ it\ until\ lifted.=Eine Markierung macht die Admins auf das Konto aufmerksam und \u00e4ndert f\u00fcr den Inhaber nichts. Eine Sperre meldet es ab und weist es ab, bis sie aufgehoben wird. +A\ passport\ or\ an\ identity\ card=Ein Reisepass oder Personalausweis +A\ photo\ of\ you=Ein Foto von dir +A\ ramp\ or\ a\ lift,\ and\ room\ to\ secure\ the\ chair=Rampe oder Lift und Platz, um den Stuhl zu sichern +A\ ride\ shows\ up\ here\ as\ soon\ as\ it\ is\ asked\ for.=Eine Fahrt erscheint hier, sobald sie angefragt wird. +A\ ride\ shows\ up\ here\ when\ it\ is\ paid\ for.=Eine Fahrt erscheint hier, sobald sie bezahlt ist. +A\ sentence\ the\ record\ will\ keep=Ein Satz, der im Protokoll bleibt +About\ ten\ minutes=Etwa zehn Minuten +About\ you=\u00dcber dich +Accepted=Akzeptiert +Account\ ending\ {0}=Konto endet auf {0} +Account\ holder=Kontoinhaber +Account\ number=Kontonummer +Add\ a\ bank\ account=Bankkonto hinzuf\u00fcgen +Add\ a\ card=Karte hinzuf\u00fcgen +Add\ a\ tip=Trinkgeld geben +Added=Hinzugef\u00fcgt +Admins=Admins +All=Alle +All\ of\ it\ goes\ to\ your\ driver.=Alles geht an deinen Fahrer. +All\ of\ it\ goes\ to\ {0}.=Alles geht an {0}. +Another\ amount=Anderer Betrag +Applicant=Bewerber +Application=Bewerbung +Application\ received=Bewerbung eingegangen +Application\ sent\ back=Bewerbung zur\u00fcckgegeben +Application\ sent.\ We\ will\ be\ in\ touch.=Bewerbung gesendet. Wir melden uns. +Application\ under\ review=Bewerbung wird gepr\u00fcft +Applications=Bewerbungen +Approve=Genehmigen +Approved=Genehmigt +Approved\ to\ drive=Zum Fahren zugelassen +As\ it\ is\ written\ on\ your\ licence.\ Riders\ see\ only\ your\ first\ name.=Wie auf deinem F\u00fchrerschein. Fahrg\u00e4ste sehen nur deinen Vornamen. +Average\ fare=Durchschnittspreis +Average\ wait=Durchschnittliche Wartezeit +Back=Zur\u00fcck +Bank=Bank +Bank\ account=Bankkonto +Base\ fare=Grundpreis +Block=Sperren +Block\ account=Konto sperren +Blocked=Gesperrt +Blue=Blau +By\ kind\ of\ ride=Nach Fahrtart +By\ outcome=Nach Ergebnis +By\ submitting\ you\ confirm\ that\ all\ of\ this\ is\ true\ and\ that\ the\ documents\ are\ your\ own.=Mit dem Absenden best\u00e4tigst du, dass alles stimmt und die Dokumente deine eigenen sind. +Calling\ is\ not\ available\ on\ this\ device=Anrufe sind auf diesem Ger\u00e4t nicht m\u00f6glich +Cancel\ this\ ride=Diese Fahrt stornieren +Cancelled=Storniert +Cancelled\ by\ Wayline=Von Wayline storniert +Card\ number=Kartennummer +Cash\ out=Auszahlen +Change\ password=Passwort \u00e4ndern +Change\ the\ password\ you\ sign\ in\ with=\u00c4ndere das Passwort f\u00fcr die Anmeldung +Changes\ needed=\u00c4nderungen n\u00f6tig +Choose\ a\ ride=Fahrt ausw\u00e4hlen +Choose\ an\ amount\ for\ the\ tip=W\u00e4hle einen Betrag f\u00fcr das Trinkgeld +Choose\ how\ to\ pay\ for\ this\ ride.=W\u00e4hle, wie du diese Fahrt bezahlst. +Collect\ {0}\ in\ cash={0} in bar kassieren +Colour=Farbe +Comfort=Comfort +Comfort\ is\ a\ newer,\ roomier\ car.\ XL\ seats\ six\ or\ more.=Comfort ist ein neueres, ger\u00e4umigeres Auto. XL hat sechs oder mehr Pl\u00e4tze. +Commission,\ %=Provision, % +Contact=Kontakt +Continue=Weiter +Continue\ application=Bewerbung fortsetzen +Current\ password=Aktuelles Passwort +Date\ of\ birth=Geburtsdatum +Default=Standard +Delete\ account=Konto l\u00f6schen +Delete\ my\ account=Mein Konto l\u00f6schen +Delete\ your\ account?=Dein Konto l\u00f6schen? +Discount=Rabatt +Distance=Strecke +Documents=Dokumente +Documents\ checked=Dokumente gepr\u00fcft +Done=Fertig +Draft=Entwurf +Drive\ with\ Wayline=Mit Wayline fahren +Drivers=Fahrer +Drivers\ apply\ after\ signing\ up\:\ a\ few\ details\ about\ you\ and\ your\ car,\ and\ five\ photos.\ You\ can\ ride\ in\ the\ meantime.=Fahrer bewerben sich nach der Registrierung: ein paar Angaben zu dir und deinem Auto und f\u00fcnf Fotos. Bis dahin kannst du mitfahren. +Drivers\ online=Fahrer online +Driving\ licence=F\u00fchrerschein +Earn\ on\ your\ own\ schedule=Verdiene nach deinem Zeitplan +Earned\ driving=Als Fahrer verdient +Every\ application\ has\ had\ its\ answer.=Jede Bewerbung wurde beantwortet. +Everyone=Alle +Expires=L\u00e4uft ab +Expires\ {0}=L\u00e4uft ab {0} +Fare=Fahrpreis +Finish\ your\ application=Schlie\u00dfe deine Bewerbung ab +Fix\ and\ resubmit=Korrigieren und erneut senden +Flag=Markieren +Flag\ account=Konto markieren +Flag\ removed=Markierung entfernt +Flagged=Markiert +Gender=Geschlecht +Have\ a\ last\ look.\ You\ can\ change\ anything\ until\ it\ is\ handed\ in.=Ein letzter Blick. Bis zum Absenden kannst du alles \u00e4ndern. +Hi,\ this\ is\ your\ Wayline\ driver.=Hallo, hier ist dein Wayline-Fahrer. +Hi,\ this\ is\ your\ Wayline\ rider.=Hallo, hier ist dein Wayline-Fahrgast. +History=Verlauf +I\ have\ finished=Ich bin fertig +I\ want\ to=Ich m\u00f6chte +In\ good\ standing=In Ordnung +Insurance=Versicherung +Its\ number,\ its\ expiry\ date\ and\ a\ photo\ of\ it=Nummer, Ablaufdatum und ein Foto davon +Keep\ my\ account=Mein Konto behalten +Kind\ of\ ride=Fahrtart +Legal\ name=Rechtlicher Name +Licence=F\u00fchrerschein +Licence\ expires=F\u00fchrerschein g\u00fcltig bis +Licence\ number=F\u00fchrerscheinnummer +Make=Marke +Man=Mann +Messages\ are\ not\ available\ on\ this\ device=Nachrichten sind auf diesem Ger\u00e4t nicht m\u00f6glich +Minimum\ fare=Mindestpreis +Missing=Fehlt +Model=Modell +Moderation=Moderation +Most\ of\ it\ is\ taking\ five\ photos=Das meiste sind f\u00fcnf Fotos +Multipliers=Multiplikatoren +Name\ an\ emergency\ contact\ in\ your\ profile\ first=Gib zuerst einen Notfallkontakt im Profil an +Name\ on\ the\ card=Name auf der Karte +Name,\ emergency\ contact,\ home\ and\ work=Name, Notfallkontakt, Zuhause und Arbeit +Needed=Fehlt noch +New\ password=Neues Passwort +New\ password,\ again=Neues Passwort, noch einmal +No=Nein +No\ payments\ yet=Noch keine Zahlungen +No\ rides\ here=Keine Fahrten hier +Nobody\ matches=Keine Treffer +Non-binary=Nicht-bin\u00e4r +Not\ provided=Nicht eingereicht +Not\ set=Nicht festgelegt +Not\ started=Nicht begonnen +Nothing\ has\ been\ done\ to\ this\ account.=Mit diesem Konto ist nichts geschehen. +Nothing\ has\ been\ paid\ out\ yet.=Es wurde noch nichts ausgezahlt. +Nothing\ here=Nichts hier +Open\ the\ secure\ page=Sichere Seite \u00f6ffnen +Paid=Bezahlt +Paid\ in\ cash=Bar bezahlt +Paid\ with=Bezahlt mit +Payment\ failed=Zahlung fehlgeschlagen +Payment\ pending=Zahlung ausstehend +Payments=Zahlungen +Payouts=Auszahlungen +People\ who\ asked\ to\ drive,\ and\ their\ papers=Wer fahren m\u00f6chte, mit Unterlagen +Per\ kilometre=Pro Kilometer +Per\ minute=Pro Minute +Photo\ ID=Lichtbildausweis +Photograph\ each\ one\ flat,\ with\ all\ four\ corners\ in\ the\ picture.=Fotografiere jedes flach, mit allen vier Ecken im Bild. +Photos\ added=Fotos hinzugef\u00fcgt +Pricing=Preise +Profile=Profil +Provided=Eingereicht +Rather\ not\ say=Keine Angabe +Rating=Bewertung +Read\ what\ was\ missing,\ put\ it\ right,\ and\ send\ the\ application\ again.=Lies, was fehlte, korrigiere es und sende die Bewerbung erneut. +Ready\ to\ cash\ out=Bereit zur Auszahlung +Reason=Grund +Receipt=Beleg +Refund=Erstatten +Refund\ this\ ride=Diese Fahrt erstatten +Refunded=Erstattet +Registered\ and\ insured,\ with\ the\ papers\ to\ show\ it=Zugelassen und versichert, mit Nachweisen +Rejected=Abgelehnt +Remove\ flag=Markierung entfernen +Remove\ this\ card=Diese Karte entfernen +Request\ {0}={0} anfragen +Requests\ accepted=Angenommene Anfragen +Requests\ by\ hour=Anfragen pro Stunde +Retake=Neu aufnehmen +Review\ and\ submit=Pr\u00fcfen und absenden +Reviewed=Gepr\u00fcft +Reviewed\ by=Gepr\u00fcft von +Riders=Fahrg\u00e4ste +Riders\ who\ ask\ for\ a\ woman\ at\ the\ wheel\ are\ offered\ to\ drivers\ who\ say\ they\ are\ one.=Fahrg\u00e4ste, die eine Frau am Steuer w\u00fcnschen, werden Fahrerinnen angeboten, die das angeben. +Right\ now=Gerade jetzt +Roles\ changed=Rollen ge\u00e4ndert +Roles,\ flags,\ and\ blocking\ an\ account=Rollen, Markierungen und Kontosperren +Save\ card=Karte speichern +Save\ prices=Preise speichern +Saved\ places=Gespeicherte Orte +Say\ why,\ in\ a\ few\ words.=Sag in wenigen Worten, warum. +Search\ by\ name\ or\ e-mail=Nach Name oder E-Mail suchen +Seats\ for\ riders=Pl\u00e4tze f\u00fcr Fahrg\u00e4ste +Security\ code=Sicherheitscode +Send\ back=Zur\u00fcckgeben +Send\ tip=Trinkgeld senden +Saves\ {0}'s\ number\ with\ your\ account\ as\ your\ trip\ contact.\ No\ message\ is\ sent.=Speichert die Nummer von {0} in deinem Konto als Fahrtkontakt. Es wird keine Nachricht gesendet. +Sent\ back=Zur\u00fcckgegeben +Service\ fee=Servicegeb\u00fchr +Service\ fee,\ %=Servicegeb\u00fchr, % +Spent\ riding=F\u00fcr Fahrten ausgegeben +Standard=Standard +Start\ application=Bewerbung starten +Step\ {0}\ of\ {1}=Schritt {0} von {1} +Submit\ application=Bewerbung absenden +Submitted=Eingereicht +Surge,\ while\ demand\ is\ high=Aufschlag bei hoher Nachfrage +Takes\ a\ wheelchair=Rollstuhlgerecht +Tap\ a\ card\ to\ make\ it\ the\ one\ that\ pays\ unless\ you\ say\ otherwise.=Tippe auf eine Karte, damit sie zahlt, sofern du nichts anderes w\u00e4hlst. +Tap\ to\ open=Zum \u00d6ffnen tippen +Tell\ us\ about\ you\ and\ your\ car,\ add\ five\ photos,\ and\ we\ take\ it\ from\ there.=Erz\u00e4hl uns von dir und deinem Auto, f\u00fcge f\u00fcnf Fotos hinzu, den Rest machen wir. +Thank\ you.\ Your\ tip\ was\ sent.=Danke. Dein Trinkgeld wurde gesendet. +That\ is\ not\ a\ card\ number.\ Check\ the\ digits.=Das ist keine Kartennummer. Pr\u00fcfe die Ziffern. +That\ picture\ could\ not\ be\ read=Das Bild konnte nicht gelesen werden +That\ place\ could\ not\ be\ found.\ Choose\ it\ again.=Der Ort wurde nicht gefunden. W\u00e4hle ihn erneut. +The\ busy\ hours,\ in\ UTC=Die Sto\u00dfzeiten, in UTC +The\ card\ was\ declined.\ Choose\ another\ way\ to\ pay\ in\ your\ wallet.=Die Karte wurde abgelehnt. W\u00e4hle im Wallet eine andere Zahlungsart. +The\ fare,\ in\ {0}=Der Fahrpreis, in {0} +The\ fee\ is\ added\ to\ the\ fare\ and\ paid\ by\ the\ rider.\ The\ commission\ is\ kept\ from\ the\ fare;\ the\ rest\ is\ the\ driver's.=Die Geb\u00fchr kommt zum Fahrpreis hinzu und wird vom Fahrgast bezahlt. Die Provision wird vom Fahrpreis einbehalten; der Rest geh\u00f6rt dem Fahrer. +The\ last\ two\ weeks=Die letzten zwei Wochen +The\ new\ prices\ are\ in\ force=Die neuen Preise gelten +The\ number\ is\ on\ the\ front\ of\ the\ card.=Die Nummer steht auf der Vorderseite der Karte. +The\ page\ with\ the\ policy's\ dates\ on\ it=Die Seite mit den Daten der Police +The\ paper\ that\ says\ the\ car\ is\ yours=Das Papier, das dich als Halter ausweist +The\ payment\ provider's\ own\ page\ opens\ in\ your\ browser.\ Wayline\ never\ sees\ the\ number.\ Come\ back\ here\ when\ you\ have\ finished.=Die Seite des Zahlungsanbieters \u00f6ffnet sich im Browser. Wayline sieht die Nummer nie. Komm hierher zur\u00fcck, wenn du fertig bist. +The\ ride\ was\ refunded=Die Fahrt wurde erstattet +Refund\ pending=Erstattung ausstehend +Refund\ failed=Erstattung fehlgeschlagen +The\ refund\ is\ pending=Die Erstattung ist ausstehend +The\ rider\ and\ the\ driver\ are\ both\ told,\ and\ nobody\ is\ charged.=Fahrgast und Fahrer werden informiert, niemand wird belastet. +The\ security\ code\ is\ the\ three\ or\ four\ digits\ on\ the\ card.=Der Sicherheitscode sind die drei oder vier Ziffern auf der Karte. +The\ service's\ share=Der Anteil des Dienstes +The\ side\ with\ your\ photo\ on\ it=Die Seite mit deinem Foto +The\ two\ new\ passwords\ are\ not\ the\ same.=Die beiden neuen Passw\u00f6rter stimmen nicht \u00fcberein. +This\ device\ cannot\ prepare\ a\ picture\ to\ send=Dieses Ger\u00e4t kann kein Bild zum Senden vorbereiten +This\ month=Diesen Monat +This\ picture\ could\ not\ be\ opened=Dieses Bild konnte nicht ge\u00f6ffnet werden +This\ screen\ becomes\ your\ driver's\ home\ the\ moment\ you\ are\ approved=Dieser Bildschirm wird dein Fahrer-Start, sobald du zugelassen bist +This\ server\ takes\ no\ real\ payments.\ Use\ the\ test\ card\ 4242\ 4242\ 4242\ 4242\ with\ any\ date\ ahead\ and\ any\ three\ digits.=Dieser Server nimmt keine echten Zahlungen an. Nutze die Testkarte 4242 4242 4242 4242 mit einem beliebigen k\u00fcnftigen Datum und drei beliebigen Ziffern. +Time=Zeit +Tip\ offered\ first=Zuerst angebotenes Trinkgeld +Tips=Trinkgelder +Top\ drivers=Top-Fahrer +Total=Gesamt +Trends=Trends +Trip=Fahrt +Type\ the\ account\ number.=Gib die Kontonummer ein. +Unblock=Entsperren +Unblocked=Entsperrt +Under\ review=In Pr\u00fcfung +Usually\ within\ two\ working\ days=Meist innerhalb von zwei Werktagen +Vehicle=Fahrzeug +Vehicle\ registration=Fahrzeugschein +View\ receipt=Beleg ansehen +Waiting=Wartend +Wallet=Wallet +We\ could\ not\ approve\ this\ yet=Wir konnten das noch nicht genehmigen +We\ have\ everything\ we\ asked\ for.\ Someone\ on\ our\ team\ is\ reading\ it.=Wir haben alles, was wir brauchen. Jemand aus dem Team liest es gerade. +What\ a\ ride\ costs,\ and\ the\ service's\ share=Was eine Fahrt kostet, und der Anteil des Dienstes +What\ happens\ next=Wie es weitergeht +What\ the\ reviewer\ said=Was der Pr\u00fcfer sagte +What\ was\ charged,\ and\ refunds=Was berechnet wurde, und Erstattungen +What\ you\ will\ need=Was du brauchst +Where\ your\ earnings\ are\ sent=Wohin deine Einnahmen gehen +Why\ does\ {0}\ need\ the\ admins'\ attention?=Warum braucht {0} die Aufmerksamkeit der Admins? +Woman=Frau +Write\ the\ expiry\ date\ as\ month\ and\ year\:\ 04/28.=Gib das Ablaufdatum als Monat und Jahr ein: 04/28. +Year=Baujahr +Yes=Ja +You=Du +You\ go\ online=Du gehst online +Your\ application=Deine Bewerbung +Your\ car=Dein Auto +Your\ card\ goes\ straight\ to\ the\ bank=Deine Karte geht direkt an die Bank +Your\ cards,\ and\ which\ of\ them\ pays\ by\ default=Deine Karten und welche standardm\u00e4\u00dfig zahlt +Your\ documents=Deine Dokumente +Your\ driver\ cannot\ be\ messaged\ right\ now=Dein Fahrer ist gerade nicht per Nachricht erreichbar +Your\ face,\ in\ good\ light,\ no\ sunglasses=Dein Gesicht, bei gutem Licht, ohne Sonnenbrille +Your\ licence=Dein F\u00fchrerschein +Your\ password\ was\ changed=Dein Passwort wurde ge\u00e4ndert +Your\ position\ is\ sent\ to\ the\ server\ you\ signed\ in\ to\ while\ you\ ride\ or\ drive,\ and\ to\ nobody\ else.\ Your\ settings\ are\ kept\ with\ your\ account\ on\ that\ server.=Dein Standort wird w\u00e4hrend der Fahrt an den Server gesendet, bei dem du angemeldet bist, und an niemanden sonst. Deine Einstellungen liegen bei deinem Konto auf diesem Server. +Your\ rider\ cannot\ be\ messaged\ right\ now=Dein Fahrgast ist gerade nicht per Nachricht erreichbar +Your\ sign-in,\ your\ profile,\ your\ saved\ cards\ and\ your\ settings\ are\ removed\ for\ good.\ Rides\ you\ took\ stay\ in\ the\ service's\ records\ without\ your\ name.=Deine Anmeldung, dein Profil, deine gespeicherten Karten und deine Einstellungen werden endg\u00fcltig entfernt. Deine Fahrten bleiben ohne deinen Namen in den Aufzeichnungen des Dienstes. +{0}\ away={0} entfernt +{0}\ can\ now\ drive={0} darf jetzt fahren +{0}\ completed,\ {1}\ cancelled={0} abgeschlossen, {1} storniert +{0}\ days\ ago=vor {0} Tagen +{0}\ goes\ back\ to\ the\ card\ it\ came\ from.={0} geht zur\u00fcck auf die Karte, von der es kam. +{0}\ is\ on\ its\ way\ to\ your\ bank={0} ist auf dem Weg zu deiner Bank +{0}\ is\ signed\ out\ everywhere\ and\ cannot\ sign\ in\ again\ until\ the\ block\ is\ lifted.={0} wird \u00fcberall abgemeldet und kann sich erst wieder anmelden, wenn die Sperre aufgehoben ist. +{0}\ new\ accounts={0} neue Konten +{0}\ of\ 5\ documents\ added.\ Pick\ up\ where\ you\ left\ off.={0} von 5 Dokumenten hinzugef\u00fcgt. Mach dort weiter, wo du aufgeh\u00f6rt hast. +{0}\ rides={0} Fahrten +{0}\ s={0} s +{0}\ sees\ what\ you\ write,\ and\ can\ apply\ again\ once\ it\ is\ put\ right.={0} sieht, was du schreibst, und kann sich erneut bewerben, sobald es korrigiert ist. +Fill\ in\ everything\ on\ this\ step\ to\ continue.=F\u00fclle in diesem Schritt alles aus, um fortzufahren. +None\ nearby=Keiner in der N\u00e4he +Pay\ the\ driver\ directly=Zahle direkt beim Fahrer +Camera=Kamera +Gallery=Galerie +Fares\ collected=Eingenommene Fahrpreise +Takes\ pets=Nimmt Haustiere mit +Riders\ travelling\ with\ a\ pet\ are\ offered\ to\ you=Fahrg\u00e4ste mit Haustier werden dir angeboten +Go=Gehe zu +Nothing\ selected=Nichts ausgew\u00e4hlt +Pick\ a\ row\ from\ the\ list\ to\ see\ it\ here.=W\u00e4hlen Sie eine Zeile aus der Liste, um sie hier zu sehen. +Usage\ statistics=Nutzungsstatistiken +Share\ which\ screens\ and\ features\ are\ used.\ Never\ your\ name,\ places\ or\ payment\ details.=Teile, welche Bildschirme und Funktionen genutzt werden. Niemals deinen Namen, deine Orte oder deine Zahlungsdaten. +Help\ improve\ Wayline=Hilf mit, Wayline zu verbessern +Wayline\ can\ count\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ so\ that\ we\ can\ see\ what\ works\ and\ fix\ what\ does\ not.=Wayline kann z\u00e4hlen, welche Bildschirme ge\u00f6ffnet und welche Funktionen genutzt werden, damit wir sehen, was funktioniert, und beheben k\u00f6nnen, was nicht funktioniert. +No\ personal\ details=Keine pers\u00f6nlichen Daten +Never\ your\ name,\ e-mail,\ phone\ number,\ places\ or\ card\ details=Niemals dein Name, deine E-Mail-Adresse, deine Telefonnummer, deine Orte oder deine Kartendaten +Turn\ it\ on\ or\ off\ at\ any\ time\ in\ Settings=Jederzeit in den Einstellungen ein- oder ausschalten +Share\ usage\ statistics=Nutzungsstatistiken teilen +With\ usage\ statistics\ turned\ on,\ the\ app\ also\ counts\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ and\ reports\ that\ to\ Codename\ One's\ analytics\ service\ under\ a\ made-up\ id\ for\ this\ device.\ Your\ name,\ e-mail,\ phone\ number,\ places\ and\ card\ details\ are\ never\ part\ of\ it,\ and\ turning\ it\ off\ in\ Settings\ stops\ it.=Sind die Nutzungsstatistiken eingeschaltet, z\u00e4hlt die App au\u00dferdem, welche Bildschirme ge\u00f6ffnet und welche Funktionen genutzt werden, und meldet das unter einer f\u00fcr dieses Ger\u00e4t erfundenen Kennung an den Analysedienst von Codename One. Dein Name, deine E-Mail-Adresse, deine Telefonnummer, deine Orte und deine Kartendaten geh\u00f6ren nie dazu, und wenn du sie in den Einstellungen ausschaltest, h\u00f6rt es auf. +Location\ is\ turned\ off.\ Try\ again=Standort ist deaktiviert. Erneut versuchen +Can't\ find\ your\ location.\ Try\ again=Standort nicht gefunden. Erneut versuchen +Still\ looking\ for\ your\ location.\ Try\ again=Standort wird noch gesucht. Erneut versuchen +Looking\ for\ your\ location...=Standort wird gesucht... diff --git a/scripts/wayline/common/src/main/l10n/Bundle_en.properties b/scripts/wayline/common/src/main/l10n/Bundle_en.properties new file mode 100644 index 00000000000..c4f215d6b99 --- /dev/null +++ b/scripts/wayline/common/src/main/l10n/Bundle_en.properties @@ -0,0 +1,532 @@ +# Wayline in English. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +@rtl=false +language.name=English + +Admin=Admin +Dashboard=Dashboard +Live\ map=Live map +Rides=Rides +People=People +Operations=Operations +Wayline\ administration=Wayline administration +Today=Today +Manage=Manage +Where\ every\ car\ online\ is=Where every car online is +The\ most\ recent,\ in\ every\ state=The most recent, in every state +Roles,\ and\ suspending\ an\ account=Roles, and suspending an account +Rides\ today=Rides today +Under\ way=Under way +Online=Online +Offline=Offline +Fares=Fares +Cars\ move\ as\ their\ drivers\ report\ in.=Cars move as their drivers report in. +No\ cars\ online=No cars online +1\ car\ online=1 car online +{0}\ cars\ online={0} cars online +Suspended=Suspended +Driver=Driver +Rider=Rider +Verified=Verified +Not\ verified=Not verified +May\ go\ online\ and\ take\ rides=May go online and take rides +May\ see\ and\ change\ everything\ here=May see and change everything here +Signed\ out,\ and\ refused\ until\ lifted=Signed out, and refused until lifted +Access=Access +Drive=Drive +Your\ trips=Your trips +Earnings=Earnings +The\ request\ went\ to\ another\ driver=The request went to another driver +The\ rider\ cancelled=The rider cancelled +Today,\ {0}\ trips=Today, {0} trips +Looking\ for\ rides=Looking for rides +You\ are\ offline=You are offline +Trip\ complete\:\ {0}=Trip complete: {0} +Stay\ close\ to\ busy\ streets;\ requests\ come\ to\ the\ nearest\ car.=Stay close to busy streets; requests come to the nearest car. +Go\ online\ to\ start\ taking\ rides.=Go online to start taking rides. +GO=GO +This\ week=This week +Trips=Trips +All\ time=All time +Go\ offline=Go offline +Go\ online=Go online +Ride\ request=Ride request +{0}\ away,\ {1}={0} away, {1} +Accept=Accept +Decline=Decline +Pick\ up\ {0}=Pick up {0} +Waiting\ for\ {0}=Waiting for {0} +On\ the\ trip=On the trip +Drop-off=Drop-off +Pickup=Pickup +I\ have\ arrived=I have arrived +Start\ trip=Start trip +Complete\ trip=Complete trip +Cancel\ ride=Cancel ride +Quiet\ ride=Quiet ride +Accessible\ vehicle=Accessible vehicle +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=Could not reach the server. Check your connection. +Please\ sign\ in\ again.=Please sign in again. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Your account is not allowed to do that. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Too many attempts. Wait a moment and try again. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Too many attempts. Wait a minute and try again. +Something\ went\ wrong\ ({0}).\ Try\ again.=Something went wrong ({0}). Try again. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=That e-mail and password do not match an account. +Signing\ in\ failed.\ Try\ again.=Signing in failed. Try again. +Where\ to?=Where to? +Destination=Destination +An\ address\ or\ a\ place=An address or a place +Nothing\ found=Nothing found +Current\ location=Current location +Ride=Ride +Your\ rides=Your rides +Your\ driver\ is\ here=Your driver is here +{0}\ to\ destination={0} to destination +Pickup\ in\ {0}=Pickup in {0} +Hello,\ {0}=Hello, {0} +Where\ are\ you\ going?=Where are you going? +Verify\ your\ phone\ number\ to\ request\ a\ ride.=Verify your phone number to request a ride. +Verify\ now=Verify now +Home=Home +Work=Work +Working\ out\ the\ fare=Working out the fare +No\ drivers\ nearby\ right\ now=No drivers nearby right now +1\ driver\ nearby,\ {0}\ away=1 driver nearby, {0} away +{0}\ drivers\ nearby,\ the\ closest\ {1}\ away={0} drivers nearby, the closest {1} away +Request\ ride=Request ride +Cancel=Cancel +Payment=Payment +Ride\ preferences=Ride preferences +Cash=Cash +Card=Card +Women\ drivers\ only=Women drivers only +Pet\ friendly=Pet friendly +To\ {0}=To {0} +Nobody\ could\ take\ the\ ride\ just\ now.\ You\ have\ not\ been\ charged.=Nobody could take the ride just now. You have not been charged. +Your\ driver\ had\ to\ cancel.\ You\ have\ not\ been\ charged.=Your driver had to cancel. You have not been charged. +The\ ride\ was\ cancelled.=The ride was cancelled. +OK=OK +Call=Call +Calls\ to\ your\ driver\ are\ not\ available\ on\ this\ server=Calls to your driver are not available on this server +Message=Message +Messages\ to\ your\ driver\ are\ not\ available\ on\ this\ server=Messages to your driver are not available on this server +Share=Share +I\ am\ on\ my\ way\ to\ {0}\ with\ {1}\ in\ a\ {2}\ ({3}).=I am on my way to {0} with {1} in a {2} ({3}). +You\ have\ arrived=You have arrived +How\ was\ the\ ride\ with\ {0}?=How was the ride with {0}? +Thanks\ for\ the\ rating=Thanks for the rating +Not\ now=Not now +Admin\ mode=Admin mode +Driver\ mode=Driver mode +Rider\ mode=Rider mode +Switch\ to=Switch to +Riding=Riding +Driving=Driving +Administration=Administration +Settings=Settings +Sign\ out=Sign out +No\ connection=No connection +The\ server\ at\ {0}\ did\ not\ answer.=The server at {0} did not answer. +Try\ again=Try again +Server\ address=Server address +Create\ an\ account=Create an account +Name=Name +What\ drivers\ will\ call\ you=What drivers will call you +E-mail=E-mail +Mobile\ number=Mobile number +Password=Password +At\ least\ 8\ characters=At least 8 characters +I\ want\ to\ drive=I want to drive +Take\ ride\ requests\ and\ earn=Take ride requests and earn +Car=Car +Blue\ Toyota\ Prius=Blue Toyota Prius +Licence\ plate=Licence plate +Create\ account=Create account +No\ rides\ yet=No rides yet +{0}\ with\ {1}={0} with {1} +Server=Server +The\ address\ of\ the\ Wayline\ server\ this\ app\ talks\ to.\ Changing\ it\ signs\ you\ out.=The address of the Wayline server this app talks to. Changing it signs you out. +Address=Address +Save=Save +Account=Account +Needed\ before\ your\ first\ ride=Needed before your first ride +Verify=Verify +Any\ driver=Any driver +Women\ only=Women only +Ask\ the\ driver\ to\ keep\ conversation\ short=Ask the driver to keep conversation short +Only\ cars\ that\ take\ a\ wheelchair=Only cars that take a wheelchair +Tell\ the\ driver\ an\ animal\ is\ coming=Tell the driver an animal is coming +Pay\ with=Pay with +Tip=Tip +None=None +Notifications=Notifications +Ride\ updates=Ride updates +Driver\ found,\ arriving,\ at\ the\ door=Driver found, arriving, at the door +Receipts=Receipts +After\ every\ ride=After every ride +Offers=Offers +Discounts\ and\ news=Discounts and news +Appearance=Appearance +Language=Language +Theme=Theme +System=System +Light=Light +Dark=Dark +Distances=Distances +Kilometres=Kilometres +Miles=Miles +Privacy\ and\ safety=Privacy and safety +Share\ my\ trips=Share my trips +Emergency\ contact=Emergency contact +Their\ name=Their name +Their\ phone\ number=Their phone number +About=About +A\ sample\ ride\ service\ built\ with\ Codename\ One=A sample ride service built with Codename One +Terms\ of\ service=Terms of service +Wayline\ is\ a\ sample.\ No\ real\ rides\ are\ booked,\ no\ real\ drivers\ are\ sent\ and\ no\ money\ changes\ hands.=Wayline is a sample. No real rides are booked, no real drivers are sent and no money changes hands. +Privacy\ policy=Privacy policy +Your\ position\ is\ sent\ to\ the\ server\ you\ signed\ in\ to\ while\ you\ ride\ or\ drive,\ and\ to\ nobody\ else.\ Settings\ are\ kept\ on\ this\ device.=Your position is sent to the server you signed in to while you ride or drive, and to nobody else. Settings are kept on this device. +Sign\ in=Sign in +Enter\ your\ e-mail\ and\ password.=Enter your e-mail and password. +{0}\ ft={0} ft +{0}\ mi={0} mi +{0}\ m={0} m +{0}\ km={0} km +{0}\ min={0} min +Finding\ a\ driver=Finding a driver +Driver\ on\ the\ way=Driver on the way +Driver\ at\ pickup=Driver at pickup +Completed=Completed +Cancelled\ by\ rider=Cancelled by rider +Cancelled\ by\ driver=Cancelled by driver +No\ driver\ found=No driver found +Verify\ your\ number=Verify your number +We\ sent\ a\ six-digit\ code\ to\ your\ phone.=We sent a six-digit code to your phone. +We\ sent\ a\ six-digit\ code\ to\ {0}.=We sent a six-digit code to {0}. +Code=Code +This\ is\ a\ development\ server,\ so\ no\ text\ was\ sent.\ Your\ code\ is\ {0}.=This is a development server, so no text was sent. Your code is {0}. +Send\ a\ new\ code=Send a new code +A\ ride\ when\ you\ need\ one,\ and\ a\ fair\ way\ to\ drive.=A ride when you need one, and a fair way to drive. + +# Driving applications, payments, the admin console and the account. +1\ application\ to\ review=1 application to review +{0}\ applications\ to\ review={0} applications to review +1\ flagged\ account=1 flagged account +{0}\ flagged\ accounts={0} flagged accounts +7\ days=7 days +14\ days=14 days +30\ days=30 days +A\ car\ with\ four\ doors=A car with four doors +A\ driving\ licence=A driving licence +A\ flag\ marks\ the\ account\ for\ the\ admins\ and\ changes\ nothing\ for\ its\ owner.\ A\ block\ signs\ it\ out\ and\ refuses\ it\ until\ lifted.=A flag marks the account for the admins and changes nothing for its owner. A block signs it out and refuses it until lifted. +A\ passport\ or\ an\ identity\ card=A passport or an identity card +A\ photo\ of\ you=A photo of you +A\ ramp\ or\ a\ lift,\ and\ room\ to\ secure\ the\ chair=A ramp or a lift, and room to secure the chair +A\ ride\ shows\ up\ here\ as\ soon\ as\ it\ is\ asked\ for.=A ride shows up here as soon as it is asked for. +A\ ride\ shows\ up\ here\ when\ it\ is\ paid\ for.=A ride shows up here when it is paid for. +A\ sentence\ the\ record\ will\ keep=A sentence the record will keep +About\ ten\ minutes=About ten minutes +About\ you=About you +Accepted=Accepted +Account\ ending\ {0}=Account ending {0} +Account\ holder=Account holder +Account\ number=Account number +Add\ a\ bank\ account=Add a bank account +Add\ a\ card=Add a card +Add\ a\ tip=Add a tip +Added=Added +Admins=Admins +All=All +All\ of\ it\ goes\ to\ your\ driver.=All of it goes to your driver. +All\ of\ it\ goes\ to\ {0}.=All of it goes to {0}. +Another\ amount=Another amount +Applicant=Applicant +Application=Application +Application\ received=Application received +Application\ sent\ back=Application sent back +Application\ sent.\ We\ will\ be\ in\ touch.=Application sent. We will be in touch. +Application\ under\ review=Application under review +Applications=Applications +Approve=Approve +Approved=Approved +Approved\ to\ drive=Approved to drive +As\ it\ is\ written\ on\ your\ licence.\ Riders\ see\ only\ your\ first\ name.=As it is written on your licence. Riders see only your first name. +Average\ fare=Average fare +Average\ wait=Average wait +Back=Back +Bank=Bank +Bank\ account=Bank account +Base\ fare=Base fare +Block=Block +Block\ account=Block account +Blocked=Blocked +Blue=Blue +By\ kind\ of\ ride=By kind of ride +By\ outcome=By outcome +By\ submitting\ you\ confirm\ that\ all\ of\ this\ is\ true\ and\ that\ the\ documents\ are\ your\ own.=By submitting you confirm that all of this is true and that the documents are your own. +Calling\ is\ not\ available\ on\ this\ device=Calling is not available on this device +Cancel\ this\ ride=Cancel this ride +Cancelled=Cancelled +Cancelled\ by\ Wayline=Cancelled by Wayline +Card\ number=Card number +Cash\ out=Cash out +Change\ password=Change password +Change\ the\ password\ you\ sign\ in\ with=Change the password you sign in with +Changes\ needed=Changes needed +Choose\ a\ ride=Choose a ride +Choose\ an\ amount\ for\ the\ tip=Choose an amount for the tip +Choose\ how\ to\ pay\ for\ this\ ride.=Choose how to pay for this ride. +Collect\ {0}\ in\ cash=Collect {0} in cash +Colour=Colour +Comfort=Comfort +Comfort\ is\ a\ newer,\ roomier\ car.\ XL\ seats\ six\ or\ more.=Comfort is a newer, roomier car. XL seats six or more. +Commission,\ %=Commission, % +Contact=Contact +Continue=Continue +Continue\ application=Continue application +Current\ password=Current password +Date\ of\ birth=Date of birth +Default=Default +Delete\ account=Delete account +Delete\ my\ account=Delete my account +Delete\ your\ account?=Delete your account? +Discount=Discount +Distance=Distance +Documents=Documents +Documents\ checked=Documents checked +Done=Done +Draft=Draft +Drive\ with\ Wayline=Drive with Wayline +Drivers=Drivers +Drivers\ apply\ after\ signing\ up\:\ a\ few\ details\ about\ you\ and\ your\ car,\ and\ five\ photos.\ You\ can\ ride\ in\ the\ meantime.=Drivers apply after signing up: a few details about you and your car, and five photos. You can ride in the meantime. +Drivers\ online=Drivers online +Driving\ licence=Driving licence +Earn\ on\ your\ own\ schedule=Earn on your own schedule +Earned\ driving=Earned driving +Every\ application\ has\ had\ its\ answer.=Every application has had its answer. +Everyone=Everyone +Expires=Expires +Expires\ {0}=Expires {0} +Fare=Fare +Finish\ your\ application=Finish your application +Fix\ and\ resubmit=Fix and resubmit +Flag=Flag +Flag\ account=Flag account +Flag\ removed=Flag removed +Flagged=Flagged +Gender=Gender +Have\ a\ last\ look.\ You\ can\ change\ anything\ until\ it\ is\ handed\ in.=Have a last look. You can change anything until it is handed in. +Hi,\ this\ is\ your\ Wayline\ driver.=Hi, this is your Wayline driver. +Hi,\ this\ is\ your\ Wayline\ rider.=Hi, this is your Wayline rider. +History=History +I\ have\ finished=I have finished +I\ want\ to=I want to +In\ good\ standing=In good standing +Insurance=Insurance +Its\ number,\ its\ expiry\ date\ and\ a\ photo\ of\ it=Its number, its expiry date and a photo of it +Keep\ my\ account=Keep my account +Kind\ of\ ride=Kind of ride +Legal\ name=Legal name +Licence=Licence +Licence\ expires=Licence expires +Licence\ number=Licence number +Make=Make +Man=Man +Messages\ are\ not\ available\ on\ this\ device=Messages are not available on this device +Minimum\ fare=Minimum fare +Missing=Missing +Model=Model +Moderation=Moderation +Most\ of\ it\ is\ taking\ five\ photos=Most of it is taking five photos +Multipliers=Multipliers +Name\ an\ emergency\ contact\ in\ your\ profile\ first=Name an emergency contact in your profile first +Name\ on\ the\ card=Name on the card +Name,\ emergency\ contact,\ home\ and\ work=Name, emergency contact, home and work +Needed=Needed +New\ password=New password +New\ password,\ again=New password, again +No=No +No\ payments\ yet=No payments yet +No\ rides\ here=No rides here +Nobody\ matches=Nobody matches +Non-binary=Non-binary +Not\ provided=Not provided +Not\ set=Not set +Not\ started=Not started +Nothing\ has\ been\ done\ to\ this\ account.=Nothing has been done to this account. +Nothing\ has\ been\ paid\ out\ yet.=Nothing has been paid out yet. +Nothing\ here=Nothing here +Open\ the\ secure\ page=Open the secure page +Paid=Paid +Paid\ in\ cash=Paid in cash +Paid\ with=Paid with +Payment\ failed=Payment failed +Payment\ pending=Payment pending +Payments=Payments +Payouts=Payouts +People\ who\ asked\ to\ drive,\ and\ their\ papers=People who asked to drive, and their papers +Per\ kilometre=Per kilometre +Per\ minute=Per minute +Photo\ ID=Photo ID +Photograph\ each\ one\ flat,\ with\ all\ four\ corners\ in\ the\ picture.=Photograph each one flat, with all four corners in the picture. +Photos\ added=Photos added +Pricing=Pricing +Profile=Profile +Provided=Provided +Rather\ not\ say=Rather not say +Rating=Rating +Read\ what\ was\ missing,\ put\ it\ right,\ and\ send\ the\ application\ again.=Read what was missing, put it right, and send the application again. +Ready\ to\ cash\ out=Ready to cash out +Reason=Reason +Receipt=Receipt +Refund=Refund +Refund\ this\ ride=Refund this ride +Refunded=Refunded +Registered\ and\ insured,\ with\ the\ papers\ to\ show\ it=Registered and insured, with the papers to show it +Rejected=Rejected +Remove\ flag=Remove flag +Remove\ this\ card=Remove this card +Request\ {0}=Request {0} +Requests\ accepted=Requests accepted +Requests\ by\ hour=Requests by hour +Retake=Retake +Review\ and\ submit=Review and submit +Reviewed=Reviewed +Reviewed\ by=Reviewed by +Riders=Riders +Riders\ who\ ask\ for\ a\ woman\ at\ the\ wheel\ are\ offered\ to\ drivers\ who\ say\ they\ are\ one.=Riders who ask for a woman at the wheel are offered to drivers who say they are one. +Right\ now=Right now +Roles\ changed=Roles changed +Roles,\ flags,\ and\ blocking\ an\ account=Roles, flags, and blocking an account +Save\ card=Save card +Save\ prices=Save prices +Saved\ places=Saved places +Say\ why,\ in\ a\ few\ words.=Say why, in a few words. +Search\ by\ name\ or\ e-mail=Search by name or e-mail +Seats\ for\ riders=Seats for riders +Security\ code=Security code +Send\ back=Send back +Send\ tip=Send tip +Saves\ {0}'s\ number\ with\ your\ account\ as\ your\ trip\ contact.\ No\ message\ is\ sent.=Saves {0}'s number with your account as your trip contact. No message is sent. +Sent\ back=Sent back +Service\ fee=Service fee +Service\ fee,\ %=Service fee, % +Spent\ riding=Spent riding +Standard=Standard +Start\ application=Start application +Step\ {0}\ of\ {1}=Step {0} of {1} +Submit\ application=Submit application +Submitted=Submitted +Surge,\ while\ demand\ is\ high=Surge, while demand is high +Takes\ a\ wheelchair=Takes a wheelchair +Tap\ a\ card\ to\ make\ it\ the\ one\ that\ pays\ unless\ you\ say\ otherwise.=Tap a card to make it the one that pays unless you say otherwise. +Tap\ to\ open=Tap to open +Tell\ us\ about\ you\ and\ your\ car,\ add\ five\ photos,\ and\ we\ take\ it\ from\ there.=Tell us about you and your car, add five photos, and we take it from there. +Thank\ you.\ Your\ tip\ was\ sent.=Thank you. Your tip was sent. +That\ is\ not\ a\ card\ number.\ Check\ the\ digits.=That is not a card number. Check the digits. +That\ picture\ could\ not\ be\ read=That picture could not be read +That\ place\ could\ not\ be\ found.\ Choose\ it\ again.=That place could not be found. Choose it again. +The\ busy\ hours,\ in\ UTC=The busy hours, in UTC +The\ card\ was\ declined.\ Choose\ another\ way\ to\ pay\ in\ your\ wallet.=The card was declined. Choose another way to pay in your wallet. +The\ fare,\ in\ {0}=The fare, in {0} +The\ fee\ is\ added\ to\ the\ fare\ and\ paid\ by\ the\ rider.\ The\ commission\ is\ kept\ from\ the\ fare;\ the\ rest\ is\ the\ driver's.=The fee is added to the fare and paid by the rider. The commission is kept from the fare; the rest is the driver's. +The\ last\ two\ weeks=The last two weeks +The\ new\ prices\ are\ in\ force=The new prices are in force +The\ number\ is\ on\ the\ front\ of\ the\ card.=The number is on the front of the card. +The\ page\ with\ the\ policy's\ dates\ on\ it=The page with the policy's dates on it +The\ paper\ that\ says\ the\ car\ is\ yours=The paper that says the car is yours +The\ payment\ provider's\ own\ page\ opens\ in\ your\ browser.\ Wayline\ never\ sees\ the\ number.\ Come\ back\ here\ when\ you\ have\ finished.=The payment provider's own page opens in your browser. Wayline never sees the number. Come back here when you have finished. +The\ ride\ was\ refunded=The ride was refunded +Refund\ pending=Refund pending +Refund\ failed=Refund failed +The\ refund\ is\ pending=The refund is pending +The\ rider\ and\ the\ driver\ are\ both\ told,\ and\ nobody\ is\ charged.=The rider and the driver are both told, and nobody is charged. +The\ security\ code\ is\ the\ three\ or\ four\ digits\ on\ the\ card.=The security code is the three or four digits on the card. +The\ service's\ share=The service's share +The\ side\ with\ your\ photo\ on\ it=The side with your photo on it +The\ two\ new\ passwords\ are\ not\ the\ same.=The two new passwords are not the same. +This\ device\ cannot\ prepare\ a\ picture\ to\ send=This device cannot prepare a picture to send +This\ month=This month +This\ picture\ could\ not\ be\ opened=This picture could not be opened +This\ screen\ becomes\ your\ driver's\ home\ the\ moment\ you\ are\ approved=This screen becomes your driver's home the moment you are approved +This\ server\ takes\ no\ real\ payments.\ Use\ the\ test\ card\ 4242\ 4242\ 4242\ 4242\ with\ any\ date\ ahead\ and\ any\ three\ digits.=This server takes no real payments. Use the test card 4242 4242 4242 4242 with any date ahead and any three digits. +Time=Time +Tip\ offered\ first=Tip offered first +Tips=Tips +Top\ drivers=Top drivers +Total=Total +Trends=Trends +Trip=Trip +Type\ the\ account\ number.=Type the account number. +Unblock=Unblock +Unblocked=Unblocked +Under\ review=Under review +Usually\ within\ two\ working\ days=Usually within two working days +Vehicle=Vehicle +Vehicle\ registration=Vehicle registration +View\ receipt=View receipt +Waiting=Waiting +Wallet=Wallet +We\ could\ not\ approve\ this\ yet=We could not approve this yet +We\ have\ everything\ we\ asked\ for.\ Someone\ on\ our\ team\ is\ reading\ it.=We have everything we asked for. Someone on our team is reading it. +What\ a\ ride\ costs,\ and\ the\ service's\ share=What a ride costs, and the service's share +What\ happens\ next=What happens next +What\ the\ reviewer\ said=What the reviewer said +What\ was\ charged,\ and\ refunds=What was charged, and refunds +What\ you\ will\ need=What you will need +Where\ your\ earnings\ are\ sent=Where your earnings are sent +Why\ does\ {0}\ need\ the\ admins'\ attention?=Why does {0} need the admins' attention? +Woman=Woman +Write\ the\ expiry\ date\ as\ month\ and\ year\:\ 04/28.=Write the expiry date as month and year: 04/28. +Year=Year +Yes=Yes +You=You +You\ go\ online=You go online +Your\ application=Your application +Your\ car=Your car +Your\ card\ goes\ straight\ to\ the\ bank=Your card goes straight to the bank +Your\ cards,\ and\ which\ of\ them\ pays\ by\ default=Your cards, and which of them pays by default +Your\ documents=Your documents +Your\ driver\ cannot\ be\ messaged\ right\ now=Your driver cannot be messaged right now +Your\ face,\ in\ good\ light,\ no\ sunglasses=Your face, in good light, no sunglasses +Your\ licence=Your licence +Your\ password\ was\ changed=Your password was changed +Your\ position\ is\ sent\ to\ the\ server\ you\ signed\ in\ to\ while\ you\ ride\ or\ drive,\ and\ to\ nobody\ else.\ Your\ settings\ are\ kept\ with\ your\ account\ on\ that\ server.=Your position is sent to the server you signed in to while you ride or drive, and to nobody else. Your settings are kept with your account on that server. +Your\ rider\ cannot\ be\ messaged\ right\ now=Your rider cannot be messaged right now +Your\ sign-in,\ your\ profile,\ your\ saved\ cards\ and\ your\ settings\ are\ removed\ for\ good.\ Rides\ you\ took\ stay\ in\ the\ service's\ records\ without\ your\ name.=Your sign-in, your profile, your saved cards and your settings are removed for good. Rides you took stay in the service's records without your name. +{0}\ away={0} away +{0}\ can\ now\ drive={0} can now drive +{0}\ completed,\ {1}\ cancelled={0} completed, {1} cancelled +{0}\ days\ ago={0} days ago +{0}\ goes\ back\ to\ the\ card\ it\ came\ from.={0} goes back to the card it came from. +{0}\ is\ on\ its\ way\ to\ your\ bank={0} is on its way to your bank +{0}\ is\ signed\ out\ everywhere\ and\ cannot\ sign\ in\ again\ until\ the\ block\ is\ lifted.={0} is signed out everywhere and cannot sign in again until the block is lifted. +{0}\ new\ accounts={0} new accounts +{0}\ of\ 5\ documents\ added.\ Pick\ up\ where\ you\ left\ off.={0} of 5 documents added. Pick up where you left off. +{0}\ rides={0} rides +{0}\ s={0} s +{0}\ sees\ what\ you\ write,\ and\ can\ apply\ again\ once\ it\ is\ put\ right.={0} sees what you write, and can apply again once it is put right. +Fill\ in\ everything\ on\ this\ step\ to\ continue.=Fill in everything on this step to continue. +None\ nearby=None nearby +Pay\ the\ driver\ directly=Pay the driver directly +Camera=Camera +Gallery=Gallery +Fares\ collected=Fares collected +Takes\ pets=Takes pets +Riders\ travelling\ with\ a\ pet\ are\ offered\ to\ you=Riders travelling with a pet are offered to you +Go=Go +Nothing\ selected=Nothing selected +Pick\ a\ row\ from\ the\ list\ to\ see\ it\ here.=Pick a row from the list to see it here. +Usage\ statistics=Usage statistics +Share\ which\ screens\ and\ features\ are\ used.\ Never\ your\ name,\ places\ or\ payment\ details.=Share which screens and features are used. Never your name, places or payment details. +Help\ improve\ Wayline=Help improve Wayline +Wayline\ can\ count\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ so\ that\ we\ can\ see\ what\ works\ and\ fix\ what\ does\ not.=Wayline can count which screens are opened and which features are used, so that we can see what works and fix what does not. +No\ personal\ details=No personal details +Never\ your\ name,\ e-mail,\ phone\ number,\ places\ or\ card\ details=Never your name, e-mail, phone number, places or card details +Turn\ it\ on\ or\ off\ at\ any\ time\ in\ Settings=Turn it on or off at any time in Settings +Share\ usage\ statistics=Share usage statistics +With\ usage\ statistics\ turned\ on,\ the\ app\ also\ counts\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ and\ reports\ that\ to\ Codename\ One's\ analytics\ service\ under\ a\ made-up\ id\ for\ this\ device.\ Your\ name,\ e-mail,\ phone\ number,\ places\ and\ card\ details\ are\ never\ part\ of\ it,\ and\ turning\ it\ off\ in\ Settings\ stops\ it.=With usage statistics turned on, the app also counts which screens are opened and which features are used, and reports that to Codename One's analytics service under a made-up id for this device. Your name, e-mail, phone number, places and card details are never part of it, and turning it off in Settings stops it. +Location\ is\ turned\ off.\ Try\ again=Location is turned off. Try again +Can't\ find\ your\ location.\ Try\ again=Can't find your location. Try again +Still\ looking\ for\ your\ location.\ Try\ again=Still looking for your location. Try again +Looking\ for\ your\ location...=Looking for your location... diff --git a/scripts/wayline/common/src/main/l10n/Bundle_es.properties b/scripts/wayline/common/src/main/l10n/Bundle_es.properties new file mode 100644 index 00000000000..dcf76580dba --- /dev/null +++ b/scripts/wayline/common/src/main/l10n/Bundle_es.properties @@ -0,0 +1,532 @@ +# Wayline in Spanish. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +@rtl=false +language.name=Espa\u00f1ol + +Admin=Administrador +Dashboard=Panel +Live\ map=Mapa en vivo +Rides=Viajes +People=Personas +Operations=Operaciones +Wayline\ administration=Administraci\u00f3n de Wayline +Today=Hoy +Manage=Gestionar +Where\ every\ car\ online\ is=D\u00f3nde est\u00e1 cada coche conectado +The\ most\ recent,\ in\ every\ state=Los m\u00e1s recientes, en cualquier estado +Roles,\ and\ suspending\ an\ account=Roles y suspensi\u00f3n de cuentas +Rides\ today=Viajes hoy +Under\ way=En curso +Online=En l\u00ednea +Offline=Desconectado +Fares=Tarifas +Cars\ move\ as\ their\ drivers\ report\ in.=Los coches se mueven seg\u00fan informan sus conductores. +No\ cars\ online=Ning\u00fan coche conectado +1\ car\ online=1 coche conectado +{0}\ cars\ online={0} coches conectados +Suspended=Suspendido +Driver=Conductor +Rider=Pasajero +Verified=Verificado +Not\ verified=Sin verificar +May\ go\ online\ and\ take\ rides=Puede conectarse y aceptar viajes +May\ see\ and\ change\ everything\ here=Puede ver y cambiar todo aqu\u00ed +Signed\ out,\ and\ refused\ until\ lifted=Sesi\u00f3n cerrada y acceso denegado hasta que se levante +Access=Acceso +Drive=Conducir +Your\ trips=Tus trayectos +Earnings=Ganancias +The\ request\ went\ to\ another\ driver=La solicitud pas\u00f3 a otro conductor +The\ rider\ cancelled=El pasajero cancel\u00f3 +Today,\ {0}\ trips=Hoy, {0} trayectos +Looking\ for\ rides=Buscando viajes +You\ are\ offline=Est\u00e1s desconectado +Trip\ complete\:\ {0}=Trayecto completado: {0} +Stay\ close\ to\ busy\ streets;\ requests\ come\ to\ the\ nearest\ car.=Qu\u00e9date cerca de calles concurridas; las solicitudes llegan al coche m\u00e1s cercano. +Go\ online\ to\ start\ taking\ rides.=Con\u00e9ctate para empezar a aceptar viajes. +GO=IR +This\ week=Esta semana +Trips=Trayectos +All\ time=Total +Go\ offline=Desconectarse +Go\ online=Conectarse +Ride\ request=Solicitud de viaje +{0}\ away,\ {1}=A {0}, {1} +Accept=Aceptar +Decline=Rechazar +Pick\ up\ {0}=Recoger a {0} +Waiting\ for\ {0}=Esperando a {0} +On\ the\ trip=En viaje +Drop-off=Destino +Pickup=Recogida +I\ have\ arrived=He llegado +Start\ trip=Iniciar trayecto +Complete\ trip=Finalizar trayecto +Cancel\ ride=Cancelar viaje +Quiet\ ride=Viaje tranquilo +Accessible\ vehicle=Veh\u00edculo accesible +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=No se pudo contactar con el servidor. Comprueba tu conexi\u00f3n. +Please\ sign\ in\ again.=Vuelve a iniciar sesi\u00f3n. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Tu cuenta no tiene permiso para hacer eso. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Demasiados intentos. Espera un momento y vuelve a intentarlo. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Demasiados intentos. Espera un minuto y vuelve a intentarlo. +Something\ went\ wrong\ ({0}).\ Try\ again.=Algo sali\u00f3 mal ({0}). Int\u00e9ntalo de nuevo. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=Ese correo y contrase\u00f1a no corresponden a ninguna cuenta. +Signing\ in\ failed.\ Try\ again.=No se pudo iniciar sesi\u00f3n. Int\u00e9ntalo de nuevo. +Where\ to?=\u00bfAd\u00f3nde vas? +Destination=Destino +An\ address\ or\ a\ place=Una direcci\u00f3n o un lugar +Nothing\ found=No se encontr\u00f3 nada +Current\ location=Ubicaci\u00f3n actual +Ride=Viajar +Your\ rides=Tus viajes +Your\ driver\ is\ here=Tu conductor ha llegado +{0}\ to\ destination={0} hasta el destino +Pickup\ in\ {0}=Recogida en {0} +Hello,\ {0}=Hola, {0} +Where\ are\ you\ going?=\u00bfAd\u00f3nde vas? +Verify\ your\ phone\ number\ to\ request\ a\ ride.=Verifica tu n\u00famero de tel\u00e9fono para pedir un viaje. +Verify\ now=Verificar ahora +Home=Casa +Work=Trabajo +Working\ out\ the\ fare=Calculando la tarifa +No\ drivers\ nearby\ right\ now=No hay conductores cerca ahora +1\ driver\ nearby,\ {0}\ away=1 conductor cerca, a {0} +{0}\ drivers\ nearby,\ the\ closest\ {1}\ away={0} conductores cerca, el m\u00e1s cercano a {1} +Request\ ride=Pedir viaje +Cancel=Cancelar +Payment=Pago +Ride\ preferences=Preferencias de viaje +Cash=Efectivo +Card=Tarjeta +Women\ drivers\ only=Solo conductoras +Pet\ friendly=Admite mascotas +To\ {0}=A {0} +Nobody\ could\ take\ the\ ride\ just\ now.\ You\ have\ not\ been\ charged.=Nadie pudo aceptar el viaje ahora. No se te ha cobrado. +Your\ driver\ had\ to\ cancel.\ You\ have\ not\ been\ charged.=Tu conductor tuvo que cancelar. No se te ha cobrado. +The\ ride\ was\ cancelled.=El viaje fue cancelado. +OK=Aceptar +Call=Llamar +Calls\ to\ your\ driver\ are\ not\ available\ on\ this\ server=Las llamadas al conductor no est\u00e1n disponibles en este servidor +Message=Mensaje +Messages\ to\ your\ driver\ are\ not\ available\ on\ this\ server=Los mensajes al conductor no est\u00e1n disponibles en este servidor +Share=Compartir +I\ am\ on\ my\ way\ to\ {0}\ with\ {1}\ in\ a\ {2}\ ({3}).=Voy de camino a {0} con {1} en un {2} ({3}). +You\ have\ arrived=Has llegado +How\ was\ the\ ride\ with\ {0}?=\u00bfQu\u00e9 tal el viaje con {0}? +Thanks\ for\ the\ rating=Gracias por la valoraci\u00f3n +Not\ now=Ahora no +Admin\ mode=Modo administrador +Driver\ mode=Modo conductor +Rider\ mode=Modo pasajero +Switch\ to=Cambiar a +Riding=Viajar +Driving=Conducir +Administration=Administraci\u00f3n +Settings=Ajustes +Sign\ out=Cerrar sesi\u00f3n +No\ connection=Sin conexi\u00f3n +The\ server\ at\ {0}\ did\ not\ answer.=El servidor en {0} no respondi\u00f3. +Try\ again=Reintentar +Server\ address=Direcci\u00f3n del servidor +Create\ an\ account=Crear una cuenta +Name=Nombre +What\ drivers\ will\ call\ you=C\u00f3mo te llamar\u00e1n los conductores +E-mail=Correo electr\u00f3nico +Mobile\ number=N\u00famero de m\u00f3vil +Password=Contrase\u00f1a +At\ least\ 8\ characters=Al menos 8 caracteres +I\ want\ to\ drive=Quiero conducir +Take\ ride\ requests\ and\ earn=Acepta solicitudes y gana dinero +Car=Coche +Blue\ Toyota\ Prius=Toyota Prius azul +Licence\ plate=Matr\u00edcula +Create\ account=Crear cuenta +No\ rides\ yet=A\u00fan no hay viajes +{0}\ with\ {1}={0} con {1} +Server=Servidor +The\ address\ of\ the\ Wayline\ server\ this\ app\ talks\ to.\ Changing\ it\ signs\ you\ out.=La direcci\u00f3n del servidor Wayline que usa esta app. Cambiarla cierra tu sesi\u00f3n. +Address=Direcci\u00f3n +Save=Guardar +Account=Cuenta +Needed\ before\ your\ first\ ride=Necesario antes de tu primer viaje +Verify=Verificar +Any\ driver=Cualquier conductor +Women\ only=Solo mujeres +Ask\ the\ driver\ to\ keep\ conversation\ short=Pide al conductor que hable poco +Only\ cars\ that\ take\ a\ wheelchair=Solo coches aptos para silla de ruedas +Tell\ the\ driver\ an\ animal\ is\ coming=Avisa al conductor de que viaja un animal +Pay\ with=Pagar con +Tip=Propina +None=Ninguna +Notifications=Notificaciones +Ride\ updates=Novedades del viaje +Driver\ found,\ arriving,\ at\ the\ door=Conductor encontrado, llegando, en la puerta +Receipts=Recibos +After\ every\ ride=Despu\u00e9s de cada viaje +Offers=Ofertas +Discounts\ and\ news=Descuentos y novedades +Appearance=Apariencia +Language=Idioma +Theme=Tema +System=Sistema +Light=Claro +Dark=Oscuro +Distances=Distancias +Kilometres=Kil\u00f3metros +Miles=Millas +Privacy\ and\ safety=Privacidad y seguridad +Share\ my\ trips=Compartir mis viajes +Emergency\ contact=Contacto de emergencia +Their\ name=Su nombre +Their\ phone\ number=Su n\u00famero de tel\u00e9fono +About=Acerca de +A\ sample\ ride\ service\ built\ with\ Codename\ One=Un servicio de viajes de ejemplo creado con Codename One +Terms\ of\ service=Condiciones del servicio +Wayline\ is\ a\ sample.\ No\ real\ rides\ are\ booked,\ no\ real\ drivers\ are\ sent\ and\ no\ money\ changes\ hands.=Wayline es un ejemplo. No se reservan viajes reales, no se env\u00edan conductores reales y no se mueve dinero. +Privacy\ policy=Pol\u00edtica de privacidad +Your\ position\ is\ sent\ to\ the\ server\ you\ signed\ in\ to\ while\ you\ ride\ or\ drive,\ and\ to\ nobody\ else.\ Settings\ are\ kept\ on\ this\ device.=Tu posici\u00f3n se env\u00eda al servidor en el que iniciaste sesi\u00f3n mientras viajas o conduces, y a nadie m\u00e1s. Los ajustes se guardan en este dispositivo. +Sign\ in=Iniciar sesi\u00f3n +Enter\ your\ e-mail\ and\ password.=Introduce tu correo y contrase\u00f1a. +{0}\ ft={0} pies +{0}\ mi={0} mi +{0}\ m={0} m +{0}\ km={0} km +{0}\ min={0} min +Finding\ a\ driver=Buscando conductor +Driver\ on\ the\ way=Conductor en camino +Driver\ at\ pickup=Conductor en el punto de recogida +Completed=Completado +Cancelled\ by\ rider=Cancelado por el pasajero +Cancelled\ by\ driver=Cancelado por el conductor +No\ driver\ found=No se encontr\u00f3 conductor +Verify\ your\ number=Verifica tu n\u00famero +We\ sent\ a\ six-digit\ code\ to\ your\ phone.=Enviamos un c\u00f3digo de seis d\u00edgitos a tu tel\u00e9fono. +We\ sent\ a\ six-digit\ code\ to\ {0}.=Enviamos un c\u00f3digo de seis d\u00edgitos a {0}. +Code=C\u00f3digo +This\ is\ a\ development\ server,\ so\ no\ text\ was\ sent.\ Your\ code\ is\ {0}.=Este es un servidor de desarrollo, as\u00ed que no se envi\u00f3 ning\u00fan SMS. Tu c\u00f3digo es {0}. +Send\ a\ new\ code=Enviar un c\u00f3digo nuevo +A\ ride\ when\ you\ need\ one,\ and\ a\ fair\ way\ to\ drive.=Un viaje cuando lo necesitas, y una forma justa de conducir. + +# Driving applications, payments, the admin console and the account. +1\ application\ to\ review=1 solicitud por revisar +{0}\ applications\ to\ review={0} solicitudes por revisar +1\ flagged\ account=1 cuenta marcada +{0}\ flagged\ accounts={0} cuentas marcadas +7\ days=7 d\u00edas +14\ days=14 d\u00edas +30\ days=30 d\u00edas +A\ car\ with\ four\ doors=Un coche de cuatro puertas +A\ driving\ licence=Un permiso de conducir +A\ flag\ marks\ the\ account\ for\ the\ admins\ and\ changes\ nothing\ for\ its\ owner.\ A\ block\ signs\ it\ out\ and\ refuses\ it\ until\ lifted.=Una marca se\u00f1ala la cuenta a los administradores y no cambia nada para su due\u00f1o. Un bloqueo cierra su sesi\u00f3n y la rechaza hasta que se levante. +A\ passport\ or\ an\ identity\ card=Un pasaporte o un documento de identidad +A\ photo\ of\ you=Una foto tuya +A\ ramp\ or\ a\ lift,\ and\ room\ to\ secure\ the\ chair=Rampa o elevador, y espacio para asegurar la silla +A\ ride\ shows\ up\ here\ as\ soon\ as\ it\ is\ asked\ for.=Un viaje aparece aqu\u00ed en cuanto se pide. +A\ ride\ shows\ up\ here\ when\ it\ is\ paid\ for.=Un viaje aparece aqu\u00ed cuando se paga. +A\ sentence\ the\ record\ will\ keep=Una frase que quedar\u00e1 registrada +About\ ten\ minutes=Unos diez minutos +About\ you=Sobre ti +Accepted=Aceptado +Account\ ending\ {0}=Cuenta terminada en {0} +Account\ holder=Titular de la cuenta +Account\ number=N\u00famero de cuenta +Add\ a\ bank\ account=A\u00f1adir una cuenta bancaria +Add\ a\ card=A\u00f1adir una tarjeta +Add\ a\ tip=A\u00f1adir propina +Added=A\u00f1adido +Admins=Administradores +All=Todos +All\ of\ it\ goes\ to\ your\ driver.=Todo va para tu conductor. +All\ of\ it\ goes\ to\ {0}.=Todo va para {0}. +Another\ amount=Otro importe +Applicant=Solicitante +Application=Solicitud +Application\ received=Solicitud recibida +Application\ sent\ back=Solicitud devuelta +Application\ sent.\ We\ will\ be\ in\ touch.=Solicitud enviada. Te avisaremos. +Application\ under\ review=Solicitud en revisi\u00f3n +Applications=Solicitudes +Approve=Aprobar +Approved=Aprobada +Approved\ to\ drive=Aprobado para conducir +As\ it\ is\ written\ on\ your\ licence.\ Riders\ see\ only\ your\ first\ name.=Como figura en tu permiso. Los pasajeros solo ven tu nombre de pila. +Average\ fare=Tarifa media +Average\ wait=Espera media +Back=Atr\u00e1s +Bank=Banco +Bank\ account=Cuenta bancaria +Base\ fare=Tarifa base +Block=Bloquear +Block\ account=Bloquear cuenta +Blocked=Bloqueada +Blue=Azul +By\ kind\ of\ ride=Por tipo de viaje +By\ outcome=Por resultado +By\ submitting\ you\ confirm\ that\ all\ of\ this\ is\ true\ and\ that\ the\ documents\ are\ your\ own.=Al enviar confirmas que todo esto es cierto y que los documentos son tuyos. +Calling\ is\ not\ available\ on\ this\ device=Este dispositivo no permite llamar +Cancel\ this\ ride=Cancelar este viaje +Cancelled=Cancelados +Cancelled\ by\ Wayline=Cancelado por Wayline +Card\ number=N\u00famero de tarjeta +Cash\ out=Retirar +Change\ password=Cambiar contrase\u00f1a +Change\ the\ password\ you\ sign\ in\ with=Cambia la contrase\u00f1a con la que entras +Changes\ needed=Requiere cambios +Choose\ a\ ride=Elige un viaje +Choose\ an\ amount\ for\ the\ tip=Elige el importe de la propina +Choose\ how\ to\ pay\ for\ this\ ride.=Elige c\u00f3mo pagar este viaje. +Collect\ {0}\ in\ cash=Cobra {0} en efectivo +Colour=Color +Comfort=Comfort +Comfort\ is\ a\ newer,\ roomier\ car.\ XL\ seats\ six\ or\ more.=Comfort es un coche m\u00e1s nuevo y amplio. XL lleva a seis o m\u00e1s. +Commission,\ %=Comisi\u00f3n, % +Contact=Contacto +Continue=Continuar +Continue\ application=Continuar la solicitud +Current\ password=Contrase\u00f1a actual +Date\ of\ birth=Fecha de nacimiento +Default=Predeterminada +Delete\ account=Eliminar cuenta +Delete\ my\ account=Eliminar mi cuenta +Delete\ your\ account?=\u00bfEliminar tu cuenta? +Discount=Descuento +Distance=Distancia +Documents=Documentos +Documents\ checked=Documentos comprobados +Done=Listo +Draft=Borrador +Drive\ with\ Wayline=Conduce con Wayline +Drivers=Conductores +Drivers\ apply\ after\ signing\ up\:\ a\ few\ details\ about\ you\ and\ your\ car,\ and\ five\ photos.\ You\ can\ ride\ in\ the\ meantime.=Los conductores presentan su solicitud tras registrarse: unos datos sobre ti y tu coche, y cinco fotos. Mientras tanto puedes viajar. +Drivers\ online=Conductores en l\u00ednea +Driving\ licence=Permiso de conducir +Earn\ on\ your\ own\ schedule=Gana a tu ritmo +Earned\ driving=Ganado conduciendo +Every\ application\ has\ had\ its\ answer.=Todas las solicitudes tienen respuesta. +Everyone=Todos +Expires=Caduca +Expires\ {0}=Caduca {0} +Fare=Tarifa +Finish\ your\ application=Termina tu solicitud +Fix\ and\ resubmit=Corregir y reenviar +Flag=Marcar +Flag\ account=Marcar cuenta +Flag\ removed=Marca retirada +Flagged=Marcada +Gender=G\u00e9nero +Have\ a\ last\ look.\ You\ can\ change\ anything\ until\ it\ is\ handed\ in.=Un \u00faltimo vistazo. Puedes cambiar lo que quieras hasta entregarla. +Hi,\ this\ is\ your\ Wayline\ driver.=Hola, soy tu conductor de Wayline. +Hi,\ this\ is\ your\ Wayline\ rider.=Hola, soy tu pasajero de Wayline. +History=Historial +I\ have\ finished=He terminado +I\ want\ to=Quiero +In\ good\ standing=En regla +Insurance=Seguro +Its\ number,\ its\ expiry\ date\ and\ a\ photo\ of\ it=Su n\u00famero, su fecha de caducidad y una foto +Keep\ my\ account=Conservar mi cuenta +Kind\ of\ ride=Tipo de viaje +Legal\ name=Nombre legal +Licence=Permiso +Licence\ expires=El permiso caduca +Licence\ number=N\u00famero de permiso +Make=Marca +Man=Hombre +Messages\ are\ not\ available\ on\ this\ device=Este dispositivo no permite enviar mensajes +Minimum\ fare=Tarifa m\u00ednima +Missing=Falta +Model=Modelo +Moderation=Moderaci\u00f3n +Most\ of\ it\ is\ taking\ five\ photos=Casi todo es hacer cinco fotos +Multipliers=Multiplicadores +Name\ an\ emergency\ contact\ in\ your\ profile\ first=Primero indica un contacto de emergencia en tu perfil +Name\ on\ the\ card=Nombre en la tarjeta +Name,\ emergency\ contact,\ home\ and\ work=Nombre, contacto de emergencia, casa y trabajo +Needed=Pendiente +New\ password=Nueva contrase\u00f1a +New\ password,\ again=Nueva contrase\u00f1a, otra vez +No=No +No\ payments\ yet=A\u00fan no hay pagos +No\ rides\ here=No hay viajes aqu\u00ed +Nobody\ matches=Nadie coincide +Non-binary=No binario +Not\ provided=No aportado +Not\ set=Sin definir +Not\ started=Sin empezar +Nothing\ has\ been\ done\ to\ this\ account.=No se ha hecho nada con esta cuenta. +Nothing\ has\ been\ paid\ out\ yet.=A\u00fan no se ha pagado nada. +Nothing\ here=Nada por aqu\u00ed +Open\ the\ secure\ page=Abrir la p\u00e1gina segura +Paid=Pagado +Paid\ in\ cash=Pagado en efectivo +Paid\ with=Pagado con +Payment\ failed=Pago fallido +Payment\ pending=Pago pendiente +Payments=Pagos +Payouts=Pagos recibidos +People\ who\ asked\ to\ drive,\ and\ their\ papers=Quienes pidieron conducir, y sus papeles +Per\ kilometre=Por kil\u00f3metro +Per\ minute=Por minuto +Photo\ ID=Documento con foto +Photograph\ each\ one\ flat,\ with\ all\ four\ corners\ in\ the\ picture.=Fotograf\u00eda cada uno en plano, con las cuatro esquinas visibles. +Photos\ added=Fotos a\u00f1adidas +Pricing=Precios +Profile=Perfil +Provided=Aportado +Rather\ not\ say=Prefiero no decirlo +Rating=Valoraci\u00f3n +Read\ what\ was\ missing,\ put\ it\ right,\ and\ send\ the\ application\ again.=Lee lo que faltaba, corr\u00edgelo y vuelve a enviar la solicitud. +Ready\ to\ cash\ out=Listo para retirar +Reason=Motivo +Receipt=Recibo +Refund=Reembolsar +Refund\ this\ ride=Reembolsar este viaje +Refunded=Reembolsado +Registered\ and\ insured,\ with\ the\ papers\ to\ show\ it=Matriculado y asegurado, con los papeles que lo prueban +Rejected=Rechazado +Remove\ flag=Quitar marca +Remove\ this\ card=Quitar esta tarjeta +Request\ {0}=Pedir {0} +Requests\ accepted=Solicitudes aceptadas +Requests\ by\ hour=Solicitudes por hora +Retake=Repetir +Review\ and\ submit=Revisar y enviar +Reviewed=Revisada +Reviewed\ by=Revisada por +Riders=Pasajeros +Riders\ who\ ask\ for\ a\ woman\ at\ the\ wheel\ are\ offered\ to\ drivers\ who\ say\ they\ are\ one.=Los pasajeros que piden una mujer al volante se ofrecen a las conductoras que indican serlo. +Right\ now=Ahora mismo +Roles\ changed=Roles cambiados +Roles,\ flags,\ and\ blocking\ an\ account=Roles, marcas y bloqueo de cuentas +Save\ card=Guardar tarjeta +Save\ prices=Guardar precios +Saved\ places=Lugares guardados +Say\ why,\ in\ a\ few\ words.=Di por qu\u00e9, en pocas palabras. +Search\ by\ name\ or\ e-mail=Buscar por nombre o correo +Seats\ for\ riders=Plazas para pasajeros +Security\ code=C\u00f3digo de seguridad +Send\ back=Devolver +Send\ tip=Enviar propina +Saves\ {0}'s\ number\ with\ your\ account\ as\ your\ trip\ contact.\ No\ message\ is\ sent.=Guarda el n\u00famero de {0} en tu cuenta como contacto de viaje. No se env\u00eda ning\u00fan mensaje. +Sent\ back=Devuelta +Service\ fee=Tarifa de servicio +Service\ fee,\ %=Tarifa de servicio, % +Spent\ riding=Gastado en viajes +Standard=Est\u00e1ndar +Start\ application=Empezar la solicitud +Step\ {0}\ of\ {1}=Paso {0} de {1} +Submit\ application=Enviar solicitud +Submitted=Enviada +Surge,\ while\ demand\ is\ high=Recargo, con demanda alta +Takes\ a\ wheelchair=Admite silla de ruedas +Tap\ a\ card\ to\ make\ it\ the\ one\ that\ pays\ unless\ you\ say\ otherwise.=Toca una tarjeta para que sea la que paga salvo que digas otra cosa. +Tap\ to\ open=Toca para abrir +Tell\ us\ about\ you\ and\ your\ car,\ add\ five\ photos,\ and\ we\ take\ it\ from\ there.=Cu\u00e9ntanos sobre ti y tu coche, a\u00f1ade cinco fotos y nosotros seguimos. +Thank\ you.\ Your\ tip\ was\ sent.=Gracias. Tu propina se envi\u00f3. +That\ is\ not\ a\ card\ number.\ Check\ the\ digits.=Eso no es un n\u00famero de tarjeta. Revisa los d\u00edgitos. +That\ picture\ could\ not\ be\ read=No se pudo leer esa imagen +That\ place\ could\ not\ be\ found.\ Choose\ it\ again.=No se encontr\u00f3 ese lugar. El\u00edgelo de nuevo. +The\ busy\ hours,\ in\ UTC=Las horas punta, en UTC +The\ card\ was\ declined.\ Choose\ another\ way\ to\ pay\ in\ your\ wallet.=La tarjeta fue rechazada. Elige otra forma de pago en tu cartera. +The\ fare,\ in\ {0}=La tarifa, en {0} +The\ fee\ is\ added\ to\ the\ fare\ and\ paid\ by\ the\ rider.\ The\ commission\ is\ kept\ from\ the\ fare;\ the\ rest\ is\ the\ driver's.=La tarifa de servicio se suma al precio y la paga el pasajero. La comisi\u00f3n se retiene del precio; el resto es del conductor. +The\ last\ two\ weeks=Las dos \u00faltimas semanas +The\ new\ prices\ are\ in\ force=Los nuevos precios est\u00e1n en vigor +The\ number\ is\ on\ the\ front\ of\ the\ card.=El n\u00famero est\u00e1 en el anverso de la tarjeta. +The\ page\ with\ the\ policy's\ dates\ on\ it=La p\u00e1gina con las fechas de la p\u00f3liza +The\ paper\ that\ says\ the\ car\ is\ yours=El papel que dice que el coche es tuyo +The\ payment\ provider's\ own\ page\ opens\ in\ your\ browser.\ Wayline\ never\ sees\ the\ number.\ Come\ back\ here\ when\ you\ have\ finished.=La p\u00e1gina del proveedor de pagos se abre en tu navegador. Wayline nunca ve el n\u00famero. Vuelve aqu\u00ed cuando termines. +The\ ride\ was\ refunded=El viaje fue reembolsado +Refund\ pending=Reembolso pendiente +Refund\ failed=Reembolso fallido +The\ refund\ is\ pending=El reembolso est\u00e1 pendiente +The\ rider\ and\ the\ driver\ are\ both\ told,\ and\ nobody\ is\ charged.=Se avisa al pasajero y al conductor, y no se cobra a nadie. +The\ security\ code\ is\ the\ three\ or\ four\ digits\ on\ the\ card.=El c\u00f3digo de seguridad son los tres o cuatro d\u00edgitos de la tarjeta. +The\ service's\ share=La parte del servicio +The\ side\ with\ your\ photo\ on\ it=La cara con tu foto +The\ two\ new\ passwords\ are\ not\ the\ same.=Las dos contrase\u00f1as nuevas no coinciden. +This\ device\ cannot\ prepare\ a\ picture\ to\ send=Este dispositivo no puede preparar una imagen para enviar +This\ month=Este mes +This\ picture\ could\ not\ be\ opened=No se pudo abrir esta imagen +This\ screen\ becomes\ your\ driver's\ home\ the\ moment\ you\ are\ approved=Esta pantalla ser\u00e1 tu inicio de conductor en cuanto te aprueben +This\ server\ takes\ no\ real\ payments.\ Use\ the\ test\ card\ 4242\ 4242\ 4242\ 4242\ with\ any\ date\ ahead\ and\ any\ three\ digits.=Este servidor no acepta pagos reales. Usa la tarjeta de prueba 4242 4242 4242 4242 con cualquier fecha futura y tres d\u00edgitos cualesquiera. +Time=Tiempo +Tip\ offered\ first=Propina sugerida +Tips=Propinas +Top\ drivers=Mejores conductores +Total=Total +Trends=Tendencias +Trip=Trayecto +Type\ the\ account\ number.=Escribe el n\u00famero de cuenta. +Unblock=Desbloquear +Unblocked=Desbloqueada +Under\ review=En revisi\u00f3n +Usually\ within\ two\ working\ days=Normalmente en dos d\u00edas laborables +Vehicle=Veh\u00edculo +Vehicle\ registration=Permiso de circulaci\u00f3n +View\ receipt=Ver recibo +Waiting=En espera +Wallet=Cartera +We\ could\ not\ approve\ this\ yet=A\u00fan no pudimos aprobarla +We\ have\ everything\ we\ asked\ for.\ Someone\ on\ our\ team\ is\ reading\ it.=Tenemos todo lo que pedimos. Alguien del equipo la est\u00e1 leyendo. +What\ a\ ride\ costs,\ and\ the\ service's\ share=Cu\u00e1nto cuesta un viaje, y la parte del servicio +What\ happens\ next=Qu\u00e9 pasa ahora +What\ the\ reviewer\ said=Lo que dijo el revisor +What\ was\ charged,\ and\ refunds=Lo cobrado, y los reembolsos +What\ you\ will\ need=Lo que necesitar\u00e1s +Where\ your\ earnings\ are\ sent=Ad\u00f3nde se env\u00edan tus ganancias +Why\ does\ {0}\ need\ the\ admins'\ attention?=\u00bfPor qu\u00e9 {0} necesita la atenci\u00f3n de los administradores? +Woman=Mujer +Write\ the\ expiry\ date\ as\ month\ and\ year\:\ 04/28.=Escribe la caducidad como mes y a\u00f1o: 04/28. +Year=A\u00f1o +Yes=S\u00ed +You=T\u00fa +You\ go\ online=Te pones en l\u00ednea +Your\ application=Tu solicitud +Your\ car=Tu coche +Your\ card\ goes\ straight\ to\ the\ bank=Tu tarjeta va directa al banco +Your\ cards,\ and\ which\ of\ them\ pays\ by\ default=Tus tarjetas, y cu\u00e1l paga por defecto +Your\ documents=Tus documentos +Your\ driver\ cannot\ be\ messaged\ right\ now=Ahora no se puede escribir a tu conductor +Your\ face,\ in\ good\ light,\ no\ sunglasses=Tu cara, con buena luz y sin gafas de sol +Your\ licence=Tu permiso +Your\ password\ was\ changed=Tu contrase\u00f1a se cambi\u00f3 +Your\ position\ is\ sent\ to\ the\ server\ you\ signed\ in\ to\ while\ you\ ride\ or\ drive,\ and\ to\ nobody\ else.\ Your\ settings\ are\ kept\ with\ your\ account\ on\ that\ server.=Tu posici\u00f3n se env\u00eda al servidor en el que iniciaste sesi\u00f3n mientras viajas o conduces, y a nadie m\u00e1s. Tus ajustes se guardan con tu cuenta en ese servidor. +Your\ rider\ cannot\ be\ messaged\ right\ now=Ahora no se puede escribir a tu pasajero +Your\ sign-in,\ your\ profile,\ your\ saved\ cards\ and\ your\ settings\ are\ removed\ for\ good.\ Rides\ you\ took\ stay\ in\ the\ service's\ records\ without\ your\ name.=Tu acceso, tu perfil, tus tarjetas guardadas y tus ajustes se eliminan para siempre. Tus viajes quedan en los registros del servicio sin tu nombre. +{0}\ away=a {0} +{0}\ can\ now\ drive={0} ya puede conducir +{0}\ completed,\ {1}\ cancelled={0} completados, {1} cancelados +{0}\ days\ ago=hace {0} d\u00edas +{0}\ goes\ back\ to\ the\ card\ it\ came\ from.={0} vuelve a la tarjeta de la que sali\u00f3. +{0}\ is\ on\ its\ way\ to\ your\ bank={0} va camino de tu banco +{0}\ is\ signed\ out\ everywhere\ and\ cannot\ sign\ in\ again\ until\ the\ block\ is\ lifted.={0} queda sin sesi\u00f3n en todas partes y no puede volver a entrar hasta que se levante el bloqueo. +{0}\ new\ accounts={0} cuentas nuevas +{0}\ of\ 5\ documents\ added.\ Pick\ up\ where\ you\ left\ off.={0} de 5 documentos a\u00f1adidos. Sigue donde lo dejaste. +{0}\ rides={0} viajes +{0}\ s={0} s +{0}\ sees\ what\ you\ write,\ and\ can\ apply\ again\ once\ it\ is\ put\ right.={0} ve lo que escribes y puede volver a solicitarlo cuando lo corrija. +Fill\ in\ everything\ on\ this\ step\ to\ continue.=Completa todo en este paso para continuar. +None\ nearby=Ninguno cerca +Pay\ the\ driver\ directly=Paga directamente al conductor +Camera=C\u00e1mara +Gallery=Galer\u00eda +Fares\ collected=Tarifas cobradas +Takes\ pets=Acepta mascotas +Riders\ travelling\ with\ a\ pet\ are\ offered\ to\ you=Se te ofrecen los pasajeros que viajan con mascota +Go=Ir +Nothing\ selected=Nada seleccionado +Pick\ a\ row\ from\ the\ list\ to\ see\ it\ here.=Elige una fila de la lista para verla aqu\u00ed. +Usage\ statistics=Estad\u00edsticas de uso +Share\ which\ screens\ and\ features\ are\ used.\ Never\ your\ name,\ places\ or\ payment\ details.=Comparte qu\u00e9 pantallas y funciones se usan. Nunca tu nombre, tus lugares ni tus datos de pago. +Help\ improve\ Wayline=Ayuda a mejorar Wayline +Wayline\ can\ count\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ so\ that\ we\ can\ see\ what\ works\ and\ fix\ what\ does\ not.=Wayline puede contar qu\u00e9 pantallas se abren y qu\u00e9 funciones se usan, para que podamos ver qu\u00e9 funciona y arreglar lo que no. +No\ personal\ details=Sin datos personales +Never\ your\ name,\ e-mail,\ phone\ number,\ places\ or\ card\ details=Nunca tu nombre, correo electr\u00f3nico, n\u00famero de tel\u00e9fono, lugares ni datos de tarjeta +Turn\ it\ on\ or\ off\ at\ any\ time\ in\ Settings=Act\u00edvalo o desact\u00edvalo cuando quieras en Ajustes +Share\ usage\ statistics=Compartir estad\u00edsticas de uso +With\ usage\ statistics\ turned\ on,\ the\ app\ also\ counts\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ and\ reports\ that\ to\ Codename\ One's\ analytics\ service\ under\ a\ made-up\ id\ for\ this\ device.\ Your\ name,\ e-mail,\ phone\ number,\ places\ and\ card\ details\ are\ never\ part\ of\ it,\ and\ turning\ it\ off\ in\ Settings\ stops\ it.=Con las estad\u00edsticas de uso activadas, la app tambi\u00e9n cuenta qu\u00e9 pantallas se abren y qu\u00e9 funciones se usan, y lo env\u00eda al servicio de anal\u00edtica de Codename One con un identificador inventado para este dispositivo. Tu nombre, correo electr\u00f3nico, n\u00famero de tel\u00e9fono, lugares y datos de tarjeta nunca forman parte de ello, y al desactivarlas en Ajustes deja de hacerlo. +Location\ is\ turned\ off.\ Try\ again=La ubicaci\u00f3n est\u00e1 desactivada. Reintentar +Can't\ find\ your\ location.\ Try\ again=No encontramos tu ubicaci\u00f3n. Reintentar +Still\ looking\ for\ your\ location.\ Try\ again=Seguimos buscando tu ubicaci\u00f3n. Reintentar +Looking\ for\ your\ location...=Buscando tu ubicaci\u00f3n... diff --git a/scripts/wayline/common/src/main/l10n/Bundle_fr.properties b/scripts/wayline/common/src/main/l10n/Bundle_fr.properties new file mode 100644 index 00000000000..e1bea89c122 --- /dev/null +++ b/scripts/wayline/common/src/main/l10n/Bundle_fr.properties @@ -0,0 +1,532 @@ +# Wayline in French. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +@rtl=false +language.name=Fran\u00e7ais + +Admin=Admin +Dashboard=Tableau de bord +Live\ map=Carte en direct +Rides=Courses +People=Personnes +Operations=Op\u00e9rations +Wayline\ administration=Administration de Wayline +Today=Aujourd'hui +Manage=G\u00e9rer +Where\ every\ car\ online\ is=O\u00f9 se trouve chaque voiture en ligne +The\ most\ recent,\ in\ every\ state=Les plus r\u00e9centes, dans tous les \u00e9tats +Roles,\ and\ suspending\ an\ account=R\u00f4les et suspension de comptes +Rides\ today=Courses aujourd'hui +Under\ way=En cours +Online=En ligne +Offline=Hors ligne +Fares=Recettes +Cars\ move\ as\ their\ drivers\ report\ in.=Les voitures bougent \u00e0 mesure que les chauffeurs se signalent. +No\ cars\ online=Aucune voiture en ligne +1\ car\ online=1 voiture en ligne +{0}\ cars\ online={0} voitures en ligne +Suspended=Suspendu +Driver=Chauffeur +Rider=Passager +Verified=V\u00e9rifi\u00e9 +Not\ verified=Non v\u00e9rifi\u00e9 +May\ go\ online\ and\ take\ rides=Peut se connecter et prendre des courses +May\ see\ and\ change\ everything\ here=Peut tout voir et tout modifier ici +Signed\ out,\ and\ refused\ until\ lifted=D\u00e9connect\u00e9, et refus\u00e9 jusqu'\u00e0 la lev\u00e9e +Access=Acc\u00e8s +Drive=Conduire +Your\ trips=Vos trajets +Earnings=Revenus +The\ request\ went\ to\ another\ driver=La demande est pass\u00e9e \u00e0 un autre chauffeur +The\ rider\ cancelled=Le passager a annul\u00e9 +Today,\ {0}\ trips=Aujourd'hui, {0} trajets +Looking\ for\ rides=Recherche de courses +You\ are\ offline=Vous \u00eates hors ligne +Trip\ complete\:\ {0}=Trajet termin\u00e9 : {0} +Stay\ close\ to\ busy\ streets;\ requests\ come\ to\ the\ nearest\ car.=Restez pr\u00e8s des rues anim\u00e9es ; les demandes vont \u00e0 la voiture la plus proche. +Go\ online\ to\ start\ taking\ rides.=Passez en ligne pour commencer \u00e0 prendre des courses. +GO=GO +This\ week=Cette semaine +Trips=Trajets +All\ time=Depuis le d\u00e9but +Go\ offline=Passer hors ligne +Go\ online=Passer en ligne +Ride\ request=Demande de course +{0}\ away,\ {1}=\u00c0 {0}, {1} +Accept=Accepter +Decline=Refuser +Pick\ up\ {0}=Prendre {0} +Waiting\ for\ {0}=En attente de {0} +On\ the\ trip=En course +Drop-off=D\u00e9pose +Pickup=Prise en charge +I\ have\ arrived=Je suis arriv\u00e9 +Start\ trip=D\u00e9marrer la course +Complete\ trip=Terminer la course +Cancel\ ride=Annuler la course +Quiet\ ride=Course silencieuse +Accessible\ vehicle=V\u00e9hicule accessible +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=Impossible de joindre le serveur. V\u00e9rifiez votre connexion. +Please\ sign\ in\ again.=Veuillez vous reconnecter. +Your\ account\ is\ not\ allowed\ to\ do\ that.=Votre compte n'est pas autoris\u00e9 \u00e0 faire cela. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=Trop de tentatives. Patientez un instant et r\u00e9essayez. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=Trop de tentatives. Patientez une minute et r\u00e9essayez. +Something\ went\ wrong\ ({0}).\ Try\ again.=Une erreur s'est produite ({0}). R\u00e9essayez. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=Cet e-mail et ce mot de passe ne correspondent \u00e0 aucun compte. +Signing\ in\ failed.\ Try\ again.=La connexion a \u00e9chou\u00e9. R\u00e9essayez. +Where\ to?=O\u00f9 allez-vous ? +Destination=Destination +An\ address\ or\ a\ place=Une adresse ou un lieu +Nothing\ found=Aucun r\u00e9sultat +Current\ location=Position actuelle +Ride=Course +Your\ rides=Vos courses +Your\ driver\ is\ here=Votre chauffeur est l\u00e0 +{0}\ to\ destination={0} jusqu'\u00e0 destination +Pickup\ in\ {0}=Prise en charge dans {0} +Hello,\ {0}=Bonjour, {0} +Where\ are\ you\ going?=O\u00f9 allez-vous ? +Verify\ your\ phone\ number\ to\ request\ a\ ride.=V\u00e9rifiez votre num\u00e9ro de t\u00e9l\u00e9phone pour commander une course. +Verify\ now=V\u00e9rifier maintenant +Home=Domicile +Work=Travail +Working\ out\ the\ fare=Calcul du tarif +No\ drivers\ nearby\ right\ now=Aucun chauffeur \u00e0 proximit\u00e9 pour le moment +1\ driver\ nearby,\ {0}\ away=1 chauffeur \u00e0 proximit\u00e9, \u00e0 {0} +{0}\ drivers\ nearby,\ the\ closest\ {1}\ away={0} chauffeurs \u00e0 proximit\u00e9, le plus proche \u00e0 {1} +Request\ ride=Commander la course +Cancel=Annuler +Payment=Paiement +Ride\ preferences=Pr\u00e9f\u00e9rences de course +Cash=Esp\u00e8ces +Card=Carte +Women\ drivers\ only=Conductrices uniquement +Pet\ friendly=Animaux accept\u00e9s +To\ {0}=Vers {0} +Nobody\ could\ take\ the\ ride\ just\ now.\ You\ have\ not\ been\ charged.=Personne n'a pu prendre la course. Vous n'avez pas \u00e9t\u00e9 d\u00e9bit\u00e9. +Your\ driver\ had\ to\ cancel.\ You\ have\ not\ been\ charged.=Votre chauffeur a d\u00fb annuler. Vous n'avez pas \u00e9t\u00e9 d\u00e9bit\u00e9. +The\ ride\ was\ cancelled.=La course a \u00e9t\u00e9 annul\u00e9e. +OK=OK +Call=Appeler +Calls\ to\ your\ driver\ are\ not\ available\ on\ this\ server=Les appels au chauffeur ne sont pas disponibles sur ce serveur +Message=Message +Messages\ to\ your\ driver\ are\ not\ available\ on\ this\ server=Les messages au chauffeur ne sont pas disponibles sur ce serveur +Share=Partager +I\ am\ on\ my\ way\ to\ {0}\ with\ {1}\ in\ a\ {2}\ ({3}).=Je suis en route vers {0} avec {1} dans une {2} ({3}). +You\ have\ arrived=Vous \u00eates arriv\u00e9 +How\ was\ the\ ride\ with\ {0}?=Comment s'est pass\u00e9e la course avec {0} ? +Thanks\ for\ the\ rating=Merci pour votre note +Not\ now=Pas maintenant +Admin\ mode=Mode admin +Driver\ mode=Mode chauffeur +Rider\ mode=Mode passager +Switch\ to=Passer \u00e0 +Riding=Passager +Driving=Conduite +Administration=Administration +Settings=R\u00e9glages +Sign\ out=Se d\u00e9connecter +No\ connection=Pas de connexion +The\ server\ at\ {0}\ did\ not\ answer.=Le serveur \u00e0 {0} n'a pas r\u00e9pondu. +Try\ again=R\u00e9essayer +Server\ address=Adresse du serveur +Create\ an\ account=Cr\u00e9er un compte +Name=Nom +What\ drivers\ will\ call\ you=Comment les chauffeurs vous appelleront +E-mail=E-mail +Mobile\ number=Num\u00e9ro de mobile +Password=Mot de passe +At\ least\ 8\ characters=Au moins 8 caract\u00e8res +I\ want\ to\ drive=Je veux conduire +Take\ ride\ requests\ and\ earn=Prenez des courses et gagnez de l'argent +Car=Voiture +Blue\ Toyota\ Prius=Toyota Prius bleue +Licence\ plate=Plaque d'immatriculation +Create\ account=Cr\u00e9er le compte +No\ rides\ yet=Pas encore de courses +{0}\ with\ {1}={0} avec {1} +Server=Serveur +The\ address\ of\ the\ Wayline\ server\ this\ app\ talks\ to.\ Changing\ it\ signs\ you\ out.=L'adresse du serveur Wayline utilis\u00e9 par cette application. La modifier vous d\u00e9connecte. +Address=Adresse +Save=Enregistrer +Account=Compte +Needed\ before\ your\ first\ ride=N\u00e9cessaire avant votre premi\u00e8re course +Verify=V\u00e9rifier +Any\ driver=Tout chauffeur +Women\ only=Femmes uniquement +Ask\ the\ driver\ to\ keep\ conversation\ short=Demander au chauffeur de limiter la conversation +Only\ cars\ that\ take\ a\ wheelchair=Uniquement des voitures acceptant un fauteuil roulant +Tell\ the\ driver\ an\ animal\ is\ coming=Pr\u00e9venir le chauffeur qu'un animal vous accompagne +Pay\ with=Payer avec +Tip=Pourboire +None=Aucun +Notifications=Notifications +Ride\ updates=Suivi de la course +Driver\ found,\ arriving,\ at\ the\ door=Chauffeur trouv\u00e9, en approche, devant la porte +Receipts=Re\u00e7us +After\ every\ ride=Apr\u00e8s chaque course +Offers=Offres +Discounts\ and\ news=R\u00e9ductions et nouveaut\u00e9s +Appearance=Apparence +Language=Langue +Theme=Th\u00e8me +System=Syst\u00e8me +Light=Clair +Dark=Sombre +Distances=Distances +Kilometres=Kilom\u00e8tres +Miles=Miles +Privacy\ and\ safety=Confidentialit\u00e9 et s\u00e9curit\u00e9 +Share\ my\ trips=Partager mes trajets +Emergency\ contact=Contact d'urgence +Their\ name=Son nom +Their\ phone\ number=Son num\u00e9ro de t\u00e9l\u00e9phone +About=\u00c0 propos +A\ sample\ ride\ service\ built\ with\ Codename\ One=Un service de courses d'exemple cr\u00e9\u00e9 avec Codename One +Terms\ of\ service=Conditions d'utilisation +Wayline\ is\ a\ sample.\ No\ real\ rides\ are\ booked,\ no\ real\ drivers\ are\ sent\ and\ no\ money\ changes\ hands.=Wayline est un exemple. Aucune course r\u00e9elle n'est r\u00e9serv\u00e9e, aucun chauffeur r\u00e9el n'est envoy\u00e9 et aucun argent n'est \u00e9chang\u00e9. +Privacy\ policy=Politique de confidentialit\u00e9 +Your\ position\ is\ sent\ to\ the\ server\ you\ signed\ in\ to\ while\ you\ ride\ or\ drive,\ and\ to\ nobody\ else.\ Settings\ are\ kept\ on\ this\ device.=Votre position est envoy\u00e9e au serveur auquel vous \u00eates connect\u00e9 pendant vos courses, et \u00e0 personne d'autre. Les r\u00e9glages restent sur cet appareil. +Sign\ in=Se connecter +Enter\ your\ e-mail\ and\ password.=Saisissez votre e-mail et votre mot de passe. +{0}\ ft={0} pi +{0}\ mi={0} mi +{0}\ m={0} m +{0}\ km={0} km +{0}\ min={0} min +Finding\ a\ driver=Recherche d'un chauffeur +Driver\ on\ the\ way=Chauffeur en route +Driver\ at\ pickup=Chauffeur sur place +Completed=Termin\u00e9e +Cancelled\ by\ rider=Annul\u00e9e par le passager +Cancelled\ by\ driver=Annul\u00e9e par le chauffeur +No\ driver\ found=Aucun chauffeur trouv\u00e9 +Verify\ your\ number=V\u00e9rifiez votre num\u00e9ro +We\ sent\ a\ six-digit\ code\ to\ your\ phone.=Nous avons envoy\u00e9 un code \u00e0 six chiffres sur votre t\u00e9l\u00e9phone. +We\ sent\ a\ six-digit\ code\ to\ {0}.=Nous avons envoy\u00e9 un code \u00e0 six chiffres au {0}. +Code=Code +This\ is\ a\ development\ server,\ so\ no\ text\ was\ sent.\ Your\ code\ is\ {0}.=Ceci est un serveur de d\u00e9veloppement, aucun SMS n'a donc \u00e9t\u00e9 envoy\u00e9. Votre code est {0}. +Send\ a\ new\ code=Envoyer un nouveau code +A\ ride\ when\ you\ need\ one,\ and\ a\ fair\ way\ to\ drive.=Une course quand il vous en faut une, et une fa\u00e7on \u00e9quitable de conduire. + +# Driving applications, payments, the admin console and the account. +1\ application\ to\ review=1 candidature \u00e0 examiner +{0}\ applications\ to\ review={0} candidatures \u00e0 examiner +1\ flagged\ account=1 compte signal\u00e9 +{0}\ flagged\ accounts={0} comptes signal\u00e9s +7\ days=7 jours +14\ days=14 jours +30\ days=30 jours +A\ car\ with\ four\ doors=Une voiture \u00e0 quatre portes +A\ driving\ licence=Un permis de conduire +A\ flag\ marks\ the\ account\ for\ the\ admins\ and\ changes\ nothing\ for\ its\ owner.\ A\ block\ signs\ it\ out\ and\ refuses\ it\ until\ lifted.=Un signalement d\u00e9signe le compte aux administrateurs et ne change rien pour son titulaire. Un blocage le d\u00e9connecte et le refuse jusqu'\u00e0 sa lev\u00e9e. +A\ passport\ or\ an\ identity\ card=Un passeport ou une carte d'identit\u00e9 +A\ photo\ of\ you=Une photo de vous +A\ ramp\ or\ a\ lift,\ and\ room\ to\ secure\ the\ chair=Une rampe ou un \u00e9l\u00e9vateur, et la place pour arrimer le fauteuil +A\ ride\ shows\ up\ here\ as\ soon\ as\ it\ is\ asked\ for.=Une course appara\u00eet ici d\u00e8s qu'elle est demand\u00e9e. +A\ ride\ shows\ up\ here\ when\ it\ is\ paid\ for.=Une course appara\u00eet ici une fois pay\u00e9e. +A\ sentence\ the\ record\ will\ keep=Une phrase qui restera au dossier +About\ ten\ minutes=Une dizaine de minutes +About\ you=\u00c0 propos de vous +Accepted=Accept\u00e9 +Account\ ending\ {0}=Compte se terminant par {0} +Account\ holder=Titulaire du compte +Account\ number=Num\u00e9ro de compte +Add\ a\ bank\ account=Ajouter un compte bancaire +Add\ a\ card=Ajouter une carte +Add\ a\ tip=Ajouter un pourboire +Added=Ajout\u00e9 +Admins=Administrateurs +All=Toutes +All\ of\ it\ goes\ to\ your\ driver.=Il revient int\u00e9gralement \u00e0 votre chauffeur. +All\ of\ it\ goes\ to\ {0}.=Il revient int\u00e9gralement \u00e0 {0}. +Another\ amount=Autre montant +Applicant=Candidat +Application=Candidature +Application\ received=Candidature re\u00e7ue +Application\ sent\ back=Candidature renvoy\u00e9e +Application\ sent.\ We\ will\ be\ in\ touch.=Candidature envoy\u00e9e. Nous vous recontacterons. +Application\ under\ review=Candidature en cours d'examen +Applications=Candidatures +Approve=Approuver +Approved=Approuv\u00e9e +Approved\ to\ drive=Autoris\u00e9 \u00e0 conduire +As\ it\ is\ written\ on\ your\ licence.\ Riders\ see\ only\ your\ first\ name.=Tel qu'il figure sur votre permis. Les passagers ne voient que votre pr\u00e9nom. +Average\ fare=Tarif moyen +Average\ wait=Attente moyenne +Back=Retour +Bank=Banque +Bank\ account=Compte bancaire +Base\ fare=Prise en charge +Block=Bloquer +Block\ account=Bloquer le compte +Blocked=Bloqu\u00e9 +Blue=Bleu +By\ kind\ of\ ride=Par type de course +By\ outcome=Par issue +By\ submitting\ you\ confirm\ that\ all\ of\ this\ is\ true\ and\ that\ the\ documents\ are\ your\ own.=En envoyant, vous confirmez que tout ceci est exact et que les documents sont les v\u00f4tres. +Calling\ is\ not\ available\ on\ this\ device=Les appels ne sont pas disponibles sur cet appareil +Cancel\ this\ ride=Annuler cette course +Cancelled=Annul\u00e9es +Cancelled\ by\ Wayline=Annul\u00e9e par Wayline +Card\ number=Num\u00e9ro de carte +Cash\ out=Encaisser +Change\ password=Changer le mot de passe +Change\ the\ password\ you\ sign\ in\ with=Changez le mot de passe de connexion +Changes\ needed=Modifications requises +Choose\ a\ ride=Choisissez une course +Choose\ an\ amount\ for\ the\ tip=Choisissez le montant du pourboire +Choose\ how\ to\ pay\ for\ this\ ride.=Choisissez comment payer cette course. +Collect\ {0}\ in\ cash=Encaissez {0} en esp\u00e8ces +Colour=Couleur +Comfort=Confort +Comfort\ is\ a\ newer,\ roomier\ car.\ XL\ seats\ six\ or\ more.=Confort : une voiture plus r\u00e9cente et plus spacieuse. XL : six places ou plus. +Commission,\ %=Commission, % +Contact=Contact +Continue=Continuer +Continue\ application=Reprendre la candidature +Current\ password=Mot de passe actuel +Date\ of\ birth=Date de naissance +Default=Par d\u00e9faut +Delete\ account=Supprimer le compte +Delete\ my\ account=Supprimer mon compte +Delete\ your\ account?=Supprimer votre compte ? +Discount=Remise +Distance=Distance +Documents=Documents +Documents\ checked=Documents v\u00e9rifi\u00e9s +Done=Termin\u00e9 +Draft=Brouillon +Drive\ with\ Wayline=Conduire avec Wayline +Drivers=Chauffeurs +Drivers\ apply\ after\ signing\ up\:\ a\ few\ details\ about\ you\ and\ your\ car,\ and\ five\ photos.\ You\ can\ ride\ in\ the\ meantime.=Les chauffeurs postulent apr\u00e8s l'inscription : quelques informations sur vous et votre voiture, et cinq photos. En attendant, vous pouvez voyager. +Drivers\ online=Chauffeurs en ligne +Driving\ licence=Permis de conduire +Earn\ on\ your\ own\ schedule=Gagnez \u00e0 votre rythme +Earned\ driving=Gagn\u00e9 en conduisant +Every\ application\ has\ had\ its\ answer.=Toutes les candidatures ont re\u00e7u leur r\u00e9ponse. +Everyone=Tous +Expires=Expire +Expires\ {0}=Expire {0} +Fare=Tarif +Finish\ your\ application=Terminez votre candidature +Fix\ and\ resubmit=Corriger et renvoyer +Flag=Signaler +Flag\ account=Signaler le compte +Flag\ removed=Signalement retir\u00e9 +Flagged=Signal\u00e9 +Gender=Genre +Have\ a\ last\ look.\ You\ can\ change\ anything\ until\ it\ is\ handed\ in.=Un dernier coup d'\u0153il. Vous pouvez tout modifier jusqu'\u00e0 l'envoi. +Hi,\ this\ is\ your\ Wayline\ driver.=Bonjour, c'est votre chauffeur Wayline. +Hi,\ this\ is\ your\ Wayline\ rider.=Bonjour, c'est votre passager Wayline. +History=Historique +I\ have\ finished=J'ai termin\u00e9 +I\ want\ to=Je veux +In\ good\ standing=En r\u00e8gle +Insurance=Assurance +Its\ number,\ its\ expiry\ date\ and\ a\ photo\ of\ it=Son num\u00e9ro, sa date d'expiration et une photo +Keep\ my\ account=Garder mon compte +Kind\ of\ ride=Type de course +Legal\ name=Nom l\u00e9gal +Licence=Permis +Licence\ expires=Expiration du permis +Licence\ number=Num\u00e9ro de permis +Make=Marque +Man=Homme +Messages\ are\ not\ available\ on\ this\ device=Les messages ne sont pas disponibles sur cet appareil +Minimum\ fare=Tarif minimum +Missing=Manquant +Model=Mod\u00e8le +Moderation=Mod\u00e9ration +Most\ of\ it\ is\ taking\ five\ photos=L'essentiel, c'est cinq photos \u00e0 prendre +Multipliers=Multiplicateurs +Name\ an\ emergency\ contact\ in\ your\ profile\ first=Indiquez d'abord un contact d'urgence dans votre profil +Name\ on\ the\ card=Nom sur la carte +Name,\ emergency\ contact,\ home\ and\ work=Nom, contact d'urgence, domicile et travail +Needed=Requis +New\ password=Nouveau mot de passe +New\ password,\ again=Nouveau mot de passe, encore +No=Non +No\ payments\ yet=Aucun paiement pour l'instant +No\ rides\ here=Aucune course ici +Nobody\ matches=Aucun r\u00e9sultat +Non-binary=Non binaire +Not\ provided=Non fourni +Not\ set=Non d\u00e9fini +Not\ started=Non commenc\u00e9e +Nothing\ has\ been\ done\ to\ this\ account.=Rien n'a \u00e9t\u00e9 fait sur ce compte. +Nothing\ has\ been\ paid\ out\ yet.=Rien n'a encore \u00e9t\u00e9 vers\u00e9. +Nothing\ here=Rien ici +Open\ the\ secure\ page=Ouvrir la page s\u00e9curis\u00e9e +Paid=Pay\u00e9 +Paid\ in\ cash=Pay\u00e9 en esp\u00e8ces +Paid\ with=Pay\u00e9 avec +Payment\ failed=Paiement \u00e9chou\u00e9 +Payment\ pending=Paiement en attente +Payments=Paiements +Payouts=Versements +People\ who\ asked\ to\ drive,\ and\ their\ papers=Ceux qui ont demand\u00e9 \u00e0 conduire, et leurs papiers +Per\ kilometre=Par kilom\u00e8tre +Per\ minute=Par minute +Photo\ ID=Pi\u00e8ce d'identit\u00e9 +Photograph\ each\ one\ flat,\ with\ all\ four\ corners\ in\ the\ picture.=Photographiez chacun \u00e0 plat, les quatre coins visibles. +Photos\ added=Photos ajout\u00e9es +Pricing=Tarification +Profile=Profil +Provided=Fourni +Rather\ not\ say=Je pr\u00e9f\u00e8re ne pas le dire +Rating=Note +Read\ what\ was\ missing,\ put\ it\ right,\ and\ send\ the\ application\ again.=Lisez ce qui manquait, corrigez-le et renvoyez la candidature. +Ready\ to\ cash\ out=Pr\u00eat \u00e0 encaisser +Reason=Motif +Receipt=Re\u00e7u +Refund=Rembourser +Refund\ this\ ride=Rembourser cette course +Refunded=Rembours\u00e9 +Registered\ and\ insured,\ with\ the\ papers\ to\ show\ it=Immatricul\u00e9e et assur\u00e9e, papiers \u00e0 l'appui +Rejected=Refus\u00e9 +Remove\ flag=Retirer le signalement +Remove\ this\ card=Retirer cette carte +Request\ {0}=Commander {0} +Requests\ accepted=Demandes accept\u00e9es +Requests\ by\ hour=Demandes par heure +Retake=Reprendre +Review\ and\ submit=V\u00e9rifier et envoyer +Reviewed=Examin\u00e9e +Reviewed\ by=Examin\u00e9e par +Riders=Passagers +Riders\ who\ ask\ for\ a\ woman\ at\ the\ wheel\ are\ offered\ to\ drivers\ who\ say\ they\ are\ one.=Les passagers qui demandent une femme au volant sont propos\u00e9s aux conductrices qui se d\u00e9clarent telles. +Right\ now=En ce moment +Roles\ changed=R\u00f4les modifi\u00e9s +Roles,\ flags,\ and\ blocking\ an\ account=R\u00f4les, signalements et blocage de comptes +Save\ card=Enregistrer la carte +Save\ prices=Enregistrer les tarifs +Saved\ places=Lieux enregistr\u00e9s +Say\ why,\ in\ a\ few\ words.=Dites pourquoi, en quelques mots. +Search\ by\ name\ or\ e-mail=Rechercher par nom ou e-mail +Seats\ for\ riders=Places passagers +Security\ code=Code de s\u00e9curit\u00e9 +Send\ back=Renvoyer +Send\ tip=Envoyer le pourboire +Saves\ {0}'s\ number\ with\ your\ account\ as\ your\ trip\ contact.\ No\ message\ is\ sent.=Enregistre le num\u00e9ro de {0} dans votre compte comme contact de trajet. Aucun message n'est envoy\u00e9. +Sent\ back=Renvoy\u00e9e +Service\ fee=Frais de service +Service\ fee,\ %=Frais de service, % +Spent\ riding=D\u00e9pens\u00e9 en courses +Standard=Standard +Start\ application=Commencer la candidature +Step\ {0}\ of\ {1}=\u00c9tape {0} sur {1} +Submit\ application=Envoyer la candidature +Submitted=Envoy\u00e9e +Surge,\ while\ demand\ is\ high=Majoration, quand la demande est forte +Takes\ a\ wheelchair=Accepte un fauteuil roulant +Tap\ a\ card\ to\ make\ it\ the\ one\ that\ pays\ unless\ you\ say\ otherwise.=Touchez une carte pour qu'elle paie par d\u00e9faut. +Tap\ to\ open=Touchez pour ouvrir +Tell\ us\ about\ you\ and\ your\ car,\ add\ five\ photos,\ and\ we\ take\ it\ from\ there.=Parlez-nous de vous et de votre voiture, ajoutez cinq photos, et nous faisons le reste. +Thank\ you.\ Your\ tip\ was\ sent.=Merci. Votre pourboire a \u00e9t\u00e9 envoy\u00e9. +That\ is\ not\ a\ card\ number.\ Check\ the\ digits.=Ce n'est pas un num\u00e9ro de carte. V\u00e9rifiez les chiffres. +That\ picture\ could\ not\ be\ read=Cette image n'a pas pu \u00eatre lue +That\ place\ could\ not\ be\ found.\ Choose\ it\ again.=Ce lieu est introuvable. Choisissez-le \u00e0 nouveau. +The\ busy\ hours,\ in\ UTC=Les heures de pointe, en UTC +The\ card\ was\ declined.\ Choose\ another\ way\ to\ pay\ in\ your\ wallet.=La carte a \u00e9t\u00e9 refus\u00e9e. Choisissez un autre moyen de paiement dans votre portefeuille. +The\ fare,\ in\ {0}=Le tarif, en {0} +The\ fee\ is\ added\ to\ the\ fare\ and\ paid\ by\ the\ rider.\ The\ commission\ is\ kept\ from\ the\ fare;\ the\ rest\ is\ the\ driver's.=Les frais s'ajoutent au tarif et sont pay\u00e9s par le passager. La commission est pr\u00e9lev\u00e9e sur le tarif ; le reste revient au chauffeur. +The\ last\ two\ weeks=Les deux derni\u00e8res semaines +The\ new\ prices\ are\ in\ force=Les nouveaux tarifs sont en vigueur +The\ number\ is\ on\ the\ front\ of\ the\ card.=Le num\u00e9ro figure au recto de la carte. +The\ page\ with\ the\ policy's\ dates\ on\ it=La page indiquant les dates de la police +The\ paper\ that\ says\ the\ car\ is\ yours=Le document attestant que la voiture est \u00e0 vous +The\ payment\ provider's\ own\ page\ opens\ in\ your\ browser.\ Wayline\ never\ sees\ the\ number.\ Come\ back\ here\ when\ you\ have\ finished.=La page du prestataire de paiement s'ouvre dans votre navigateur. Wayline ne voit jamais le num\u00e9ro. Revenez ici une fois termin\u00e9. +The\ ride\ was\ refunded=La course a \u00e9t\u00e9 rembours\u00e9e +Refund\ pending=Remboursement en attente +Refund\ failed=Remboursement \u00e9chou\u00e9 +The\ refund\ is\ pending=Le remboursement est en attente +The\ rider\ and\ the\ driver\ are\ both\ told,\ and\ nobody\ is\ charged.=Le passager et le chauffeur sont pr\u00e9venus, et personne n'est d\u00e9bit\u00e9. +The\ security\ code\ is\ the\ three\ or\ four\ digits\ on\ the\ card.=Le code de s\u00e9curit\u00e9 correspond aux trois ou quatre chiffres de la carte. +The\ service's\ share=La part du service +The\ side\ with\ your\ photo\ on\ it=Le c\u00f4t\u00e9 portant votre photo +The\ two\ new\ passwords\ are\ not\ the\ same.=Les deux nouveaux mots de passe sont diff\u00e9rents. +This\ device\ cannot\ prepare\ a\ picture\ to\ send=Cet appareil ne peut pas pr\u00e9parer une image \u00e0 envoyer +This\ month=Ce mois-ci +This\ picture\ could\ not\ be\ opened=Cette image n'a pas pu \u00eatre ouverte +This\ screen\ becomes\ your\ driver's\ home\ the\ moment\ you\ are\ approved=Cet \u00e9cran devient votre accueil chauffeur d\u00e8s votre approbation +This\ server\ takes\ no\ real\ payments.\ Use\ the\ test\ card\ 4242\ 4242\ 4242\ 4242\ with\ any\ date\ ahead\ and\ any\ three\ digits.=Ce serveur n'accepte aucun paiement r\u00e9el. Utilisez la carte de test 4242 4242 4242 4242 avec une date future et trois chiffres quelconques. +Time=Temps +Tip\ offered\ first=Pourboire propos\u00e9 d'abord +Tips=Pourboires +Top\ drivers=Meilleurs chauffeurs +Total=Total +Trends=Tendances +Trip=Trajet +Type\ the\ account\ number.=Saisissez le num\u00e9ro de compte. +Unblock=D\u00e9bloquer +Unblocked=D\u00e9bloqu\u00e9 +Under\ review=En cours d'examen +Usually\ within\ two\ working\ days=G\u00e9n\u00e9ralement sous deux jours ouvr\u00e9s +Vehicle=V\u00e9hicule +Vehicle\ registration=Carte grise +View\ receipt=Voir le re\u00e7u +Waiting=En attente +Wallet=Portefeuille +We\ could\ not\ approve\ this\ yet=Nous n'avons pas encore pu l'approuver +We\ have\ everything\ we\ asked\ for.\ Someone\ on\ our\ team\ is\ reading\ it.=Nous avons tout ce que nous avons demand\u00e9. Un membre de l'\u00e9quipe la lit. +What\ a\ ride\ costs,\ and\ the\ service's\ share=Le prix d'une course, et la part du service +What\ happens\ next=La suite +What\ the\ reviewer\ said=Ce qu'a dit l'examinateur +What\ was\ charged,\ and\ refunds=Les montants d\u00e9bit\u00e9s, et les remboursements +What\ you\ will\ need=Ce qu'il vous faut +Where\ your\ earnings\ are\ sent=O\u00f9 vos gains sont envoy\u00e9s +Why\ does\ {0}\ need\ the\ admins'\ attention?=Pourquoi {0} requiert-il l'attention des administrateurs ? +Woman=Femme +Write\ the\ expiry\ date\ as\ month\ and\ year\:\ 04/28.=Saisissez la date d'expiration sous la forme mois et ann\u00e9e : 04/28. +Year=Ann\u00e9e +Yes=Oui +You=Vous +You\ go\ online=Vous passez en ligne +Your\ application=Votre candidature +Your\ car=Votre voiture +Your\ card\ goes\ straight\ to\ the\ bank=Votre carte va directement \u00e0 la banque +Your\ cards,\ and\ which\ of\ them\ pays\ by\ default=Vos cartes, et celle qui paie par d\u00e9faut +Your\ documents=Vos documents +Your\ driver\ cannot\ be\ messaged\ right\ now=Impossible d'\u00e9crire \u00e0 votre chauffeur pour l'instant +Your\ face,\ in\ good\ light,\ no\ sunglasses=Votre visage, bien \u00e9clair\u00e9, sans lunettes de soleil +Your\ licence=Votre permis +Your\ password\ was\ changed=Votre mot de passe a \u00e9t\u00e9 modifi\u00e9 +Your\ position\ is\ sent\ to\ the\ server\ you\ signed\ in\ to\ while\ you\ ride\ or\ drive,\ and\ to\ nobody\ else.\ Your\ settings\ are\ kept\ with\ your\ account\ on\ that\ server.=Votre position est envoy\u00e9e au serveur auquel vous \u00eates connect\u00e9 pendant vos courses, et \u00e0 personne d'autre. Vos r\u00e9glages sont conserv\u00e9s avec votre compte sur ce serveur. +Your\ rider\ cannot\ be\ messaged\ right\ now=Impossible d'\u00e9crire \u00e0 votre passager pour l'instant +Your\ sign-in,\ your\ profile,\ your\ saved\ cards\ and\ your\ settings\ are\ removed\ for\ good.\ Rides\ you\ took\ stay\ in\ the\ service's\ records\ without\ your\ name.=Votre connexion, votre profil, vos cartes enregistr\u00e9es et vos r\u00e9glages sont supprim\u00e9s d\u00e9finitivement. Vos courses restent dans les registres du service sans votre nom. +{0}\ away=\u00e0 {0} +{0}\ can\ now\ drive={0} peut d\u00e9sormais conduire +{0}\ completed,\ {1}\ cancelled={0} termin\u00e9es, {1} annul\u00e9es +{0}\ days\ ago=il y a {0} jours +{0}\ goes\ back\ to\ the\ card\ it\ came\ from.={0} retourne sur la carte d'origine. +{0}\ is\ on\ its\ way\ to\ your\ bank={0} est en route vers votre banque +{0}\ is\ signed\ out\ everywhere\ and\ cannot\ sign\ in\ again\ until\ the\ block\ is\ lifted.={0} est d\u00e9connect\u00e9 partout et ne peut plus se connecter tant que le blocage n'est pas lev\u00e9. +{0}\ new\ accounts={0} nouveaux comptes +{0}\ of\ 5\ documents\ added.\ Pick\ up\ where\ you\ left\ off.={0} documents sur 5 ajout\u00e9s. Reprenez o\u00f9 vous en \u00e9tiez. +{0}\ rides={0} courses +{0}\ s={0} s +{0}\ sees\ what\ you\ write,\ and\ can\ apply\ again\ once\ it\ is\ put\ right.={0} voit ce que vous \u00e9crivez et peut postuler de nouveau une fois corrig\u00e9. +Fill\ in\ everything\ on\ this\ step\ to\ continue.=Remplissez tout sur cette \u00e9tape pour continuer. +None\ nearby=Aucun \u00e0 proximit\u00e9 +Pay\ the\ driver\ directly=Payez directement le chauffeur +Camera=Appareil photo +Gallery=Galerie +Fares\ collected=Recettes encaiss\u00e9es +Takes\ pets=Accepte les animaux +Riders\ travelling\ with\ a\ pet\ are\ offered\ to\ you=Les passagers voyageant avec un animal vous sont propos\u00e9s +Go=Aller +Nothing\ selected=Aucune s\u00e9lection +Pick\ a\ row\ from\ the\ list\ to\ see\ it\ here.=Choisissez une ligne de la liste pour l'afficher ici. +Usage\ statistics=Statistiques d'utilisation +Share\ which\ screens\ and\ features\ are\ used.\ Never\ your\ name,\ places\ or\ payment\ details.=Partagez quels \u00e9crans et quelles fonctions sont utilis\u00e9s. Jamais votre nom, vos lieux ni vos donn\u00e9es de paiement. +Help\ improve\ Wayline=Aidez \u00e0 am\u00e9liorer Wayline +Wayline\ can\ count\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ so\ that\ we\ can\ see\ what\ works\ and\ fix\ what\ does\ not.=Wayline peut compter quels \u00e9crans sont ouverts et quelles fonctions sont utilis\u00e9es, pour que nous puissions voir ce qui marche et corriger ce qui ne marche pas. +No\ personal\ details=Aucune donn\u00e9e personnelle +Never\ your\ name,\ e-mail,\ phone\ number,\ places\ or\ card\ details=Jamais votre nom, votre e-mail, votre num\u00e9ro de t\u00e9l\u00e9phone, vos lieux ni les donn\u00e9es de votre carte +Turn\ it\ on\ or\ off\ at\ any\ time\ in\ Settings=Activez ou d\u00e9sactivez-les \u00e0 tout moment dans R\u00e9glages +Share\ usage\ statistics=Partager les statistiques d'utilisation +With\ usage\ statistics\ turned\ on,\ the\ app\ also\ counts\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ and\ reports\ that\ to\ Codename\ One's\ analytics\ service\ under\ a\ made-up\ id\ for\ this\ device.\ Your\ name,\ e-mail,\ phone\ number,\ places\ and\ card\ details\ are\ never\ part\ of\ it,\ and\ turning\ it\ off\ in\ Settings\ stops\ it.=Lorsque les statistiques d'utilisation sont activ\u00e9es, l'application compte aussi quels \u00e9crans sont ouverts et quelles fonctions sont utilis\u00e9es, et le transmet au service d'analyse de Codename One sous un identifiant invent\u00e9 pour cet appareil. Votre nom, votre e-mail, votre num\u00e9ro de t\u00e9l\u00e9phone, vos lieux et les donn\u00e9es de votre carte n'en font jamais partie, et les d\u00e9sactiver dans R\u00e9glages y met fin. +Location\ is\ turned\ off.\ Try\ again=La localisation est d\u00e9sactiv\u00e9e. R\u00e9essayer +Can't\ find\ your\ location.\ Try\ again=Position introuvable. R\u00e9essayer +Still\ looking\ for\ your\ location.\ Try\ again=Recherche de votre position en cours. R\u00e9essayer +Looking\ for\ your\ location...=Recherche de votre position... diff --git a/scripts/wayline/common/src/main/l10n/Bundle_he.properties b/scripts/wayline/common/src/main/l10n/Bundle_he.properties new file mode 100644 index 00000000000..a5848b0ede7 --- /dev/null +++ b/scripts/wayline/common/src/main/l10n/Bundle_he.properties @@ -0,0 +1,532 @@ +# Wayline in Hebrew. The key of every line is the English text the code +# names; see ui/Lang.java for how a line is found and how to add one. +@rtl=true +language.name=\u05e2\u05d1\u05e8\u05d9\u05ea + +Admin=\u05de\u05e0\u05d4\u05dc +Dashboard=\u05dc\u05d5\u05d7 \u05d1\u05e7\u05e8\u05d4 +Live\ map=\u05de\u05e4\u05d4 \u05d7\u05d9\u05d4 +Rides=\u05e0\u05e1\u05d9\u05e2\u05d5\u05ea +People=\u05d0\u05e0\u05e9\u05d9\u05dd +Operations=\u05ea\u05e4\u05e2\u05d5\u05dc +Wayline\ administration=\u05e0\u05d9\u05d4\u05d5\u05dc Wayline +Today=\u05d4\u05d9\u05d5\u05dd +Manage=\u05e0\u05d9\u05d4\u05d5\u05dc +Where\ every\ car\ online\ is=\u05d4\u05d9\u05db\u05df \u05e0\u05de\u05e6\u05d0 \u05db\u05dc \u05e8\u05db\u05d1 \u05de\u05d7\u05d5\u05d1\u05e8 +The\ most\ recent,\ in\ every\ state=\u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d5\u05ea, \u05d1\u05db\u05dc \u05de\u05e6\u05d1 +Roles,\ and\ suspending\ an\ account=\u05ea\u05e4\u05e7\u05d9\u05d3\u05d9\u05dd \u05d5\u05d4\u05e9\u05e2\u05d9\u05d9\u05ea \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea +Rides\ today=\u05e0\u05e1\u05d9\u05e2\u05d5\u05ea \u05d4\u05d9\u05d5\u05dd +Under\ way=\u05d1\u05d3\u05e8\u05da +Online=\u05de\u05d7\u05d5\u05d1\u05e8 +Offline=\u05de\u05e0\u05d5\u05ea\u05e7 +Fares=\u05d4\u05db\u05e0\u05e1\u05d5\u05ea +Cars\ move\ as\ their\ drivers\ report\ in.=\u05d4\u05e8\u05db\u05d1\u05d9\u05dd \u05d6\u05d6\u05d9\u05dd \u05dc\u05e4\u05d9 \u05d3\u05d9\u05d5\u05d5\u05d7\u05d9 \u05d4\u05e0\u05d4\u05d2\u05d9\u05dd. +No\ cars\ online=\u05d0\u05d9\u05df \u05e8\u05db\u05d1\u05d9\u05dd \u05de\u05d7\u05d5\u05d1\u05e8\u05d9\u05dd +1\ car\ online=\u05e8\u05db\u05d1 \u05d0\u05d7\u05d3 \u05de\u05d7\u05d5\u05d1\u05e8 +{0}\ cars\ online={0} \u05e8\u05db\u05d1\u05d9\u05dd \u05de\u05d7\u05d5\u05d1\u05e8\u05d9\u05dd +Suspended=\u05de\u05d5\u05e9\u05e2\u05d4 +Driver=\u05e0\u05d4\u05d2 +Rider=\u05e0\u05d5\u05e1\u05e2 +Verified=\u05de\u05d0\u05d5\u05de\u05ea +Not\ verified=\u05dc\u05d0 \u05de\u05d0\u05d5\u05de\u05ea +May\ go\ online\ and\ take\ rides=\u05e8\u05e9\u05d0\u05d9 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05d5\u05dc\u05e7\u05d1\u05dc \u05e0\u05e1\u05d9\u05e2\u05d5\u05ea +May\ see\ and\ change\ everything\ here=\u05e8\u05e9\u05d0\u05d9 \u05dc\u05e8\u05d0\u05d5\u05ea \u05d5\u05dc\u05e9\u05e0\u05d5\u05ea \u05db\u05d0\u05df \u05d4\u05db\u05d5\u05dc +Signed\ out,\ and\ refused\ until\ lifted=\u05de\u05e0\u05d5\u05ea\u05e7, \u05d5\u05e0\u05d3\u05d7\u05d4 \u05e2\u05d3 \u05dc\u05d4\u05e1\u05e8\u05ea \u05d4\u05d4\u05e9\u05e2\u05d9\u05d4 +Access=\u05d2\u05d9\u05e9\u05d4 +Drive=\u05e0\u05d4\u05d9\u05d2\u05d4 +Your\ trips=\u05d4\u05e0\u05e1\u05d9\u05e2\u05d5\u05ea \u05e9\u05dc\u05da +Earnings=\u05d4\u05db\u05e0\u05e1\u05d5\u05ea +The\ request\ went\ to\ another\ driver=\u05d4\u05d1\u05e7\u05e9\u05d4 \u05e2\u05d1\u05e8\u05d4 \u05dc\u05e0\u05d4\u05d2 \u05d0\u05d7\u05e8 +The\ rider\ cancelled=\u05d4\u05e0\u05d5\u05e1\u05e2 \u05d1\u05d9\u05d8\u05dc +Today,\ {0}\ trips=\u05d4\u05d9\u05d5\u05dd, {0} \u05e0\u05e1\u05d9\u05e2\u05d5\u05ea +Looking\ for\ rides=\u05de\u05d7\u05e4\u05e9 \u05e0\u05e1\u05d9\u05e2\u05d5\u05ea +You\ are\ offline=\u05d0\u05d9\u05e0\u05da \u05de\u05d7\u05d5\u05d1\u05e8 +Trip\ complete\:\ {0}=\u05d4\u05e0\u05e1\u05d9\u05e2\u05d4 \u05d4\u05d5\u05e9\u05dc\u05de\u05d4: {0} +Stay\ close\ to\ busy\ streets;\ requests\ come\ to\ the\ nearest\ car.=\u05d4\u05d9\u05e9\u05d0\u05e8 \u05dc\u05d9\u05d3 \u05e8\u05d7\u05d5\u05d1\u05d5\u05ea \u05e1\u05d5\u05d0\u05e0\u05d9\u05dd; \u05d4\u05d1\u05e7\u05e9\u05d5\u05ea \u05de\u05d2\u05d9\u05e2\u05d5\u05ea \u05dc\u05e8\u05db\u05d1 \u05d4\u05e7\u05e8\u05d5\u05d1 \u05d1\u05d9\u05d5\u05ea\u05e8. +Go\ online\ to\ start\ taking\ rides.=\u05d4\u05ea\u05d7\u05d1\u05e8 \u05db\u05d3\u05d9 \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05dc\u05e7\u05d1\u05dc \u05e0\u05e1\u05d9\u05e2\u05d5\u05ea. +GO=\u05e6\u05d0 +This\ week=\u05d4\u05e9\u05d1\u05d5\u05e2 +Trips=\u05e0\u05e1\u05d9\u05e2\u05d5\u05ea +All\ time=\u05e1\u05da \u05d4\u05db\u05d5\u05dc +Go\ offline=\u05d4\u05ea\u05e0\u05ea\u05e7 +Go\ online=\u05d4\u05ea\u05d7\u05d1\u05e8 +Ride\ request=\u05d1\u05e7\u05e9\u05ea \u05e0\u05e1\u05d9\u05e2\u05d4 +{0}\ away,\ {1}=\u05d1\u05de\u05e8\u05d7\u05e7 {0}, {1} +Accept=\u05e7\u05d1\u05dc +Decline=\u05d3\u05d7\u05d4 +Pick\ up\ {0}=\u05dc\u05d0\u05e1\u05d5\u05e3 \u05d0\u05ea {0} +Waiting\ for\ {0}=\u05de\u05de\u05ea\u05d9\u05df \u05dc-{0} +On\ the\ trip=\u05d1\u05e0\u05e1\u05d9\u05e2\u05d4 +Drop-off=\u05d9\u05e2\u05d3 +Pickup=\u05d0\u05d9\u05e1\u05d5\u05e3 +I\ have\ arrived=\u05d4\u05d2\u05e2\u05ea\u05d9 +Start\ trip=\u05d4\u05ea\u05d7\u05dc \u05e0\u05e1\u05d9\u05e2\u05d4 +Complete\ trip=\u05e1\u05d9\u05d9\u05dd \u05e0\u05e1\u05d9\u05e2\u05d4 +Cancel\ ride=\u05d1\u05d8\u05dc \u05e0\u05e1\u05d9\u05e2\u05d4 +Quiet\ ride=\u05e0\u05e1\u05d9\u05e2\u05d4 \u05e9\u05e7\u05d8\u05d4 +Accessible\ vehicle=\u05e8\u05db\u05d1 \u05e0\u05d2\u05d9\u05e9 +Could\ not\ reach\ the\ server.\ Check\ your\ connection.=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05d2\u05d9\u05e2 \u05dc\u05e9\u05e8\u05ea. \u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea \u05d4\u05d7\u05d9\u05d1\u05d5\u05e8. +Please\ sign\ in\ again.=\u05e0\u05d0 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05de\u05d7\u05d3\u05e9. +Your\ account\ is\ not\ allowed\ to\ do\ that.=\u05dc\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da \u05d0\u05d9\u05df \u05d4\u05e8\u05e9\u05d0\u05d4 \u05dc\u05db\u05da. +Too\ many\ attempts.\ Wait\ a\ moment\ and\ try\ again.=\u05d9\u05d5\u05ea\u05e8 \u05de\u05d3\u05d9 \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea. \u05d4\u05de\u05ea\u05df \u05e8\u05d2\u05e2 \u05d5\u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. +Too\ many\ attempts.\ Wait\ a\ minute\ and\ try\ again.=\u05d9\u05d5\u05ea\u05e8 \u05de\u05d3\u05d9 \u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea. \u05d4\u05de\u05ea\u05df \u05d3\u05e7\u05d4 \u05d5\u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. +Something\ went\ wrong\ ({0}).\ Try\ again.=\u05de\u05e9\u05d4\u05d5 \u05d4\u05e9\u05ea\u05d1\u05e9 ({0}). \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. +That\ e-mail\ and\ password\ do\ not\ match\ an\ account.=\u05d4\u05d3\u05d5\u05d0"\u05dc \u05d5\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05d0\u05d9\u05e0\u05dd \u05ea\u05d5\u05d0\u05de\u05d9\u05dd \u05dc\u05d7\u05e9\u05d1\u05d5\u05df. +Signing\ in\ failed.\ Try\ again.=\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4. \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. +Where\ to?=\u05dc\u05d0\u05df? +Destination=\u05d9\u05e2\u05d3 +An\ address\ or\ a\ place=\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05d5 \u05de\u05e7\u05d5\u05dd +Nothing\ found=\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0 \u05d3\u05d1\u05e8 +Current\ location=\u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05d4\u05e0\u05d5\u05db\u05d7\u05d9 +Ride=\u05e0\u05e1\u05d9\u05e2\u05d4 +Your\ rides=\u05d4\u05e0\u05e1\u05d9\u05e2\u05d5\u05ea \u05e9\u05dc\u05da +Your\ driver\ is\ here=\u05d4\u05e0\u05d4\u05d2 \u05e9\u05dc\u05da \u05db\u05d0\u05df +{0}\ to\ destination={0} \u05e2\u05d3 \u05d4\u05d9\u05e2\u05d3 +Pickup\ in\ {0}=\u05d0\u05d9\u05e1\u05d5\u05e3 \u05d1\u05e2\u05d5\u05d3 {0} +Hello,\ {0}=\u05e9\u05dc\u05d5\u05dd, {0} +Where\ are\ you\ going?=\u05dc\u05d0\u05df \u05e0\u05d5\u05e1\u05e2\u05d9\u05dd? +Verify\ your\ phone\ number\ to\ request\ a\ ride.=\u05d0\u05de\u05ea \u05d0\u05ea \u05de\u05e1\u05e4\u05e8 \u05d4\u05d8\u05dc\u05e4\u05d5\u05df \u05db\u05d3\u05d9 \u05dc\u05d4\u05d6\u05de\u05d9\u05df \u05e0\u05e1\u05d9\u05e2\u05d4. +Verify\ now=\u05d0\u05de\u05ea \u05e2\u05db\u05e9\u05d9\u05d5 +Home=\u05d1\u05d9\u05ea +Work=\u05e2\u05d1\u05d5\u05d3\u05d4 +Working\ out\ the\ fare=\u05de\u05d7\u05e9\u05d1 \u05d0\u05ea \u05d4\u05de\u05d7\u05d9\u05e8 +No\ drivers\ nearby\ right\ now=\u05d0\u05d9\u05df \u05db\u05e8\u05d2\u05e2 \u05e0\u05d4\u05d2\u05d9\u05dd \u05d1\u05e7\u05e8\u05d1\u05ea \u05de\u05e7\u05d5\u05dd +1\ driver\ nearby,\ {0}\ away=\u05e0\u05d4\u05d2 \u05d0\u05d7\u05d3 \u05d1\u05e7\u05e8\u05d1\u05ea \u05de\u05e7\u05d5\u05dd, \u05d1\u05de\u05e8\u05d7\u05e7 {0} +{0}\ drivers\ nearby,\ the\ closest\ {1}\ away={0} \u05e0\u05d4\u05d2\u05d9\u05dd \u05d1\u05e7\u05e8\u05d1\u05ea \u05de\u05e7\u05d5\u05dd, \u05d4\u05e7\u05e8\u05d5\u05d1 \u05d1\u05d9\u05d5\u05ea\u05e8 \u05d1\u05de\u05e8\u05d7\u05e7 {1} +Request\ ride=\u05d4\u05d6\u05de\u05df \u05e0\u05e1\u05d9\u05e2\u05d4 +Cancel=\u05d1\u05d9\u05d8\u05d5\u05dc +Payment=\u05ea\u05e9\u05dc\u05d5\u05dd +Ride\ preferences=\u05d4\u05e2\u05d3\u05e4\u05d5\u05ea \u05e0\u05e1\u05d9\u05e2\u05d4 +Cash=\u05de\u05d6\u05d5\u05de\u05df +Card=\u05db\u05e8\u05d8\u05d9\u05e1 +Women\ drivers\ only=\u05e0\u05d4\u05d2\u05d5\u05ea \u05d1\u05dc\u05d1\u05d3 +Pet\ friendly=\u05d9\u05d3\u05d9\u05d3\u05d5\u05ea\u05d9 \u05dc\u05d7\u05d9\u05d5\u05ea \u05de\u05d7\u05de\u05d3 +To\ {0}=\u05d0\u05dc {0} +Nobody\ could\ take\ the\ ride\ just\ now.\ You\ have\ not\ been\ charged.=\u05d0\u05e3 \u05d0\u05d7\u05d3 \u05dc\u05d0 \u05d9\u05db\u05d5\u05dc \u05d4\u05d9\u05d4 \u05dc\u05e7\u05d7\u05ea \u05d0\u05ea \u05d4\u05e0\u05e1\u05d9\u05e2\u05d4 \u05db\u05e2\u05ea. \u05dc\u05d0 \u05d7\u05d5\u05d9\u05d1\u05ea. +Your\ driver\ had\ to\ cancel.\ You\ have\ not\ been\ charged.=\u05d4\u05e0\u05d4\u05d2 \u05e0\u05d0\u05dc\u05e5 \u05dc\u05d1\u05d8\u05dc. \u05dc\u05d0 \u05d7\u05d5\u05d9\u05d1\u05ea. +The\ ride\ was\ cancelled.=\u05d4\u05e0\u05e1\u05d9\u05e2\u05d4 \u05d1\u05d5\u05d8\u05dc\u05d4. +OK=\u05d0\u05d9\u05e9\u05d5\u05e8 +Call=\u05d4\u05ea\u05e7\u05e9\u05e8 +Calls\ to\ your\ driver\ are\ not\ available\ on\ this\ server=\u05e9\u05d9\u05d7\u05d5\u05ea \u05dc\u05e0\u05d4\u05d2 \u05d0\u05d9\u05e0\u05df \u05d6\u05de\u05d9\u05e0\u05d5\u05ea \u05d1\u05e9\u05e8\u05ea \u05d6\u05d4 +Message=\u05d4\u05d5\u05d3\u05e2\u05d4 +Messages\ to\ your\ driver\ are\ not\ available\ on\ this\ server=\u05d4\u05d5\u05d3\u05e2\u05d5\u05ea \u05dc\u05e0\u05d4\u05d2 \u05d0\u05d9\u05e0\u05df \u05d6\u05de\u05d9\u05e0\u05d5\u05ea \u05d1\u05e9\u05e8\u05ea \u05d6\u05d4 +Share=\u05e9\u05ea\u05e3 +I\ am\ on\ my\ way\ to\ {0}\ with\ {1}\ in\ a\ {2}\ ({3}).=\u05d0\u05e0\u05d9 \u05d1\u05d3\u05e8\u05da \u05d0\u05dc {0} \u05e2\u05dd {1} \u05d1\u05e8\u05db\u05d1 {2} ({3}). +You\ have\ arrived=\u05d4\u05d2\u05e2\u05ea +How\ was\ the\ ride\ with\ {0}?=\u05d0\u05d9\u05da \u05d4\u05d9\u05d9\u05ea\u05d4 \u05d4\u05e0\u05e1\u05d9\u05e2\u05d4 \u05e2\u05dd {0}? +Thanks\ for\ the\ rating=\u05ea\u05d5\u05d3\u05d4 \u05e2\u05dc \u05d4\u05d3\u05d9\u05e8\u05d5\u05d2 +Not\ now=\u05dc\u05d0 \u05e2\u05db\u05e9\u05d9\u05d5 +Admin\ mode=\u05de\u05e6\u05d1 \u05de\u05e0\u05d4\u05dc +Driver\ mode=\u05de\u05e6\u05d1 \u05e0\u05d4\u05d2 +Rider\ mode=\u05de\u05e6\u05d1 \u05e0\u05d5\u05e1\u05e2 +Switch\ to=\u05e2\u05d1\u05d5\u05e8 \u05d0\u05dc +Riding=\u05e0\u05e1\u05d9\u05e2\u05d4 +Driving=\u05e0\u05d4\u05d9\u05d2\u05d4 +Administration=\u05e0\u05d9\u05d4\u05d5\u05dc +Settings=\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea +Sign\ out=\u05d4\u05ea\u05e0\u05ea\u05e7 +No\ connection=\u05d0\u05d9\u05df \u05d7\u05d9\u05d1\u05d5\u05e8 +The\ server\ at\ {0}\ did\ not\ answer.=\u05d4\u05e9\u05e8\u05ea \u05d1\u05db\u05ea\u05d5\u05d1\u05ea {0} \u05dc\u05d0 \u05e2\u05e0\u05d4. +Try\ again=\u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1 +Server\ address=\u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05e9\u05e8\u05ea +Create\ an\ account=\u05e6\u05d5\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df +Name=\u05e9\u05dd +What\ drivers\ will\ call\ you=\u05db\u05da \u05d9\u05e7\u05e8\u05d0\u05d5 \u05dc\u05da \u05d4\u05e0\u05d4\u05d2\u05d9\u05dd +E-mail=\u05d3\u05d5\u05d0"\u05dc +Mobile\ number=\u05de\u05e1\u05e4\u05e8 \u05e0\u05d9\u05d9\u05d3 +Password=\u05e1\u05d9\u05e1\u05de\u05d4 +At\ least\ 8\ characters=\u05dc\u05e4\u05d7\u05d5\u05ea 8 \u05ea\u05d5\u05d5\u05d9\u05dd +I\ want\ to\ drive=\u05d0\u05e0\u05d9 \u05e8\u05d5\u05e6\u05d4 \u05dc\u05e0\u05d4\u05d5\u05d2 +Take\ ride\ requests\ and\ earn=\u05e7\u05d1\u05dc \u05d1\u05e7\u05e9\u05d5\u05ea \u05e0\u05e1\u05d9\u05e2\u05d4 \u05d5\u05d4\u05e8\u05d5\u05d5\u05d7 +Car=\u05e8\u05db\u05d1 +Blue\ Toyota\ Prius=\u05d8\u05d5\u05d9\u05d5\u05d8\u05d4 \u05e4\u05e8\u05d9\u05d5\u05e1 \u05db\u05d7\u05d5\u05dc\u05d4 +Licence\ plate=\u05dc\u05d5\u05d7\u05d9\u05ea \u05e8\u05d9\u05e9\u05d5\u05d9 +Create\ account=\u05e6\u05d5\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df +No\ rides\ yet=\u05d0\u05d9\u05df \u05e2\u05d3\u05d9\u05d9\u05df \u05e0\u05e1\u05d9\u05e2\u05d5\u05ea +{0}\ with\ {1}={0} \u05e2\u05dd {1} +Server=\u05e9\u05e8\u05ea +The\ address\ of\ the\ Wayline\ server\ this\ app\ talks\ to.\ Changing\ it\ signs\ you\ out.=\u05db\u05ea\u05d5\u05d1\u05ea \u05e9\u05e8\u05ea Wayline \u05e9\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e4\u05d5\u05e0\u05d4 \u05d0\u05dc\u05d9\u05d5. \u05e9\u05d9\u05e0\u05d5\u05d9 \u05e9\u05dc\u05d4 \u05d9\u05e0\u05ea\u05e7 \u05d0\u05d5\u05ea\u05da. +Address=\u05db\u05ea\u05d5\u05d1\u05ea +Save=\u05e9\u05de\u05d5\u05e8 +Account=\u05d7\u05e9\u05d1\u05d5\u05df +Needed\ before\ your\ first\ ride=\u05e0\u05d3\u05e8\u05e9 \u05dc\u05e4\u05e0\u05d9 \u05d4\u05e0\u05e1\u05d9\u05e2\u05d4 \u05d4\u05e8\u05d0\u05e9\u05d5\u05e0\u05d4 +Verify=\u05d0\u05de\u05ea +Any\ driver=\u05db\u05dc \u05e0\u05d4\u05d2 +Women\ only=\u05e0\u05e9\u05d9\u05dd \u05d1\u05dc\u05d1\u05d3 +Ask\ the\ driver\ to\ keep\ conversation\ short=\u05d1\u05e7\u05e9 \u05de\u05d4\u05e0\u05d4\u05d2 \u05dc\u05de\u05e2\u05d8 \u05d1\u05e9\u05d9\u05d7\u05d4 +Only\ cars\ that\ take\ a\ wheelchair=\u05e8\u05e7 \u05e8\u05db\u05d1\u05d9\u05dd \u05d4\u05de\u05ea\u05d0\u05d9\u05de\u05d9\u05dd \u05dc\u05db\u05d9\u05e1\u05d0 \u05d2\u05dc\u05d2\u05dc\u05d9\u05dd +Tell\ the\ driver\ an\ animal\ is\ coming=\u05d4\u05d5\u05d3\u05e2 \u05dc\u05e0\u05d4\u05d2 \u05e9\u05de\u05e6\u05d8\u05e8\u05e4\u05ea \u05d7\u05d9\u05d4 +Pay\ with=\u05ea\u05e9\u05dc\u05d5\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea +Tip=\u05d8\u05d9\u05e4 +None=\u05dc\u05dc\u05d0 +Notifications=\u05d4\u05ea\u05e8\u05d0\u05d5\u05ea +Ride\ updates=\u05e2\u05d3\u05db\u05d5\u05e0\u05d9 \u05e0\u05e1\u05d9\u05e2\u05d4 +Driver\ found,\ arriving,\ at\ the\ door=\u05e0\u05de\u05e6\u05d0 \u05e0\u05d4\u05d2, \u05de\u05ea\u05e7\u05e8\u05d1, \u05d1\u05e4\u05ea\u05d7 +Receipts=\u05e7\u05d1\u05dc\u05d5\u05ea +After\ every\ ride=\u05d0\u05d7\u05e8\u05d9 \u05db\u05dc \u05e0\u05e1\u05d9\u05e2\u05d4 +Offers=\u05de\u05d1\u05e6\u05e2\u05d9\u05dd +Discounts\ and\ news=\u05d4\u05e0\u05d7\u05d5\u05ea \u05d5\u05d7\u05d3\u05e9\u05d5\u05ea +Appearance=\u05de\u05e8\u05d0\u05d4 +Language=\u05e9\u05e4\u05d4 +Theme=\u05e2\u05e8\u05db\u05ea \u05e0\u05d5\u05e9\u05d0 +System=\u05de\u05e2\u05e8\u05db\u05ea +Light=\u05d1\u05d4\u05d9\u05e8 +Dark=\u05db\u05d4\u05d4 +Distances=\u05de\u05e8\u05d7\u05e7\u05d9\u05dd +Kilometres=\u05e7\u05d9\u05dc\u05d5\u05de\u05d8\u05e8\u05d9\u05dd +Miles=\u05de\u05d9\u05d9\u05dc\u05d9\u05dd +Privacy\ and\ safety=\u05e4\u05e8\u05d8\u05d9\u05d5\u05ea \u05d5\u05d1\u05d8\u05d9\u05d7\u05d5\u05ea +Share\ my\ trips=\u05e9\u05ea\u05e3 \u05d0\u05ea \u05d4\u05e0\u05e1\u05d9\u05e2\u05d5\u05ea \u05e9\u05dc\u05d9 +Emergency\ contact=\u05d0\u05d9\u05e9 \u05e7\u05e9\u05e8 \u05dc\u05d7\u05d9\u05e8\u05d5\u05dd +Their\ name=\u05d4\u05e9\u05dd +Their\ phone\ number=\u05de\u05e1\u05e4\u05e8 \u05d4\u05d8\u05dc\u05e4\u05d5\u05df +About=\u05d0\u05d5\u05d3\u05d5\u05ea +A\ sample\ ride\ service\ built\ with\ Codename\ One=\u05e9\u05d9\u05e8\u05d5\u05ea \u05e0\u05e1\u05d9\u05e2\u05d5\u05ea \u05dc\u05d3\u05d5\u05d2\u05de\u05d4 \u05e9\u05e0\u05d1\u05e0\u05d4 \u05e2\u05dd Codename One +Terms\ of\ service=\u05ea\u05e0\u05d0\u05d9 \u05d4\u05e9\u05d9\u05e8\u05d5\u05ea +Wayline\ is\ a\ sample.\ No\ real\ rides\ are\ booked,\ no\ real\ drivers\ are\ sent\ and\ no\ money\ changes\ hands.=Wayline \u05d4\u05d9\u05d0 \u05d3\u05d5\u05d2\u05de\u05d4. \u05dc\u05d0 \u05de\u05d5\u05d6\u05de\u05e0\u05d5\u05ea \u05e0\u05e1\u05d9\u05e2\u05d5\u05ea \u05d0\u05de\u05d9\u05ea\u05d9\u05d5\u05ea, \u05dc\u05d0 \u05e0\u05e9\u05dc\u05d7\u05d9\u05dd \u05e0\u05d4\u05d2\u05d9\u05dd \u05d0\u05de\u05d9\u05ea\u05d9\u05d9\u05dd \u05d5\u05db\u05e1\u05e3 \u05d0\u05d9\u05e0\u05d5 \u05de\u05d7\u05dc\u05d9\u05e3 \u05d9\u05d3\u05d9\u05d9\u05dd. +Privacy\ policy=\u05de\u05d3\u05d9\u05e0\u05d9\u05d5\u05ea \u05e4\u05e8\u05d8\u05d9\u05d5\u05ea +Your\ position\ is\ sent\ to\ the\ server\ you\ signed\ in\ to\ while\ you\ ride\ or\ drive,\ and\ to\ nobody\ else.\ Settings\ are\ kept\ on\ this\ device.=\u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05e9\u05dc\u05da \u05e0\u05e9\u05dc\u05d7 \u05dc\u05e9\u05e8\u05ea \u05e9\u05d0\u05dc\u05d9\u05d5 \u05d4\u05ea\u05d7\u05d1\u05e8\u05ea \u05d1\u05d6\u05de\u05df \u05e0\u05e1\u05d9\u05e2\u05d4 \u05d0\u05d5 \u05e0\u05d4\u05d9\u05d2\u05d4, \u05d5\u05dc\u05d0\u05e3 \u05d0\u05d7\u05d3 \u05d0\u05d7\u05e8. \u05d4\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05e0\u05e9\u05de\u05e8\u05d5\u05ea \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05d6\u05d4. +Sign\ in=\u05d4\u05ea\u05d7\u05d1\u05e8 +Enter\ your\ e-mail\ and\ password.=\u05d4\u05d6\u05df \u05d3\u05d5\u05d0"\u05dc \u05d5\u05e1\u05d9\u05e1\u05de\u05d4. +{0}\ ft={0} \u05e8\u05d2\u05dc +{0}\ mi={0} \u05de\u05d9\u05d9\u05dc +{0}\ m={0} \u05de' +{0}\ km={0} \u05e7"\u05de +{0}\ min={0} \u05d3\u05e7' +Finding\ a\ driver=\u05de\u05d7\u05e4\u05e9 \u05e0\u05d4\u05d2 +Driver\ on\ the\ way=\u05d4\u05e0\u05d4\u05d2 \u05d1\u05d3\u05e8\u05da +Driver\ at\ pickup=\u05d4\u05e0\u05d4\u05d2 \u05d1\u05e0\u05e7\u05d5\u05d3\u05ea \u05d4\u05d0\u05d9\u05e1\u05d5\u05e3 +Completed=\u05d4\u05d5\u05e9\u05dc\u05de\u05d4 +Cancelled\ by\ rider=\u05d1\u05d5\u05d8\u05dc\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 \u05d4\u05e0\u05d5\u05e1\u05e2 +Cancelled\ by\ driver=\u05d1\u05d5\u05d8\u05dc\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 \u05d4\u05e0\u05d4\u05d2 +No\ driver\ found=\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0 \u05e0\u05d4\u05d2 +Verify\ your\ number=\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05de\u05e1\u05e4\u05e8 +We\ sent\ a\ six-digit\ code\ to\ your\ phone.=\u05e9\u05dc\u05d7\u05e0\u05d5 \u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05e1\u05e4\u05e8\u05d5\u05ea \u05dc\u05d8\u05dc\u05e4\u05d5\u05df \u05e9\u05dc\u05da. +We\ sent\ a\ six-digit\ code\ to\ {0}.=\u05e9\u05dc\u05d7\u05e0\u05d5 \u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05e1\u05e4\u05e8\u05d5\u05ea \u05d0\u05dc {0}. +Code=\u05e7\u05d5\u05d3 +This\ is\ a\ development\ server,\ so\ no\ text\ was\ sent.\ Your\ code\ is\ {0}.=\u05d6\u05d4\u05d5 \u05e9\u05e8\u05ea \u05e4\u05d9\u05ea\u05d5\u05d7, \u05d5\u05dc\u05db\u05df \u05dc\u05d0 \u05e0\u05e9\u05dc\u05d7\u05d4 \u05d4\u05d5\u05d3\u05e2\u05d4. \u05d4\u05e7\u05d5\u05d3 \u05e9\u05dc\u05da \u05d4\u05d5\u05d0 {0}. +Send\ a\ new\ code=\u05e9\u05dc\u05d7 \u05e7\u05d5\u05d3 \u05d7\u05d3\u05e9 +A\ ride\ when\ you\ need\ one,\ and\ a\ fair\ way\ to\ drive.=\u05e0\u05e1\u05d9\u05e2\u05d4 \u05db\u05e9\u05e6\u05e8\u05d9\u05da, \u05d5\u05d3\u05e8\u05da \u05d4\u05d5\u05d2\u05e0\u05ea \u05dc\u05e0\u05d4\u05d5\u05d2. + +# Driving applications, payments, the admin console and the account. +1\ application\ to\ review=\u05d1\u05e7\u05e9\u05d4 \u05d0\u05d7\u05ea \u05dc\u05d1\u05d3\u05d9\u05e7\u05d4 +{0}\ applications\ to\ review={0} \u05d1\u05e7\u05e9\u05d5\u05ea \u05dc\u05d1\u05d3\u05d9\u05e7\u05d4 +1\ flagged\ account=\u05d7\u05e9\u05d1\u05d5\u05df \u05de\u05e1\u05d5\u05de\u05df \u05d0\u05d7\u05d3 +{0}\ flagged\ accounts={0} \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05de\u05e1\u05d5\u05de\u05e0\u05d9\u05dd +7\ days=7 \u05d9\u05de\u05d9\u05dd +14\ days=14 \u05d9\u05de\u05d9\u05dd +30\ days=30 \u05d9\u05de\u05d9\u05dd +A\ car\ with\ four\ doors=\u05e8\u05db\u05d1 \u05e2\u05dd \u05d0\u05e8\u05d1\u05e2 \u05d3\u05dc\u05ea\u05d5\u05ea +A\ driving\ licence=\u05e8\u05d9\u05e9\u05d9\u05d5\u05df \u05e0\u05d4\u05d9\u05d2\u05d4 +A\ flag\ marks\ the\ account\ for\ the\ admins\ and\ changes\ nothing\ for\ its\ owner.\ A\ block\ signs\ it\ out\ and\ refuses\ it\ until\ lifted.=\u05e1\u05d9\u05de\u05d5\u05df \u05de\u05e4\u05e0\u05d4 \u05d0\u05ea \u05ea\u05e9\u05d5\u05de\u05ea \u05dc\u05d1 \u05d4\u05de\u05e0\u05d4\u05dc\u05d9\u05dd \u05dc\u05d7\u05e9\u05d1\u05d5\u05df \u05d5\u05d0\u05d9\u05e0\u05d5 \u05de\u05e9\u05e0\u05d4 \u05d3\u05d1\u05e8 \u05dc\u05d1\u05e2\u05dc\u05d9\u05d5. \u05d7\u05e1\u05d9\u05de\u05d4 \u05de\u05e0\u05ea\u05e7\u05ea \u05d0\u05d5\u05ea\u05d5 \u05d5\u05d3\u05d5\u05d7\u05d4 \u05d0\u05d5\u05ea\u05d5 \u05e2\u05d3 \u05e9\u05ea\u05d5\u05e1\u05e8. +A\ passport\ or\ an\ identity\ card=\u05d3\u05e8\u05db\u05d5\u05df \u05d0\u05d5 \u05ea\u05e2\u05d5\u05d3\u05ea \u05d6\u05d4\u05d5\u05ea +A\ photo\ of\ you=\u05ea\u05de\u05d5\u05e0\u05d4 \u05e9\u05dc\u05da +A\ ramp\ or\ a\ lift,\ and\ room\ to\ secure\ the\ chair=\u05e8\u05de\u05e4\u05d4 \u05d0\u05d5 \u05de\u05e2\u05dc\u05d5\u05df, \u05d5\u05de\u05e7\u05d5\u05dd \u05dc\u05e2\u05d2\u05df \u05d0\u05ea \u05d4\u05db\u05d9\u05e1\u05d0 +A\ ride\ shows\ up\ here\ as\ soon\ as\ it\ is\ asked\ for.=\u05e0\u05e1\u05d9\u05e2\u05d4 \u05de\u05d5\u05e4\u05d9\u05e2\u05d4 \u05db\u05d0\u05df \u05d1\u05e8\u05d2\u05e2 \u05e9\u05de\u05d6\u05de\u05d9\u05e0\u05d9\u05dd \u05d0\u05d5\u05ea\u05d4. +A\ ride\ shows\ up\ here\ when\ it\ is\ paid\ for.=\u05e0\u05e1\u05d9\u05e2\u05d4 \u05de\u05d5\u05e4\u05d9\u05e2\u05d4 \u05db\u05d0\u05df \u05dc\u05d0\u05d7\u05e8 \u05e9\u05e9\u05d5\u05dc\u05de\u05d4. +A\ sentence\ the\ record\ will\ keep=\u05de\u05e9\u05e4\u05d8 \u05e9\u05d9\u05d9\u05e9\u05de\u05e8 \u05d1\u05e8\u05e9\u05d5\u05de\u05d4 +About\ ten\ minutes=\u05db\u05e2\u05e9\u05e8 \u05d3\u05e7\u05d5\u05ea +About\ you=\u05e2\u05dc\u05d9\u05da +Accepted=\u05d4\u05ea\u05e7\u05d1\u05dc +Account\ ending\ {0}=\u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05de\u05e1\u05ea\u05d9\u05d9\u05dd \u05d1-{0} +Account\ holder=\u05d1\u05e2\u05dc \u05d4\u05d7\u05e9\u05d1\u05d5\u05df +Account\ number=\u05de\u05e1\u05e4\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df +Add\ a\ bank\ account=\u05d4\u05d5\u05e1\u05e4\u05ea \u05d7\u05e9\u05d1\u05d5\u05df \u05d1\u05e0\u05e7 +Add\ a\ card=\u05d4\u05d5\u05e1\u05e4\u05ea \u05db\u05e8\u05d8\u05d9\u05e1 +Add\ a\ tip=\u05d4\u05d5\u05e1\u05e4\u05ea \u05d8\u05d9\u05e4 +Added=\u05e0\u05d5\u05e1\u05e3 +Admins=\u05de\u05e0\u05d4\u05dc\u05d9\u05dd +All=\u05d4\u05db\u05d5\u05dc +All\ of\ it\ goes\ to\ your\ driver.=\u05db\u05d5\u05dc\u05d5 \u05e2\u05d5\u05d1\u05e8 \u05dc\u05e0\u05d4\u05d2 \u05e9\u05dc\u05da. +All\ of\ it\ goes\ to\ {0}.=\u05db\u05d5\u05dc\u05d5 \u05e2\u05d5\u05d1\u05e8 \u05d0\u05dc {0}. +Another\ amount=\u05e1\u05db\u05d5\u05dd \u05d0\u05d7\u05e8 +Applicant=\u05d4\u05de\u05d1\u05e7\u05e9 +Application=\u05d1\u05e7\u05e9\u05d4 +Application\ received=\u05d4\u05d1\u05e7\u05e9\u05d4 \u05d4\u05ea\u05e7\u05d1\u05dc\u05d4 +Application\ sent\ back=\u05d4\u05d1\u05e7\u05e9\u05d4 \u05d4\u05d5\u05d7\u05d6\u05e8\u05d4 +Application\ sent.\ We\ will\ be\ in\ touch.=\u05d4\u05d1\u05e7\u05e9\u05d4 \u05e0\u05e9\u05dc\u05d7\u05d4. \u05e0\u05d9\u05e6\u05d5\u05e8 \u05e7\u05e9\u05e8. +Application\ under\ review=\u05d4\u05d1\u05e7\u05e9\u05d4 \u05d1\u05d1\u05d3\u05d9\u05e7\u05d4 +Applications=\u05d1\u05e7\u05e9\u05d5\u05ea +Approve=\u05d0\u05d9\u05e9\u05d5\u05e8 +Approved=\u05d0\u05d5\u05e9\u05e8\u05d4 +Approved\ to\ drive=\u05d0\u05d5\u05e9\u05e8 \u05dc\u05e0\u05d4\u05d5\u05d2 +As\ it\ is\ written\ on\ your\ licence.\ Riders\ see\ only\ your\ first\ name.=\u05db\u05e4\u05d9 \u05e9\u05db\u05ea\u05d5\u05d1 \u05d1\u05e8\u05d9\u05e9\u05d9\u05d5\u05df. \u05d4\u05e0\u05d5\u05e1\u05e2\u05d9\u05dd \u05e8\u05d5\u05d0\u05d9\u05dd \u05e8\u05e7 \u05d0\u05ea \u05e9\u05de\u05da \u05d4\u05e4\u05e8\u05d8\u05d9. +Average\ fare=\u05de\u05d7\u05d9\u05e8 \u05de\u05de\u05d5\u05e6\u05e2 +Average\ wait=\u05d4\u05de\u05ea\u05e0\u05d4 \u05de\u05de\u05d5\u05e6\u05e2\u05ea +Back=\u05d7\u05d6\u05e8\u05d4 +Bank=\u05d1\u05e0\u05e7 +Bank\ account=\u05d7\u05e9\u05d1\u05d5\u05df \u05d1\u05e0\u05e7 +Base\ fare=\u05de\u05d7\u05d9\u05e8 \u05d1\u05e1\u05d9\u05e1 +Block=\u05d7\u05e1\u05d9\u05de\u05d4 +Block\ account=\u05d7\u05e1\u05d9\u05de\u05ea \u05d7\u05e9\u05d1\u05d5\u05df +Blocked=\u05d7\u05e1\u05d5\u05dd +Blue=\u05db\u05d7\u05d5\u05dc +By\ kind\ of\ ride=\u05dc\u05e4\u05d9 \u05e1\u05d5\u05d2 \u05e0\u05e1\u05d9\u05e2\u05d4 +By\ outcome=\u05dc\u05e4\u05d9 \u05ea\u05d5\u05e6\u05d0\u05d4 +By\ submitting\ you\ confirm\ that\ all\ of\ this\ is\ true\ and\ that\ the\ documents\ are\ your\ own.=\u05d1\u05e9\u05dc\u05d9\u05d7\u05d4 \u05d0\u05ea\u05d4 \u05de\u05d0\u05e9\u05e8 \u05e9\u05db\u05dc \u05d4\u05e4\u05e8\u05d8\u05d9\u05dd \u05e0\u05db\u05d5\u05e0\u05d9\u05dd \u05d5\u05e9\u05d4\u05de\u05e1\u05de\u05db\u05d9\u05dd \u05e9\u05dc\u05da. +Calling\ is\ not\ available\ on\ this\ device=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05e7\u05e9\u05e8 \u05de\u05de\u05db\u05e9\u05d9\u05e8 \u05d6\u05d4 +Cancel\ this\ ride=\u05d1\u05d9\u05d8\u05d5\u05dc \u05d4\u05e0\u05e1\u05d9\u05e2\u05d4 +Cancelled=\u05d1\u05d5\u05d8\u05dc\u05d5 +Cancelled\ by\ Wayline=\u05d1\u05d5\u05d8\u05dc\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 Wayline +Card\ number=\u05de\u05e1\u05e4\u05e8 \u05db\u05e8\u05d8\u05d9\u05e1 +Cash\ out=\u05de\u05e9\u05d9\u05db\u05d4 +Change\ password=\u05e9\u05d9\u05e0\u05d5\u05d9 \u05e1\u05d9\u05e1\u05de\u05d4 +Change\ the\ password\ you\ sign\ in\ with=\u05e9\u05d9\u05e0\u05d5\u05d9 \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d0\u05d9\u05ea\u05d4 \u05e0\u05db\u05e0\u05e1\u05d9\u05dd +Changes\ needed=\u05e0\u05d3\u05e8\u05e9\u05d9\u05dd \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd +Choose\ a\ ride=\u05d1\u05d7\u05d9\u05e8\u05ea \u05e0\u05e1\u05d9\u05e2\u05d4 +Choose\ an\ amount\ for\ the\ tip=\u05d9\u05e9 \u05dc\u05d1\u05d7\u05d5\u05e8 \u05e1\u05db\u05d5\u05dd \u05dc\u05d8\u05d9\u05e4 +Choose\ how\ to\ pay\ for\ this\ ride.=\u05d1\u05d7\u05d9\u05e8\u05ea \u05d0\u05de\u05e6\u05e2\u05d9 \u05ea\u05e9\u05dc\u05d5\u05dd \u05dc\u05e0\u05e1\u05d9\u05e2\u05d4 \u05d6\u05d5. +Collect\ {0}\ in\ cash=\u05dc\u05d2\u05d1\u05d5\u05ea {0} \u05d1\u05de\u05d6\u05d5\u05de\u05df +Colour=\u05e6\u05d1\u05e2 +Comfort=\u05e7\u05d5\u05de\u05e4\u05d5\u05e8\u05d8 +Comfort\ is\ a\ newer,\ roomier\ car.\ XL\ seats\ six\ or\ more.=\u05e7\u05d5\u05de\u05e4\u05d5\u05e8\u05d8 \u05d4\u05d5\u05d0 \u05e8\u05db\u05d1 \u05d7\u05d3\u05e9 \u05d5\u05de\u05e8\u05d5\u05d5\u05d7 \u05d9\u05d5\u05ea\u05e8. XL \u05de\u05d5\u05e9\u05d9\u05d1 \u05e9\u05d9\u05e9\u05d4 \u05d0\u05d5 \u05d9\u05d5\u05ea\u05e8. +Commission,\ %=\u05e2\u05de\u05dc\u05d4, % +Contact=\u05e4\u05e8\u05d8\u05d9 \u05e7\u05e9\u05e8 +Continue=\u05d4\u05de\u05e9\u05da +Continue\ application=\u05d4\u05de\u05e9\u05da \u05d4\u05d1\u05e7\u05e9\u05d4 +Current\ password=\u05e1\u05d9\u05e1\u05de\u05d4 \u05e0\u05d5\u05db\u05d7\u05d9\u05ea +Date\ of\ birth=\u05ea\u05d0\u05e8\u05d9\u05da \u05dc\u05d9\u05d3\u05d4 +Default=\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc +Delete\ account=\u05de\u05d7\u05d9\u05e7\u05ea \u05d7\u05e9\u05d1\u05d5\u05df +Delete\ my\ account=\u05de\u05d7\u05d9\u05e7\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05d9 +Delete\ your\ account?=\u05dc\u05de\u05d7\u05d5\u05e7 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df? +Discount=\u05d4\u05e0\u05d7\u05d4 +Distance=\u05de\u05e8\u05d7\u05e7 +Documents=\u05de\u05e1\u05de\u05db\u05d9\u05dd +Documents\ checked=\u05d4\u05de\u05e1\u05de\u05db\u05d9\u05dd \u05e0\u05d1\u05d3\u05e7\u05d9\u05dd +Done=\u05e1\u05d9\u05d5\u05dd +Draft=\u05d8\u05d9\u05d5\u05d8\u05d4 +Drive\ with\ Wayline=\u05dc\u05e0\u05d4\u05d5\u05d2 \u05e2\u05dd Wayline +Drivers=\u05e0\u05d4\u05d2\u05d9\u05dd +Drivers\ apply\ after\ signing\ up\:\ a\ few\ details\ about\ you\ and\ your\ car,\ and\ five\ photos.\ You\ can\ ride\ in\ the\ meantime.=\u05e0\u05d4\u05d2\u05d9\u05dd \u05de\u05d2\u05d9\u05e9\u05d9\u05dd \u05d1\u05e7\u05e9\u05d4 \u05dc\u05d0\u05d7\u05e8 \u05d4\u05d4\u05e8\u05e9\u05de\u05d4: \u05db\u05de\u05d4 \u05e4\u05e8\u05d8\u05d9\u05dd \u05e2\u05dc\u05d9\u05da \u05d5\u05e2\u05dc \u05d4\u05e8\u05db\u05d1, \u05d5\u05d7\u05de\u05e9 \u05ea\u05de\u05d5\u05e0\u05d5\u05ea. \u05d1\u05d9\u05e0\u05ea\u05d9\u05d9\u05dd \u05d0\u05e4\u05e9\u05e8 \u05dc\u05e0\u05e1\u05d5\u05e2. +Drivers\ online=\u05e0\u05d4\u05d2\u05d9\u05dd \u05de\u05d7\u05d5\u05d1\u05e8\u05d9\u05dd +Driving\ licence=\u05e8\u05d9\u05e9\u05d9\u05d5\u05df \u05e0\u05d4\u05d9\u05d2\u05d4 +Earn\ on\ your\ own\ schedule=\u05dc\u05d4\u05e8\u05d5\u05d5\u05d9\u05d7 \u05d1\u05d6\u05de\u05df \u05e9\u05dc\u05da +Earned\ driving=\u05d4\u05e8\u05d5\u05d5\u05d9\u05d7 \u05d1\u05e0\u05d4\u05d9\u05d2\u05d4 +Every\ application\ has\ had\ its\ answer.=\u05db\u05dc \u05d4\u05d1\u05e7\u05e9\u05d5\u05ea \u05e0\u05e2\u05e0\u05d5. +Everyone=\u05db\u05d5\u05dc\u05dd +Expires=\u05ea\u05d5\u05e7\u05e3 +Expires\ {0}=\u05d1\u05ea\u05d5\u05e7\u05e3 \u05e2\u05d3 {0} +Fare=\u05de\u05d7\u05d9\u05e8 \u05e0\u05e1\u05d9\u05e2\u05d4 +Finish\ your\ application=\u05d4\u05e9\u05dc\u05de\u05ea \u05d4\u05d1\u05e7\u05e9\u05d4 +Fix\ and\ resubmit=\u05ea\u05d9\u05e7\u05d5\u05df \u05d5\u05e9\u05dc\u05d9\u05d7\u05d4 \u05de\u05d7\u05d3\u05e9 +Flag=\u05e1\u05d9\u05de\u05d5\u05df +Flag\ account=\u05e1\u05d9\u05de\u05d5\u05df \u05d7\u05e9\u05d1\u05d5\u05df +Flag\ removed=\u05d4\u05e1\u05d9\u05de\u05d5\u05df \u05d4\u05d5\u05e1\u05e8 +Flagged=\u05de\u05e1\u05d5\u05de\u05df +Gender=\u05de\u05d2\u05d3\u05e8 +Have\ a\ last\ look.\ You\ can\ change\ anything\ until\ it\ is\ handed\ in.=\u05de\u05d1\u05d8 \u05d0\u05d7\u05e8\u05d5\u05df. \u05d0\u05e4\u05e9\u05e8 \u05dc\u05e9\u05e0\u05d5\u05ea \u05d4\u05db\u05d5\u05dc \u05e2\u05d3 \u05dc\u05d4\u05d2\u05e9\u05d4. +Hi,\ this\ is\ your\ Wayline\ driver.=\u05e9\u05dc\u05d5\u05dd, \u05de\u05d3\u05d1\u05e8 \u05d4\u05e0\u05d4\u05d2 \u05e9\u05dc\u05da \u05de-Wayline. +Hi,\ this\ is\ your\ Wayline\ rider.=\u05e9\u05dc\u05d5\u05dd, \u05de\u05d3\u05d1\u05e8 \u05d4\u05e0\u05d5\u05e1\u05e2 \u05e9\u05dc\u05da \u05de-Wayline. +History=\u05d4\u05d9\u05e1\u05d8\u05d5\u05e8\u05d9\u05d4 +I\ have\ finished=\u05e1\u05d9\u05d9\u05de\u05ea\u05d9 +I\ want\ to=\u05d0\u05e0\u05d9 \u05e8\u05d5\u05e6\u05d4 +In\ good\ standing=\u05ea\u05e7\u05d9\u05df +Insurance=\u05d1\u05d9\u05d8\u05d5\u05d7 +Its\ number,\ its\ expiry\ date\ and\ a\ photo\ of\ it=\u05d4\u05de\u05e1\u05e4\u05e8, \u05ea\u05d0\u05e8\u05d9\u05da \u05d4\u05ea\u05e4\u05d5\u05d2\u05d4 \u05d5\u05ea\u05de\u05d5\u05e0\u05d4 \u05e9\u05dc\u05d5 +Keep\ my\ account=\u05dc\u05d4\u05e9\u05d0\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df +Kind\ of\ ride=\u05e1\u05d5\u05d2 \u05e0\u05e1\u05d9\u05e2\u05d4 +Legal\ name=\u05e9\u05dd \u05e8\u05e9\u05de\u05d9 +Licence=\u05e8\u05d9\u05e9\u05d9\u05d5\u05df +Licence\ expires=\u05ea\u05d5\u05e7\u05e3 \u05d4\u05e8\u05d9\u05e9\u05d9\u05d5\u05df +Licence\ number=\u05de\u05e1\u05e4\u05e8 \u05e8\u05d9\u05e9\u05d9\u05d5\u05df +Make=\u05d9\u05e6\u05e8\u05df +Man=\u05d2\u05d1\u05e8 +Messages\ are\ not\ available\ on\ this\ device=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05e9\u05dc\u05d5\u05d7 \u05d4\u05d5\u05d3\u05e2\u05d5\u05ea \u05de\u05de\u05db\u05e9\u05d9\u05e8 \u05d6\u05d4 +Minimum\ fare=\u05de\u05d7\u05d9\u05e8 \u05de\u05d9\u05e0\u05d9\u05de\u05d5\u05dd +Missing=\u05d7\u05e1\u05e8 +Model=\u05d3\u05d2\u05dd +Moderation=\u05e4\u05d9\u05e7\u05d5\u05d7 +Most\ of\ it\ is\ taking\ five\ photos=\u05e8\u05d5\u05d1 \u05d4\u05d6\u05de\u05df \u05d4\u05d5\u05d0 \u05e6\u05d9\u05dc\u05d5\u05dd \u05d7\u05de\u05e9 \u05ea\u05de\u05d5\u05e0\u05d5\u05ea +Multipliers=\u05de\u05db\u05e4\u05d9\u05dc\u05d9\u05dd +Name\ an\ emergency\ contact\ in\ your\ profile\ first=\u05d9\u05e9 \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05ea\u05d7\u05d9\u05dc\u05d4 \u05d0\u05d9\u05e9 \u05e7\u05e9\u05e8 \u05dc\u05d7\u05d9\u05e8\u05d5\u05dd \u05d1\u05e4\u05e8\u05d5\u05e4\u05d9\u05dc +Name\ on\ the\ card=\u05d4\u05e9\u05dd \u05e9\u05e2\u05dc \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 +Name,\ emergency\ contact,\ home\ and\ work=\u05e9\u05dd, \u05d0\u05d9\u05e9 \u05e7\u05e9\u05e8 \u05dc\u05d7\u05d9\u05e8\u05d5\u05dd, \u05d1\u05d9\u05ea \u05d5\u05e2\u05d1\u05d5\u05d3\u05d4 +Needed=\u05e0\u05d3\u05e8\u05e9 +New\ password=\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3\u05e9\u05d4 +New\ password,\ again=\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3\u05e9\u05d4, \u05e9\u05d5\u05d1 +No=\u05dc\u05d0 +No\ payments\ yet=\u05d0\u05d9\u05df \u05e2\u05d3\u05d9\u05d9\u05df \u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd +No\ rides\ here=\u05d0\u05d9\u05df \u05db\u05d0\u05df \u05e0\u05e1\u05d9\u05e2\u05d5\u05ea +Nobody\ matches=\u05d0\u05d9\u05df \u05d4\u05ea\u05d0\u05de\u05d5\u05ea +Non-binary=\u05d0-\u05d1\u05d9\u05e0\u05d0\u05e8\u05d9 +Not\ provided=\u05dc\u05d0 \u05e1\u05d5\u05e4\u05e7 +Not\ set=\u05dc\u05d0 \u05d4\u05d5\u05d2\u05d3\u05e8 +Not\ started=\u05d8\u05e8\u05dd \u05d4\u05ea\u05d7\u05d9\u05dc\u05d4 +Nothing\ has\ been\ done\ to\ this\ account.=\u05dc\u05d0 \u05d1\u05d5\u05e6\u05e2\u05d4 \u05e9\u05d5\u05dd \u05e4\u05e2\u05d5\u05dc\u05d4 \u05d1\u05d7\u05e9\u05d1\u05d5\u05df \u05d6\u05d4. +Nothing\ has\ been\ paid\ out\ yet.=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05e9\u05d5\u05dc\u05dd \u05d3\u05d1\u05e8. +Nothing\ here=\u05d0\u05d9\u05df \u05db\u05d0\u05df \u05d3\u05d1\u05e8 +Open\ the\ secure\ page=\u05e4\u05ea\u05d9\u05d7\u05ea \u05d4\u05d3\u05e3 \u05d4\u05de\u05d0\u05d5\u05d1\u05d8\u05d7 +Paid=\u05e9\u05d5\u05dc\u05dd +Paid\ in\ cash=\u05e9\u05d5\u05dc\u05dd \u05d1\u05de\u05d6\u05d5\u05de\u05df +Paid\ with=\u05e9\u05d5\u05dc\u05dd \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea +Payment\ failed=\u05d4\u05ea\u05e9\u05dc\u05d5\u05dd \u05e0\u05db\u05e9\u05dc +Payment\ pending=\u05ea\u05e9\u05dc\u05d5\u05dd \u05de\u05de\u05ea\u05d9\u05df +Payments=\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd +Payouts=\u05d4\u05e2\u05d1\u05e8\u05d5\u05ea +People\ who\ asked\ to\ drive,\ and\ their\ papers=\u05de\u05d9 \u05e9\u05d1\u05d9\u05e7\u05e9\u05d5 \u05dc\u05e0\u05d4\u05d5\u05d2, \u05d5\u05d4\u05de\u05e1\u05de\u05db\u05d9\u05dd \u05e9\u05dc\u05d4\u05dd +Per\ kilometre=\u05dc\u05e7\u05d9\u05dc\u05d5\u05de\u05d8\u05e8 +Per\ minute=\u05dc\u05d3\u05e7\u05d4 +Photo\ ID=\u05ea\u05e2\u05d5\u05d3\u05d4 \u05de\u05d6\u05d4\u05d4 +Photograph\ each\ one\ flat,\ with\ all\ four\ corners\ in\ the\ picture.=\u05d9\u05e9 \u05dc\u05e6\u05dc\u05dd \u05db\u05dc \u05de\u05e1\u05de\u05da \u05db\u05e9\u05d4\u05d5\u05d0 \u05e9\u05d8\u05d5\u05d7 \u05d5\u05db\u05dc \u05d0\u05e8\u05d1\u05e2 \u05d4\u05e4\u05d9\u05e0\u05d5\u05ea \u05d1\u05ea\u05de\u05d5\u05e0\u05d4. +Photos\ added=\u05ea\u05de\u05d5\u05e0\u05d5\u05ea \u05e9\u05e0\u05d5\u05e1\u05e4\u05d5 +Pricing=\u05ea\u05de\u05d7\u05d5\u05e8 +Profile=\u05e4\u05e8\u05d5\u05e4\u05d9\u05dc +Provided=\u05e1\u05d5\u05e4\u05e7 +Rather\ not\ say=\u05de\u05e2\u05d3\u05d9\u05e3 \u05dc\u05d0 \u05dc\u05e6\u05d9\u05d9\u05df +Rating=\u05d3\u05d9\u05e8\u05d5\u05d2 +Read\ what\ was\ missing,\ put\ it\ right,\ and\ send\ the\ application\ again.=\u05e7\u05e8\u05d0\u05d5 \u05de\u05d4 \u05d4\u05d9\u05d4 \u05d7\u05e1\u05e8, \u05ea\u05e7\u05e0\u05d5 \u05d5\u05e9\u05dc\u05d7\u05d5 \u05d0\u05ea \u05d4\u05d1\u05e7\u05e9\u05d4 \u05e9\u05d5\u05d1. +Ready\ to\ cash\ out=\u05d6\u05de\u05d9\u05df \u05dc\u05de\u05e9\u05d9\u05db\u05d4 +Reason=\u05e1\u05d9\u05d1\u05d4 +Receipt=\u05e7\u05d1\u05dc\u05d4 +Refund=\u05d4\u05d7\u05d6\u05e8 +Refund\ this\ ride=\u05d4\u05d7\u05d6\u05e8 \u05e2\u05dc \u05e0\u05e1\u05d9\u05e2\u05d4 \u05d6\u05d5 +Refunded=\u05d4\u05d5\u05d7\u05d6\u05e8 +Registered\ and\ insured,\ with\ the\ papers\ to\ show\ it=\u05e8\u05e9\u05d5\u05dd \u05d5\u05de\u05d1\u05d5\u05d8\u05d7, \u05e2\u05dd \u05d4\u05de\u05e1\u05de\u05db\u05d9\u05dd \u05e9\u05de\u05d5\u05db\u05d9\u05d7\u05d9\u05dd \u05d6\u05d0\u05ea +Rejected=\u05e0\u05d3\u05d7\u05d4 +Remove\ flag=\u05d4\u05e1\u05e8\u05ea \u05e1\u05d9\u05de\u05d5\u05df +Remove\ this\ card=\u05d4\u05e1\u05e8\u05ea \u05db\u05e8\u05d8\u05d9\u05e1 \u05d6\u05d4 +Request\ {0}=\u05d4\u05d6\u05de\u05e0\u05ea {0} +Requests\ accepted=\u05d1\u05e7\u05e9\u05d5\u05ea \u05e9\u05d4\u05ea\u05e7\u05d1\u05dc\u05d5 +Requests\ by\ hour=\u05d1\u05e7\u05e9\u05d5\u05ea \u05dc\u05e4\u05d9 \u05e9\u05e2\u05d4 +Retake=\u05e6\u05d9\u05dc\u05d5\u05dd \u05de\u05d7\u05d3\u05e9 +Review\ and\ submit=\u05d1\u05d3\u05d9\u05e7\u05d4 \u05d5\u05e9\u05dc\u05d9\u05d7\u05d4 +Reviewed=\u05e0\u05d1\u05d3\u05e7\u05d4 +Reviewed\ by=\u05e0\u05d1\u05d3\u05e7\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 +Riders=\u05e0\u05d5\u05e1\u05e2\u05d9\u05dd +Riders\ who\ ask\ for\ a\ woman\ at\ the\ wheel\ are\ offered\ to\ drivers\ who\ say\ they\ are\ one.=\u05e0\u05d5\u05e1\u05e2\u05d9\u05dd \u05e9\u05de\u05d1\u05e7\u05e9\u05d9\u05dd \u05e0\u05d4\u05d2\u05ea \u05de\u05d5\u05e6\u05e2\u05d9\u05dd \u05dc\u05e0\u05d4\u05d2\u05d5\u05ea \u05e9\u05e6\u05d9\u05d9\u05e0\u05d5 \u05d6\u05d0\u05ea. +Right\ now=\u05e2\u05db\u05e9\u05d9\u05d5 +Roles\ changed=\u05d4\u05ea\u05e4\u05e7\u05d9\u05d3\u05d9\u05dd \u05e9\u05d5\u05e0\u05d5 +Roles,\ flags,\ and\ blocking\ an\ account=\u05ea\u05e4\u05e7\u05d9\u05d3\u05d9\u05dd, \u05e1\u05d9\u05de\u05d5\u05e0\u05d9\u05dd \u05d5\u05d7\u05e1\u05d9\u05de\u05ea \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea +Save\ card=\u05e9\u05de\u05d9\u05e8\u05ea \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 +Save\ prices=\u05e9\u05de\u05d9\u05e8\u05ea \u05d4\u05de\u05d7\u05d9\u05e8\u05d9\u05dd +Saved\ places=\u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05e9\u05de\u05d5\u05e8\u05d9\u05dd +Say\ why,\ in\ a\ few\ words.=\u05d9\u05e9 \u05dc\u05e6\u05d9\u05d9\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05d1\u05d4 \u05d1\u05db\u05de\u05d4 \u05de\u05d9\u05dc\u05d9\u05dd. +Search\ by\ name\ or\ e-mail=\u05d7\u05d9\u05e4\u05d5\u05e9 \u05dc\u05e4\u05d9 \u05e9\u05dd \u05d0\u05d5 \u05d3\u05d5\u05d0"\u05dc +Seats\ for\ riders=\u05de\u05d5\u05e9\u05d1\u05d9\u05dd \u05dc\u05e0\u05d5\u05e1\u05e2\u05d9\u05dd +Security\ code=\u05e7\u05d5\u05d3 \u05d0\u05d1\u05d8\u05d7\u05d4 +Send\ back=\u05d4\u05d7\u05d6\u05e8\u05d4 +Send\ tip=\u05e9\u05dc\u05d9\u05d7\u05ea \u05d8\u05d9\u05e4 +Saves\ {0}'s\ number\ with\ your\ account\ as\ your\ trip\ contact.\ No\ message\ is\ sent.=\u05e9\u05d5\u05de\u05e8 \u05d0\u05ea \u05d4\u05de\u05e1\u05e4\u05e8 \u05e9\u05dc {0} \u05d1\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da \u05db\u05d0\u05d9\u05e9 \u05d4\u05e7\u05e9\u05e8 \u05dc\u05e0\u05e1\u05d9\u05e2\u05d5\u05ea. \u05dc\u05d0 \u05e0\u05e9\u05dc\u05d7\u05ea \u05d4\u05d5\u05d3\u05e2\u05d4. +Sent\ back=\u05d4\u05d5\u05d7\u05d6\u05e8\u05d4 +Service\ fee=\u05d3\u05de\u05d9 \u05e9\u05d9\u05e8\u05d5\u05ea +Service\ fee,\ %=\u05d3\u05de\u05d9 \u05e9\u05d9\u05e8\u05d5\u05ea, % +Spent\ riding=\u05d4\u05d5\u05e6\u05d9\u05d0 \u05e2\u05dc \u05e0\u05e1\u05d9\u05e2\u05d5\u05ea +Standard=\u05e8\u05d2\u05d9\u05dc +Start\ application=\u05d4\u05ea\u05d7\u05dc\u05ea \u05d4\u05d1\u05e7\u05e9\u05d4 +Step\ {0}\ of\ {1}=\u05e9\u05dc\u05d1 {0} \u05de\u05ea\u05d5\u05da {1} +Submit\ application=\u05e9\u05dc\u05d9\u05d7\u05ea \u05d4\u05d1\u05e7\u05e9\u05d4 +Submitted=\u05d4\u05d5\u05d2\u05e9\u05d4 +Surge,\ while\ demand\ is\ high=\u05ea\u05d5\u05e1\u05e4\u05ea \u05d1\u05d6\u05de\u05df \u05d1\u05d9\u05e7\u05d5\u05e9 \u05d2\u05d1\u05d5\u05d4 +Takes\ a\ wheelchair=\u05de\u05ea\u05d0\u05d9\u05dd \u05dc\u05db\u05d9\u05e1\u05d0 \u05d2\u05dc\u05d2\u05dc\u05d9\u05dd +Tap\ a\ card\ to\ make\ it\ the\ one\ that\ pays\ unless\ you\ say\ otherwise.=\u05d4\u05e7\u05d9\u05e9\u05d5 \u05e2\u05dc \u05db\u05e8\u05d8\u05d9\u05e1 \u05db\u05d3\u05d9 \u05e9\u05d9\u05e9\u05de\u05e9 \u05dc\u05ea\u05e9\u05dc\u05d5\u05dd \u05d0\u05dc\u05d0 \u05d0\u05dd \u05ea\u05d1\u05d7\u05e8\u05d5 \u05d0\u05d7\u05e8\u05ea. +Tap\ to\ open=\u05d4\u05e7\u05d9\u05e9\u05d5 \u05dc\u05e4\u05ea\u05d9\u05d7\u05d4 +Tell\ us\ about\ you\ and\ your\ car,\ add\ five\ photos,\ and\ we\ take\ it\ from\ there.=\u05e1\u05e4\u05e8\u05d5 \u05dc\u05e0\u05d5 \u05e2\u05dc\u05d9\u05db\u05dd \u05d5\u05e2\u05dc \u05d4\u05e8\u05db\u05d1, \u05d4\u05d5\u05e1\u05d9\u05e4\u05d5 \u05d7\u05de\u05e9 \u05ea\u05de\u05d5\u05e0\u05d5\u05ea, \u05d5\u05d0\u05e0\u05d7\u05e0\u05d5 \u05e0\u05de\u05e9\u05d9\u05da \u05de\u05e9\u05dd. +Thank\ you.\ Your\ tip\ was\ sent.=\u05ea\u05d5\u05d3\u05d4. \u05d4\u05d8\u05d9\u05e4 \u05e0\u05e9\u05dc\u05d7. +That\ is\ not\ a\ card\ number.\ Check\ the\ digits.=\u05d6\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05de\u05e1\u05e4\u05e8 \u05db\u05e8\u05d8\u05d9\u05e1. \u05d1\u05d3\u05e7\u05d5 \u05d0\u05ea \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea. +That\ picture\ could\ not\ be\ read=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05e7\u05e8\u05d5\u05d0 \u05d0\u05ea \u05d4\u05ea\u05de\u05d5\u05e0\u05d4 +That\ place\ could\ not\ be\ found.\ Choose\ it\ again.=\u05d4\u05de\u05e7\u05d5\u05dd \u05dc\u05d0 \u05e0\u05de\u05e6\u05d0. \u05d1\u05d7\u05e8\u05d5 \u05d0\u05d5\u05ea\u05d5 \u05e9\u05d5\u05d1. +The\ busy\ hours,\ in\ UTC=\u05e9\u05e2\u05d5\u05ea \u05d4\u05e2\u05d5\u05de\u05e1, \u05d1-UTC +The\ card\ was\ declined.\ Choose\ another\ way\ to\ pay\ in\ your\ wallet.=\u05d4\u05db\u05e8\u05d8\u05d9\u05e1 \u05e0\u05d3\u05d7\u05d4. \u05d1\u05d7\u05e8\u05d5 \u05d0\u05de\u05e6\u05e2\u05d9 \u05ea\u05e9\u05dc\u05d5\u05dd \u05d0\u05d7\u05e8 \u05d1\u05d0\u05e8\u05e0\u05e7. +The\ fare,\ in\ {0}=\u05d4\u05de\u05d7\u05d9\u05e8, \u05d1-{0} +The\ fee\ is\ added\ to\ the\ fare\ and\ paid\ by\ the\ rider.\ The\ commission\ is\ kept\ from\ the\ fare;\ the\ rest\ is\ the\ driver's.=\u05d3\u05de\u05d9 \u05d4\u05e9\u05d9\u05e8\u05d5\u05ea \u05e0\u05d5\u05e1\u05e4\u05d9\u05dd \u05dc\u05de\u05d7\u05d9\u05e8 \u05d5\u05de\u05e9\u05d5\u05dc\u05de\u05d9\u05dd \u05e2\u05dc \u05d9\u05d3\u05d9 \u05d4\u05e0\u05d5\u05e1\u05e2. \u05d4\u05e2\u05de\u05dc\u05d4 \u05de\u05e0\u05d5\u05db\u05d4 \u05de\u05d4\u05de\u05d7\u05d9\u05e8; \u05d4\u05d9\u05ea\u05e8 \u05e9\u05d9\u05d9\u05da \u05dc\u05e0\u05d4\u05d2. +The\ last\ two\ weeks=\u05d4\u05e9\u05d1\u05d5\u05e2\u05d9\u05d9\u05dd \u05d4\u05d0\u05d7\u05e8\u05d5\u05e0\u05d9\u05dd +The\ new\ prices\ are\ in\ force=\u05d4\u05de\u05d7\u05d9\u05e8\u05d9\u05dd \u05d4\u05d7\u05d3\u05e9\u05d9\u05dd \u05d1\u05ea\u05d5\u05e7\u05e3 +The\ number\ is\ on\ the\ front\ of\ the\ card.=\u05d4\u05de\u05e1\u05e4\u05e8 \u05de\u05d5\u05e4\u05d9\u05e2 \u05d1\u05d7\u05d6\u05d9\u05ea \u05d4\u05db\u05e8\u05d8\u05d9\u05e1. +The\ page\ with\ the\ policy's\ dates\ on\ it=\u05d4\u05e2\u05de\u05d5\u05d3 \u05e9\u05d1\u05d5 \u05de\u05d5\u05e4\u05d9\u05e2\u05d9\u05dd \u05ea\u05d0\u05e8\u05d9\u05db\u05d9 \u05d4\u05e4\u05d5\u05dc\u05d9\u05e1\u05d4 +The\ paper\ that\ says\ the\ car\ is\ yours=\u05d4\u05de\u05e1\u05de\u05da \u05e9\u05de\u05e2\u05d9\u05d3 \u05e9\u05d4\u05e8\u05db\u05d1 \u05e9\u05dc\u05da +The\ payment\ provider's\ own\ page\ opens\ in\ your\ browser.\ Wayline\ never\ sees\ the\ number.\ Come\ back\ here\ when\ you\ have\ finished.=\u05d3\u05e3 \u05e1\u05e4\u05e7 \u05d4\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05e0\u05e4\u05ea\u05d7 \u05d1\u05d3\u05e4\u05d3\u05e4\u05df. Wayline \u05d0\u05d9\u05e0\u05d4 \u05e8\u05d5\u05d0\u05d4 \u05d0\u05ea \u05d4\u05de\u05e1\u05e4\u05e8. \u05d7\u05d6\u05e8\u05d5 \u05dc\u05db\u05d0\u05df \u05d1\u05e1\u05d9\u05d5\u05dd. +The\ ride\ was\ refunded=\u05e0\u05d9\u05ea\u05df \u05d4\u05d7\u05d6\u05e8 \u05e2\u05dc \u05d4\u05e0\u05e1\u05d9\u05e2\u05d4 +Refund\ pending=\u05d4\u05d7\u05d6\u05e8 \u05de\u05de\u05ea\u05d9\u05df +Refund\ failed=\u05d4\u05d7\u05d6\u05e8 \u05e0\u05db\u05e9\u05dc +The\ refund\ is\ pending=\u05d4\u05d4\u05d7\u05d6\u05e8 \u05de\u05de\u05ea\u05d9\u05df +The\ rider\ and\ the\ driver\ are\ both\ told,\ and\ nobody\ is\ charged.=\u05d4\u05e0\u05d5\u05e1\u05e2 \u05d5\u05d4\u05e0\u05d4\u05d2 \u05de\u05e7\u05d1\u05dc\u05d9\u05dd \u05d4\u05d5\u05d3\u05e2\u05d4, \u05d5\u05d0\u05d9\u05e9 \u05d0\u05d9\u05e0\u05d5 \u05de\u05d7\u05d5\u05d9\u05d1. +The\ security\ code\ is\ the\ three\ or\ four\ digits\ on\ the\ card.=\u05e7\u05d5\u05d3 \u05d4\u05d0\u05d1\u05d8\u05d7\u05d4 \u05d4\u05d5\u05d0 \u05e9\u05dc\u05d5\u05e9 \u05d0\u05d5 \u05d0\u05e8\u05d1\u05e2 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05e9\u05e2\u05dc \u05d4\u05db\u05e8\u05d8\u05d9\u05e1. +The\ service's\ share=\u05d7\u05dc\u05e7\u05d5 \u05e9\u05dc \u05d4\u05e9\u05d9\u05e8\u05d5\u05ea +The\ side\ with\ your\ photo\ on\ it=\u05d4\u05e6\u05d3 \u05e9\u05d1\u05d5 \u05de\u05d5\u05e4\u05d9\u05e2\u05d4 \u05ea\u05de\u05d5\u05e0\u05ea\u05da +The\ two\ new\ passwords\ are\ not\ the\ same.=\u05e9\u05ea\u05d9 \u05d4\u05e1\u05d9\u05e1\u05de\u05d0\u05d5\u05ea \u05d4\u05d7\u05d3\u05e9\u05d5\u05ea \u05d0\u05d9\u05e0\u05df \u05d6\u05d4\u05d5\u05ea. +This\ device\ cannot\ prepare\ a\ picture\ to\ send=\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d0\u05d9\u05e0\u05d5 \u05d9\u05db\u05d5\u05dc \u05dc\u05d4\u05db\u05d9\u05df \u05ea\u05de\u05d5\u05e0\u05d4 \u05dc\u05e9\u05dc\u05d9\u05d7\u05d4 +This\ month=\u05d4\u05d7\u05d5\u05d3\u05e9 +This\ picture\ could\ not\ be\ opened=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05e4\u05ea\u05d5\u05d7 \u05d0\u05ea \u05d4\u05ea\u05de\u05d5\u05e0\u05d4 +This\ screen\ becomes\ your\ driver's\ home\ the\ moment\ you\ are\ approved=\u05d4\u05de\u05e1\u05da \u05d4\u05d6\u05d4 \u05d9\u05d4\u05e4\u05d5\u05da \u05dc\u05de\u05e1\u05da \u05d4\u05e0\u05d4\u05d2 \u05e9\u05dc\u05da \u05d1\u05e8\u05d2\u05e2 \u05e9\u05ea\u05d0\u05d5\u05e9\u05e8 +This\ server\ takes\ no\ real\ payments.\ Use\ the\ test\ card\ 4242\ 4242\ 4242\ 4242\ with\ any\ date\ ahead\ and\ any\ three\ digits.=\u05e9\u05e8\u05ea \u05d6\u05d4 \u05d0\u05d9\u05e0\u05d5 \u05de\u05e7\u05d1\u05dc \u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05d0\u05de\u05d9\u05ea\u05d9\u05d9\u05dd. \u05d4\u05e9\u05ea\u05de\u05e9\u05d5 \u05d1\u05db\u05e8\u05d8\u05d9\u05e1 \u05d4\u05d1\u05d3\u05d9\u05e7\u05d4 4242 4242 4242 4242 \u05e2\u05dd \u05ea\u05d0\u05e8\u05d9\u05da \u05e2\u05ea\u05d9\u05d3\u05d9 \u05db\u05dc\u05e9\u05d4\u05d5 \u05d5\u05e9\u05dc\u05d5\u05e9 \u05e1\u05e4\u05e8\u05d5\u05ea \u05db\u05dc\u05e9\u05d4\u05df. +Time=\u05d6\u05de\u05df +Tip\ offered\ first=\u05d4\u05d8\u05d9\u05e4 \u05e9\u05de\u05d5\u05e6\u05e2 \u05ea\u05d7\u05d9\u05dc\u05d4 +Tips=\u05d8\u05d9\u05e4\u05d9\u05dd +Top\ drivers=\u05d4\u05e0\u05d4\u05d2\u05d9\u05dd \u05d4\u05de\u05d5\u05d1\u05d9\u05dc\u05d9\u05dd +Total=\u05e1\u05d4"\u05db +Trends=\u05de\u05d2\u05de\u05d5\u05ea +Trip=\u05d4\u05de\u05e1\u05dc\u05d5\u05dc +Type\ the\ account\ number.=\u05d9\u05e9 \u05dc\u05d4\u05e7\u05dc\u05d9\u05d3 \u05d0\u05ea \u05de\u05e1\u05e4\u05e8 \u05d4\u05d7\u05e9\u05d1\u05d5\u05df. +Unblock=\u05d1\u05d9\u05d8\u05d5\u05dc \u05d7\u05e1\u05d9\u05de\u05d4 +Unblocked=\u05d4\u05d7\u05e1\u05d9\u05de\u05d4 \u05d4\u05d5\u05e1\u05e8\u05d4 +Under\ review=\u05d1\u05d1\u05d3\u05d9\u05e7\u05d4 +Usually\ within\ two\ working\ days=\u05d1\u05d3\u05e8\u05da \u05db\u05dc\u05dc \u05d1\u05ea\u05d5\u05da \u05e9\u05e0\u05d9 \u05d9\u05de\u05d9 \u05e2\u05e1\u05e7\u05d9\u05dd +Vehicle=\u05e8\u05db\u05d1 +Vehicle\ registration=\u05e8\u05d9\u05e9\u05d9\u05d5\u05df \u05e8\u05db\u05d1 +View\ receipt=\u05d4\u05e6\u05d2\u05ea \u05d4\u05e7\u05d1\u05dc\u05d4 +Waiting=\u05de\u05de\u05ea\u05d9\u05e0\u05d4 +Wallet=\u05d0\u05e8\u05e0\u05e7 +We\ could\ not\ approve\ this\ yet=\u05e2\u05d3\u05d9\u05d9\u05df \u05dc\u05d0 \u05d9\u05db\u05d5\u05dc\u05e0\u05d5 \u05dc\u05d0\u05e9\u05e8 +We\ have\ everything\ we\ asked\ for.\ Someone\ on\ our\ team\ is\ reading\ it.=\u05e7\u05d9\u05d1\u05dc\u05e0\u05d5 \u05d0\u05ea \u05db\u05dc \u05de\u05d4 \u05e9\u05d1\u05d9\u05e7\u05e9\u05e0\u05d5. \u05de\u05d9\u05e9\u05d4\u05d5 \u05de\u05d4\u05e6\u05d5\u05d5\u05ea \u05e7\u05d5\u05e8\u05d0 \u05d0\u05d5\u05ea\u05d4. +What\ a\ ride\ costs,\ and\ the\ service's\ share=\u05db\u05de\u05d4 \u05e2\u05d5\u05dc\u05d4 \u05e0\u05e1\u05d9\u05e2\u05d4, \u05d5\u05d7\u05dc\u05e7\u05d5 \u05e9\u05dc \u05d4\u05e9\u05d9\u05e8\u05d5\u05ea +What\ happens\ next=\u05de\u05d4 \u05e7\u05d5\u05e8\u05d4 \u05e2\u05db\u05e9\u05d9\u05d5 +What\ the\ reviewer\ said=\u05de\u05d4 \u05d0\u05de\u05e8 \u05d4\u05d1\u05d5\u05d3\u05e7 +What\ was\ charged,\ and\ refunds=\u05de\u05d4 \u05d7\u05d5\u05d9\u05d1, \u05d5\u05d4\u05d7\u05d6\u05e8\u05d9\u05dd +What\ you\ will\ need=\u05de\u05d4 \u05ea\u05e6\u05d8\u05e8\u05db\u05d5 +Where\ your\ earnings\ are\ sent=\u05dc\u05d0\u05df \u05e0\u05e9\u05dc\u05d7\u05d5\u05ea \u05d4\u05d4\u05db\u05e0\u05e1\u05d5\u05ea \u05e9\u05dc\u05da +Why\ does\ {0}\ need\ the\ admins'\ attention?=\u05de\u05d3\u05d5\u05e2 {0} \u05d3\u05d5\u05e8\u05e9 \u05d0\u05ea \u05ea\u05e9\u05d5\u05de\u05ea \u05dc\u05d1 \u05d4\u05de\u05e0\u05d4\u05dc\u05d9\u05dd? +Woman=\u05d0\u05d9\u05e9\u05d4 +Write\ the\ expiry\ date\ as\ month\ and\ year\:\ 04/28.=\u05d9\u05e9 \u05dc\u05db\u05ea\u05d5\u05d1 \u05d0\u05ea \u05d4\u05ea\u05d5\u05e7\u05e3 \u05db\u05d7\u05d5\u05d3\u05e9 \u05d5\u05e9\u05e0\u05d4: 04/28. +Year=\u05e9\u05e0\u05d4 +Yes=\u05db\u05df +You=\u05d0\u05ea\u05d4 +You\ go\ online=\u05de\u05ea\u05d7\u05d1\u05e8\u05d9\u05dd +Your\ application=\u05d4\u05d1\u05e7\u05e9\u05d4 \u05e9\u05dc\u05da +Your\ car=\u05d4\u05e8\u05db\u05d1 \u05e9\u05dc\u05da +Your\ card\ goes\ straight\ to\ the\ bank=\u05d4\u05db\u05e8\u05d8\u05d9\u05e1 \u05e9\u05dc\u05da \u05e2\u05d5\u05d1\u05e8 \u05d9\u05e9\u05d9\u05e8\u05d5\u05ea \u05dc\u05d1\u05e0\u05e7 +Your\ cards,\ and\ which\ of\ them\ pays\ by\ default=\u05d4\u05db\u05e8\u05d8\u05d9\u05e1\u05d9\u05dd \u05e9\u05dc\u05da, \u05d5\u05de\u05d9 \u05de\u05d4\u05dd \u05de\u05e9\u05dc\u05dd \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc +Your\ documents=\u05d4\u05de\u05e1\u05de\u05db\u05d9\u05dd \u05e9\u05dc\u05da +Your\ driver\ cannot\ be\ messaged\ right\ now=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05e9\u05dc\u05d5\u05d7 \u05db\u05e8\u05d2\u05e2 \u05d4\u05d5\u05d3\u05e2\u05d4 \u05dc\u05e0\u05d4\u05d2 +Your\ face,\ in\ good\ light,\ no\ sunglasses=\u05d4\u05e4\u05e0\u05d9\u05dd \u05e9\u05dc\u05da, \u05d1\u05ea\u05d0\u05d5\u05e8\u05d4 \u05d8\u05d5\u05d1\u05d4, \u05d1\u05dc\u05d9 \u05de\u05e9\u05e7\u05e4\u05d9 \u05e9\u05de\u05e9 +Your\ licence=\u05d4\u05e8\u05d9\u05e9\u05d9\u05d5\u05df \u05e9\u05dc\u05da +Your\ password\ was\ changed=\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d5\u05e0\u05ea\u05d4 +Your\ position\ is\ sent\ to\ the\ server\ you\ signed\ in\ to\ while\ you\ ride\ or\ drive,\ and\ to\ nobody\ else.\ Your\ settings\ are\ kept\ with\ your\ account\ on\ that\ server.=\u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05e9\u05dc\u05da \u05e0\u05e9\u05dc\u05d7 \u05dc\u05e9\u05e8\u05ea \u05e9\u05d0\u05dc\u05d9\u05d5 \u05d4\u05ea\u05d7\u05d1\u05e8\u05ea \u05d1\u05d6\u05de\u05df \u05e0\u05e1\u05d9\u05e2\u05d4 \u05d0\u05d5 \u05e0\u05d4\u05d9\u05d2\u05d4, \u05d5\u05dc\u05d0\u05e3 \u05d0\u05d7\u05d3 \u05d0\u05d7\u05e8. \u05d4\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05e0\u05e9\u05de\u05e8\u05d5\u05ea \u05e2\u05dd \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da \u05d1\u05d0\u05d5\u05ea\u05d5 \u05e9\u05e8\u05ea. +Your\ rider\ cannot\ be\ messaged\ right\ now=\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05e9\u05dc\u05d5\u05d7 \u05db\u05e8\u05d2\u05e2 \u05d4\u05d5\u05d3\u05e2\u05d4 \u05dc\u05e0\u05d5\u05e1\u05e2 +Your\ sign-in,\ your\ profile,\ your\ saved\ cards\ and\ your\ settings\ are\ removed\ for\ good.\ Rides\ you\ took\ stay\ in\ the\ service's\ records\ without\ your\ name.=\u05e4\u05e8\u05d8\u05d9 \u05d4\u05db\u05e0\u05d9\u05e1\u05d4, \u05d4\u05e4\u05e8\u05d5\u05e4\u05d9\u05dc, \u05d4\u05db\u05e8\u05d8\u05d9\u05e1\u05d9\u05dd \u05d4\u05e9\u05de\u05d5\u05e8\u05d9\u05dd \u05d5\u05d4\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05e9\u05dc\u05da \u05e0\u05de\u05d7\u05e7\u05d9\u05dd \u05dc\u05e6\u05de\u05d9\u05ea\u05d5\u05ea. \u05d4\u05e0\u05e1\u05d9\u05e2\u05d5\u05ea \u05e9\u05dc\u05da \u05e0\u05e9\u05d0\u05e8\u05d5\u05ea \u05d1\u05e8\u05e9\u05d5\u05de\u05d5\u05ea \u05d4\u05e9\u05d9\u05e8\u05d5\u05ea \u05dc\u05dc\u05d0 \u05e9\u05de\u05da. +{0}\ away=\u05d1\u05de\u05e8\u05d7\u05e7 {0} +{0}\ can\ now\ drive={0} \u05e8\u05e9\u05d0\u05d9 \u05db\u05e2\u05ea \u05dc\u05e0\u05d4\u05d5\u05d2 +{0}\ completed,\ {1}\ cancelled={0} \u05d4\u05d5\u05e9\u05dc\u05de\u05d5, {1} \u05d1\u05d5\u05d8\u05dc\u05d5 +{0}\ days\ ago=\u05dc\u05e4\u05e0\u05d9 {0} \u05d9\u05de\u05d9\u05dd +{0}\ goes\ back\ to\ the\ card\ it\ came\ from.={0} \u05d7\u05d5\u05d6\u05e8 \u05dc\u05db\u05e8\u05d8\u05d9\u05e1 \u05e9\u05de\u05de\u05e0\u05d5 \u05e9\u05d5\u05dc\u05dd. +{0}\ is\ on\ its\ way\ to\ your\ bank={0} \u05d1\u05d3\u05e8\u05da \u05dc\u05d1\u05e0\u05e7 \u05e9\u05dc\u05da +{0}\ is\ signed\ out\ everywhere\ and\ cannot\ sign\ in\ again\ until\ the\ block\ is\ lifted.={0} \u05de\u05e0\u05d5\u05ea\u05e7 \u05de\u05db\u05dc \u05de\u05e7\u05d5\u05dd \u05d5\u05dc\u05d0 \u05d9\u05d5\u05db\u05dc \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05e9\u05d5\u05d1 \u05e2\u05d3 \u05e9\u05d4\u05d7\u05e1\u05d9\u05de\u05d4 \u05ea\u05d5\u05e1\u05e8. +{0}\ new\ accounts={0} \u05d7\u05e9\u05d1\u05d5\u05e0\u05d5\u05ea \u05d7\u05d3\u05e9\u05d9\u05dd +{0}\ of\ 5\ documents\ added.\ Pick\ up\ where\ you\ left\ off.=\u05e0\u05d5\u05e1\u05e4\u05d5 {0} \u05de\u05ea\u05d5\u05da 5 \u05de\u05e1\u05de\u05db\u05d9\u05dd. \u05d4\u05de\u05e9\u05d9\u05db\u05d5 \u05de\u05d4\u05de\u05e7\u05d5\u05dd \u05e9\u05d1\u05d5 \u05e2\u05e6\u05e8\u05ea\u05dd. +{0}\ rides={0} \u05e0\u05e1\u05d9\u05e2\u05d5\u05ea +{0}\ s={0} \u05e9\u05e0' +{0}\ sees\ what\ you\ write,\ and\ can\ apply\ again\ once\ it\ is\ put\ right.={0} \u05e8\u05d5\u05d0\u05d4 \u05d0\u05ea \u05de\u05d4 \u05e9\u05db\u05ea\u05d1\u05ea \u05d5\u05d9\u05db\u05d5\u05dc \u05dc\u05d4\u05d2\u05d9\u05e9 \u05e9\u05d5\u05d1 \u05dc\u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e7\u05d5\u05df. +Fill\ in\ everything\ on\ this\ step\ to\ continue.=\u05d9\u05e9 \u05dc\u05de\u05dc\u05d0 \u05d0\u05ea \u05db\u05dc \u05d4\u05e4\u05e8\u05d8\u05d9\u05dd \u05d1\u05e9\u05dc\u05d1 \u05d6\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d4\u05de\u05e9\u05d9\u05da. +None\ nearby=\u05d0\u05d9\u05df \u05d1\u05e7\u05e8\u05d1\u05ea \u05de\u05e7\u05d5\u05dd +Pay\ the\ driver\ directly=\u05de\u05e9\u05dc\u05de\u05d9\u05dd \u05d9\u05e9\u05d9\u05e8\u05d5\u05ea \u05dc\u05e0\u05d4\u05d2 +Camera=\u05de\u05e6\u05dc\u05de\u05d4 +Gallery=\u05d2\u05dc\u05e8\u05d9\u05d4 +Fares\ collected=\u05ea\u05e9\u05dc\u05d5\u05de\u05d9\u05dd \u05e9\u05e0\u05d2\u05d1\u05d5 +Takes\ pets=\u05de\u05e1\u05d9\u05e2 \u05d7\u05d9\u05d5\u05ea \u05de\u05d7\u05de\u05d3 +Riders\ travelling\ with\ a\ pet\ are\ offered\ to\ you=\u05e0\u05d5\u05e1\u05e2\u05d9\u05dd \u05e2\u05dd \u05d7\u05d9\u05d9\u05ea \u05de\u05d7\u05de\u05d3 \u05d9\u05d5\u05e6\u05e2\u05d5 \u05dc\u05da +Go=\u05de\u05e2\u05d1\u05e8 +Nothing\ selected=\u05dc\u05d0 \u05e0\u05d1\u05d7\u05e8 \u05d3\u05d1\u05e8 +Pick\ a\ row\ from\ the\ list\ to\ see\ it\ here.=\u05d1\u05d7\u05e8\u05d5 \u05e9\u05d5\u05e8\u05d4 \u05de\u05d4\u05e8\u05e9\u05d9\u05de\u05d4 \u05db\u05d3\u05d9 \u05dc\u05e8\u05d0\u05d5\u05ea \u05d0\u05d5\u05ea\u05d4 \u05db\u05d0\u05df. +Usage\ statistics=\u05e0\u05ea\u05d5\u05e0\u05d9 \u05e9\u05d9\u05de\u05d5\u05e9 +Share\ which\ screens\ and\ features\ are\ used.\ Never\ your\ name,\ places\ or\ payment\ details.=\u05e9\u05d9\u05ea\u05d5\u05e3 \u05d4\u05de\u05e1\u05db\u05d9\u05dd \u05d5\u05d4\u05ea\u05db\u05d5\u05e0\u05d5\u05ea \u05e9\u05d1\u05e9\u05d9\u05de\u05d5\u05e9. \u05dc\u05e2\u05d5\u05dc\u05dd \u05dc\u05d0 \u05d4\u05e9\u05dd \u05e9\u05dc\u05da, \u05d4\u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05e9\u05dc\u05da \u05d0\u05d5 \u05e4\u05e8\u05d8\u05d9 \u05d4\u05ea\u05e9\u05dc\u05d5\u05dd. +Help\ improve\ Wayline=\u05e2\u05d6\u05e8\u05d5 \u05dc\u05e9\u05e4\u05e8 \u05d0\u05ea Wayline +Wayline\ can\ count\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ so\ that\ we\ can\ see\ what\ works\ and\ fix\ what\ does\ not.=Wayline \u05d9\u05db\u05d5\u05dc\u05d4 \u05dc\u05e1\u05e4\u05d5\u05e8 \u05d0\u05d9\u05dc\u05d5 \u05de\u05e1\u05db\u05d9\u05dd \u05e0\u05e4\u05ea\u05d7\u05d9\u05dd \u05d5\u05d1\u05d0\u05d9\u05dc\u05d5 \u05ea\u05db\u05d5\u05e0\u05d5\u05ea \u05de\u05e9\u05ea\u05de\u05e9\u05d9\u05dd, \u05db\u05d3\u05d9 \u05e9\u05e0\u05d5\u05db\u05dc \u05dc\u05e8\u05d0\u05d5\u05ea \u05de\u05d4 \u05e2\u05d5\u05d1\u05d3 \u05d5\u05dc\u05ea\u05e7\u05df \u05d0\u05ea \u05de\u05d4 \u05e9\u05dc\u05d0. +No\ personal\ details=\u05dc\u05dc\u05d0 \u05e4\u05e8\u05d8\u05d9\u05dd \u05d0\u05d9\u05e9\u05d9\u05d9\u05dd +Never\ your\ name,\ e-mail,\ phone\ number,\ places\ or\ card\ details=\u05dc\u05e2\u05d5\u05dc\u05dd \u05dc\u05d0 \u05d4\u05e9\u05dd \u05e9\u05dc\u05da, \u05d4\u05d3\u05d5\u05d0"\u05dc, \u05de\u05e1\u05e4\u05e8 \u05d4\u05d8\u05dc\u05e4\u05d5\u05df, \u05d4\u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05d0\u05d5 \u05e4\u05e8\u05d8\u05d9 \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 +Turn\ it\ on\ or\ off\ at\ any\ time\ in\ Settings=\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05d5 \u05dc\u05db\u05d1\u05d5\u05ea \u05d1\u05db\u05dc \u05e2\u05ea \u05d1\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea +Share\ usage\ statistics=\u05e9\u05d9\u05ea\u05d5\u05e3 \u05e0\u05ea\u05d5\u05e0\u05d9 \u05e9\u05d9\u05de\u05d5\u05e9 +With\ usage\ statistics\ turned\ on,\ the\ app\ also\ counts\ which\ screens\ are\ opened\ and\ which\ features\ are\ used,\ and\ reports\ that\ to\ Codename\ One's\ analytics\ service\ under\ a\ made-up\ id\ for\ this\ device.\ Your\ name,\ e-mail,\ phone\ number,\ places\ and\ card\ details\ are\ never\ part\ of\ it,\ and\ turning\ it\ off\ in\ Settings\ stops\ it.=\u05db\u05e9\u05e0\u05ea\u05d5\u05e0\u05d9 \u05d4\u05e9\u05d9\u05de\u05d5\u05e9 \u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd, \u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e1\u05d5\u05e4\u05e8\u05ea \u05d2\u05dd \u05d0\u05d9\u05dc\u05d5 \u05de\u05e1\u05db\u05d9\u05dd \u05e0\u05e4\u05ea\u05d7\u05d9\u05dd \u05d5\u05d1\u05d0\u05d9\u05dc\u05d5 \u05ea\u05db\u05d5\u05e0\u05d5\u05ea \u05de\u05e9\u05ea\u05de\u05e9\u05d9\u05dd, \u05d5\u05de\u05d3\u05d5\u05d5\u05d7\u05ea \u05e2\u05dc \u05db\u05da \u05dc\u05e9\u05d9\u05e8\u05d5\u05ea \u05d4\u05d0\u05e0\u05dc\u05d9\u05d8\u05d9\u05e7\u05d4 \u05e9\u05dc Codename One \u05ea\u05d7\u05ea \u05de\u05d6\u05d4\u05d4 \u05d1\u05d3\u05d5\u05d9 \u05e9\u05dc \u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05d4\u05d6\u05d4. \u05d4\u05e9\u05dd \u05e9\u05dc\u05da, \u05d4\u05d3\u05d5\u05d0"\u05dc, \u05de\u05e1\u05e4\u05e8 \u05d4\u05d8\u05dc\u05e4\u05d5\u05df, \u05d4\u05de\u05e7\u05d5\u05de\u05d5\u05ea \u05d5\u05e4\u05e8\u05d8\u05d9 \u05d4\u05db\u05e8\u05d8\u05d9\u05e1 \u05dc\u05e2\u05d5\u05dc\u05dd \u05d0\u05d9\u05e0\u05dd \u05d7\u05dc\u05e7 \u05de\u05d6\u05d4, \u05d5\u05db\u05d9\u05d1\u05d5\u05d9 \u05d1\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05de\u05e4\u05e1\u05d9\u05e7 \u05d6\u05d0\u05ea. +Location\ is\ turned\ off.\ Try\ again=\u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05d5\u05d9. \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1 +Can't\ find\ your\ location.\ Try\ again=\u05dc\u05d0 \u05de\u05e6\u05d0\u05e0\u05d5 \u05d0\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05e9\u05dc\u05da. \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1 +Still\ looking\ for\ your\ location.\ Try\ again=\u05e2\u05d3\u05d9\u05d9\u05df \u05de\u05d7\u05e4\u05e9\u05d9\u05dd \u05d0\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05e9\u05dc\u05da. \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1 +Looking\ for\ your\ location...=\u05de\u05d7\u05e4\u05e9\u05d9\u05dd \u05d0\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05e9\u05dc\u05da... diff --git a/scripts/wayline/common/src/main/resources/surfaces.json b/scripts/wayline/common/src/main/resources/surfaces.json new file mode 100644 index 00000000000..13b1c3a51e4 --- /dev/null +++ b/scripts/wayline/common/src/main/resources/surfaces.json @@ -0,0 +1,3 @@ +{ + "liveActivities": true +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/Actor.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/Actor.java new file mode 100644 index 00000000000..57f6cb63380 --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/Actor.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.io.CharArrayReader; +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.RequestAuthorizer; +import com.codename1.ui.CN; +import com.codenameone.examples.wayline.net.Session; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/// The other person in a test. The app on screen is one user; a ride needs +/// two, so the second one acts through the API directly, with their own token. +/// +/// Their requests opt out of the app's authorizer, which would otherwise put +/// the on-screen user's token on anything sent to `/api`. +final class Actor { + private final String token; + int status; + + /// One sign-in for each account for the whole run. The server limits how + /// often an address may sign in, as it should, and a suite that signed in + /// again for every step would be turned away by it. + private static final Map SIGNED_IN = new HashMap(); + + private Actor(String token) { + this.token = token; + } + + /// Signs `email` in the way the app does, and keeps the token to itself. + static Actor signIn(String email) { + Actor known = SIGNED_IN.get(email); + if (known != null) { + return new Actor(known.token); + } + Actor fresh = authenticate(email); + SIGNED_IN.put(email, fresh); + return fresh; + } + + /// Whether `email` can sign in at this moment, asked of the server and + /// kept by nobody: this is how a test sees a blocked account refused. + static boolean canSignIn(String email) { + try { + authenticate(email); + return true; + } catch (RuntimeException refused) { + return false; + } + } + + private static Actor authenticate(String email) { + final String[] token = new String[1]; + final String[] problem = new String[1]; + CN.callSerially(() -> Session.authenticate(email, E2e.PASSWORD, + tokens -> token[0] = tokens.getAccessToken(), + (status, message) -> problem[0] = status + " " + message)); + long deadline = System.currentTimeMillis() + 30000; + while (token[0] == null && problem[0] == null && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(50); + } catch (InterruptedException stop) { + break; + } + } + if (token[0] == null) { + throw new RuntimeException("Could not sign " + email + " in: " + problem[0]); + } + return new Actor(token[0]); + } + + Map get(String path) { + return send("GET", path, null); + } + + Map put(String path, String json) { + return send("PUT", path, json); + } + + Map post(String path, String json) { + return send("POST", path, json == null ? "{}" : json); + } + + private Map send(String method, String path, String json) { + ConnectionRequest request = new ConnectionRequest(); + request.setUrl(AppConfig.serverUrl() + path); + request.setHttpMethod(method); + request.setPost(json != null); + if (json != null) { + request.setContentType("application/json"); + request.setRequestBody(json); + } + request.addRequestHeader("Authorization", "Bearer " + token); + request.setAuthorizer(RequestAuthorizer.NONE); + request.setReadResponseForErrors(true); + request.setFailSilently(true); + request.setDuplicateSupported(true); + NetworkManager.getInstance().addToQueueAndWait(request); + status = request.getResponseCode(); + byte[] body = request.getResponseData(); + if (body == null || body.length == 0 || body[0] != '{') { + return new HashMap(); + } + try { + return new JSONParser().parseJSON(new CharArrayReader( + new String(body, "UTF-8").toCharArray())); + } catch (IOException malformed) { + return new HashMap(); + } + } + + /// A driver's position report. + Map online(boolean online, double lat, double lng) { + return post("/api/driver/status", "{\"online\":" + online + ",\"lat\":" + lat + + ",\"lng\":" + lng + ",\"heading\":0}"); + } + + static String text(Map json, String key) { + Object value = json.get(key); + return value instanceof String ? (String) value : ""; + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AdminDesktopTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AdminDesktopTest.java new file mode 100644 index 00000000000..4d9c95d20e3 --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AdminDesktopTest.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.ui.CN; +import com.codenameone.examples.wayline.ui.Layouts; + +/// The admin console in a desktop window: the places down the side instead of +/// in a drawer, and a list beside what was picked from it. +/// +/// It runs in the second pass of `run-e2e.sh`, where the simulator is a +/// desktop and not a phone. There the app is under the platform's own theme -- +/// GNOME's, on the Linux machine the tests run on -- and the console wears it. +public class AdminDesktopTest extends E2e { + @Override + protected boolean desktop() { + return true; + } + + /// What the app is given to draw in, in pixels: the size of every capture + /// this test takes. + private static final int WIDE = 1280; + private static final int HEIGHT = 754; + + /// What the window adds around the app, as it was last measured. + private int frameWidth; + private int frameHeight = 46; + + /// Makes the app's part of the window exactly `width` by `height`. + /// + /// The simulator is asked for the size of the whole window, and what is + /// left for the app is that less the title bar, the border and the + /// simulator's menu -- which differ with the window manager and the font + /// the menu is drawn in, and are not known until the window is up. A + /// capture of "a 1280 by 800 window" was 754 pixels high on one run and + /// 750 on another, and a capture of another size cannot be compared with + /// its golden at all. So the frame is measured and the request corrected + /// until the app has the size asked for. + private void window(final int width, final int height) { + for (int attempt = 0; attempt < 8; attempt++) { + final int askWidth = width + frameWidth; + final int askHeight = height + frameHeight; + onEdt(() -> CN.setWindowSize(askWidth, askHeight)); + int[] size = resized(); + if (size[0] == width && size[1] == height) { + return; + } + frameWidth = askWidth - size[0]; + frameHeight = askHeight - size[1]; + } + fail("The window could not be made " + width + " by " + height + "; the app has " + + CN.getDisplayWidth() + " by " + CN.getDisplayHeight()); + } + + /// The size the app has once the window has stopped changing. A resize is + /// carried out by the window system some time after it is asked for. + private int[] resized() { + int[] size = {CN.getDisplayWidth(), CN.getDisplayHeight()}; + int still = 0; + for (int waited = 0; waited < 60 && still < 4; waited++) { + waitFor(100); + int[] now = {CN.getDisplayWidth(), CN.getDisplayHeight()}; + still = now[0] == size[0] && now[1] == size[1] ? still + 1 : 0; + size = now; + } + return size; + } + + @Override + public void prepare() { + if (desktopRun()) { + // The window is whatever size it was left; the captures need one. + window(WIDE, HEIGHT); + until(() -> Layouts.wide(), 20000, + "the window to be laid out wide (it is " + CN.getDisplayWidth() + ")"); + } + super.prepare(); + } + + @Override + protected boolean run() throws Exception { + assertTrue(CN.isDesktop(), "this pass runs the simulator as a desktop"); + assertTrue(Layouts.desktopLook(), "the platform's desktop theme is installed"); + onEdt(() -> AppConfig.setMode("admin")); + signIn(ADMIN, "Admin"); + + // The console, not the phone's home: the places are on the screen, + // and there is no drawer to open. + until(() -> has("nav-applications"), 20000, "the places down the side"); + assertFalse(has("menu-open"), "a wide console has a drawer"); + until(() -> findByName("chartRides") != null && has("alert-applications"), 20000, + "the dashboard to be filled in"); + // The numbers are one row, and the charts side by side. + // Waited for: the charts are added when their numbers arrive, and are + // laid out on the turn of the event thread after that. + until(() -> sameRow("ridesToday", "revenue"), 10000, "the four numbers to be in one row"); + until(() -> sameRow("chartRides", "chartRevenue"), 10000, "the charts to be side by side"); + shot("admin-desktop-dashboard"); + + // An application opens beside the list it was picked from. + click("nav-applications"); + until(() -> findByName("Applications") != null, 20000, "the applications"); + click("application-applicant@wayline.example"); + until(() -> findByName("ApplicationReview") != null, 20000, "the application"); + untilText("applicationStatus", "Waiting"); + assertTrue(has("application-applicant@wayline.example"), + "the list is still there beside the application"); + assertTrue(leftOf("Applications", "ApplicationReview"), + "the application is beside the list"); + shot("admin-desktop-applications"); + // A document is shown over the application, and left with back. + click("document-licence"); + until(() -> findByName("Document") != null && findByName("picture") != null, 20000, + "the licence to be shown"); + assertTrue(findByName("ApplicationReview") == null, "the document covers the application"); + click("pane-back"); + until(() -> findByName("ApplicationReview") != null, 20000, + "back to lead to the application"); + + // An account, the same way. + click("nav-users"); + until(() -> findByName("Users") != null && has("user-" + RIDER), 20000, "the accounts"); + assertTrue(findByName("nothing") != null, "nothing is picked yet"); + click("user-" + RIDER); + until(() -> findByName("User") != null, 20000, "the account"); + untilText("standing", "In good standing"); + assertTrue(has("user-" + DRIVER), "the list is still there beside the account"); + shot("admin-desktop-user"); + + // Asking why is shown in the detail's place too, and refuses nothing. + click("block"); + until(() -> findByName("Reason") != null, 20000, "the question why"); + click("pane-back"); + until(() -> findByName("User") != null, 20000, "back to lead to the account"); + untilText("standing", "In good standing"); + + // The other places open, the dashboard again among them. + click("nav-rides"); + until(() -> findByName("Rides") != null, 20000, "the rides"); + shot("admin-desktop-rides"); + click("nav-payments"); + until(() -> findByName("Payments") != null, 20000, "the payments"); + shot("admin-desktop-payments"); + click("nav-pricing"); + until(() -> findByName("Pricing") != null && text("baseFare").length() > 0, 20000, + "the prices"); + shot("admin-desktop-pricing"); + click("nav-home"); + until(() -> findByName("chartRides") != null, 20000, "the dashboard again"); + + // A window dragged narrow is laid out as the phone's console, with + // its drawer, and dragged wide again is this one. + window(520, HEIGHT); + until(() -> has("menu-open") && !has("nav-applications"), 20000, + "a narrow window to be laid out as a phone"); + window(WIDE, HEIGHT); + until(() -> has("nav-applications") && !has("menu-open"), 20000, + "a wide window to be laid out side by side again"); + until(() -> findByName("chartRides") != null, 20000, "the dashboard, wide again"); + + // The map takes the whole window, and comes back to the console. + click("nav-fleet"); + waitForForm("Fleet"); + shot("admin-desktop-live-map"); + back(); + waitForForm("Admin"); + + // The screens that are not the console's are the phone's, kept to a + // column in the middle of the window rather than stretched across it. + click("menu-settings"); + waitForForm("Settings"); + shot("settings-desktop"); + back(); + waitForForm("Admin"); + click("switch-rider"); + waitForForm("Rider"); + shot("rider-desktop"); + signOut(); + return true; + } + + /// Whether the two things called `first` and `second` are level. + private boolean sameRow(String first, String second) { + return findByName(first) != null && findByName(second) != null + && Math.abs(findByName(first).getAbsoluteY() - findByName(second).getAbsoluteY()) + < CN.convertToPixels(3f) + && findByName(first).getAbsoluteX() < findByName(second).getAbsoluteX(); + } + + private boolean leftOf(String first, String second) { + return findByName(first).getAbsoluteX() + findByName(first).getWidth() + <= findByName(second).getAbsoluteX(); + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AdminModerationTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AdminModerationTest.java new file mode 100644 index 00000000000..2c57baea135 --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AdminModerationTest.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +/// Flagging an account and blocking one, from the admin's screens, and what +/// each does to the person it is done to. +public class AdminModerationTest extends E2e { + private static final String OTHER_RIDER = "rider2@wayline.example"; + + @Override + protected boolean run() throws Exception { + final Actor other = Actor.signIn(OTHER_RIDER); + other.get("/api/me"); + assertEqual(200, other.status); + + onEdt(() -> AppConfig.setMode("admin")); + signIn(ADMIN, "Admin"); + // One account arrives already flagged, and the dashboard says so. + until(() -> has("alert-flagged"), 20000, "the flagged account on the dashboard"); + click("users"); + waitForForm("Users"); + type("search", "RIDER2"); + until(() -> has("user-" + OTHER_RIDER) && !has("user-" + RIDER), 20000, + "the search to narrow the list, whatever the case"); + click("user-" + OTHER_RIDER); + waitForForm("User"); + untilText("standing", "In good standing"); + + // A flag is for the admins. The account works as before. + click("flag"); + waitForForm("Reason"); + type("reason", "Three drivers reported the same thing"); + click("confirm"); + untilText("standing", "Flagged"); + untilText("flagReason", "Three drivers reported the same thing"); + other.get("/api/me"); + assertEqual(200, other.status, "a flagged account still works"); + + // A block is for the account: signed out, and kept out. + click("block"); + waitForForm("Reason"); + type("reason", "Blocked while the reports are looked into"); + click("confirm"); + untilText("standing", "Blocked"); + other.get("/api/me"); + assertEqual(401, other.status, "a blocked account's token"); + other.post("/api/rides", "{\"pickupLat\":37.8085,\"pickupLng\":-122.4125," + + "\"pickupAddress\":\"Pier 39\",\"dropoffLat\":37.7955,\"dropoffLng\":-122.3937," + + "\"dropoffAddress\":\"Ferry Building\",\"distanceMeters\":2400," + + "\"durationSeconds\":540}"); + assertEqual(401, other.status, "a blocked account asking for a ride"); + assertFalse(Actor.canSignIn(OTHER_RIDER), "a blocked account can sign in"); + assertTrue(findByName("event-1") != null, "the flag and the block are in the history"); + shot("admin-user"); + + // Through the app, too: the sign-in screen says no. + back(); + waitForForm("Users"); + click("filter-blocked"); + until(() -> has("user-" + OTHER_RIDER), 20000, "the account among the blocked"); + back(); + waitForForm("Admin"); + signOut(); + click("signIn"); + waitForForm("SignIn"); + type("email", OTHER_RIDER); + type("password", PASSWORD); + click("submit"); + until(() -> text("error").length() > 0, 20000, "the blocked sign-in to be refused"); + back(); + waitForForm("Welcome"); + + // Both are lifted again, from the same screen. + signIn(ADMIN, "Admin"); + click("alert-flagged"); + waitForForm("Users"); + click("user-" + OTHER_RIDER); + waitForForm("User"); + click("unblock"); + untilText("standing", "Flagged"); + click("unflag"); + untilText("standing", "In good standing"); + until(() -> { + other.get("/api/me"); + return other.status == 200; + }, 20000, "the account to work again"); + assertTrue(Actor.canSignIn(OTHER_RIDER), "an unblocked account cannot sign in"); + return true; + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AdminTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AdminTest.java new file mode 100644 index 00000000000..aa8e3b92e8e --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AdminTest.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.components.Switch; +import com.codename1.ui.Component; + +import java.util.Map; + +/// The admin's screens, and a role granted from them taking effect on a token +/// that was issued before the grant. +public class AdminTest extends E2e { + @Override + protected boolean run() throws Exception { + // Someone to promote, and a car for the map. + Actor rider = Actor.signIn(RIDER); + rider.get("/api/admin/stats"); + assertEqual(403, rider.status); + Actor driver = Actor.signIn(DRIVER); + driver.online(true, HERE.getLatitude() + 0.003, HERE.getLongitude() - 0.004); + + onEdt(() -> AppConfig.setMode("admin")); + signIn(ADMIN, "Admin"); + until(() -> text("driversOnline").startsWith("1 /"), 20000, "one driver online"); + shot("admin-dashboard"); + // The charts are under the fold. + reveal("chartRevenue"); + shot("admin-trends"); + + click("fleet"); + waitForForm("Fleet"); + untilText("fleetCount", "1 car online"); + driver.online(false, 0, 0); + untilText("fleetCount", "No cars online"); + back(); + waitForForm("Admin"); + + // The one application that is waiting, with its documents. + until(() -> has("alert-applications"), 20000, "the dashboard to say one is waiting"); + click("applications"); + waitForForm("Applications"); + click("application-applicant@wayline.example"); + waitForForm("ApplicationReview"); + untilText("applicationStatus", "Waiting"); + click("document-licence"); + waitForForm("Document"); + until(() -> findByName("picture") != null, 20000, "the licence to be shown"); + back(); + waitForForm("ApplicationReview"); + back(); + waitForForm("Applications"); + back(); + waitForForm("Admin"); + + // Make the rider an admin. + click("users"); + waitForForm("Users"); + click("user-" + RIDER); + waitForForm("User"); + onEdt(() -> { + Component toggle = findByName("admin"); + ((Switch) toggle).setValue(true); + }); + // The token the rider was holding all along now opens the admin API: + // the server reads the account's roles on every request. + until(() -> { + rider.get("/api/admin/stats"); + return rider.status == 200; + }, 20000, "the grant to reach the rider's existing token"); + Map me = rider.get("/api/me"); + assertEqual(Boolean.TRUE, bool(me.get("admin")), "/api/me reports the new role"); + + // And taking it away closes it again. + onEdt(() -> ((Switch) findByName("admin")).setValue(false)); + until(() -> { + rider.get("/api/admin/stats"); + return rider.status == 403; + }, 20000, "the role to be taken away again"); + + // Blocking signs the account out wherever it is, and says why. + click("block"); + waitForForm("Reason"); + click("confirm"); + until(() -> text("error").length() > 0, 20000, "a block with no reason to be refused"); + type("reason", "Asked to be locked while travelling"); + click("confirm"); + untilText("standing", "Blocked"); + rider.get("/api/me"); + assertEqual(401, rider.status, "a blocked account's token"); + click("unblock"); + untilText("standing", "In good standing"); + until(() -> { + rider.get("/api/me"); + return rider.status == 200; + }, 20000, "the block to be lifted"); + assertTrue(findByName("event-1") != null, "both are in the account's history"); + back(); + waitForForm("Users"); + back(); + waitForForm("Admin"); + + // The rest of the console opens, and the prices can be saved. + click("rides"); + waitForForm("Rides"); + until(() -> has("ride-0"), 20000, "the rides"); + click("ride-0"); + waitForForm("RideDetail"); + back(); + waitForForm("Rides"); + back(); + waitForForm("Admin"); + click("payments"); + waitForForm("Payments"); + back(); + waitForForm("Admin"); + click("pricing"); + waitForForm("Pricing"); + until(() -> text("baseFare").length() > 0, 20000, "the prices"); + final String base = text("baseFare"); + type("commissionPercent", "95"); + click("submit"); + until(() -> text("error").length() > 0, 20000, "a commission of 95% to be refused"); + back(); + waitForForm("Admin"); + click("pricing"); + waitForForm("Pricing"); + untilText("baseFare", base); + return true; + } + + private static Boolean bool(Object value) { + return Boolean.valueOf(Boolean.TRUE.equals(value) || "true".equals(value)); + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AnalyticsTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AnalyticsTest.java new file mode 100644 index 00000000000..5991a81b478 --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/AnalyticsTest.java @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.analytics.AbstractAnalyticsProvider; +import com.codename1.analytics.AnalyticsCrashReport; +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.components.Switch; + +import java.util.ArrayList; +import java.util.List; + +/// What the app reports about how it is used: nothing until the user agrees, +/// then each screen and what was done on it, and never who did it. +/// +/// The reports go to a provider that only writes them down, so none of this +/// leaves the test. +public class AnalyticsTest extends E2e { + private final Recorder recorder = new Recorder(); + + @Override + protected boolean run() throws Exception { + // As on a device the app was just installed on: nobody has answered. + onEdt(() -> { + Telemetry.use(recorder); + Telemetry.unask(); + }); + + // The first sign-in is asked, before the first screen. + signIn(RIDER, "Consent"); + shot("analytics-consent"); + assertFalse(Telemetry.asked(), "the question is answered before anyone answered it"); + click("consentNo"); + waitForForm("Rider"); + assertTrue(Telemetry.asked() && !Telemetry.allowed(), "the answer was no"); + + // A no is a no: screens are shown, a fare is worked out, and nothing + // is reported. + quote(); + click("back"); + click("menu-open"); + click("menu-settings"); + waitForForm("Settings"); + assertEqual(0, recorder.seen.size(), "reported without consent: " + recorder.seen); + + // The switch in Settings is the same answer, and can be changed. + reveal("usageStatistics"); + assertFalse(((Switch) findByName("usageStatistics")).isValue(), "the switch shows yes"); + // Further than it needs to be seen, so that it is whole in the capture. + reveal("privacy"); + shot("settings-analytics"); + onEdt(() -> ((Switch) findByName("usageStatistics")).setValue(true)); + assertTrue(Telemetry.allowed(), "the switch did not grant consent"); + // From the yes on: who, in the two words kept about them, and the + // screen they are on. + until(() -> recorder.has("screen:Settings"), 5000, "the Settings screen view"); + assertTrue(recorder.has("property:role=rider"), "the role: " + recorder.seen); + assertTrue(recorder.has("property:language=en"), "the language: " + recorder.seen); + + // Each screen, once, under the name it was built with, and where the + // user came to it from. + back(); + waitForForm("Rider"); + until(() -> recorder.has("screen:Rider recorder.event("ride_quoted") != null, 5000, "the quote to be reported"); + assertTrue(recorder.event("ride_quoted").startsWith("ride_quoted[ride] {products="), + "the quote: " + recorder.event("ride_quoted")); + + // Asking for the ride, with what an operator would chart about it. + click("request"); + until(() -> recorder.event("ride_requested") != null, 20000, + "the request to be reported"); + assertEqual("ride_requested[ride] {product=standard, payment=cash}", + recorder.event("ride_requested")); + // Nobody is driving, so the server may give the ride up before the + // rider does; either way the screen ends where it began. + until(() -> { + clickIfThere("dismiss"); + clickIfThere("cancel"); + return findByName("whereTo") != null; + }, 30000, "the ride to be cancelled"); + + // Signing out, and in again: both are reported now. + signOut(); + until(() -> recorder.has("event:logout[account] {reason=user}"), 5000, + "the sign-out: " + recorder.seen); + assertTrue(recorder.has("screen:Welcome reported = new ArrayList(); + onEdt(() -> reported.addAll(recorder.seen)); + for (String line : reported) { + assertFalse(line.indexOf('@') >= 0 || line.indexOf("Riley") >= 0 + || line.indexOf("Ferry") >= 0 || line.indexOf("37.") >= 0 + || line.indexOf("122.") >= 0 || line.indexOf("userId") >= 0, + "personal data in a report: " + line); + } + + // Taking the yes back stops it at once. + click("menu-open"); + click("menu-settings"); + waitForForm("Settings"); + until(() -> recorder.has("screen:Settings ((Switch) findByName("usageStatistics")).setValue(false)); + assertFalse(Telemetry.allowed(), "the switch did not withdraw consent"); + final int before = recorder.seen.size(); + click("signOut"); + waitForForm("Welcome"); + waitFor(500); + assertEqual(before, recorder.seen.size(), "reported after consent was withdrawn: " + + recorder.seen.subList(before, recorder.seen.size())); + return true; + } + + @Override + public void cleanup() { + if (!desktopRun()) { + // The tests after this one are not asked, and report nothing. + onEdt(() -> Telemetry.allow(false)); + } + super.cleanup(); + } + + /// Writes down what it is told, a line each. + static final class Recorder extends AbstractAnalyticsProvider { + final List seen = new ArrayList(); + + @Override + public String getName() { + return "recorder"; + } + + @Override + public void trackScreen(String name, String referrer) { + seen.add("screen:" + name + (referrer == null ? "" : "<" + referrer)); + } + + @Override + public void trackEvent(AnalyticsEvent event) { + seen.add("event:" + event.getName() + "[" + event.getCategory() + "] " + + event.getParameters()); + } + + @Override + public void setUserId(String id) { + seen.add("userId:" + id); + } + + @Override + public void setUserProperty(String key, String value) { + seen.add("property:" + key + "=" + value); + } + + @Override + public void reportCrash(AnalyticsCrashReport report) { + seen.add("crash:" + report.getMessage()); + } + + boolean has(String line) { + return seen.contains(line); + } + + /// The last report of the event called `name`, without the `event:` + /// it is written down with; null when there is none. + String event(String name) { + for (int iter = seen.size() - 1; iter >= 0; iter--) { + if (seen.get(iter).startsWith("event:" + name + "[")) { + return seen.get(iter).substring("event:".length()); + } + } + return null; + } + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/DriverModeTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/DriverModeTest.java new file mode 100644 index 00000000000..0823a45ed4f --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/DriverModeTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.ui.Button; + +import java.util.Map; + +/// A whole ride from the driver's side of the app, with the rider as the +/// other actor. +public class DriverModeTest extends E2e { + @Override + protected boolean run() throws Exception { + onEdt(() -> AppConfig.setMode("driver")); + signIn(DRIVER, "Driver"); + untilText("driverState", "You are offline"); + shot("driver-offline"); + click("goOnline"); + untilText("driverState", "Looking for rides"); + + Actor rider = Actor.signIn(RIDER); + Map ride = rider.post("/api/rides", "{\"pickupLat\":37.8085," + + "\"pickupLng\":-122.4125,\"pickupAddress\":\"Pier 39\"," + + "\"dropoffLat\":37.7955,\"dropoffLng\":-122.3937," + + "\"dropoffAddress\":\"Ferry Building\",\"distanceMeters\":2400," + + "\"durationSeconds\":540}"); + assertEqual(200, rider.status, "the rider's request"); + final String id = Actor.text(ride, "id"); + + untilText("driverState", "Ride request"); + assertTrue(text("fare").startsWith("$"), "the offer shows the fare"); + shot("driver-offer"); + click("accept"); + untilText("driverState", "Pick up Riley Rider"); + assertEqual("ACCEPTED", Actor.text(rider.get("/api/rides/" + id), "state")); + click("arrived"); + untilText("driverState", "Waiting for Riley Rider"); + click("start"); + untilText("driverState", "On the trip"); + shot("driver-on-trip"); + assertFalse(findByName("cancel") instanceof Button, + "a ride under way cannot be cancelled by the driver"); + click("complete"); + untilText("driverState", "Looking for rides"); + assertEqual("COMPLETED", Actor.text(rider.get("/api/rides/" + id), "state")); + + // What the driver earned shows up on their own screen. + until(() -> !"0".equals(text("trips")) && text("trips").length() > 0, 20000, + "the trip to be counted"); + click("goOffline"); + untilText("driverState", "You are offline"); + + // And on the screen that says what driving has paid. + click("menu-open"); + click("menu-earnings"); + waitForForm("Earnings"); + until(() -> text("balance").startsWith("$"), 20000, "the balance"); + shot("driver-earnings"); + back(); + waitForForm("Driver"); + return true; + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/DriverOnboardingTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/DriverOnboardingTest.java new file mode 100644 index 00000000000..0763a41c560 --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/DriverOnboardingTest.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.ui.Photos; + +/// Becoming a driver, start to finish: an account that asks to drive, the +/// application with its photographs, an admin's approval, and the driver's +/// screen that only then opens. +public class DriverOnboardingTest extends E2e { + private static final String[] KINDS = {"photo_id", "licence", "selfie", + "vehicle_registration", "insurance"}; + + @Override + protected boolean run() throws Exception { + // One address per pass, not per run: the server is new for every run, both + // passes share it, and a timestamp in the address wrapped differently from + // run to run in the screens that are compared. + final String email = "dana." + (desktopRun() ? "desk" : "phone") + "@wayline.example"; + Photos.useSource(camera -> photograph()); + + click("register"); + waitForForm("Register"); + type("name", "Dana Driver"); + type("email", email); + type("phone", "+1 555 011 " + (System.currentTimeMillis() % 9000 + 1000)); + type("password", PASSWORD); + click("want-driver"); + click("submit"); + waitForForm("VerifyPhone"); + until(() -> text("demoCode").indexOf("code is ") > 0, 20000, "the demo code"); + String notice = text("demoCode"); + int at = notice.indexOf("code is ") + 8; + type("code", notice.substring(at, at + 6)); + click("submit"); + + // Asking to drive does not make a driver. It leads to the application. + waitForForm("Apply"); + assertFalse(Session.user().driver(), "a new account is not a driver"); + untilText("applicationStatus", "Not started"); + assertFalse(has("goOnline"), "an applicant can go online"); + click("apply"); + waitForForm("Application"); + + untilText("step", "Step 1 of 5"); + type("legalName", "Dana Morgan Driver"); + date("dateOfBirth", 1990, 5, 17); + click("gender-female"); + click("next"); + untilText("step", "Step 2 of 5"); + type("licenceNumber", "D7781204"); + date("licenceExpiry", 2031, 8, 31); + click("next"); + untilText("step", "Step 3 of 5"); + // Moving on with the car left out is refused. + click("next"); + until(() -> text("error").length() > 0, 20000, "an empty step to be refused"); + type("vehicleMake", "Toyota"); + type("vehicleModel", "Corolla"); + type("vehicleYear", "2022"); + type("vehicleColor", "Silver"); + type("vehiclePlate", "8DNA204"); + click("next"); + untilText("step", "Step 4 of 5"); + + // One by the camera, one from the gallery; three of the five so far. + untilText("status-photo_id", "Needed"); + click("camera-photo_id"); + untilText("status-photo_id", "Added"); + click("gallery-licence"); + untilText("status-licence", "Added"); + click("camera-selfie"); + untilText("status-selfie", "Added"); + shot("onboarding-documents"); + // The server will not take an application with papers missing. + click("next"); + untilText("step", "Step 5 of 5"); + untilText("documentsAdded", "3 / 5"); + click("submitApplication"); + until(() -> text("error").length() > 0, 20000, "an incomplete application to be refused"); + click("backStep"); + untilText("step", "Step 4 of 5"); + click("camera-vehicle_registration"); + untilText("status-vehicle_registration", "Added"); + click("gallery-insurance"); + untilText("status-insurance", "Added"); + click("next"); + untilText("documentsAdded", "5 / 5"); + click("submitApplication"); + + waitForForm("Apply"); + untilText("applicationStatus", "Under review"); + assertFalse(has("goOnline"), "an applicant under review can go online"); + Actor applicant = Actor.signIn(email); + applicant.online(true, HERE.getLatitude(), HERE.getLongitude()); + assertEqual(403, applicant.status, "an applicant going online through the API"); + shot("onboarding-under-review"); + signOut(); + + // The admin reads it, opens every paper, and approves. + signIn(ADMIN, "Admin"); + click("applications"); + waitForForm("Applications"); + click("application-" + email); + waitForForm("ApplicationReview"); + untilText("applicationStatus", "Waiting"); + shot("admin-application"); + for (int iter = 0; iter < KINDS.length; iter++) { + click("document-" + KINDS[iter]); + waitForForm("Document"); + until(() -> findByName("picture") != null, 20000, KINDS[iter] + " to be shown"); + back(); + waitForForm("ApplicationReview"); + } + click("approve"); + waitForForm("Applications"); + back(); + waitForForm("Admin"); + signOut(); + + // Approved: the same account now opens on the driver's screen, and + // going online works. + signIn(email, "Driver"); + assertTrue(Session.user().driver(), "approval makes the account a driver"); + untilText("driverState", "You are offline"); + click("goOnline"); + untilText("driverState", "Looking for rides"); + click("goOffline"); + untilText("driverState", "You are offline"); + return true; + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/E2e.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/E2e.java new file mode 100644 index 00000000000..e760ffc7adb --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/E2e.java @@ -0,0 +1,522 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.components.SpanLabel; +import com.codename1.io.FileSystemStorage; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.TokenStore; +import com.codename1.maps.LatLng; +import com.codename1.maps.MapView; +import com.codename1.maps.vector.BundledTileSource; +import com.codename1.maps.vector.TileCallback; +import com.codename1.maps.vector.TileSource; +import com.codename1.testing.AbstractTest; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.spinner.Picker; +import com.codename1.ui.util.ImageIO; +import com.codename1.util.AsyncResource; +import com.codenameone.examples.wayline.live.LiveChannel; +import com.codenameone.examples.wayline.map.Locator; +import com.codenameone.examples.wayline.map.Maps; +import com.codenameone.examples.wayline.map.Routes; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.ui.Lang; +import com.codenameone.examples.wayline.ui.Look; +import com.codenameone.examples.wayline.ui.Nav; +import com.codenameone.examples.wayline.ui.Photos; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Calendar; +import java.util.Map; + +/// What the end-to-end tests share. Each of them drives the real app, through +/// its real screens, against a real server -- the one `run-e2e.sh` starts on the +/// `test` profile, which has the four demo accounts, a geocoder that knows ten +/// places and a text-message sender that hands the code back. +/// +/// Three things are pinned so that a run is the same wherever it happens: the +/// map draws from tiles bundled with the tests, a route is the straight line, +/// and the device stands at a fixed point. None of it touches the server. +public abstract class E2e extends AbstractTest { + public static final String PASSWORD = "wayline-demo"; + public static final String RIDER = "rider@wayline.example"; + public static final String DRIVER = "driver@wayline.example"; + public static final String ADMIN = "admin@wayline.example"; + /// Where the device stands: the middle of the bundled tiles. + public static final LatLng HERE = new LatLng(37.8080, -122.4120); + + protected interface Check { + boolean ok(); + } + + @Override + public boolean shouldExecuteOnEDT() { + return false; + } + + @Override + public int getTimeoutMillis() { + return 180000; + } + + /// Whether this run of the tests is the one in a desktop window. + /// + /// `run-e2e.sh` runs the tests twice: on the simulator's phone, and then + /// with the simulator as a desktop, where the app is a window an admin + /// works in. A test belongs to one of the two -- see [#desktop()] -- and + /// passes without doing anything in the other. + static boolean desktopRun() { + return "true".equals(System.getProperty("wayline.e2e.desktop")); + } + + /// Whether this test is one for the desktop window. The phone's are not. + protected boolean desktop() { + return false; + } + + private boolean mine() { + return desktop() == desktopRun(); + } + + /// The test itself. + protected abstract boolean run() throws Exception; + + @Override + public final boolean runTest() throws Exception { + if (!mine()) { + log("Not a test for " + (desktopRun() ? "the desktop" : "the phone") + "; skipped"); + return true; + } + return run(); + } + + @Override + public void prepare() { + if (!mine()) { + return; + } + Session.useStore(new MemoryStore()); + Maps.useTiles(new Tiles()); + Routes.useStraightLines(true); + Locator.pin(HERE); + onEdt(() -> { + LiveChannel.stop(); + // The tests read the screens in English, whatever the machine + // they run on speaks. + Prefs.setLanguage("en"); + Prefs.setTheme(Prefs.THEME_LIGHT); + // Answered, with a no: no test is stopped by the question a first + // sign-in is asked, and none reports how it used the app. The + // test of the reports gives its own answers. + Telemetry.allow(false); + Look.apply(); + Lang.apply(); + Session.start(AppConfig.serverUrl()); + AppConfig.setMode(Nav.RIDER); + Nav.launch(); + }); + waitForForm("Welcome"); + quiet(); + } + + /// Puts the server back where a test expects to find it: nobody driving, + /// and the rider with no ride under way. The tests share one server, and + /// without this a test that failed half way would fail the ones after it + /// for a reason that has nothing to do with them. + private void quiet() { + Actor.signIn(DRIVER).online(false, 0, 0); + Actor rider = Actor.signIn(RIDER); + String id = Actor.text(rider.get("/api/rides/active"), "id"); + if (id.length() > 0) { + rider.post("/api/rides/" + id + "/cancel", null); + } + Photos.useSource(null); + } + + @Override + public void cleanup() { + if (!mine()) { + return; + } + onEdt(() -> { + LiveChannel.stop(); + Session.start(AppConfig.serverUrl()); + }); + } + + // ------------------------------------------------------------- driving + + protected void onEdt(Runnable work) { + CN.callSeriallyAndWait(work); + } + + /// Clicks the button called `name` on the screen that is showing, once it + /// is there. + protected void click(final String name) { + until(() -> findByName(name) instanceof Button, 20000, "a button called " + name); + onEdt(() -> { + Button button = (Button) findByName(name); + button.pressed(); + button.released(); + }); + } + + /// Clicks the button called `name` if it is on the screen at this moment. + /// + /// For a button the server may take away while the test looks: asking + /// whether it is there and then clicking it are two turns of the event + /// thread, and the screen can change between them. + protected void clickIfThere(final String name) { + onEdt(() -> { + Component found = findByName(name); + if (found instanceof Button) { + Button button = (Button) found; + button.pressed(); + button.released(); + } + }); + } + + protected void type(final String name, final String text) { + until(() -> findByName(name) instanceof TextArea, 20000, "a field called " + name); + onEdt(() -> { + TextArea field = (TextArea) findByName(name); + field.setText(text); + }); + } + + /// The text of the label or field called `name`, or "" while there is none. + protected String text(String name) { + Component found = findByName(name); + if (found instanceof SpanLabel) { + return ((SpanLabel) found).getText(); + } + if (found instanceof Label) { + return ((Label) found).getText(); + } + if (found instanceof TextArea) { + return ((TextArea) found).getText(); + } + return ""; + } + + protected void until(Check check, int timeoutMillis, String what) { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (!check.ok()) { + if (System.currentTimeMillis() > deadline) { + Form current = Display.getInstance().getCurrent(); + throw new RuntimeException("Timed out waiting for " + what + " (on " + + (current == null ? "no form" : current.getName()) + ")"); + } + waitFor(100); + } + } + + /// Waits for the screen called `name` to be the one showing. + protected void waitForForm(final String name) { + until(() -> { + Form current = Display.getInstance().getCurrent(); + return current != null && name.equals(current.getName()); + }, 30000, "the " + name + " screen"); + waitFor(100); + } + + protected void untilText(final String name, final String expected) { + until(() -> expected.equals(text(name)), 30000, + name + " to read \"" + expected + "\" (it reads \"" + text(name) + "\")"); + } + + /// Signs in through the app's own screens. + protected void signIn(String email, String homeForm) { + click("signIn"); + waitForForm("SignIn"); + type("email", email); + type("password", PASSWORD); + click("submit"); + waitForForm(homeForm); + } + + /// Whether there is something to press called `name` on the screen. + protected boolean has(String name) { + return findByName(name) instanceof Button; + } + + /// Scrolls whatever is called `name` into view. + protected void reveal(final String name) { + until(() -> findByName(name) != null, 20000, name + " to be there"); + onEdt(() -> { + Component found = findByName(name); + // The nearest thing above it that scrolls, which on a home screen + // is not the form. + Container holder = found.getParent(); + while (holder != null && !holder.isScrollableY()) { + holder = holder.getParent(); + } + if (holder != null) { + holder.scrollComponentToVisible(found); + } + }); + } + + /// Goes back, the way the arrow in the title bar does. + protected void back() { + onEdt(() -> Display.getInstance().getCurrent().getBackCommand().actionPerformed(null)); + } + + /// Signs out through the menu of a home screen. + protected void signOut() { + click("menu-open"); + click("menu-signOut"); + waitForForm("Welcome"); + } + + /// Sets the date field called `name`. A test cannot turn the wheels of + /// the platform's date picker, which is not part of the app. + protected void date(final String name, final int year, final int month, final int day) { + until(() -> findByName(name) instanceof Picker, 20000, "a date called " + name); + onEdt(() -> { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); + calendar.set(Calendar.DAY_OF_MONTH, day); + ((Picker) findByName(name)).setDate(calendar.getTime()); + }); + } + + /// Books the ride to the Ferry Building as far as the sheet that prices + /// it, which is where a test chooses what to do next. + protected void quote() { + click("whereTo"); + waitForForm("PlaceSearch"); + type("query", "Ferry"); + click("place-0"); + waitForForm("Rider"); + until(() -> text("fare").startsWith("$"), 20000, "the fare"); + } + + /// Waits for a ride to be offered to `driver` and returns its id. + protected String offered(final Actor driver) { + final String[] id = new String[1]; + until(() -> { + Map ride = driver.get("/api/driver/active"); + if ("OFFERED".equals(Actor.text(ride, "state"))) { + id[0] = Actor.text(ride, "id"); + } + return id[0] != null; + }, 30000, "the offer to reach the driver"); + return id[0]; + } + + /// A picture for the app to take as a photograph: the tests have neither + /// a camera nor a gallery. It is drawn rather than read from a file so + /// that it is the same picture wherever the tests run. + protected static Image photograph() { + Image picture = Image.createImage(640, 400, 0xffe9edf2); + Graphics g = picture.getGraphics(); + g.setColor(0x2f6fed); + g.fillRect(0, 0, 640, 70); + g.setColor(0xb8c2cf); + g.fillRect(40, 110, 150, 190); + g.setColor(0x8795a7); + for (int line = 0; line < 5; line++) { + g.fillRect(230, 120 + line * 38, line % 2 == 0 ? 340 : 250, 16); + } + return picture; + } + + // -------------------------------------------------------- screenshots + + /// Captures the screen as `name`.png and holds it against the golden of + /// the same name. + /// + /// `wayline.shots.dir` is where the capture goes; without it nothing is + /// captured, which is a run from an IDE. `wayline.goldens.dir` is where + /// the goldens are. A capture with no golden passes -- that is how a new + /// one is made: run, look at it, copy it over. + /// + /// The comparison is loose on purpose: a pixel counts as different only + /// when it is far off, and a capture fails only when a twentieth of it is. + /// Text is drawn a little differently from one machine to the next, and + /// this is here to notice a screen that changed, not a glyph. + protected void shot(String name) { + String out = fileUrl(System.getProperty("wayline.shots.dir")); + if (out == null) { + return; + } + settle(); + final Image[] captured = new Image[1]; + onEdt(() -> captured[0] = Display.getInstance().captureScreen()); + FileSystemStorage files = FileSystemStorage.getInstance(); + try { + files.mkdir(out); + OutputStream png = files.openOutputStream(out + "/" + name + ".png"); + try { + ImageIO.getImageIO().save(captured[0], png, ImageIO.FORMAT_PNG, 1f); + } finally { + png.close(); + } + String goldens = fileUrl(System.getProperty("wayline.goldens.dir")); + String golden = goldens == null ? "" : goldens + "/" + name + ".png"; + if (golden.length() == 0 || !files.exists(golden)) { + log("No golden for " + name + "; captured only"); + return; + } + InputStream in = files.openInputStream(golden); + Image expected; + try { + expected = Image.createImage(in); + } finally { + in.close(); + } + assertEqual(expected.getWidth(), captured[0].getWidth(), name + ": width changed"); + assertEqual(expected.getHeight(), captured[0].getHeight(), name + ": height changed"); + int[] want = expected.getRGB(); + int[] got = captured[0].getRGB(); + int off = 0; + for (int iter = 0; iter < want.length; iter++) { + if (far(want[iter], got[iter])) { + off++; + } + } + assertTrue(off * 20L <= want.length, name + ": " + off + " of " + want.length + + " pixels differ from the golden"); + } catch (java.io.IOException err) { + throw new RuntimeException("Could not capture " + name + ": " + err); + } + } + + /// A directory the build names as an ordinary path, in the form + /// `FileSystemStorage` takes one; null when it names none. + private static String fileUrl(String path) { + if (path == null || path.length() == 0) { + return null; + } + return path.startsWith("file:") ? path : "file://" + path; + } + + private static boolean far(int a, int b) { + return Math.abs(((a >> 16) & 0xff) - ((b >> 16) & 0xff)) > 60 + || Math.abs(((a >> 8) & 0xff) - ((b >> 8) & 0xff)) > 60 + || Math.abs((a & 0xff) - (b & 0xff)) > 60; + } + + /// Waits for the screen to stop moving: transitions over, the map drawn. + private void settle() { + waitFor(1500); + until(() -> { + Component map = findByName("map"); + return !(map instanceof MapView) || ((MapView) map).isMapReady(); + }, 20000, "the map"); + waitFor(500); + } + + /// The map of the neighbourhood the tests happen in, read from the test + /// resources, so that a run draws the same streets with no network. + /// + /// A tile outside the bundled area is answered with an empty one. A tile + /// that fails is asked for again and is never drawn, and the map then + /// never reports itself ready -- which a test waiting to take a capture + /// would read as a map that is still loading. + static final class Tiles implements TileSource { + private static final byte[] EMPTY = new byte[0]; + // At the root of the resources: a Codename One resource has no + // directory, and the simulator refuses a path that names one. + private final TileSource bundled = + new BundledTileSource("/mt_{z}_{x}_{y}.mvt", true, 13, 13); + + @Override + public boolean isVector() { + return true; + } + + @Override + public int getTileSize() { + return 256; + } + + @Override + public int getMinZoom() { + return 13; + } + + @Override + public int getMaxZoom() { + return 13; + } + + @Override + public String getAttribution() { + return "(c) OpenStreetMap contributors"; + } + + @Override + public void fetchTile(int z, int x, int y, final TileCallback callback) { + bundled.fetchTile(z, x, y, new TileCallback() { + @Override + public void tileLoaded(int tz, int tx, int ty, byte[] data) { + callback.tileLoaded(tz, tx, ty, data); + } + + @Override + public void tileFailed(int tz, int tx, int ty) { + callback.tileLoaded(tz, tx, ty, EMPTY); + } + }); + } + } + + /// Tokens kept for the length of the run and nowhere else. + static final class MemoryStore implements TokenStore { + private OidcTokens kept; + + @Override + public AsyncResource load(String key) { + AsyncResource out = new AsyncResource(); + out.complete(kept); + return out; + } + + @Override + public AsyncResource save(String key, OidcTokens tokens) { + kept = tokens; + AsyncResource out = new AsyncResource(); + out.complete(Boolean.TRUE); + return out; + } + + @Override + public AsyncResource clear(String key) { + kept = null; + AsyncResource out = new AsyncResource(); + out.complete(Boolean.TRUE); + return out; + } + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/PaymentsTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/PaymentsTest.java new file mode 100644 index 00000000000..3737316eb53 --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/PaymentsTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import java.util.Map; + +/// Paying: a card that is refused, a card that is added, a ride charged to it, +/// a tip on top, and the receipt that says all of it. +public class PaymentsTest extends E2e { + @Override + protected boolean run() throws Exception { + Actor driver = Actor.signIn(DRIVER); + driver.online(true, HERE.getLatitude() + 0.004, HERE.getLongitude() + 0.003); + signIn(RIDER, "Rider"); + + click("menu-open"); + click("menu-wallet"); + waitForForm("Wallet"); + until(() -> has("method-cash"), 20000, "the wallet"); + click("addCard"); + waitForForm("Card"); + // A number that is not a card number is caught before it is sent. + until(() -> has("saveCard"), 20000, "the card form"); + type("cardNumber", "4242 4242 4242 4241"); + type("cardExpiry", "12/34"); + type("cardCvc", "123"); + type("cardHolder", "Riley Rider"); + click("saveCard"); + until(() -> text("error").length() > 0, 20000, "a mistyped number to be caught"); + // A card the bank declines is refused by the server, in its words. + type("cardNumber", "4000 0000 0000 0002"); + click("saveCard"); + until(() -> text("error").length() > 0 && has("saveCard") + && findByName("saveCard").isEnabled(), 20000, "the declined card to be refused"); + type("cardNumber", "4242 4242 4242 4242"); + click("saveCard"); + waitForForm("Wallet"); + final String card = card(); + // Tapping it makes it the one that pays. + click(card); + Actor rider = Actor.signIn(RIDER); + until(() -> { + Map preferences = rider.get("/api/account/preferences"); + String chosen = Actor.text(preferences, "defaultPaymentMethodId"); + return chosen.length() > 0 && !"cash".equals(chosen); + }, 20000, "the card to become the account's default"); + shot("wallet"); + back(); + waitForForm("Rider"); + + quote(); + until(() -> text("payWith").indexOf("4242") >= 0, 20000, + "the ride to be paid by the new card (it says \"" + text("payWith") + "\")"); + click("request"); + untilText("rideState", "Finding a driver"); + final String id = offered(driver); + driver.post("/api/driver/offers/" + id + "/accept", null); + driver.post("/api/driver/rides/" + id + "/arrived", null); + driver.post("/api/driver/rides/" + id + "/start", null); + driver.post("/api/driver/rides/" + id + "/complete", null); + assertEqual(200, driver.status, "completing the ride"); + untilText("rideState", "You have arrived"); + until(() -> text("paidWith").indexOf("4242") >= 0, 20000, "what the ride was paid with"); + Map receipt = rider.get("/api/payments/receipts/" + id); + assertEqual("paid", Actor.text(receipt, "status"), "the charge"); + assertEqual(0, ((Number) receipt.get("tip")).intValue(), "no tip before one is given"); + + click("star-5"); + click("tip-15"); + click("done"); + until(() -> findByName("whereTo") != null, 20000, "the rider's screen to reset"); + until(() -> ((Number) rider.get("/api/payments/receipts/" + id).get("tip")).intValue() + > 0, 20000, "the tip to be charged"); + + // The ride is first in the history, and opens as its receipt. + click("menu-open"); + click("menu-history"); + waitForForm("Rides"); + click("ride-0"); + waitForForm("Receipt"); + untilText("paymentStatus", "Paid"); + assertTrue(text("tipPaid").startsWith("$"), "the receipt shows the tip"); + assertFalse(has("sendTip"), "a ride can be tipped once"); + shot("receipt"); + back(); + waitForForm("Rides"); + back(); + waitForForm("Rider"); + driver.online(false, 0, 0); + return true; + } + + /// The name of the row of the first card in the wallet. + private String card() { + final String[] found = new String[1]; + until(() -> { + for (int iter = 0; iter < 6 && found[0] == null; iter++) { + if (has("method-" + iter)) { + found[0] = "method-" + iter; + } + } + return found[0] != null; + }, 20000, "the card to be in the wallet"); + return found[0]; + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/RegisterTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/RegisterTest.java new file mode 100644 index 00000000000..f84b25cf7b8 --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/RegisterTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codenameone.examples.wayline.net.Session; + +/// A new account: register, be signed in, verify the phone, arrive. +public class RegisterTest extends E2e { + @Override + protected boolean run() throws Exception { + // One address per pass, not per run: the server is new for every run, both + // passes share it, and a timestamp in the address wrapped differently from + // run to run in the screens that are compared. + String email = "new." + (desktopRun() ? "desk" : "phone") + "@wayline.example"; + click("register"); + waitForForm("Register"); + type("name", "Nico Newcomer"); + type("email", email); + type("phone", "+1 555 010 " + (System.currentTimeMillis() % 9000 + 1000)); + type("password", PASSWORD); + shot("register"); + click("submit"); + + // Registered and signed in, and asked for the code. The test server + // sends no text; it says what the code is. + waitForForm("VerifyPhone"); + assertEqual(email, Session.user().username()); + assertFalse(Session.user().phoneVerified(), "a new account starts unverified"); + until(() -> text("demoCode").indexOf("code is ") > 0, 20000, "the demo code"); + shot("verify-phone"); + String notice = text("demoCode"); + int at = notice.indexOf("code is ") + 8; + String code = notice.substring(at, at + 6); + + // A wrong code is refused by the server, and says so. + type("code", "000000".equals(code) ? "111111" : "000000"); + click("submit"); + until(() -> text("error").length() > 0, 20000, "the wrong-code error"); + assertFalse(Session.user().phoneVerified()); + + type("code", code); + click("submit"); + waitForForm("Rider"); + assertTrue(Session.user().phoneVerified(), "the right code verifies the number"); + return true; + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/RideFlowTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/RideFlowTest.java new file mode 100644 index 00000000000..7f9544a662b --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/RideFlowTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import java.util.Map; + +/// A whole ride, from the rider's side of the app: the driver is the other +/// actor, and everything the rider sees change is the server telling the app. +public class RideFlowTest extends E2e { + @Override + protected boolean run() throws Exception { + // A car near the rider, online. + Actor driver = Actor.signIn(DRIVER); + driver.online(true, HERE.getLatitude() + 0.004, HERE.getLongitude() + 0.003); + assertEqual(200, driver.status, "the driver going online"); + + signIn(RIDER, "Rider"); + quote(); + assertTrue(text("price-standard").startsWith("$"), "each kind of ride has its price"); + shot("ride-quote"); + click("request"); + untilText("rideState", "Finding a driver"); + + // The offer reaches the driver, who takes it. + String id = offered(driver); + driver.post("/api/driver/offers/" + id + "/accept", null); + assertEqual(200, driver.status, "accepting the offer"); + untilText("rideState", "Driver on the way"); + driver.online(true, HERE.getLatitude() + 0.002, HERE.getLongitude() + 0.001); + shot("ride-driver-on-the-way"); + + driver.post("/api/driver/rides/" + id + "/arrived", null); + untilText("rideState", "Driver at pickup"); + driver.post("/api/driver/rides/" + id + "/start", null); + untilText("rideState", "On the trip"); + driver.post("/api/driver/rides/" + id + "/complete", null); + assertEqual(200, driver.status, "completing the ride"); + untilText("rideState", "You have arrived"); + assertTrue(text("fare").startsWith("$"), "the receipt shows what was charged"); + shot("ride-receipt"); + + // The stars are sent as they are tapped, and the sheet stays for the + // rest of what a rider may want to say about the ride. + click("star-5"); + click("done"); + until(() -> findByName("whereTo") != null, 20000, "the rider's screen to reset"); + + // Rated once: the server holds the ride as the rider left it. + Actor rider = Actor.signIn(RIDER); + Map ride = rider.get("/api/rides/" + id); + assertEqual("COMPLETED", Actor.text(ride, "state")); + assertEqual(5, ((Number) ride.get("rating")).intValue()); + driver.online(false, 0, 0); + return true; + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/SettingsTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/SettingsTest.java new file mode 100644 index 00000000000..5b76bee2a68 --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/SettingsTest.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codenameone.examples.wayline.net.Account; +import java.util.Map; + +/// The settings: a language and a theme that change the app on the spot, and +/// a ride preference that reaches the server and the next ride asked for. +public class SettingsTest extends E2e { + @Override + protected boolean run() throws Exception { + final Actor rider = Actor.signIn(RIDER); + signIn(RIDER, "Rider"); + click("menu-open"); + click("menu-settings"); + waitForForm("Settings"); + untilText("signOut", "Sign out"); + shot("settings"); + + // Another language, at once and on the account. + click("language-he"); + until(() -> text("signOut").length() > 0 && !"Sign out".equals(text("signOut")), 20000, + "the screen to be in Hebrew"); + until(() -> "he".equals(Actor.text(rider.get("/api/account/preferences"), "language")), + 20000, "the language to be kept with the account"); + shot("settings-hebrew"); + click("language-en"); + untilText("signOut", "Sign out"); + + // Dark, and the screen behind is dark as well. + click("theme-dark"); + until(() -> "dark".equals(Actor.text(rider.get("/api/account/preferences"), "theme")), + 20000, "the theme to be kept with the account"); + shot("settings-dark"); + back(); + waitForForm("Rider"); + shot("rider-home-dark"); + click("menu-open"); + click("menu-settings"); + waitForForm("Settings"); + click("theme-system"); + + // A woman at the wheel: kept with the account, and sent with the ride. + click("driverGender-women"); + until(() -> Account.lastSent() != null + && "women".equals(Account.lastSent().driverGender()), 20000, + "the preference to be sent"); + until(() -> { + Map kept = rider.get("/api/account/preferences"); + return "women".equals(Actor.text(kept, "driverGender")) + && "system".equals(Actor.text(kept, "theme")) + && "en".equals(Actor.text(kept, "language")); + }, 20000, "the account to hold what was chosen"); + back(); + waitForForm("Rider"); + quote(); + click("request"); + untilText("rideState", "Finding a driver"); + Map ride = rider.get("/api/rides/active"); + assertEqual("women", Actor.text(ride, "driverGender"), "the ride asks for a woman"); + // With no woman driving nearby the server may give the ride up before + // the rider does; either way the screen ends where it began. + until(() -> { + clickIfThere("dismiss"); + clickIfThere("cancel"); + return findByName("whereTo") != null; + }, 30000, "the ride to be cancelled"); + + // Back as it was, for whoever uses the account next. + click("menu-open"); + click("menu-settings"); + waitForForm("Settings"); + click("driverGender-any"); + until(() -> "any".equals(Actor.text(rider.get("/api/account/preferences"), + "driverGender")), 20000, "the preference to be put back"); + click("signOut"); + waitForForm("Welcome"); + return true; + } +} diff --git a/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/SignInTest.java b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/SignInTest.java new file mode 100644 index 00000000000..b8d5d22149a --- /dev/null +++ b/scripts/wayline/common/src/test/java/com/codenameone/examples/wayline/SignInTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline; + +import com.codename1.ui.Button; +import com.codenameone.examples.wayline.net.Session; +import com.codenameone.examples.wayline.ui.Nav; + +/// Signing in, and what a role does and does not open. +public class SignInTest extends E2e { + @Override + protected boolean run() throws Exception { + shot("welcome"); + + // A wrong password says so and stays put. + click("signIn"); + waitForForm("SignIn"); + type("email", RIDER); + type("password", "not-the-password"); + click("submit"); + until(() -> text("error").length() > 0, 20000, "the sign-in error"); + assertEqual("SignIn", com.codename1.ui.Display.getInstance().getCurrent().getName()); + assertNull(Session.user(), "nobody is signed in after a wrong password"); + + // The right one lands a rider on the rider's screen. + type("password", PASSWORD); + click("submit"); + waitForForm("Rider"); + assertEqual(RIDER, Session.user().username()); + assertTrue(Session.user().rider() && !Session.user().driver() && !Session.user().admin(), + "the demo rider has the rider role and no other"); + shot("rider-home"); + + // Asking for the admin's screens without the role gets the rider's. + onEdt(() -> Nav.switchTo(Nav.ADMIN)); + waitForForm("Rider"); + // Asking to drive gets the way there, the application, and nothing a + // driver has: there is no going online from it. + onEdt(() -> Nav.switchTo(Nav.DRIVER)); + waitForForm("Apply"); + untilText("applicationStatus", "Not started"); + assertFalse(has("goOnline"), "someone who is not a driver can go online"); + onEdt(() -> Nav.switchTo(Nav.RIDER)); + waitForForm("Rider"); + // And the server agrees, which is the part that matters: the same + // person's token is refused on the driver's and the admin's API. + Actor rider = Actor.signIn(RIDER); + rider.get("/api/admin/stats"); + assertEqual(403, rider.status, "a rider asking for the admin's numbers"); + rider.online(true, HERE.getLatitude(), HERE.getLongitude()); + assertEqual(403, rider.status, "a rider going online as a driver"); + rider.get("/api/me"); + assertEqual(200, rider.status); + + // The menu offers no mode to switch to -- only the way to apply for + // one -- and leads to the settings. + assertTrue(has("menu-drive"), "a rider is shown how to become a driver"); + assertFalse(findByName("switch-driver") instanceof Button, "a rider is offered driving"); + assertFalse(findByName("switch-admin") instanceof Button, "a rider is offered admin"); + click("menu-open"); + click("menu-settings"); + waitForForm("Settings"); + click("signOut"); + waitForForm("Welcome"); + assertNull(Session.user()); + return true; + } +} diff --git a/scripts/wayline/common/src/test/resources/mt_13_1307_3163.mvt b/scripts/wayline/common/src/test/resources/mt_13_1307_3163.mvt new file mode 100644 index 00000000000..abc40971843 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1307_3163.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1307_3164.mvt b/scripts/wayline/common/src/test/resources/mt_13_1307_3164.mvt new file mode 100644 index 00000000000..723a129b0a0 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1307_3164.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1307_3165.mvt b/scripts/wayline/common/src/test/resources/mt_13_1307_3165.mvt new file mode 100644 index 00000000000..d46dfb38e7f Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1307_3165.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1307_3166.mvt b/scripts/wayline/common/src/test/resources/mt_13_1307_3166.mvt new file mode 100644 index 00000000000..70fe6d04bea Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1307_3166.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1308_3163.mvt b/scripts/wayline/common/src/test/resources/mt_13_1308_3163.mvt new file mode 100644 index 00000000000..d8431d56cfb Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1308_3163.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1308_3164.mvt b/scripts/wayline/common/src/test/resources/mt_13_1308_3164.mvt new file mode 100644 index 00000000000..e9dc6b66b9a Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1308_3164.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1308_3165.mvt b/scripts/wayline/common/src/test/resources/mt_13_1308_3165.mvt new file mode 100644 index 00000000000..55b836f9d06 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1308_3165.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1308_3166.mvt b/scripts/wayline/common/src/test/resources/mt_13_1308_3166.mvt new file mode 100644 index 00000000000..6dc88cc9695 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1308_3166.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1309_3163.mvt b/scripts/wayline/common/src/test/resources/mt_13_1309_3163.mvt new file mode 100644 index 00000000000..2c54b33fdc2 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1309_3163.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1309_3164.mvt b/scripts/wayline/common/src/test/resources/mt_13_1309_3164.mvt new file mode 100644 index 00000000000..34811b5ff81 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1309_3164.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1309_3165.mvt b/scripts/wayline/common/src/test/resources/mt_13_1309_3165.mvt new file mode 100644 index 00000000000..7587fa55a75 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1309_3165.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1309_3166.mvt b/scripts/wayline/common/src/test/resources/mt_13_1309_3166.mvt new file mode 100644 index 00000000000..119272d2d42 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1309_3166.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1310_3163.mvt b/scripts/wayline/common/src/test/resources/mt_13_1310_3163.mvt new file mode 100644 index 00000000000..6783a6ef2a2 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1310_3163.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1310_3164.mvt b/scripts/wayline/common/src/test/resources/mt_13_1310_3164.mvt new file mode 100644 index 00000000000..fd54beb0b51 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1310_3164.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1310_3165.mvt b/scripts/wayline/common/src/test/resources/mt_13_1310_3165.mvt new file mode 100644 index 00000000000..6b4e1625922 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1310_3165.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1310_3166.mvt b/scripts/wayline/common/src/test/resources/mt_13_1310_3166.mvt new file mode 100644 index 00000000000..0c9b9c0e4ef Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1310_3166.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1311_3163.mvt b/scripts/wayline/common/src/test/resources/mt_13_1311_3163.mvt new file mode 100644 index 00000000000..1e302766674 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1311_3163.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1311_3164.mvt b/scripts/wayline/common/src/test/resources/mt_13_1311_3164.mvt new file mode 100644 index 00000000000..0aab6e1cddc Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1311_3164.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1311_3165.mvt b/scripts/wayline/common/src/test/resources/mt_13_1311_3165.mvt new file mode 100644 index 00000000000..e5bc5bfcff8 Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1311_3165.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1311_3166.mvt b/scripts/wayline/common/src/test/resources/mt_13_1311_3166.mvt new file mode 100644 index 00000000000..4086bb920ad Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1311_3166.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1312_3163.mvt b/scripts/wayline/common/src/test/resources/mt_13_1312_3163.mvt new file mode 100644 index 00000000000..b1f6183d68b Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1312_3163.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1312_3164.mvt b/scripts/wayline/common/src/test/resources/mt_13_1312_3164.mvt new file mode 100644 index 00000000000..2252efa6d1f Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1312_3164.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1312_3165.mvt b/scripts/wayline/common/src/test/resources/mt_13_1312_3165.mvt new file mode 100644 index 00000000000..f496265936a Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1312_3165.mvt differ diff --git a/scripts/wayline/common/src/test/resources/mt_13_1312_3166.mvt b/scripts/wayline/common/src/test/resources/mt_13_1312_3166.mvt new file mode 100644 index 00000000000..bc97d1cb6bc Binary files /dev/null and b/scripts/wayline/common/src/test/resources/mt_13_1312_3166.mvt differ diff --git a/scripts/wayline/pom.xml b/scripts/wayline/pom.xml new file mode 100644 index 00000000000..73f2c067ae8 --- /dev/null +++ b/scripts/wayline/pom.xml @@ -0,0 +1,314 @@ + + 4.0.0 + com.codenameone.examples.wayline + wayline + 1.0-SNAPSHOT + pom + wayline + wayline + https://www.codenameone.com + + + GPL v2 With Classpath Exception + https://openjdk.java.net/legal/gplv2+ce.html + repo + A business-friendly OSS license + + + + + shared + common + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + 1.8 + 11 + 1.7.11 + 3.8.0 + 1.8 + 1.8 + 1.8 + wayline + + + + + com.codenameone + java-runtime + ${cn1.version} + + + com.codenameone + codenameone-core + ${cn1.version} + + + com.codenameone + codenameone-javase + ${cn1.version} + + + com.codenameone + codenameone-android-compat + ${cn1.version} + provided + + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + com.codenameone + codenameone-buildclient + ${cn1.version} + system + ${user.home}/.codenameone/CodeNameOneBuildClient.jar + + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.codehaus.mojo + exec-maven-plugin + 3.0.0 + + + maven-antrun-plugin + org.apache.maven.plugins + 3.1.0 + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + codenameone + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + codenameone-plugins + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + + + cn1-client-tests + + + codename1.platform + !backend + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + + javascript + + + codename1.platform + javascript + + + ${basedir}/javascript/pom.xml + + + + javascript + + + + ios + + + codename1.platform + ios + + + ${basedir}/ios/pom.xml + + + + ios + + + + win + + + codename1.platform + win + + + ${basedir}/win/pom.xml + + + + win + + + + linux + + + codename1.platform + linux + + + ${basedir}/linux/pom.xml + + + + linux + + + + + backend + + + codename1.platform + backend + + + ${basedir}/backend/pom.xml + + + + backend + + + + android + + + codename1.platform + android + + + ${basedir}/android/pom.xml + + + + android + + + + javase + + + codename1.platform + javase + + + ${basedir}/javase/pom.xml + + + + javase + + + + cn1libs + + + ${basedir}/cn1libs/pom.xml + + + + cn1libs + + + + diff --git a/scripts/wayline/run-e2e.sh b/scripts/wayline/run-e2e.sh new file mode 100755 index 00000000000..9031b6ea196 --- /dev/null +++ b/scripts/wayline/run-e2e.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# +# The end-to-end tests: starts the Wayline server on the `test` profile, then +# runs the app's test suite against it in the Codename One simulator. +# +# run-e2e.sh [--native] [--prebuilt] +# +# --native runs the tests against the compiled server +# --prebuilt uses the server as it was last built, without building it again +# +# The tests drive the real screens: they sign in, request a ride, drive it and +# administer it, while a second user acts through the API. They capture a few +# screens into target/e2e-shots and hold them against screenshots/. +# +# Needs JAVA_HOME on JDK 17 or newer, and the Codename One artifacts this project +# builds against in the local Maven repository. The simulator needs a display: +# xvfb-run is used when there is one, which is what makes this run on a CI +# machine and in a container. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +MVN="${MVN:-mvn}" +PORT="${WAYLINE_PORT:-18080}" +LOG="${WAYLINE_SERVER_LOG:-$HERE/target/e2e-server.log}" +SHOTS="$HERE/target/e2e-shots" +MODE="--jvm" +BUILD=1 +for arg in "$@"; do + case "$arg" in + --native) MODE="--native" ;; + --prebuilt) BUILD=0 ;; + *) echo "[wayline-e2e] unknown option: $arg" >&2; exit 2 ;; + esac +done + +mkdir -p "$HERE/target" +rm -rf "$SHOTS" +mkdir -p "$SHOTS" + +echo "[wayline-e2e] java: $(java -version 2>&1 | head -1)" +if [ "$BUILD" = 1 ]; then + "$HERE/backend/server.sh" build "$MODE" +fi + +echo "[wayline-e2e] starting the server on port $PORT" +CN1_PROFILE=test "$HERE/backend/server.sh" run "$MODE" --prebuilt --port "$PORT" >"$LOG" 2>&1 & +SERVER_PID=$! +cleanup() { + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +ready=0 +for _ in $(seq 1 90); do + if curl -fs -o /dev/null "http://localhost:$PORT/oauth2/jwks" 2>/dev/null; then + ready=1 + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done +if [ "$ready" != "1" ]; then + echo "[wayline-e2e] the server did not start; its log:" >&2 + cat "$LOG" >&2 || true + exit 1 +fi + +RUNNER=() +if command -v xvfb-run >/dev/null 2>&1; then + RUNNER=(xvfb-run -a -s "-screen 0 1280x1024x24") +fi + +# The simulator is a JVM the build forks, and JAVA_TOOL_OPTIONS is what reaches +# it: where the server is, where the captures go and where the goldens are. +# The heap is given a ceiling too. Left alone a JVM takes a quarter of the +# machine's memory before it collects anything, which on a shared runner is +# enough to have the kernel end it between two tests. +export JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS:-} -Xmx768m -Dwayline.server.url=http://localhost:$PORT -Dwayline.shots.dir=$SHOTS -Dwayline.goldens.dir=$HERE/screenshots" + +set +e +${RUNNER[@]+"${RUNNER[@]}"} "$MVN" -B -f "$HERE/pom.xml" -Dcodename1.platform=javase -Ptest verify +STATUS=$? +set -e + +# The second pass: the same tests with the simulator as a desktop window and +# not a phone, which is where the admin console is laid out side by side and +# wears the platform's own theme. Each test knows which pass it is for and +# passes untouched in the other (E2e.desktop()). +# +# The simulator is a desktop when its "desktopSkin" preference says so; there +# is no switch for it on the command line. Java keeps preferences in files only +# on Linux, where java.util.prefs.userRoot can point them at a directory of +# this run's own -- so that is where this pass runs, which covers CI and a +# container. Elsewhere it would mean rewriting the developer's own simulator +# preferences, and it is skipped with a word. +if [ "$STATUS" -eq 0 ] && [ "$(uname -s)" = "Linux" ]; then + PREFS="$HERE/target/e2e-desktop-prefs" + NODE="$PREFS/.java/.userPrefs/com/codename1/impl/javase" + rm -rf "$PREFS" + mkdir -p "$NODE" + cat >"$NODE/prefs.xml" <<'XML' + + + + + +XML + echo "[wayline-e2e] the desktop pass" + # The last two are the project's own build hints, from + # common/codenameone_settings.properties. A packaged desktop app is given + # them by its launcher; a test run starts the simulator in + # common/target/cn1-reports, from where it does not find the project's + # settings, so it has to be told. + export JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS -Dwayline.e2e.desktop=true -Djava.util.prefs.userRoot=$PREFS -Dcodename1.arg.nativeTheme=native -Dcodename1.arg.desktop.titleBar=native" + set +e + ${RUNNER[@]+"${RUNNER[@]}"} "$MVN" -B -f "$HERE/pom.xml" -Dcodename1.platform=javase -Ptest verify + STATUS=$? + set -e +elif [ "$STATUS" -eq 0 ]; then + echo "[wayline-e2e] not on Linux: the desktop pass was skipped" +fi + +if [ "$STATUS" -ne 0 ]; then + echo "[wayline-e2e] the tests failed; the end of the server's log:" >&2 + tail -n 80 "$LOG" >&2 || true + exit "$STATUS" +fi +echo "[wayline-e2e] passed; captures are in $SHOTS" diff --git a/scripts/wayline/run-web-e2e.sh b/scripts/wayline/run-web-e2e.sh new file mode 100755 index 00000000000..ef719359a43 --- /dev/null +++ b/scripts/wayline/run-web-e2e.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# +# The web app, end to end: builds the Wayline server and the browser build of +# the app, starts the server with the app staged where it serves it from, and +# drives the hosted app in a headless browser (web-e2e.mjs). +# +# run-web-e2e.sh [--native] [--prebuilt] +# +# --native runs the compiled server; --prebuilt skips both builds and uses what +# `backend/server.sh build --web` left. +# +# What it proves: a person who opens the server's address gets the app, can sign +# in as the demo rider and reaches the home screen, with the live channel open. +# Screenshots of each step and the browser's log go to target/web-e2e. +# +# Needs JAVA_HOME on JDK 17 or newer, the Codename One artifacts this project builds +# against in the local Maven repository, and Node with Playwright and its +# Chromium: `npm ci && npx playwright install chromium` in scripts/, or a +# NODE_PATH that has it. Nothing here opens a window. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PORT="${WAYLINE_WEB_PORT:-18081}" +OUT="${WAYLINE_WEB_OUT:-$HERE/target/web-e2e}" +LOG="$OUT/server.log" +MODE="--jvm" +PREBUILT=0 +for arg in "$@"; do + case "$arg" in + --native) MODE="--native" ;; + --prebuilt) PREBUILT=1 ;; + *) echo "[wayline-web] unknown argument $arg" >&2; exit 2 ;; + esac +done + +if ! command -v node >/dev/null 2>&1; then + echo "[wayline-web] node is not on the PATH; the browser is driven with Playwright" >&2 + exit 2 +fi + +rm -rf "$OUT" +mkdir -p "$OUT" + +echo "[wayline-web] java: $(java -version 2>&1 | head -1)" +if [ "$PREBUILT" = 0 ]; then + "$HERE/backend/server.sh" build "$MODE" --web +fi +if [ ! -f "$HERE/backend/target/webapp/index.html" ]; then + echo "[wayline-web] no web app is staged in backend/target/webapp" >&2 + exit 1 +fi + +echo "[wayline-web] starting the server on port $PORT" +CN1_PROFILE=test "$HERE/backend/server.sh" run "$MODE" --prebuilt --port "$PORT" >"$LOG" 2>&1 & +SERVER_PID=$! +cleanup() { + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +ready=0 +for _ in $(seq 1 90); do + if curl -fs -o /dev/null "http://localhost:$PORT/oauth2/jwks" 2>/dev/null; then + ready=1 + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done +if [ "$ready" != "1" ]; then + echo "[wayline-web] the server did not start; its log:" >&2 + cat "$LOG" >&2 || true + exit 1 +fi + +fail() { + echo "[wayline-web] FAILED: $*" >&2 + exit 1 +} + +# What no browser is needed to see: the app is at /, the API kept its paths, and +# the script that is most of the download goes out compressed. +headers="$(curl -fsS -D - -o /dev/null "http://localhost:$PORT/")" \ + || fail "the server did not answer at /" +echo "$headers" | grep -qi '^content-type: text/html' || fail "/ is not served as a page" +echo "$headers" | grep -qi '^cache-control: no-cache' || fail "/ is not revalidated" +headers="$(curl -fsS -H 'Accept-Encoding: gzip' -D - -o /dev/null \ + "http://localhost:$PORT/translated_app.js")" || fail "translated_app.js is not served" +echo "$headers" | grep -qi '^content-encoding: gzip' \ + || fail "translated_app.js was not served compressed" +status="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/me")" +[ "$status" = "401" ] || fail "/api/me answered $status without a token, not 401" + +set +e +URL="http://localhost:$PORT/" OUT="$OUT" node "$HERE/web-e2e.mjs" +STATUS=$? +set -e + +if [ "$STATUS" -ne 0 ]; then + echo "[wayline-web] the end of the browser's log:" >&2 + tail -n 40 "$OUT/browser.log" >&2 || true + echo "[wayline-web] the end of the server's log:" >&2 + tail -n 40 "$LOG" >&2 || true + exit "$STATUS" +fi +echo "[wayline-web] passed; screenshots are in $OUT" diff --git a/scripts/wayline/screenshots/admin-application.png b/scripts/wayline/screenshots/admin-application.png new file mode 100644 index 00000000000..50f6b7ce516 Binary files /dev/null and b/scripts/wayline/screenshots/admin-application.png differ diff --git a/scripts/wayline/screenshots/admin-desktop-applications.png b/scripts/wayline/screenshots/admin-desktop-applications.png new file mode 100644 index 00000000000..3eb5eb534e1 Binary files /dev/null and b/scripts/wayline/screenshots/admin-desktop-applications.png differ diff --git a/scripts/wayline/screenshots/admin-desktop-dashboard.png b/scripts/wayline/screenshots/admin-desktop-dashboard.png new file mode 100644 index 00000000000..cf98814fe8e Binary files /dev/null and b/scripts/wayline/screenshots/admin-desktop-dashboard.png differ diff --git a/scripts/wayline/screenshots/admin-desktop-rides.png b/scripts/wayline/screenshots/admin-desktop-rides.png new file mode 100644 index 00000000000..d3ee98549dd Binary files /dev/null and b/scripts/wayline/screenshots/admin-desktop-rides.png differ diff --git a/scripts/wayline/screenshots/admin-trends.png b/scripts/wayline/screenshots/admin-trends.png new file mode 100644 index 00000000000..816fe0b855e Binary files /dev/null and b/scripts/wayline/screenshots/admin-trends.png differ diff --git a/scripts/wayline/screenshots/driver-earnings.png b/scripts/wayline/screenshots/driver-earnings.png new file mode 100644 index 00000000000..59282c8b6ea Binary files /dev/null and b/scripts/wayline/screenshots/driver-earnings.png differ diff --git a/scripts/wayline/screenshots/driver-offer.png b/scripts/wayline/screenshots/driver-offer.png new file mode 100644 index 00000000000..91fad5eaf3f Binary files /dev/null and b/scripts/wayline/screenshots/driver-offer.png differ diff --git a/scripts/wayline/screenshots/driver-offline.png b/scripts/wayline/screenshots/driver-offline.png new file mode 100644 index 00000000000..b944c27c445 Binary files /dev/null and b/scripts/wayline/screenshots/driver-offline.png differ diff --git a/scripts/wayline/screenshots/onboarding-documents.png b/scripts/wayline/screenshots/onboarding-documents.png new file mode 100644 index 00000000000..f9fd4f2c8d0 Binary files /dev/null and b/scripts/wayline/screenshots/onboarding-documents.png differ diff --git a/scripts/wayline/screenshots/receipt.png b/scripts/wayline/screenshots/receipt.png new file mode 100644 index 00000000000..a8bff4c6ae1 Binary files /dev/null and b/scripts/wayline/screenshots/receipt.png differ diff --git a/scripts/wayline/screenshots/ride-driver-on-the-way.png b/scripts/wayline/screenshots/ride-driver-on-the-way.png new file mode 100644 index 00000000000..6a1da0bd474 Binary files /dev/null and b/scripts/wayline/screenshots/ride-driver-on-the-way.png differ diff --git a/scripts/wayline/screenshots/ride-quote.png b/scripts/wayline/screenshots/ride-quote.png new file mode 100644 index 00000000000..a7a1dd51e6d Binary files /dev/null and b/scripts/wayline/screenshots/ride-quote.png differ diff --git a/scripts/wayline/screenshots/rider-home.png b/scripts/wayline/screenshots/rider-home.png new file mode 100644 index 00000000000..44c78d7d975 Binary files /dev/null and b/scripts/wayline/screenshots/rider-home.png differ diff --git a/scripts/wayline/screenshots/settings-dark.png b/scripts/wayline/screenshots/settings-dark.png new file mode 100644 index 00000000000..6166e750055 Binary files /dev/null and b/scripts/wayline/screenshots/settings-dark.png differ diff --git a/scripts/wayline/screenshots/settings-hebrew.png b/scripts/wayline/screenshots/settings-hebrew.png new file mode 100644 index 00000000000..e431e46fcb5 Binary files /dev/null and b/scripts/wayline/screenshots/settings-hebrew.png differ diff --git a/scripts/wayline/screenshots/settings.png b/scripts/wayline/screenshots/settings.png new file mode 100644 index 00000000000..a0bf8701814 Binary files /dev/null and b/scripts/wayline/screenshots/settings.png differ diff --git a/scripts/wayline/screenshots/wallet.png b/scripts/wayline/screenshots/wallet.png new file mode 100644 index 00000000000..1124aaff540 Binary files /dev/null and b/scripts/wayline/screenshots/wallet.png differ diff --git a/scripts/wayline/screenshots/welcome.png b/scripts/wayline/screenshots/welcome.png new file mode 100644 index 00000000000..0adbd107921 Binary files /dev/null and b/scripts/wayline/screenshots/welcome.png differ diff --git a/scripts/wayline/shared/pom.xml b/scripts/wayline/shared/pom.xml new file mode 100644 index 00000000000..a23af201079 --- /dev/null +++ b/scripts/wayline/shared/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + com.codenameone.examples.wayline + wayline + 1.0-SNAPSHOT + + com.codenameone.examples.wayline + wayline-shared + 1.0-SNAPSHOT + jar + wayline-shared + + + + 17 + 17 + + + + + com.codenameone + codenameone-core + provided + + + diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AccountApi.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AccountApi.java new file mode 100644 index 00000000000..0d196c36aad --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AccountApi.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.PUT; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +/// Opening an account, proving a phone number, asking who is signed in, and the +/// settings an account keeps. +/// +/// The app calls these through the generated client. The server implements the +/// generated `AccountApiServer`, and its build routes each path to that +/// implementation; neither side writes the other's half. +@RestClient +public interface AccountApi { + /// Opens an account. The one call here that needs no sign-in. + @POST("/api/account/register") + void register(@Body RegisterDto request, OnComplete> callback); + + @GET("/api/me") + void me(OnComplete> callback); + + /// Sends a verification code to the phone number on the account. + @POST("/api/account/phone/start") + void startPhoneVerification(OnComplete> callback); + + @POST("/api/account/phone/verify") + void verifyPhone(@Body PhoneCodeDto code, OnComplete> callback); + + /// A ticket for the live channel; see [TicketDto]. + @POST("/api/live/ticket") + void liveTicket(OnComplete> callback); + + @GET("/api/account/preferences") + void preferences(OnComplete> callback); + + @PUT("/api/account/preferences") + void savePreferences(@Body PreferencesDto preferences, + OnComplete> callback); + + @GET("/api/account/profile") + void profile(OnComplete> callback); + + @PUT("/api/account/profile") + void saveProfile(@Body ProfileDto profile, OnComplete> callback); + + @POST("/api/account/password") + void changePassword(@Body PasswordChangeDto change, OnComplete> callback); + + /// Closes the account for good. Refused while a ride is under way, and + /// without the account's password: a token is what a borrowed phone + /// holds too, and this cannot be taken back. + @POST("/api/account/delete") + void deleteAccount(@Body AccountClosureDto closure, OnComplete> callback); + + // tag::wayline-push-contract[] + /// Records a device the signed-in account is to be sent notifications on. + /// Registering the same token again moves it to this account: a phone + /// belongs to whoever signed in on it last. + @POST("/api/account/push") + void registerPush(@Body PushDeviceDto device, OnComplete> callback); + + /// Forgets a device, on signing out: the next person to use the phone is + /// not to be told about this account's rides. + @POST("/api/account/push/remove") + void unregisterPush(@Body PushDeviceDto device, OnComplete> callback); + // end::wayline-push-contract[] +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AccountClosureDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AccountClosureDto.java new file mode 100644 index 00000000000..b3c218118d8 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AccountClosureDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// The request to close an account: the password of whoever is asking. +@Mapped +public record AccountClosureDto(String password) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AdminApi.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AdminApi.java new file mode 100644 index 00000000000..5abde81c1ae --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AdminApi.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.PUT; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Admin mode: the numbers, the fleet, who may drive, and who may do what. +@RestClient +public interface AdminApi { + @GET("/api/admin/stats") + void stats(OnComplete> callback); + + /// The charts, over the last `days` days; 1 to 90. + @GET("/api/admin/stats/series") + void statsSeries(@Query("days") int days, OnComplete> callback); + + @GET("/api/admin/rides") + void rides(OnComplete>> callback); + + @GET("/api/admin/users") + void users(OnComplete>> callback); + + @GET("/api/admin/users/{username}") + void user(@Path("username") String username, OnComplete> callback); + + @GET("/api/admin/drivers") + void drivers(OnComplete>> callback); + + @POST("/api/admin/users/{username}/roles") + void setRoles(@Path("username") String username, @Body RoleChangeDto roles, + OnComplete> callback); + + /// Blocks an account, or lifts the block. + @POST("/api/admin/users/{username}/suspend") + void suspend(@Path("username") String username, @Body SuspendDto suspend, + OnComplete> callback); + + @POST("/api/admin/users/{username}/flag") + void flag(@Path("username") String username, @Body FlagDto flag, + OnComplete> callback); + + /// Driving applications, newest first. `status` narrows them to one status; + /// empty lists them all. + @GET("/api/admin/applications") + void applications(@Query("status") String status, + OnComplete>> callback); + + @GET("/api/admin/applications/{username}") + void application(@Path("username") String username, + OnComplete> callback); + + @GET("/api/admin/applications/{username}/documents/{kind}") + void document(@Path("username") String username, @Path("kind") String kind, + OnComplete> callback); + + /// Approves an application, which makes its account a driver. + @POST("/api/admin/applications/{username}/approve") + void approve(@Path("username") String username, + OnComplete> callback); + + @POST("/api/admin/applications/{username}/reject") + void reject(@Path("username") String username, @Body ReasonDto reason, + OnComplete> callback); + + @POST("/api/admin/rides/{id}/cancel") + void cancelRide(@Path("id") String id, @Body ReasonDto reason, + OnComplete> callback); + + @POST("/api/admin/rides/{id}/refund") + void refundRide(@Path("id") String id, @Body ReasonDto reason, + OnComplete> callback); + + /// Every receipt, newest first. + @GET("/api/admin/payments") + void payments(OnComplete>> callback); + + @GET("/api/admin/pricing") + void pricing(OnComplete> callback); + + @PUT("/api/admin/pricing") + void savePricing(@Body PricingDto pricing, OnComplete> callback); +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AdminStatsDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AdminStatsDto.java new file mode 100644 index 00000000000..818a276f592 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/AdminStatsDto.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// The numbers on the admin dashboard. +@Mapped +public record AdminStatsDto( + int users, + int drivers, + int driversOnline, + int ridesToday, + int ridesActive, + int ridesCompleted, + long revenueCents, + String currency) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/CardDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/CardDto.java new file mode 100644 index 00000000000..21544d3c0e1 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/CardDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A card as typed into the app. Read by the simulated processor only, which +/// keeps the brand and the last four digits and nothing else; with a real +/// processor the card never reaches the app or this server. +@Mapped +public record CardDto(String number, int expMonth, int expYear, String cvc, String holder) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DayAmountDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DayAmountDto.java new file mode 100644 index 00000000000..a1a0aed75be --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DayAmountDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// One day of a driver's earnings. +/// +/// @param date `yyyy-MM-dd`, in UTC. +@Mapped +public record DayAmountDto(String date, long amount, int trips) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DayStatsDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DayStatsDto.java new file mode 100644 index 00000000000..b84b482becd --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DayStatsDto.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// One day of the service. +/// +/// @param date `yyyy-MM-dd`, in UTC. +/// @param revenue Cents: the fares of the day's completed, paid rides. +@Mapped +public record DayStatsDto( + String date, + int rides, + int completed, + int cancelled, + long revenue, + int newUsers) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DocumentContentDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DocumentContentDto.java new file mode 100644 index 00000000000..6dc542e8629 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DocumentContentDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A stored document, picture included. +@Mapped +public record DocumentContentDto(String kind, String contentType, String dataBase64) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DocumentDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DocumentDto.java new file mode 100644 index 00000000000..bec26cd0b61 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DocumentDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// One of the documents of a driving application. The picture itself is read +/// separately, and only by the applicant and the admins. +/// +/// @param kind `photo_id`, `licence`, `selfie`, `vehicle_registration` or `insurance`. +/// @param status `missing`, `uploaded`, `accepted` or `rejected`. +/// @param uploadedAt Milliseconds since the epoch, or 0 when missing. +@Mapped +public record DocumentDto(String kind, String status, long uploadedAt) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DocumentUploadDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DocumentUploadDto.java new file mode 100644 index 00000000000..93d42a7ea2f --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DocumentUploadDto.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A picture of a document, sent as base64. JPEG or PNG, and small: the app +/// shrinks a photograph before sending it. +/// +/// @param contentType `image/jpeg` or `image/png`. +@Mapped +public record DocumentUploadDto(String kind, String contentType, String dataBase64) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverApi.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverApi.java new file mode 100644 index 00000000000..316f54c5226 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverApi.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.PUT; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Driver mode: going on line, answering offers, running a ride and being paid +/// for it. +@RestClient +public interface DriverApi { + /// Goes on or off line, and reports where the car is. + @POST("/api/driver/status") + void status(@Body DriverStatusDto status, OnComplete> callback); + + @GET("/api/driver/me") + void me(OnComplete> callback); + + /// The offer waiting for an answer or the ride under way, or one whose state + /// is NONE. + @GET("/api/driver/active") + void active(OnComplete> callback); + + @POST("/api/driver/offers/{id}/accept") + void accept(@Path("id") String id, OnComplete> callback); + + @POST("/api/driver/offers/{id}/decline") + void decline(@Path("id") String id, OnComplete> callback); + + @POST("/api/driver/rides/{id}/arrived") + void arrived(@Path("id") String id, OnComplete> callback); + + @POST("/api/driver/rides/{id}/start") + void start(@Path("id") String id, OnComplete> callback); + + @POST("/api/driver/rides/{id}/complete") + void complete(@Path("id") String id, OnComplete> callback); + + @POST("/api/driver/rides/{id}/cancel") + void cancel(@Path("id") String id, OnComplete> callback); + + @GET("/api/driver/earnings") + void earnings(OnComplete> callback); + + @GET("/api/driver/history") + void history(OnComplete>> callback); + + @GET("/api/driver/earnings/summary") + void earningsSummary(OnComplete> callback); + + @GET("/api/driver/payouts") + void payouts(OnComplete>> callback); + + /// Pays the whole balance out to the payout account. Refused when there is + /// no balance, or no account to send it to. + @POST("/api/driver/payouts") + void cashOut(OnComplete> callback); + + @GET("/api/driver/payout-account") + void payoutAccount(OnComplete> callback); + + @PUT("/api/driver/payout-account") + void savePayoutAccount(@Body PayoutAccountDto account, + OnComplete> callback); +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverApplicationDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverApplicationDto.java new file mode 100644 index 00000000000..f85f8fc7931 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverApplicationDto.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// An application to drive: who, which car, and the papers that prove both. +/// +/// @param status `none`, `draft`, `pending`, `approved` or `rejected`. +/// @param dateOfBirth `yyyy-MM-dd`. +/// @param licenceExpiry `yyyy-MM-dd`. +/// @param product `standard`, `comfort` or `xl`. +/// @param accessible Whether the car takes a wheelchair. +/// @param petFriendly Whether a rider's pet is welcome in the car. +/// @param documents One entry for each kind of document asked for, uploaded or not. +/// @param submittedAt Milliseconds since the epoch; 0 until it is handed in. +/// @param reviewedAt Milliseconds since the epoch; 0 until an admin decides. +/// @param reviewedBy The admin who decided; empty until one has. +@Mapped +public record DriverApplicationDto( + String username, + String name, + String status, + String legalName, + String dateOfBirth, + String licenceNumber, + String licenceExpiry, + String vehicleMake, + String vehicleModel, + int vehicleYear, + String vehicleColor, + String vehiclePlate, + int vehicleSeats, + String product, + boolean accessible, + boolean petFriendly, + List documents, + String rejectionReason, + long submittedAt, + long reviewedAt, + String reviewedBy) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverDto.java new file mode 100644 index 00000000000..43cd029a5cb --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverDto.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A driver, for the admin's fleet view and for the driver's own status. +/// +/// @param approved Whether the driving application was approved. Only an approved driver can go on +/// line. +/// @param product `standard`, `comfort` or `xl`: the kind of ride this car is offered. +/// @param acceptanceRate The share of the offers this driver was sent that they took, 0 to 1; 1 +/// before any was sent. +@Mapped +public record DriverDto( + String username, + String displayName, + String vehicle, + String plate, + boolean online, + double lat, + double lng, + double heading, + double rating, + long lastSeen, + int rides, + boolean approved, + String product, + String gender, + boolean accessible, + double acceptanceRate, + boolean flagged) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverStatusDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverStatusDto.java new file mode 100644 index 00000000000..90229e03921 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DriverStatusDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A driver going on or off line, or reporting where the car is. +@Mapped +public record DriverStatusDto(boolean online, double lat, double lng, double heading) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DrivingApi.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DrivingApi.java new file mode 100644 index 00000000000..4351a41111d --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/DrivingApi.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.PUT; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +/// Applying to drive. Open to anyone signed in, and only ever about the +/// caller's own application: the DRIVER role, and `DriverApi` with it, comes +/// once an admin approves. +@RestClient +public interface DrivingApi { + /// The caller's application; its status is `none` before one is begun. + @GET("/api/driving/application") + void application(OnComplete> callback); + + /// Saves the form as a draft. Nothing has to be complete yet. + @PUT("/api/driving/application") + void save(@Body DriverApplicationDto application, + OnComplete> callback); + + /// Stores one document, replacing an earlier one of the same kind. + @POST("/api/driving/application/documents") + void upload(@Body DocumentUploadDto document, + OnComplete> callback); + + /// One of the caller's own documents, to show back to them. + @GET("/api/driving/application/documents/{kind}") + void document(@Path("kind") String kind, OnComplete> callback); + + /// Hands the application to the admins. Refused until every field is filled + /// and every document is there. + @POST("/api/driving/application/submit") + void submit(OnComplete> callback); +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/EarningsDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/EarningsDto.java new file mode 100644 index 00000000000..2314349630c --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/EarningsDto.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// What a driver has earned. `rides` counts every paid ride the driver ever +/// completed, and `todayRides` those of them that `todayCents` is made of. +@Mapped +public record EarningsDto( + long todayCents, + long weekCents, + long totalCents, + int rides, + int todayRides, + String currency) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/EarningsSummaryDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/EarningsSummaryDto.java new file mode 100644 index 00000000000..15d5fc3edcf --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/EarningsSummaryDto.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// A driver's money: what came in, what is owed, and how the last two weeks +/// went. Amounts are cents, and are the driver's share -- the fare less the +/// service's commission, plus tips. +/// +/// @param balance Earned and not yet paid out. +/// @param onlineMinutes Time on line today. +/// @param acceptanceRate 0 to 1. +/// @param daily The last 14 days, oldest first, every day present. +@Mapped +public record EarningsSummaryDto( + String currency, + long today, + long week, + long month, + long balance, + int trips, + long tips, + int onlineMinutes, + double rating, + double acceptanceRate, + List daily) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/FareOptionDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/FareOptionDto.java new file mode 100644 index 00000000000..d0a0e9b0bed --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/FareOptionDto.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// One product a ride can be taken as, priced for the ride being quoted. +/// +/// @param product `standard`, `comfort` or `xl`. +/// @param fare Cents, like every amount here. +/// @param total What the rider would pay: [#fare()] plus [#serviceFee()]. +/// @param driversNearby Drivers of this product in reach; 0 when there is none. +/// @param etaMinutes Minutes for the nearest of them to arrive; 0 when there is none. +@Mapped +public record FareOptionDto( + String product, + String name, + String description, + int seats, + long fare, + long serviceFee, + long total, + int driversNearby, + int etaMinutes) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/FareQuoteDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/FareQuoteDto.java new file mode 100644 index 00000000000..08bd2d2885b --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/FareQuoteDto.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// What a ride would cost and how soon a driver could be there. +/// +/// @param amountCents The fare of the product asked for, before the service fee. +/// @param driversNearby Drivers online within reach of the pickup right now. +/// @param etaSeconds A rough time for the nearest of them to arrive, or 0 when there is none. +/// @param options The same ride priced as each product, cheapest first. +@Mapped +public record FareQuoteDto( + long amountCents, + String currency, + double distanceMeters, + double durationSeconds, + int driversNearby, + int etaSeconds, + List options) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/FlagDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/FlagDto.java new file mode 100644 index 00000000000..c1c9bc1e3b7 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/FlagDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// Marks an account for a closer look, or clears the mark. +@Mapped +public record FlagDto(boolean flagged, String reason) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/GeoApi.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/GeoApi.java new file mode 100644 index 00000000000..3956033f04e --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/GeoApi.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Finding places. The server asks a geocoding service on the app's behalf, so +/// the app holds no key for one and the service can be changed in one place. +@RestClient +public interface GeoApi { + /// Places matching `text`, nearest to the given point first. + @GET("/api/geo/search") + void search(@Query("q") String text, @Query("lat") double lat, @Query("lng") double lng, + OnComplete>> callback); + + /// The address at a point. + @GET("/api/geo/reverse") + void reverse(@Query("lat") double lat, @Query("lng") double lng, + OnComplete> callback); +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ModerationEventDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ModerationEventDto.java new file mode 100644 index 00000000000..ba700995c49 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ModerationEventDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// Something an admin did to an account, and why. +/// +/// @param at Milliseconds since the epoch. +/// @param by The admin who did it. +/// @param action `flag`, `unflag`, `block`, `unblock`, `approve`, `reject`, `roles`. +@Mapped +public record ModerationEventDto(long at, String by, String action, String reason) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/NameCountDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/NameCountDto.java new file mode 100644 index 00000000000..0884cd258de --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/NameCountDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A slice of a chart: a name and how many. +@Mapped +public record NameCountDto(String name, int count) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/NearbyDriverDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/NearbyDriverDto.java new file mode 100644 index 00000000000..6bae5a1ebd8 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/NearbyDriverDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A free driver near a point, for the cars on the rider's map. Says where and +/// what, and not who. +@Mapped +public record NearbyDriverDto(double lat, double lng, double heading, String product) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PasswordChangeDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PasswordChangeDto.java new file mode 100644 index 00000000000..58ff2c80152 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PasswordChangeDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A change of password: the one in use, and the one to use from now on. +@Mapped +public record PasswordChangeDto(String current, String replacement) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentApi.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentApi.java new file mode 100644 index 00000000000..7834d954603 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentApi.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// Ways to pay, and what was paid. Everything here is the signed-in user's own. +@RestClient +public interface PaymentApi { + @GET("/api/payments/config") + void config(OnComplete> callback); + + /// The saved cards, and cash, which is always there. + @GET("/api/payments/methods") + void methods(OnComplete>> callback); + + /// Begins adding a card; see [PaymentSetupDto] for the two ways it goes on. + @POST("/api/payments/methods/setup") + void startSetup(OnComplete> callback); + + /// Finishes adding a card. `card` is read by the simulated processor and + /// ignored when the setup was hosted. + @POST("/api/payments/methods/setup/{id}/complete") + void completeSetup(@Path("id") String id, @Body CardDto card, + OnComplete> callback); + + @POST("/api/payments/methods/{id}/default") + void makeDefault(@Path("id") String id, OnComplete> callback); + + @POST("/api/payments/methods/{id}/remove") + void remove(@Path("id") String id, OnComplete> callback); + + /// The caller's receipts as a rider, newest first. + @GET("/api/payments/receipts") + void receipts(OnComplete>> callback); + + @GET("/api/payments/receipts/{rideId}") + void receipt(@Path("rideId") String rideId, OnComplete> callback); + + /// Tips the driver of a completed ride, once. + @POST("/api/rides/{id}/tip") + void tip(@Path("id") String id, @Body TipDto tip, OnComplete> callback); +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentConfigDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentConfigDto.java new file mode 100644 index 00000000000..0d533126ec2 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentConfigDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// How this server takes payments, so the app knows which way to add a card. +/// +/// @param provider `simulated` or `stripe`. +@Mapped +public record PaymentConfigDto(String provider, String currency) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentMethodDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentMethodDto.java new file mode 100644 index 00000000000..b8af209a437 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentMethodDto.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A way to pay. Cash is always one of them, under the id `cash`. +/// +/// @param kind `card` or `cash`. +/// @param label The method as a person would name it: "Visa 4242", "Cash". +@Mapped +public record PaymentMethodDto( + String id, + String kind, + String brand, + String last4, + int expMonth, + int expYear, + boolean isDefault, + String label) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentSetupDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentSetupDto.java new file mode 100644 index 00000000000..d818dd1c066 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PaymentSetupDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// An attempt to add a card, begun. +/// +/// When `hosted` is true the card is typed into the payment processor's own page +/// at `url`: open it in the browser, and call complete when the user returns. +/// Otherwise the app collects a [CardDto] itself and sends it with complete. +@Mapped +public record PaymentSetupDto(String id, boolean hosted, String url) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PayoutAccountDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PayoutAccountDto.java new file mode 100644 index 00000000000..1091b07ac55 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PayoutAccountDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// Where a driver's payouts go. Only the last four digits of the account are +/// kept. +@Mapped +public record PayoutAccountDto(String holder, String bankName, String accountLast4) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PayoutDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PayoutDto.java new file mode 100644 index 00000000000..0729be02cc9 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PayoutDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// Money sent to a driver's bank. +/// +/// @param status `paid`. +/// @param createdAt Milliseconds since the epoch. +/// @param destination The account it went to, as its owner would recognise it. +@Mapped +public record PayoutDto(String id, long amount, String status, long createdAt, String destination) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PhoneChallengeDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PhoneChallengeDto.java new file mode 100644 index 00000000000..79e3f69ebd4 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PhoneChallengeDto.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// The answer to asking for a verification code: how long the code that was just +/// sent stays good. +/// +/// @param demoCode The code itself, but only from a server that has no way to send a text message +/// and is running on a development profile. It lets the sample be tried end to end without an +/// SMS account; a server configured with a real sender leaves it null. +@Mapped +public record PhoneChallengeDto(int expiresInSeconds, String demoCode) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PhoneCodeDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PhoneCodeDto.java new file mode 100644 index 00000000000..b7e92d8eb46 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PhoneCodeDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// The code the user typed in. +@Mapped +public record PhoneCodeDto(String code) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PlaceDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PlaceDto.java new file mode 100644 index 00000000000..f5cbf8b6fb1 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PlaceDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A place a search or a reverse lookup found. +@Mapped +public record PlaceDto(String name, String address, double lat, double lng) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PreferencesDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PreferencesDto.java new file mode 100644 index 00000000000..acdc82a65c3 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PreferencesDto.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// How one person wants the app and their rides. Kept on the server, so it +/// follows the account to another phone. +/// +/// @param language `en`, `es`, `fr`, `de` or `he`. +/// @param theme `system`, `light` or `dark`. +/// @param units `km` or `mi`. +/// @param driverGender `any` or `women`: whom a ride may be offered to. +/// @param defaultPaymentMethodId A saved method's id, or `cash`. +/// @param defaultTipPercent 0, 10, 15 or 20. +/// @param shareTripsWith The phone number trips are shared with, or empty. +@Mapped +public record PreferencesDto( + String language, + String theme, + String units, + boolean notifyRideUpdates, + boolean notifyReceipts, + boolean notifyPromotions, + String driverGender, + boolean quietRide, + boolean accessibleVehicle, + boolean petFriendly, + String defaultPaymentMethodId, + int defaultTipPercent, + String shareTripsWith) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PricingDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PricingDto.java new file mode 100644 index 00000000000..e6543398fd2 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PricingDto.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// What rides cost. Amounts are cents; the percentages are of the fare. +/// +/// @param serviceFeePercent Added to the fare and paid by the rider. +/// @param commissionPercent Kept from the fare; the rest is the driver's. +/// @param surgeMultiplier Multiplies every fare while demand is high; 1 otherwise. +@Mapped +public record PricingDto( + String currency, + long baseFare, + long perKm, + long perMinute, + long minimumFare, + double serviceFeePercent, + double commissionPercent, + double surgeMultiplier, + double comfortMultiplier, + double xlMultiplier) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ProfileDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ProfileDto.java new file mode 100644 index 00000000000..d2ba5b69355 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ProfileDto.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// The parts of an account its owner edits. +/// +/// @param gender `unspecified`, `female`, `male` or `nonbinary`. +@Mapped +public record ProfileDto( + String name, + String gender, + String emergencyContactName, + String emergencyContactPhone, + String homeAddress, + String workAddress) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PushDeviceDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PushDeviceDto.java new file mode 100644 index 00000000000..82d6d761d9b --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/PushDeviceDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A device that can be sent push notifications, as the app registers it. +/// +/// `provider` is the push service the device's token belongs to -- `fcm`, +/// `apns`, `huawei`, `wns` or `web` -- and `token` the token that service gave +/// the app. `platform` is the device's platform name, for the record only. +@Mapped +public record PushDeviceDto(String provider, String token, String platform) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RatingDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RatingDto.java new file mode 100644 index 00000000000..a66f3d47a9c --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RatingDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A rider's rating of a completed ride. +@Mapped +public record RatingDto(int stars) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ReasonDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ReasonDto.java new file mode 100644 index 00000000000..c5c2e70dc46 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ReasonDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// Why an admin did something: rejected an application, cancelled a ride, +/// refunded one. +@Mapped +public record ReasonDto(String reason) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ReceiptDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ReceiptDto.java new file mode 100644 index 00000000000..ba3f5052005 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/ReceiptDto.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// What a ride cost and how it was paid. Every amount is in cents. +/// +/// @param baseFare The part of the fare that does not depend on the trip. +/// @param distanceFare The part of the fare charged by distance. +/// @param timeFare The part of the fare charged by time. +/// @param total The fare, the service fee and the tip, less the discount. +/// @param status `pending`, `paid`, `failed`, `refunded` or `cash`; while a refund the processor +/// took has not finished, `refund_pending`, and `refund_failed` when it then did not return the +/// money. +/// @param paidAt Milliseconds since the epoch, or 0 while unpaid. +/// @param riderUsername Who paid. Filled for an admin; a rider's own receipts leave it empty. +@Mapped +public record ReceiptDto( + String rideId, + String currency, + long baseFare, + long distanceFare, + long timeFare, + long serviceFee, + long tip, + long discount, + long total, + String methodLabel, + String status, + long paidAt, + String pickupName, + String dropoffName, + double distanceMeters, + double durationSeconds, + String driverName, + String vehicle, + String riderUsername) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RegisterDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RegisterDto.java new file mode 100644 index 00000000000..28109ff7365 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RegisterDto.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// What opening an account takes. The password travels once, in the body of a +/// request over TLS, and the server keeps only its hash. +/// +/// @param driver The older spelling of [#wantsToDrive()], kept for apps built before it. It no +/// longer grants anything: it opens a driving application like the newer flag. +/// @param wantsToDrive Opens a driving application with the account. The account is a rider until +/// an admin approves the application. +/// @param gender `unspecified`, `female`, `male` or `nonbinary`; empty reads as unspecified. +@Mapped +public record RegisterDto( + String email, + String password, + String displayName, + String phone, + boolean driver, + String vehicle, + String plate, + boolean wantsToDrive, + String gender) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideActivityDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideActivityDto.java new file mode 100644 index 00000000000..af2d9860ba1 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideActivityDto.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// The Live Activity a rider's phone started for a ride, by the id the +/// platform gave it. The server addresses its updates to that id. +@Mapped +public record RideActivityDto(String activityId) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideActivityKeys.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideActivityKeys.java new file mode 100644 index 00000000000..7b770d1ec46 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideActivityKeys.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +/// The words the app and the server agree on for a ride's Live Activity: the +/// "your driver is on the way" card on the lock screen. +/// +/// The app starts the activity with a state built from these keys, and the +/// server sends later states with the same keys. +public final class RideActivityKeys { + /// The kind of activity. + public static final String KIND = "ride"; + /// What is happening, in words: "On the way", "Your driver is here". + public static final String STATUS = "status"; + /// The driver's name. + public static final String DRIVER = "driver"; + /// The car, as the driver described it. + public static final String CAR = "car"; + /// The car's license plate. + public static final String PLATE = "plate"; + /// When the car gets where it is going next, in words: "4 min", or empty + /// once it is there. + public static final String ETA = "eta"; + /// How far along the ride is, a number from 0 to 1 for the progress bar. + public static final String PROGRESS = "progress"; + + /// The values of [#PROGRESS] at each step. + public static final double ON_THE_WAY = 0.25d; + public static final double ARRIVED = 0.5d; + public static final double IN_TRIP = 0.75d; + public static final double DONE = 1d; + + private RideActivityKeys() { + } +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideDto.java new file mode 100644 index 00000000000..b5babf304b2 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideDto.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A ride, as each party to it sees it. The same object answers the rider, the +/// driver and the admin; nothing in it is private to one of them. +/// +/// @param state NONE means "there is no such ride", which is how a question like "what is my active +/// ride?" is answered when there is not one. +/// @param driverLat Where the driver was last seen. Meaningful once a driver is attached. +/// @param driverLng See [#driverLat()]. +/// @param driverHeading The way the driver's car points, in degrees clockwise from north. +/// @param fareCents The fare, before the service fee and any tip. +/// @param requestedAt Milliseconds since the epoch. +/// @param updatedAt Milliseconds since the epoch. +/// @param rating The rider's rating of the ride, 1 to 5, or 0 before there is one. +/// @param paymentStatus PENDING until the ride completes, then PAID or FAILED; REFUNDED once an +/// admin has returned the money. A cash ride is PAID when it completes. A refund the processor +/// has not finished is REFUND_PENDING until it ends REFUNDED, or REFUND_FAILED when the money +/// did not go back. +/// @param offerExpiresInSeconds For a driver looking at an offer: how long is left to answer it. +/// @param product `standard`, `comfort` or `xl`. +/// @param paymentMethodLabel How the ride is paid for, as the rider would recognise it: "Cash", +/// "Visa 4242". +/// @param cash True when the rider pays the driver in cash, so that the driver's screen can say to +/// collect it. The label above is for people and is not compared. +/// @param serviceFee Cents, like every amount here. +/// @param total What the rider pays: the fare, the service fee and the tip. +/// @param driverPhotoInitials The driver's initials, for a badge where a photo would go. +/// @param etaMinutes Minutes until the driver reaches the pickup, or the drop-off once the trip has +/// started; 0 when there is nothing to estimate. +/// @param driverGender What the rider asked for, for the driver to read: this and the four that +/// follow. +/// @param driverPhone The number to call or text the driver at, given to the rider. Filled only +/// while a driver has the ride and it is not over, and never for anyone else. +/// @param riderPhone The number to call or text the rider at, given to the driver, on the same +/// terms as [#driverPhone()]. +@Mapped +public record RideDto( + String id, + RideState state, + String riderUsername, + String riderName, + String driverUsername, + String driverName, + String vehicle, + String plate, + double driverRating, + double pickupLat, + double pickupLng, + String pickupAddress, + double dropoffLat, + double dropoffLng, + String dropoffAddress, + double driverLat, + double driverLng, + double driverHeading, + long fareCents, + String currency, + double distanceMeters, + double durationSeconds, + long requestedAt, + long updatedAt, + int rating, + String paymentStatus, + int offerExpiresInSeconds, + String product, + String paymentMethodLabel, + boolean cash, + long serviceFee, + long tip, + long total, + String driverPhotoInitials, + int etaMinutes, + String driverGender, + boolean quietRide, + boolean accessibleVehicle, + boolean petFriendly, + String note, + String driverPhone, + String riderPhone) { + + /// The answer when there is no ride to give: a question like "what is my + /// active ride?" asked by someone who has none. Its state is + /// [RideState#NONE] and every other component is empty. + public static RideDto none() { + return new RideDto(null, RideState.NONE, null, null, null, null, null, null, 0d, 0d, 0d, + null, 0d, 0d, null, 0d, 0d, 0d, 0L, null, 0d, 0d, 0L, 0L, 0, null, 0, null, null, + false, 0L, 0L, 0L, null, 0, null, false, false, false, null, null, null); + } +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideRequestDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideRequestDto.java new file mode 100644 index 00000000000..552ee772fde --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideRequestDto.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +// tag::wayline-contract-dto[] +/// Where a ride starts and ends, and how the rider wants it, for a quote and for +/// the request itself. +/// +/// @param distanceMeters The length of the route the app drew, when it has one. The server prices +/// from this and [#durationSeconds()] only while they are plausible for the two points; +/// otherwise it prices from its own estimate. +/// @param durationSeconds How long the app's route takes, when it has one. +/// @param paymentMethodId A saved method's id, or `cash`. Empty means the rider's default method, +/// and cash when there is none. +/// @param product `standard`, `comfort` or `xl`; empty reads as standard. +/// @param driverGender `any` or `women`; empty reads as any. +/// @param quietRide Whether the rider would rather not talk. +/// @param accessibleVehicle Whether the car has to take a wheelchair. +/// @param petFriendly Whether a pet is coming: only drivers who take pets are offered the ride. +/// @param note A few words for the driver: which door, what to look for. +@Mapped +public record RideRequestDto( + double pickupLat, + double pickupLng, + String pickupAddress, + double dropoffLat, + double dropoffLng, + String dropoffAddress, + double distanceMeters, + double durationSeconds, + String paymentMethodId, + String product, + String driverGender, + boolean quietRide, + boolean accessibleVehicle, + boolean petFriendly, + String note) { +} +// end::wayline-contract-dto[] diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideState.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideState.java new file mode 100644 index 00000000000..6b7f87d457f --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RideState.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +/// The states of a ride, as [RideDto#state] carries them and the server stores +/// them: by name, on the wire and in the database column. +public enum RideState { + /// No ride. Never stored; it answers "is there one?". + NONE, + /// Asked for; no driver has been offered it yet. + REQUESTED, + /// Offered to one driver, who has a few seconds to answer. + OFFERED, + /// A driver took it and is on the way to the pickup. + ACCEPTED, + /// The driver is at the pickup. + ARRIVED, + /// The rider is in the car. + IN_PROGRESS, + COMPLETED, + CANCELLED_BY_RIDER, + CANCELLED_BY_DRIVER, + /// Stopped by an admin, at any point before it completed. + CANCELLED_BY_ADMIN, + /// Nobody was near enough, or nobody took it. + NO_DRIVERS; + + /// Whether a ride in this state is still going on: someone is waiting for a + /// driver, or a driver is involved and the trip is not over. + public boolean isActive() { + return this == REQUESTED || this == OFFERED || this == ACCEPTED || this == ARRIVED + || this == IN_PROGRESS; + } + + /// The state called `name`, or null for a name that is none of them -- one + /// a newer server sends, say. Unlike `valueOf` it never throws, so a + /// caller reading a name off the wire needs no `catch`. + public static RideState named(String name) { + RideState[] all = values(); + for (int iter = 0; iter < all.length; iter++) { + if (all[iter].name().equals(name)) { + return all[iter]; + } + } + return null; + } +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RiderApi.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RiderApi.java new file mode 100644 index 00000000000..20b2eef7442 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RiderApi.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +// tag::wayline-contract-api[] +/// Asking for a ride and following it. +@RestClient +public interface RiderApi { + @POST("/api/rides/quote") + void quote(@Body RideRequestDto request, OnComplete> callback); + + @POST("/api/rides") + void request(@Body RideRequestDto request, OnComplete> callback); + + /// The ride this rider has under way, or one whose state is NONE. + @GET("/api/rides/active") + void active(OnComplete> callback); + + @GET("/api/rides/history") + void history(OnComplete>> callback); + + @GET("/api/rides/{id}") + void ride(@Path("id") String id, OnComplete> callback); + + @POST("/api/rides/{id}/cancel") + void cancel(@Path("id") String id, OnComplete> callback); + + @POST("/api/rides/{id}/rate") + void rate(@Path("id") String id, @Body RatingDto rating, OnComplete> callback); + + /// The free drivers around a point, for the cars on the map. + @GET("/api/rides/nearby") + void nearby(@Query("lat") double lat, @Query("lng") double lng, + OnComplete>> callback); + + /// Tells the server which Live Activity this phone shows the ride in, so + /// that it can keep it up to date while the app is in the background. + @POST("/api/rides/{id}/activity") + void activity(@Path("id") String id, @Body RideActivityDto activity, + OnComplete> callback); +} +// end::wayline-contract-api[] diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RoleChangeDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RoleChangeDto.java new file mode 100644 index 00000000000..782b3243f0d --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/RoleChangeDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// The modes an admin grants a user beyond riding. +@Mapped +public record RoleChangeDto(boolean driver, boolean admin) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/StatsSeriesDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/StatsSeriesDto.java new file mode 100644 index 00000000000..4bff36f492e --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/StatsSeriesDto.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// The admin's charts: how the service did, day by day. +/// +/// @param days Oldest first, every day present. +/// @param byHour Rides asked for in each hour of the day (UTC), 24 numbers. +/// @param topDrivers The drivers who completed the most rides in the period; `rides` is that count. +/// @param averageFare Cents. +/// @param averageWaitSeconds From a ride being asked for to a driver taking it. +/// @param completionRate Completed rides over rides asked for, 0 to 1. +@Mapped +public record StatsSeriesDto( + List days, + List byHour, + List byState, + List byProduct, + List topDrivers, + long averageFare, + double averageWaitSeconds, + double completionRate, + int pendingApplications, + int flaggedUsers, + String currency) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/SuspendDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/SuspendDto.java new file mode 100644 index 00000000000..568f0590597 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/SuspendDto.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// Blocks an account, or lifts the block. +/// +/// @param reason Why, for the moderation record. May be empty. +@Mapped +public record SuspendDto(boolean suspended, String reason) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/TicketDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/TicketDto.java new file mode 100644 index 00000000000..f98ee873600 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/TicketDto.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A single-use ticket for opening the live channel. +/// +/// A WebSocket handshake cannot carry the bearer token on every platform, so the +/// app asks for a ticket over the authenticated API and presents it in the +/// connection address. It is good for one connection and for a few seconds. +@Mapped +public record TicketDto(String ticket, int expiresInSeconds) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/TipDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/TipDto.java new file mode 100644 index 00000000000..8aff3c65919 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/TipDto.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// A tip for the driver of a completed ride, in cents. +@Mapped +public record TipDto(long amount) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/UserDetailDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/UserDetailDto.java new file mode 100644 index 00000000000..a3b992d6732 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/UserDetailDto.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +import java.util.List; + +/// One account in full, for the admin deciding what to do about it. +/// +/// @param rides Rides taken part in, as rider or driver. +/// @param cancellations How many of those rides were cancelled. +/// @param rating As a driver; 0 for an account that has not driven. +/// @param spent Cents paid as a rider. +/// @param earned Cents earned as a driver. +/// @param events Newest first. +@Mapped +public record UserDetailDto( + UserDto user, + int rides, + int cancellations, + double rating, + long spent, + long earned, + List events) { +} diff --git a/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/UserDto.java b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/UserDto.java new file mode 100644 index 00000000000..633928ef392 --- /dev/null +++ b/scripts/wayline/shared/src/main/java/com/codenameone/examples/wayline/api/UserDto.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.wayline.api; + +import com.codename1.annotations.Mapped; + +/// Who is signed in, as the app needs to know it: enough to greet them, to +/// decide which modes to offer, and to tell whether they still owe a phone +/// verification. +/// +/// @param username The sign-in name, which is the e-mail address the account was opened with. +/// @param rider Every account can ride. +/// @param driver Granted: the account may drive. +/// @param admin Granted: the account may run the service. +/// @param suspended Blocked by an admin: the account cannot sign in or call the API. +/// @param vehicle A driver's car, as riders see it. Empty for anyone else. +/// @param plate The car's licence plate. Empty for anyone else. +/// @param gender `unspecified`, `female`, `male` or `nonbinary`. +/// @param driverStatus Where the account stands on driving: `none`, `draft`, `pending`, `approved` +/// or `rejected`. The DRIVER role follows `approved`. +/// @param flagged Marked by an admin for a closer look. The account still works; `suspended` is +/// what stops one. +/// @param flagReason Why it was flagged, for the other admins. +/// @param language The language the app is shown in: `en`, `es`, `fr`, `de` or `he`. +@Mapped +public record UserDto( + String username, + String displayName, + String phone, + boolean phoneVerified, + boolean rider, + boolean driver, + boolean admin, + boolean suspended, + String vehicle, + String plate, + String gender, + String driverStatus, + boolean flagged, + String flagReason, + String language) { +} diff --git a/scripts/wayline/web-e2e.mjs b/scripts/wayline/web-e2e.mjs new file mode 100644 index 00000000000..7f685d34e73 --- /dev/null +++ b/scripts/wayline/web-e2e.mjs @@ -0,0 +1,326 @@ +// The Wayline web app, hosted by its own server, driven in a headless browser. +// +// URL=http://localhost:18081/ OUT=target/web-e2e node web-e2e.mjs +// +// It checks what a person opening the server's address gets: the app loads from +// the server, reaches the welcome screen, signs in as the demo rider, lands on +// the home screen with its map, and has its live channel open. Then it signs +// out, signs in as the demo admin and reaches the admin console. +// run-web-e2e.sh builds everything, starts the server and runs this; see there. +// +// The app draws on a canvas, so there are no buttons in the page to ask a +// browser automation tool for. What there is, is the accessibility tree the +// port keeps over the canvas -- an element per component, with its role, its +// label and where it is -- and that is what this reads to find a button and +// what it clicks at. Text fields become real elements while they are +// being edited, so typing is ordinary typing. +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +// require() and not import: it also looks in NODE_PATH, which is how a +// container whose packages are installed somewhere else finds Playwright. +const require = createRequire(import.meta.url); +let chromium; +try { + ({ chromium } = require('playwright')); +} catch (err) { + console.error('[wayline-web] Playwright is not installed. Run `npm ci` in scripts/ and ' + + '`npx playwright install chromium`, or point NODE_PATH at a node_modules that has it.'); + console.error(String(err)); + process.exit(2); +} + +const url = process.env.URL; +const out = process.env.OUT || 'target/web-e2e'; +const email = process.env.WAYLINE_USER || 'rider@wayline.example'; +const password = process.env.WAYLINE_PASSWORD || 'wayline-demo'; +const admin = process.env.WAYLINE_ADMIN || 'admin@wayline.example'; +// A cold runner translates nothing at this point, but it does parse several +// megabytes of JavaScript before the first screen. +const startSeconds = Number(process.env.WAYLINE_WEB_START_SECONDS || '120'); +const stepSeconds = Number(process.env.WAYLINE_WEB_STEP_SECONDS || '45'); +if (!url) { + console.error('[wayline-web] URL is required'); + process.exit(2); +} +fs.mkdirSync(out, { recursive: true }); +const logFile = path.join(out, 'browser.log'); +fs.writeFileSync(logFile, ''); +const note = line => fs.appendFileSync(logFile, line + '\n'); +const say = line => { console.log('[wayline-web] ' + line); note('# ' + line); }; + +const origin = new URL(url).origin; +const requests = []; // "200 POST /login", in order +const problems = []; // anything that must not happen +const sockets = []; // { url, frames: [...] } + +const browser = await chromium.launch({ + headless: true, + // A headless page counts as hidden, and a hidden page's timers are throttled + // to about one a minute once its budget is spent. The app's threads sleep on + // those timers. + args: ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', '--disable-features=IntensiveWakeUpThrottling'] +}); +const page = await browser.newPage({ viewport: { width: 420, height: 860 } }); +page.on('console', message => { + const text = message.text(); + if (text.startsWith('LF-SHIM')) { + return; + } + note(`[${message.type()}] ${text}`); + if (/internal application error|Missing JS member/i.test(text)) { + problems.push('the app reported an error: ' + text.split('\n')[0]); + } +}); +page.on('pageerror', err => { + note('[pageerror] ' + err); + problems.push('the page threw: ' + err); +}); +page.on('response', response => { + const at = new URL(response.url()); + if (at.origin !== origin) { + return; + } + const line = `${response.status()} ${response.request().method()} ${at.pathname}`; + requests.push(line); + note('NET ' + line); +}); +page.on('websocket', socket => { + const seen = { url: socket.url(), frames: [] }; + sockets.push(seen); + note('WS open ' + socket.url().replace(/ticket=.*/, 'ticket=...')); + socket.on('framereceived', frame => { + seen.frames.push(String(frame.payload)); + note('WS frame ' + String(frame.payload).slice(0, 200)); + }); +}); + +/// What the app has on screen, as the port describes it to assistive technology. +async function components() { + return page.evaluate(() => Array.from( + document.querySelectorAll('#cn1-accessibility-tree [data-cn1-accessibility-id]')).map(el => { + const box = el.getBoundingClientRect(); + return { + role: el.getAttribute('role'), + label: el.getAttribute('aria-label'), + text: el.innerText, + x: box.x, y: box.y, width: box.width, height: box.height + }; + })); +} + +async function screenText() { + return page.evaluate(() => document.body.innerText); +} + +async function waitFor(what, seconds, probe) { + const end = Date.now() + seconds * 1000; + for (;;) { + const hit = await probe(); + if (hit) { + return hit; + } + if (problems.length > 0) { + throw new Error(problems[0]); + } + if (Date.now() > end) { + throw new Error(`timed out after ${seconds}s waiting for ${what}`); + } + await page.waitForTimeout(250); + } +} + +const button = label => async () => + (await components()).find(c => c.role === 'button' && c.label === label); + +/// The single-line fields of the form on screen, top to bottom. +async function fields() { + return (await components()).filter(c => c.role === 'textbox' && c.height > 40 && c.height < 120) + .sort((a, b) => a.y - b.y); +} + +async function click(component) { + await page.mouse.click(component.x + component.width / 2, component.y + component.height / 2); +} + +/// Waits until nothing on screen has moved for a moment. A form slides in, and +/// a click aimed at where a field is half-way through lands on what was there. +async function settled() { + let before = JSON.stringify(await components()); + await waitFor('the screen to stop moving', stepSeconds, async () => { + await page.waitForTimeout(350); + const now = JSON.stringify(await components()); + const still = now === before; + before = now; + return still; + }); +} + +async function typeInto(index, text, type) { + const editing = () => page.evaluate( + wanted => document.activeElement && document.activeElement.tagName === 'INPUT' + && document.activeElement.type === wanted, type); + await waitFor(`the ${type} field to take the keyboard`, stepSeconds, async () => { + await settled(); + const all = await fields(); + if (all.length <= index) { + return false; + } + await click(all[index]); + await page.waitForTimeout(500); + return editing(); + }); + await page.keyboard.type(text, { delay: 10 }); +} + +/// Fills in the sign-in form the welcome screen opens, and sends it. +async function signInAs(who) { + await click(await waitFor('the welcome screen', startSeconds, button('Sign in'))); + await typeInto(0, who, 'email'); + await typeInto(1, password, 'password'); + // The form's own button now: the welcome screen's is gone with its form. + await settled(); + await click(await waitFor('the form\'s Sign in button', stepSeconds, async () => + (await fields()).length >= 2 ? button('Sign in')() : null)); + say('submitted the sign-in form as ' + who); +} + +/// Fails on the dialog the app shows for an error nothing handled. +async function noErrorDialog() { + if (/internal application error/i.test(await screenText())) { + problems.push('the app is showing its internal-error dialog'); + } + if (problems.length > 0) { + throw new Error(problems[0]); + } +} + +let failed = null; +try { + say('opening ' + url); + const first = await page.goto(url, { waitUntil: 'load' }); + if (!first || first.status() !== 200) { + throw new Error('the server answered ' + (first ? first.status() : 'nothing') + ' at /'); + } + const type = first.headers()['content-type'] || ''; + if (!type.startsWith('text/html')) { + throw new Error('/ is served as ' + type + ', not as a page'); + } + + // 1. The welcome screen: the app started, in the browser, from this server. + const signIn = await waitFor('the welcome screen', startSeconds, button('Sign in')); + await waitFor('the welcome screen to offer an account', stepSeconds, button('Create an account')); + await page.screenshot({ path: path.join(out, '1-welcome.png') }); + say('the welcome screen is up'); + for (const file of ['/translated_app.js', '/port.js', '/worker.js']) { + if (!requests.includes('200 GET ' + file)) { + throw new Error(file + ' was not served by this server: ' + requests.join(', ')); + } + } + + // 2. Signing in. The form is the app's own; the flow behind it is the + // authorization-code flow against the server the page came from. + await signInAs(email); + + // The first sign-in in a browser is asked whether usage statistics may be + // shared. The answer is kept with the page's storage, so the admin's + // sign-in further down is not asked again. + await click(await waitFor('the usage statistics question', stepSeconds, button('Not now'))); + say('declined usage statistics'); + + // 3. The home screen. + await waitFor('the home screen', stepSeconds, async () => + (await screenText()).includes('Where to?')); + await page.screenshot({ path: path.join(out, '3-home.png') }); + say('signed in: the home screen is up'); + + const expected = ['200 POST /login', '200 GET /signin/code', '200 POST /oauth2/token', + '200 GET /api/me']; + for (const line of expected) { + if (!requests.includes(line)) { + throw new Error('signing in did not make the request "' + line + '"; it made: ' + + requests.filter(r => !/\.(js|css|png|res|gif|json|ttf)$/.test(r)).join(', ')); + } + } + + // 4. The live channel, a WebSocket to the same origin. + const live = await waitFor('the live channel', stepSeconds, async () => + sockets.find(s => s.frames.some(f => f.includes('"ready"')))); + const expectedSocket = origin.replace(/^http/, 'ws') + '/ws/live?ticket='; + if (!live.url.startsWith(expectedSocket)) { + throw new Error('the live channel went to ' + live.url + ', not to ' + expectedSocket); + } + say('the live channel is open'); + + // Give a late failure -- the first location request, the first map tile -- + // the moment it needs to show itself. + await page.waitForTimeout(3000); + await noErrorDialog(); + await page.screenshot({ path: path.join(out, '4-settled.png') }); + + // 5. The session that carried the sign-in is over: the app ended it when it + // had its tokens, so the cookie the browser still holds is worth nothing. + await waitFor('the app to end its sign-in session', stepSeconds, async () => + requests.includes('204 POST /logout')); + const again = await page.evaluate(async () => (await fetch('/oauth2/authorize?response_type=code' + + '&client_id=wayline-app&scope=openid&state=x&code_challenge_method=S256' + + '&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' + + '&redirect_uri=' + encodeURIComponent(location.origin + '/signin/code'), + { headers: { Accept: 'application/json' } })).status); + if (again === 200) { + throw new Error('the browser can still get an authorization code after signing in'); + } + say('the sign-in session is over (asking for another code is answered ' + again + ')'); + + // 6. Signing out, from the menu. Its button is an icon with no words: the + // one button at the top of the screen. + await settled(); + const menu = await waitFor('the menu button', stepSeconds, async () => + (await components()).filter(c => c.role === 'button' && c.y < 140 && c.width < 120 + && c.width > 20).sort((a, b) => a.x - b.x)[0]); + await click(menu); + await settled(); + await click(await waitFor('Sign out in the menu', stepSeconds, button('Sign out'))); + await waitFor('the welcome screen after signing out', stepSeconds, button('Create an account')); + await settled(); + await noErrorDialog(); + await page.screenshot({ path: path.join(out, '5-signed-out.png') }); + say('signed out: the welcome screen is back'); + + // 7. The admin, who lands in the console. + await signInAs(admin); + await waitFor('the admin console', stepSeconds, async () => { + // "Operations" on a screen this narrow; "Dashboard", down the side, on a + // wide one. The figures are the same. + const text = await screenText(); + // A caption goes onto two lines where its tile is narrow. + return /Operations|Dashboard/.test(text) && /Fares\s+collected/.test(text); + }); + await waitFor('the console to ask the server for its figures', stepSeconds, async () => + requests.some(r => /^200 GET \/api\/admin\//.test(r))); + await page.waitForTimeout(3000); + await settled(); + await noErrorDialog(); + await page.screenshot({ path: path.join(out, '6-admin.png') }); + say('signed in as ' + admin + ': the admin console is up'); +} catch (err) { + failed = err; + try { + await page.screenshot({ path: path.join(out, 'failure.png') }); + fs.writeFileSync(path.join(out, 'failure.txt'), await screenText()); + fs.writeFileSync(path.join(out, 'failure-components.json'), + JSON.stringify(await components(), null, 1)); + } catch (ignored) { + // The page is gone; the log is what there is. + } +} +await browser.close(); + +if (failed) { + console.error('[wayline-web] FAILED: ' + failed.message); + console.error('[wayline-web] the browser log is ' + logFile); + process.exit(1); +} +say('passed'); diff --git a/scripts/website/check-javadoc-urls.sh b/scripts/website/check-javadoc-urls.sh index fae0f4b186e..e371cfdf206 100755 --- a/scripts/website/check-javadoc-urls.sh +++ b/scripts/website/check-javadoc-urls.sh @@ -13,11 +13,13 @@ # # Usage: # scripts/website/check-javadoc-urls.sh [port] +# +# With no port, a free one is chosen. set -euo pipefail PUBLIC_DIR="${1:?usage: check-javadoc-urls.sh [port]}" -PORT="${2:-8788}" -WRANGLER_VERSION="${WRANGLER_VERSION:-4.129.0}" +# No port means a free one the OS picks. +PORT="${2:-}" for reference in javadoc backend/javadoc; do if [ ! -d "${PUBLIC_DIR}/${reference}" ]; then @@ -26,17 +28,14 @@ for reference in javadoc backend/javadoc; do fi done -npx --yes "wrangler@${WRANGLER_VERSION}" pages dev "${PUBLIC_DIR}" \ - --port "${PORT}" --log-level error >/tmp/check-javadoc-urls.log 2>&1 & -server=$! -trap 'kill "${server}" 2>/dev/null || true' EXIT - -for _ in $(seq 1 60); do - if curl -fsS -o /dev/null "http://localhost:${PORT}/" 2>/dev/null; then - break - fi - sleep 2 -done +# The server runs in its own process group and is stopped as a group: killing +# only npx left wrangler's workerd holding the port after this script returned, +# and a later check on the same port tested that leftover instead of its own +# server. See pages-dev-server.sh. +. "$(dirname "$0")/pages-dev-server.sh" +trap pages_dev_stop EXIT +pages_dev_start "${PUBLIC_DIR}" /tmp/check-javadoc-urls.log "${PORT}" --log-level error +BASE_URL="${PAGES_DEV_URL}" # Every shape a reader can arrive by. The .html spellings are what javadoc # publishes and what 304 links in the guide and the site content use; the @@ -64,8 +63,8 @@ URLS=( failures=0 for url in "${URLS[@]}"; do body="$(mktemp)" - code="$(curl -sS -o "${body}" -w '%{http_code}' -L "http://localhost:${PORT}${url}")" - final="$(curl -sS -o /dev/null -w '%{url_effective}' -L "http://localhost:${PORT}${url}")" + code="$(curl -sS -o "${body}" -w '%{http_code}' -L "${BASE_URL}${url}")" + final="$(curl -sS -o /dev/null -w '%{url_effective}' -L "${BASE_URL}${url}")" title="$(grep -o '[^<]*' "${body}" | head -1 | sed 's/<[^>]*>//g' || true)" rm -f "${body}" @@ -78,7 +77,7 @@ for url in "${URLS[@]}"; do # for: an alias built with the production baseURL sent preview readers to # www.codenameone.com, so the preview could never show the new pages at all. case "${final}" in - "http://localhost:${PORT}"*) ;; + "${BASE_URL}"*) ;; *) echo "FAIL ${url} -> left the deployment: ${final}"; failures=$((failures + 1)); continue ;; esac if [ -z "${title}" ]; then diff --git a/scripts/website/pages-dev-server.sh b/scripts/website/pages-dev-server.sh new file mode 100644 index 00000000000..9a924255159 --- /dev/null +++ b/scripts/website/pages-dev-server.sh @@ -0,0 +1,113 @@ +# shellcheck shell=bash +# +# Start and stop a local `wrangler pages dev` server so that the server a check +# talks to is provably the one it started, and is gone when the check ends. +# +# Source this file; do not execute it. +# +# . scripts/website/pages-dev-server.sh +# trap pages_dev_stop EXIT +# pages_dev_start docs/website/public /tmp/pages-dev.log "" [extra wrangler args] +# curl "${PAGES_DEV_URL}/..." +# +# Why this exists. `npx wrangler pages dev ... & kill $!` kills npx and nothing +# else: wrangler's node process, esbuild and the two workerd processes it spawns +# (the proxy that owns --port, and the user worker on an ephemeral port) are +# left behind as orphans, sometimes all of them, sometimes only some. build.sh +# ran check-javadoc-urls.sh on 8788 that way, and the redirects step of +# website-docs.yml later started its own server on the same 8788. Its readiness +# curl was answered by build.sh's leftover workerd, test_redirects.py passed +# against that stranger before the new wrangler had even written its first log +# line, the new proxy workerd then died on "Address already in use", and when +# the leftover went away a few seconds later test_search.mjs got +# ERR_CONNECTION_REFUSED (run 38724184374). The runner's own cleanup listed +# the leftover workerd processes as orphans at the end of that job, and the +# green runs list orphaned wrangler node and esbuild processes from the same +# step. +# +# So: +# - the server runs in its own process group, and pages_dev_stop signals the +# whole group and waits for it to be gone, which takes workerd with it; +# - with no port given, the OS picks a free one, so two checks never meet; +# - a port that already answers is refused outright: a check that passes +# against somebody else's server has tested nothing; +# - readiness requires our process to still be alive, so a wrangler that +# failed to bind can never be reported ready by whatever holds the port. + +PAGES_DEV_PGID="" +PAGES_DEV_PORT="" +PAGES_DEV_URL="" +PAGES_DEV_LOG="" + +pages_dev_free_port() { + python3 -c 'import socket +s = socket.socket() +s.bind(("127.0.0.1", 0)) +print(s.getsockname()[1]) +s.close()' +} + +pages_dev_start() { + local dir="$1" log="$2" port="${3:-}" + if [ $# -ge 3 ]; then shift 3; else shift $#; fi + if [ -z "${port}" ]; then + port="$(pages_dev_free_port)" + fi + PAGES_DEV_PORT="${port}" + PAGES_DEV_URL="http://127.0.0.1:${port}" + PAGES_DEV_LOG="${log}" + + if curl -s -o /dev/null --max-time 2 "${PAGES_DEV_URL}/" 2>/dev/null; then + echo "pages-dev: ${PAGES_DEV_URL} already answers before wrangler was started;" \ + "refusing to run checks against a server this script did not start." >&2 + return 1 + fi + + # Monitor mode puts the background job in a process group of its own whose + # id is the job's pid, so the whole tree can be signalled at once. stdin is + # /dev/null because a job outside the foreground group must not read a tty. + local had_monitor=0 + case "$-" in *m*) had_monitor=1 ;; esac + set -m + npx --yes "wrangler@${WRANGLER_VERSION:-4.129.0}" pages dev "${dir}" \ + --port "${port}" --ip 127.0.0.1 "$@" "${log}" 2>&1 & + PAGES_DEV_PGID=$! + [ "${had_monitor}" -eq 1 ] || set +m + + local _ + for _ in $(seq 1 120); do + if ! kill -0 "${PAGES_DEV_PGID}" 2>/dev/null; then + echo "pages-dev: wrangler exited before it was ready; log:" >&2 + tail -n 200 "${log}" >&2 || true + return 1 + fi + if curl -fsS -o /dev/null --max-time 5 "${PAGES_DEV_URL}/" 2>/dev/null; then + # Answering is not enough on its own: a wrangler whose proxy lost the + # bind keeps node alive for a moment, so look for the failure too. + if grep -q 'Address already in use' "${log}" 2>/dev/null; then + echo "pages-dev: wrangler could not bind ${PAGES_DEV_URL}; log:" >&2 + tail -n 200 "${log}" >&2 || true + return 1 + fi + return 0 + fi + sleep 1 + done + echo "pages-dev: wrangler pages dev did not become ready on ${PAGES_DEV_URL}; log:" >&2 + tail -n 200 "${log}" >&2 || true + return 1 +} + +pages_dev_stop() { + local pgid="${PAGES_DEV_PGID}" + [ -n "${pgid}" ] || return 0 + PAGES_DEV_PGID="" + kill -TERM -- "-${pgid}" 2>/dev/null || return 0 + local _ + for _ in $(seq 1 50); do + kill -0 -- "-${pgid}" 2>/dev/null || return 0 + sleep 0.2 + done + kill -KILL -- "-${pgid}" 2>/dev/null || true + return 0 +} diff --git a/scripts/windows/screenshots/NativeMapFallback.png b/scripts/windows/screenshots/NativeMapFallback.png index ade912494f6..89d56a0ca65 100644 Binary files a/scripts/windows/screenshots/NativeMapFallback.png and b/scripts/windows/screenshots/NativeMapFallback.png differ diff --git a/scripts/windows/screenshots/RealOsmVector.png b/scripts/windows/screenshots/RealOsmVector.png index 308875be398..d38f1a7edce 100644 Binary files a/scripts/windows/screenshots/RealOsmVector.png and b/scripts/windows/screenshots/RealOsmVector.png differ diff --git a/scripts/windows/screenshots/VectorMapDarkStyle.png b/scripts/windows/screenshots/VectorMapDarkStyle.png index 1f0091fc012..abcbedc5cd4 100644 Binary files a/scripts/windows/screenshots/VectorMapDarkStyle.png and b/scripts/windows/screenshots/VectorMapDarkStyle.png differ diff --git a/scripts/windows/screenshots/VectorMapMarkers.png b/scripts/windows/screenshots/VectorMapMarkers.png index f0e6fde4161..272386d9450 100644 Binary files a/scripts/windows/screenshots/VectorMapMarkers.png and b/scripts/windows/screenshots/VectorMapMarkers.png differ diff --git a/vm/ByteCodeTranslator/src/cn1_globals.m b/vm/ByteCodeTranslator/src/cn1_globals.m index ff4a9a6f6b7..248b05f5f7b 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.m +++ b/vm/ByteCodeTranslator/src/cn1_globals.m @@ -1209,13 +1209,26 @@ static inline int cn1GcSweepReclaimsObj(JAVA_OBJECT o, int mark) { * the last sweep; the clamp degrades to "no change" otherwise rather than risking * the object being mistaken for an already-freed slot. * - * WHY A WRONG PROOF IS NOT FATAL. This does not hide the object. If it is in fact - * reachable from any root, the mark reaches it and overwrites this value with the - * current epoch, and the sweep keeps it. The proof failing costs the optimization, - * not the object. The one residual window is an object reachable ONLY from a - * location the mark cannot see -- which is what the grace rule exists for, and why - * this is applied only where escape analysis PROVED the reference never leaves the - * frame, rather than to fresh objects in general. + * A WRONG PROOF IS FATAL, and this comment used to say the opposite: that a + * reachable object is simply marked again and the stamp costs only the + * optimization. That holds for an object reached through another object's field. + * It does not hold for one reached from a ROOT. A root is validated through + * cn1ConservativeResolve before it is marked, and that asks cn1GcWasReclaimed of + * the mark word; the value written here stops satisfying it the moment the next + * sweep raises cn1GcReclaimedBefore. From then on the root scan reads the slot as + * already freed and declines to mark it, and the sweep after that frees it. + * + * Observed, not inferred: a java.lang.Thread stamped by the frame that started it + * (mark 6), still the currentThreadObject of its running thread, was passed to + * gcMarkObject by the root scan of epoch 8, came back unmarked, and was finalized + * by that cycle's sweep -- Thread.finalize() then freed the thread's own state + * under it. The translator had "proved" the object frame local because the only + * method called on it, the native Thread.start(), had no bytecode to object to. + * + * So nothing here second-guesses the translator, and the proof has to be right. + * What it rests on is spelled out at Parser.calleesKeepThis and + * BytecodeMethod.freezeRetireCandidates; a new way for a reference to leave a + * frame has to be taught to that analysis before it is allowed to reach here. */ void cn1MarkDeadNow(JAVA_OBJECT o) { #ifndef CN1_DISABLE_BIBOP diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java index 708270c6fed..a2fa2d486d2 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java @@ -1806,8 +1806,9 @@ public void appendMethodC(StringBuilder b) { // optimize()'s return fast-paths (plain return, no frame release) so it must // be set first. frameless = isFramelessEligible(); - // Phase 1 shares the raw-bytecode requirement with frameless eligibility. - collectRetireCandidates(); + // Phase 1 of frame-exit retirement shares the raw-bytecode requirement, and + // more strictly: it reads OTHER methods' bodies too, so it cannot run here at + // all. See freezeRetireCandidates, which Parser calls before any rewrite. boolean hasInstructions = true; if(optimizerOn) { @@ -4697,7 +4698,29 @@ private void scalarReplaceStackAllocations() { /// and conservatively answers ESCAPES for everything. Measured: running this after /// optimize() dropped 209 of 209 non-stack sites, against 14 kept before it. The /// same ordering constraint is why `frameless` is decided on raw bytecode too. - public void collectRetireCandidates() { + /// + /// AND RAW MEANS EVERY METHOD, NOT THIS ONE. A candidate is only as good as the + /// check of the methods called on it, and that check walks the CALLEE's body. This + /// used to run from appendMethodC, i.e. during code generation, where every method + /// emitted earlier has already been through optimize(): `KEEP[id] = this` was by + /// then one fused instruction with the load of `this` inside it, the walk never + /// saw `this` appear, and a body that never touches the reference is SAFE. Whether + /// a leaking callee was caught depended on the order two methods were emitted in. + /// The "conservatively answers ESCAPES" above holds for a value the walk is + /// already following; it does not hold for a source it can no longer see. + /// + /// So the candidates are frozen in a pass of their own, before the first rewrite + /// of any method -- the same reason freezeFramelessEligibility exists. + void freezeRetireCandidates() { + if (!retireCandidatesFrozen) { + retireCandidatesFrozen = true; + collectRetireCandidates(); + } + } + + private boolean retireCandidatesFrozen; + + private void collectRetireCandidates() { if (DISABLE_FRAME_RETIRE || isNative() || abstractMethod) { return; } @@ -4722,7 +4745,22 @@ public void collectRetireCandidates() { } java.util.List calls = new java.util.ArrayList(IteratorEscape.receiverCalls); - if (!Parser.calleesKeepThis(calls, new java.util.HashMap())) { + // A CLUSTER'S MEMBERS ARE REACHABLE FROM EACH OTHER, and a callee check + // only asks whether the callee leaks `this`. Once one tracked object has + // been stored into another, a method called on the holder can publish + // the member it holds -- `Node next() { return next; }` leaks nothing of + // its own receiver -- and no walk of this frame sees it. So a frame that + // built a cluster may construct its members and read their fields, which + // the walk follows, and nothing else. Constructors are exempt because an + // object cannot have had anything stored into it before its constructor + // ran, and the one that takes a member as a parameter has been shape + // checked by ctorOnlyStoresParamsIntoThis. lastClusterStore is read here, + // before the callee check below runs walks of its own and resets it. + if (IteratorEscape.lastClusterStore && callsBeyondConstructors(calls)) { + continue; + } + if (!Parser.calleesKeepThis(ti.getTypeName(), calls, + new java.util.HashMap())) { continue; } if (retireCandidates == null) { @@ -4746,6 +4784,15 @@ public void collectRetireCandidates() { } } + private static boolean callsBeyondConstructors(java.util.List calls) { + for (int i = 0; i < calls.size(); i++) { + if (calls.get(i).indexOf(".(") < 0) { + return true; + } + } + return false; + } + /// PHASE 2, after optimize(). Only now is it known whether a site still has a heap /// object -- scalar replacement and stack allocation are decided inside optimize -- /// and whether this method has a frame to exit at all. @@ -4768,6 +4815,12 @@ public void analyzeRetirableLocals() { if (guard >= 8) { break; // slots[8] in CN1RetireScope } + if (!instructions.contains(ti)) { + // The candidates were found before the rewrites that run between the + // parse and code generation; one of them removed this allocation. + // Nothing would ever write the guard, so do not declare one. + continue; + } // The SITE writes its own guard, so the scope can only ever retire the // object this analysis actually reasoned about. ti.setDeadGuardId(guard); @@ -4816,7 +4869,8 @@ private void unusedOldRetirePass() { } java.util.List calls = new java.util.ArrayList(IteratorEscape.receiverCalls); - if (!Parser.calleesKeepThis(calls, new java.util.HashMap())) { + if (!Parser.calleesKeepThis(ti.getTypeName(), calls, + new java.util.HashMap())) { continue; } if (frameless) { diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/IteratorEscape.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/IteratorEscape.java index 388166e206d..9142360b46a 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/IteratorEscape.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/IteratorEscape.java @@ -78,6 +78,19 @@ class IteratorEscape { /// A site is only retirable if none of these leaks `this`. static final java.util.List receiverCalls = new java.util.ArrayList(); + /// Prefix of a receiverCalls entry made by INVOKESPECIAL. Such a call is not + /// dispatched on the object: it runs the method of the class it names, so whoever + /// checks the callee must not look for an override. Everything else in the list is + /// a dispatched call and has to be resolved from the object's own class -- see + /// Parser.calleesKeepThis. A space cannot occur in a class name. + static final String EXACT_CALL = "special "; + + /// Did the last clusterEscapes walk see one tracked object stored INTO another? + /// Valid only immediately after that walk. When it did, the tracked objects no + /// longer stand alone: any of them may be reachable through a field of another, + /// which is a route no callee check looks down. See collectRetireCandidates. + static boolean lastClusterStore = false; + /// The local the last walk followed, or -1 if the value never reached one. Valid /// only immediately after a walk. Frame-exit retirement needs it: the object to /// retire is whatever that local holds when the frame goes. @@ -93,6 +106,20 @@ static int thisEscapes(BytecodeMethod m) { if (m.isStatic()) { return SAFE; // no `this` to leak } + if (m.isNative()) { + // NO BYTECODE IS NOT "NOTHING LEAKS". + // + // A native method has an empty instruction list, and the walk below + // answers SAFE for a body that never touches the reference. Read that way + // Thread.start() -- which hands its receiver to a new thread -- keeps + // `this` to itself. What native code does with its receiver is not + // knowable from here, so the only sound answer is the one the walk gives + // for anything else it cannot follow. + receiverCalls.clear(); + lastTrackedLocal = -1; + lastReason = "native method: no body to examine"; + return UNKNOWN; + } return walk(m, true, null, true); } @@ -225,6 +252,7 @@ private static int walk(BytecodeMethod m, boolean trackThis, String newOwner, boolean returnIsLeak) { receiverCalls.clear(); lastTrackedLocal = -1; + lastClusterStore = false; List ins = m.getInstructions(); // WHICH LABELS ARE ACTUALLY JOIN POINTS. // @@ -373,6 +401,7 @@ private static int walk(BytecodeMethod m, boolean trackThis, String newOwner, // frame. Per-object escape analysis rejects every member of it, // which is why HotSpot does not scalar-replace these either. if (target && clusterStores) { + lastClusterStore = true; continue; } return ESCAPES; // stored into the heap @@ -388,8 +417,24 @@ private static int walk(BytecodeMethod m, boolean trackThis, String newOwner, continue; } if (op == Opcodes.GETFIELD) { - sp = Math.max(0, sp - 1); // receiver consumed, result is "other" - stack = push(stack, sp++, false); + boolean receiver = sp > 0 && stack[sp - 1]; + sp = Math.max(0, sp - 1); // receiver consumed + // WHAT COMES OUT OF A CLUSTER MEMBER MAY BE A CLUSTER MEMBER. + // + // The cluster walk lets one tracked object be stored into a field + // of another. Reading that field back therefore yields a tracked + // object, and calling the result "other" lost it: `a.next = b; + // GLOBAL = a.next;` was a store of an untracked value, and `b` was + // retired at frame exit while GLOBAL pointed at it. Any reference + // read out of a tracked object is tracked from here on. That is an + // over-approximation for a field holding something else, which can + // only turn SAFE into ESCAPES; and it is what the linked-list shape + // needs anyway, since `p = p.next` now stays followed. Arrays are + // left out because a tracked value can never be put in one. + String fd = f.getDesc(); + boolean member = receiver && clusterStores + && fd != null && fd.startsWith("L"); + stack = push(stack, sp++, member); continue; } if (op == Opcodes.GETSTATIC) { @@ -424,6 +469,9 @@ && ctorOnlyStoresParamsIntoThis(inv.getOwner(), inv.getDesc())) { if (idx >= 0 && stack[idx] && !ctorAbsorbs) { return ESCAPES; } + if (idx >= 0 && stack[idx]) { + lastClusterStore = true; // absorbed into the receiver's fields + } } // The tracked value as a RECEIVER is not an escape here, but only // because the callee is checked separately. Record WHICH callee, so @@ -436,7 +484,8 @@ && ctorOnlyStoresParamsIntoThis(inv.getOwner(), inv.getDesc())) { if (hasReceiver) { int ridx = sp - 1 - argCount; if (ridx >= 0 && stack[ridx]) { - receiverCalls.add(inv.getOwner() + "." + inv.getName() + inv.getDesc()); + receiverCalls.add((op == Opcodes.INVOKESPECIAL ? EXACT_CALL : "") + + inv.getOwner() + "." + inv.getName() + inv.getDesc()); } } sp = Math.max(0, sp - argCount - (hasReceiver ? 1 : 0)); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java index bb5c045270d..821f850c9bf 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java @@ -478,6 +478,11 @@ public static synchronized String resolveConcreteIteratorType(ByteCodeClass owne private static final MethodDependencyGraph dependencyGraph = new MethodDependencyGraph(); private int lambdaCounter; private int stringConcatCounter; + /// The simple name the InnerClasses attribute gives this class, which is + /// what a record's toString starts with; null for a top-level class. + private String innerSimpleName; + /// The record helpers already added to this class, by name. + private final Set recordHelpers = new HashSet<>(); public static void cleanup() { if ("true".equals(System.getProperty("cn1.iteratorCensus"))) { // Reported HERE rather than from the census itself: the census runs BEFORE @@ -1492,6 +1497,17 @@ public static void writeOutput(File outputDirectory) throws Exception { for (ByteCodeClass ownershipClass : classes) { for (BytecodeMethod method : ownershipClass.getMethods()) method.freezeBuilderOwnership(); } + // A loop of its own, and before the one below: a frame-exit retirement + // candidate is decided by reading the bodies of the methods called on + // the object, so EVERY body has to be raw while ANY candidate is + // decided. See BytecodeMethod.freezeRetireCandidates. + if (ByteCodeTranslator.output != ByteCodeTranslator.OutputType.OUTPUT_TYPE_JAVASCRIPT) { + for (ByteCodeClass retireCls : classes) { + for (BytecodeMethod retireMtd : retireCls.getMethods()) { + retireMtd.freezeRetireCandidates(); + } + } + } for (ByteCodeClass fuseCls : classes) { for (BytecodeMethod fuseMtd : fuseCls.getMethods()) { // These rewrites preserve local-stack semantics but introduce @@ -2097,6 +2113,7 @@ public FieldVisitor visitField(int access, String name, String desc, String sign public void visitInnerClass(String name, String outerName, String innerName, int access) { if(name.equals(cls.getOriginalClassName())) { cls.setIsAnonymous(innerName==null); + innerSimpleName = innerName; } super.visitInnerClass(name, outerName, innerName, access); } @@ -2326,6 +2343,195 @@ public void visitJumpInsn(int opcode, Label label) { super.visitJumpInsn(opcode, label); } + /** + * A record's equals, hashCode or toString. javac does not write these out: + * each is an invokedynamic of java.lang.runtime.ObjectMethods, a bootstrap + * this runtime does not have, so a record of any kind failed translation + * -- a transfer object shared with a server included. The call is replaced + * with one to a static helper holding what ObjectMethods would build: the + * components compared, hashed (31 * h + each, as the JDK does) and printed + * as Name[a=1, b=x]. Float and double components are compared and hashed by + * their bits with every NaN made the canonical one, which is what the JDK's + * Float.floatToIntBits and Double.doubleToLongBits answer; the native + * conversions keep a NaN's payload, so they are not asked. The arguments + * are the record class, the component names and one getter handle per + * component. Answers false, leaving the call unsupported, for a shape + * javac does not emit. + */ + private boolean recordMethod(String name, String desc, Object[] bsmArgs) { + if (bsmArgs == null || bsmArgs.length < 2 || !(bsmArgs[0] instanceof Type)) { + return false; + } + String owner = ((Type) bsmArgs[0]).getInternalName(); + List getters = new ArrayList<>(); + for (int i = 2; i < bsmArgs.length; i++) { + if (!(bsmArgs[i] instanceof Handle) || ((Handle) bsmArgs[i]).getTag() != Opcodes.H_GETFIELD) { + return false; + } + getters.add((Handle) bsmArgs[i]); + } + String helperName = "cn1$record$" + name; + boolean equals = "equals".equals(name) && ("(L" + owner + ";Ljava/lang/Object;)Z").equals(desc); + boolean hash = "hashCode".equals(name) && ("(L" + owner + ";)I").equals(desc); + boolean text = "toString".equals(name) && ("(L" + owner + ";)Ljava/lang/String;").equals(desc); + if (!equals && !hash && !text) { + return false; + } + if (recordHelpers.add(helperName)) { + BytecodeMethod helper = new BytecodeMethod(clsName, + Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, helperName, desc, null, null); + cls.addMethod(helper); + if (equals) { + Label no = new Label(); + helper.addVariableOperation(Opcodes.ALOAD, 1); + helper.addTypeInstruction(Opcodes.INSTANCEOF, owner); + helper.addJump(Opcodes.IFEQ, no); + for (Handle g : getters) { + if ("F".equals(g.getDesc()) || "D".equals(g.getDesc())) { + recordBits(helper, owner, g, 0); + recordBits(helper, owner, g, 1); + if ("D".equals(g.getDesc())) { + helper.addInstruction(Opcodes.LCMP); + helper.addJump(Opcodes.IFNE, no); + } else { + helper.addJump(Opcodes.IF_ICMPNE, no); + } + } else { + recordComponent(helper, owner, g, 0); + recordComponent(helper, owner, g, 1); + helper.addInvoke(Opcodes.INVOKESTATIC, "java/util/Objects", "equals", + "(Ljava/lang/Object;Ljava/lang/Object;)Z", false); + helper.addJump(Opcodes.IFEQ, no); + } + } + helper.addInstruction(Opcodes.ICONST_1); + helper.addInstruction(Opcodes.IRETURN); + helper.addLabel(no); + helper.addInstruction(Opcodes.ICONST_0); + helper.addInstruction(Opcodes.IRETURN); + helper.setMaxes(8, 2); + } else if (hash) { + helper.addInstruction(Opcodes.ICONST_0); + for (Handle g : getters) { + helper.addInstruction(Opcodes.BIPUSH, 31); + helper.addInstruction(Opcodes.IMUL); + if ("Z".equals(g.getDesc())) { + // Boolean.hashCode without a branch: 1237 - 6 * value. + helper.addInstruction(Opcodes.SIPUSH, 1237); + helper.addVariableOperation(Opcodes.ALOAD, 0); + helper.addField(cls, Opcodes.GETFIELD, owner, g.getName(), "Z"); + helper.addInstruction(Opcodes.BIPUSH, 6); + helper.addInstruction(Opcodes.IMUL); + helper.addInstruction(Opcodes.ISUB); + } else if ("F".equals(g.getDesc())) { + recordBits(helper, owner, g, 0); + } else if ("D".equals(g.getDesc())) { + recordBits(helper, owner, g, 0); + helper.addInstruction(Opcodes.DUP2); + helper.addInstruction(Opcodes.BIPUSH, 32); + helper.addInstruction(Opcodes.LUSHR); + helper.addInstruction(Opcodes.LXOR); + helper.addInstruction(Opcodes.L2I); + } else { + recordComponent(helper, owner, g, 0); + helper.addInvoke(Opcodes.INVOKESTATIC, "java/util/Objects", "hashCode", + "(Ljava/lang/Object;)I", false); + } + helper.addInstruction(Opcodes.IADD); + } + helper.addInstruction(Opcodes.IRETURN); + helper.setMaxes(8, 1); + } else { + String simple = innerSimpleName != null ? innerSimpleName + : owner.substring(owner.lastIndexOf('/') + 1); + helper.addTypeInstruction(Opcodes.NEW, "java/lang/StringBuilder"); + helper.addInstruction(Opcodes.DUP); + helper.addLdc(simple + "["); + helper.addInvoke(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", "", "(Ljava/lang/String;)V", false); + boolean first = true; + for (Handle g : getters) { + helper.addLdc((first ? "" : ", ") + g.getName() + "="); + helper.addInvoke(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", + "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); + recordComponent(helper, owner, g, 0); + helper.addInvoke(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", + "(Ljava/lang/Object;)Ljava/lang/StringBuilder;", false); + first = false; + } + helper.addLdc("]"); + helper.addInvoke(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", + "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); + helper.addInvoke(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", + "()Ljava/lang/String;", false); + helper.addInstruction(Opcodes.ARETURN); + helper.setMaxes(8, 1); + } + } + mtd.addInvoke(Opcodes.INVOKESTATIC, clsName, helperName, desc, false); + return true; + } + + /** Pushes a record component, boxed when it is a primitive, read from local `local`. */ + private void recordComponent(BytecodeMethod helper, String owner, Handle getter, int local) { + helper.addVariableOperation(Opcodes.ALOAD, local); + if (local != 0) { + // Guarded by the INSTANCEOF the equals helper starts with. + helper.addTypeInstruction(Opcodes.CHECKCAST, owner); + } + helper.addField(cls, Opcodes.GETFIELD, owner, getter.getName(), getter.getDesc()); + String wrapper; + switch (getter.getDesc().charAt(0)) { + case 'Z': wrapper = "Boolean"; break; + case 'B': wrapper = "Byte"; break; + case 'C': wrapper = "Character"; break; + case 'S': wrapper = "Short"; break; + case 'I': wrapper = "Integer"; break; + case 'J': wrapper = "Long"; break; + case 'F': wrapper = "Float"; break; + case 'D': wrapper = "Double"; break; + default: return; + } + helper.addInvoke(Opcodes.INVOKESTATIC, "java/lang/" + wrapper, "valueOf", + "(" + getter.getDesc() + ")Ljava/lang/" + wrapper + ";", false); + } + + /** Pushes a float or double component's canonical bits, an int or a long. */ + private void recordBits(BytecodeMethod helper, String owner, Handle getter, int local) { + boolean isDouble = "D".equals(getter.getDesc()); + String bitsName = isDouble ? "cn1$recordDoubleBits" : "cn1$recordFloatBits"; + String bitsDesc = isDouble ? "(D)J" : "(F)I"; + if (recordHelpers.add(bitsName)) { + BytecodeMethod bits = new BytecodeMethod(clsName, + Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, bitsName, bitsDesc, null, null); + cls.addMethod(bits); + Label notNaN = new Label(); + int load = isDouble ? Opcodes.DLOAD : Opcodes.FLOAD; + bits.addVariableOperation(load, 0); + bits.addVariableOperation(load, 0); + bits.addInstruction(isDouble ? Opcodes.DCMPL : Opcodes.FCMPL); + bits.addJump(Opcodes.IFEQ, notNaN); + if (isDouble) { + bits.addLdc(Long.valueOf(0x7ff8000000000000L)); + bits.addInstruction(Opcodes.LRETURN); + } else { + bits.addLdc(Integer.valueOf(0x7fc00000)); + bits.addInstruction(Opcodes.IRETURN); + } + bits.addLabel(notNaN); + bits.addVariableOperation(load, 0); + bits.addInvoke(Opcodes.INVOKESTATIC, isDouble ? "java/lang/Double" : "java/lang/Float", + isDouble ? "doubleToLongBits" : "floatToIntBits", bitsDesc, false); + bits.addInstruction(isDouble ? Opcodes.LRETURN : Opcodes.IRETURN); + bits.setMaxes(4, 2); + } + helper.addVariableOperation(Opcodes.ALOAD, local); + if (local != 0) { + helper.addTypeInstruction(Opcodes.CHECKCAST, owner); + } + helper.addField(cls, Opcodes.GETFIELD, owner, getter.getName(), getter.getDesc()); + helper.addInvoke(Opcodes.INVOKESTATIC, clsName, bitsName, bitsDesc, false); + } + @Override public void visitInvokeDynamicInsn(String name, String desc, Handle bsm, Object... bsmArgs) { super.visitInvokeDynamicInsn(name, desc, bsm, bsmArgs); @@ -2334,6 +2540,10 @@ public void visitInvokeDynamicInsn(String name, String desc, Handle bsm, Object. mtd.unsupportedBootstrap(bsm.getOwner() + "." + bsm.getName()); return; } + if ("java/lang/runtime/ObjectMethods".equals(bsm.getOwner()) && "bootstrap".equals(bsm.getName()) + && recordMethod(name, desc, bsmArgs)) { + return; + } if ("java/lang/invoke/StringConcatFactory".equals(bsm.getOwner()) && ("makeConcatWithConstants".equals(bsm.getName()) || "makeConcat".equals(bsm.getName()))) { @@ -3159,33 +3369,104 @@ public static boolean isStackIterator(String mangledClsName) { /// Do all of these callees keep `this` to themselves? The precise form of the /// receiver check: only the methods a site ACTUALLY invokes on the tracked object /// matter, not every method the class happens to declare. - static boolean calleesKeepThis(List calls, Map memo) { + /// + /// WHAT "CHECKED ON ITS OWN" HAS TO MEAN. + /// + /// The escape walk lets the tracked object be a receiver because the callee is + /// examined separately, and frame-exit retirement stamps the object dead on the + /// strength of that examination. A wrong yes is not a missed optimization: a + /// retired object that is still reachable only from a thread's roots is not + /// re-marked (the root scan refuses a reference whose mark reads as already + /// reclaimed), so the next sweep frees it under its user. Four ways the answer + /// used to be yes without the body that runs ever having been read, each of + /// which emitted a retirement for a live object: + /// + /// 1. A NATIVE callee has no bytecode, so the walk saw an empty method and + /// found nothing wrong with it. `Thread.start()` is native and hands its + /// receiver to a new thread: `Thread t = new Thread(r); t.start();` with + /// `t` kept in a local retired the Thread object of a running thread, and + /// `Thread.finalize()` then freed that thread's native state while it was + /// executing on it. An ABSTRACT callee is the same empty body. + /// 2. The call site names the method by the receiver's STATIC type. What runs + /// is the override in the object's own class, which the static name does + /// not lead to. The allocation site knows the exact class -- it is the + /// operand of the NEW -- so virtual calls are resolved from there. + /// 3. The callee's own calls on `this` were recorded by its walk and then + /// dropped: `void publish() { register(); }` read as keeping `this` + /// whatever `register` did. They are followed now, against the same exact + /// class, because `this` in the callee is the same object. + /// 4. A callee that could not be found answered no already, and still does. + /// + /// `exactClass` is the class named by the NEW whose object is being retired. + static boolean calleesKeepThis(String exactClass, List calls, + Map memo) { for (int i = 0; i < calls.size(); i++) { - String key = calls.get(i); - Boolean cached = memo.get(key); - if (cached == null) { - cached = Boolean.valueOf(calleeIsSafe(key)); - memo.put(key, cached); - } - if (!cached.booleanValue()) { + if (!calleeKeepsThis(exactClass, calls.get(i), memo)) { return false; } } return true; } - /// owner.name+desc -> does that one method keep `this`? A callee that cannot be - /// resolved in this closed world answers NO: an unknown body is an unchecked body. - static boolean calleeIsSafe(String key) { + /// One callee, and everything it in turn calls on `this`. + /// + /// A callee already being examined further up this same query answers yes for + /// now. That is sound and not a shortcut: "nothing reachable from here leaks + /// `this`" is refuted only by an actual leaking instruction, every method on a + /// cycle has its own body walked exactly once, and one refusal anywhere fails the + /// whole query. The provisional yes must therefore never outlive the query, which + /// is why the memo is created per allocation site and is not a field. + private static boolean calleeKeepsThis(String exactClass, String call, + Map memo) { + Boolean cached = memo.get(call); + if (cached != null) { + return cached.booleanValue(); + } + memo.put(call, Boolean.TRUE); + boolean safe = false; + BytecodeMethod m = resolveReceiverCall(exactClass, call); + // An unresolved callee, or one with no body, answers NO: an unknown body is + // an unchecked body. + if (m != null && !m.isNative() && !m.isAbstract() + && IteratorEscape.thisEscapes(m) == IteratorEscape.SAFE) { + // Copied before recursing: the list belongs to the last walk and the + // next thisEscapes call clears it. + List inner = new ArrayList(IteratorEscape.receiverCalls); + safe = true; + for (int i = 0; i < inner.size(); i++) { + if (!calleeKeepsThis(exactClass, inner.get(i), memo)) { + safe = false; + break; + } + } + } + memo.put(call, Boolean.valueOf(safe)); + return safe; + } + + /// The method a recorded receiver call actually runs, or null when that cannot + /// be established. + /// + /// `call` is owner.name+desc as IteratorEscape recorded it, prefixed with + /// IteratorEscape.EXACT_CALL for an INVOKESPECIAL. That one is not dispatched: it + /// runs the method of the class it names (a constructor, a private method, a + /// `super.` call), found there or inherited from above it. Everything else is + /// dispatched on the object, so the search starts at the object's own class and + /// the first declaration going up the superclass chain is the one that runs. + /// + /// A default method inherited from an interface is found by neither search and + /// answers null, which the caller reads as no. + private static BytecodeMethod resolveReceiverCall(String exactClass, String call) { + boolean exact = call.startsWith(IteratorEscape.EXACT_CALL); + String key = exact ? call.substring(IteratorEscape.EXACT_CALL.length()) : call; int dot = key.indexOf('.'); if (dot < 0) { - return false; + return null; } - String owner = IteratorEscape.mangle(key.substring(0, dot)); String rest = key.substring(dot + 1); int paren = rest.indexOf('('); if (paren < 0) { - return false; + return null; } String name = rest.substring(0, paren); // BytecodeMethod renames to __INIT__ (see BytecodeMethod:902). Without @@ -3196,18 +3477,24 @@ static boolean calleeIsSafe(String key) { name = "__INIT__"; } String desc = rest.substring(paren); - for (ByteCodeClass c : classes) { - if (!IteratorEscape.mangle(c.getClsName()).equals(owner)) { - continue; + String start = exact ? key.substring(0, dot) : exactClass; + // Bounded so that a malformed hierarchy cannot spin here. + for (int depth = 0; start != null && depth < 256; depth++) { + ByteCodeClass c = getClassByName(start.replace('.', '_')); + if (c == null) { + return null; // not in this closed world } for (BytecodeMethod m : c.getMethods()) { if (m.getMethodName().equals(name) && desc.equals(m.getDesc())) { - return IteratorEscape.thisEscapes(m) == IteratorEscape.SAFE; + return m; } } - return false; // class found, method not -- inherited or synthetic + if ("__INIT__".equals(name)) { + return null; // a constructor is never inherited + } + start = c.getBaseClass(); } - return false; // not in this closed world + return null; } static void iteratorStackCensus() { diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index f6e39357c4b..fa716ede19e 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -2294,11 +2294,36 @@ return callback; } + // The worker gives each timeout a callback of its own and forgets it once + // the timer has fired or been cleared (port.js, cn1SetHostTimer). The proxy + // cached for it here goes at the same moment, or it would be kept for good. + // Timer id -> worker callback id. + var oneShotTimerCallbacks = Object.create(null); + + function forgetOneShotTimer(id) { + var callbackId = oneShotTimerCallbacks[id]; + if (callbackId != null) { + delete oneShotTimerCallbacks[id]; + delete workerCallbackProxies[callbackId]; + } + } + hostBridge.register('__cn1_timer_set_timeout__', function(callback, delay) { - return global.setTimeout(cn1TimerCallback(callback), Math.max(0, delay | 0)); + var fn = cn1TimerCallback(callback); + var callbackId = typeof fn.__cn1WorkerCallbackId === 'number' ? fn.__cn1WorkerCallbackId : null; + if (callbackId == null) { + return global.setTimeout(fn, Math.max(0, delay | 0)); + } + var id = global.setTimeout(function() { + forgetOneShotTimer(id); + fn(); + }, Math.max(0, delay | 0)); + oneShotTimerCallbacks[id] = callbackId; + return id; }); hostBridge.register('__cn1_timer_clear_timeout__', function(id) { + forgetOneShotTimer(id | 0); global.clearTimeout(id | 0); return true; }); diff --git a/vm/JavaAPI/src/java/net/URLDecoder.java b/vm/JavaAPI/src/java/net/URLDecoder.java new file mode 100644 index 00000000000..c00bd96b843 --- /dev/null +++ b/vm/JavaAPI/src/java/net/URLDecoder.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.net; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; + +/** + * Decodes application/x-www-form-urlencoded text with the JDK's rules: a + * {@code +} becomes a space, each run of {@code %XY} escapes is collected into + * bytes and decoded in the named charset, and every other character is kept. + * An escape that is cut short or is not hexadecimal is refused with an + * {@link IllegalArgumentException}, as the JDK refuses it. + * + * Pure Java, for the reason {@link URLEncoder} gives. + */ +public class URLDecoder { + private URLDecoder() { + } + + /** + * Decodes {@code s} as UTF-8. + * + * @deprecated name the charset with {@link #decode(String, String)}, as the + * JDK asks. + */ + public static String decode(String s) { + try { + return decode(s, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e.toString()); + } + } + + /** + * Decodes {@code s}, reading escaped bytes in the charset named {@code enc}. + * + * Charset support is platform specific; this class library is not the + * JDK. Only UTF-8 and ISO-8859-1 are supported everywhere, and any other + * name is whatever the platform's conversion makes of it. + * + * @throws UnsupportedEncodingException when the charset is empty or not supported + */ + public static String decode(String s, String enc) throws UnsupportedEncodingException { + if (enc == null) { + throw new NullPointerException("charsetName"); + } + if (enc.length() == 0) { + throw new UnsupportedEncodingException("URLDecoder: empty string enc parameter"); + } + return decodeWith(s, enc); + } + + /** + * Decodes {@code s}, reading escaped bytes in {@code charset}. + */ + public static String decode(String s, Charset charset) { + if (charset == null) { + throw new NullPointerException("Charset"); + } + try { + return decodeWith(s, charset.name()); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException(e.toString()); + } + } + + private static String decodeWith(String s, String enc) throws UnsupportedEncodingException { + int length = s.length(); + StringBuilder out = null; + int i = 0; + while (i < length) { + char c = s.charAt(i); + if (c != '+' && c != '%') { + if (out != null) { + out.append(c); + } + i++; + continue; + } + if (out == null) { + out = new StringBuilder(length); + out.append(s, 0, i); + } + if (c == '+') { + out.append(' '); + i++; + continue; + } + // A run of escapes is one byte sequence: a multi-byte character is + // spread over several of them and only decodes as a whole. + byte[] bytes = new byte[(length - i) / 3]; + int count = 0; + while (i + 2 < length && s.charAt(i) == '%') { + int high = Character.digit(s.charAt(i + 1), 16); + int low = Character.digit(s.charAt(i + 2), 16); + if (high < 0 || low < 0) { + throw new IllegalArgumentException( + "URLDecoder: Illegal hex characters in escape (%) pattern - " + + s.substring(i + 1, i + 3)); + } + bytes[count++] = (byte) ((high << 4) | low); + i += 3; + } + if (i < length && s.charAt(i) == '%') { + throw new IllegalArgumentException( + "URLDecoder: Incomplete trailing escape (%) pattern"); + } + out.append(new String(bytes, 0, count, enc)); + } + // A string with no escapes comes back as it is without `enc` being + // looked at, so an encoding this runtime lacks is reported only once + // an escape needs it. The JDK would refuse it at once. This is not the + // JDK: the runtime has UTF-8 and ISO-8859-1, which are what a URL is + // decoded with, and a check for the others on every call would be + // paid by every caller for a mistake none of them makes. + return out == null ? s : out.toString(); + } +} diff --git a/vm/JavaAPI/src/java/net/URLEncoder.java b/vm/JavaAPI/src/java/net/URLEncoder.java new file mode 100644 index 00000000000..b39d81a5216 --- /dev/null +++ b/vm/JavaAPI/src/java/net/URLEncoder.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package java.net; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; + +/** + * Encodes text in the application/x-www-form-urlencoded format, with the JDK's + * rules: the letters, the digits and the four characters {@code . - * _} are + * kept, a space becomes {@code +}, and every other character is converted to + * bytes in the named charset and each byte written as {@code %XY} in upper case + * hexadecimal. + * + * Pure Java on purpose. A server written against the JDK calls this class, and + * the compiled backend has to answer the same bytes a JVM would, so nothing + * here may depend on a native. + */ +public class URLEncoder { + private static final char[] HEX = "0123456789ABCDEF".toCharArray(); + + private URLEncoder() { + } + + /** + * Encodes {@code s} as UTF-8, which is the platform charset on every + * runtime this library runs on. + * + * @deprecated name the charset with {@link #encode(String, String)}, as the + * JDK asks. + */ + public static String encode(String s) { + try { + return encode(s, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e.toString()); + } + } + + /** + * Encodes {@code s} with the bytes of the charset named {@code enc}. + * + * Charset support is platform specific; this class library is not the + * JDK. Only UTF-8 and ISO-8859-1 are supported everywhere. Any other name, + * including one the platform does not know, is whatever the platform's + * conversion makes of it: it may convert, fall back or throw, and text + * that needs no conversion is returned unchecked. + * + * @throws UnsupportedEncodingException when the charset is empty, or when the + * platform refuses it + */ + public static String encode(String s, String enc) throws UnsupportedEncodingException { + if (enc == null) { + throw new NullPointerException("charsetName"); + } + if (enc.length() == 0) { + throw new UnsupportedEncodingException(enc); + } + return encodeWith(s, enc); + } + + /** + * Encodes {@code s} with the bytes of {@code charset}. + */ + public static String encode(String s, Charset charset) { + if (charset == null) { + throw new NullPointerException("charset"); + } + try { + return encodeWith(s, charset.name()); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException(e.toString()); + } + } + + private static String encodeWith(String s, String enc) throws UnsupportedEncodingException { + int length = s.length(); + StringBuilder out = null; + int i = 0; + while (i < length) { + char c = s.charAt(i); + if (keep(c)) { + if (out != null) { + out.append(c); + } + i++; + continue; + } + if (out == null) { + out = new StringBuilder(length + 16); + out.append(s, 0, i); + } + if (c == ' ') { + out.append('+'); + i++; + continue; + } + // The whole run of characters that need encoding is converted at + // once, as the JDK does, so a surrogate pair reaches the charset + // together and becomes the four bytes of one code point. + int start = i; + while (i < length && !keep(s.charAt(i)) && s.charAt(i) != ' ') { + i++; + } + byte[] bytes = bytes(s.substring(start, i), enc); + for (int b = 0; b < bytes.length; b++) { + int v = bytes[b] & 0xff; + out.append('%').append(HEX[v >> 4]).append(HEX[v & 0xf]); + } + } + // As in URLDecoder: a string that needs no escape is returned without + // `enc` being looked at, so an encoding this runtime lacks is reported + // only once a character needs it. This is not the JDK; the runtime has + // UTF-8 and ISO-8859-1, which are what a URL is encoded with. + return out == null ? s : out.toString(); + } + + private static boolean keep(char c) { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' + || c == '.' || c == '-' || c == '*' || c == '_'; + } + + /** + * UTF-8 is converted here rather than by the runtime's charset support, so + * that a lone surrogate is written the way the JDK writes it -- as the + * replacement byte {@code ?} -- whatever the native conversion would do. + */ + private static byte[] bytes(String run, String enc) throws UnsupportedEncodingException { + if (!"UTF-8".equalsIgnoreCase(enc) && !"UTF8".equalsIgnoreCase(enc)) { + return run.getBytes(enc); + } + // At most three bytes per char: a pair is two chars and four bytes. + byte[] out = new byte[run.length() * 3]; + int at = 0; + int n = run.length(); + for (int i = 0; i < n; i++) { + int c = run.charAt(i); + if (c >= 0xD800 && c <= 0xDBFF && i + 1 < n + && run.charAt(i + 1) >= 0xDC00 && run.charAt(i + 1) <= 0xDFFF) { + int cp = 0x10000 + ((c - 0xD800) << 10) + (run.charAt(i + 1) - 0xDC00); + i++; + out[at++] = (byte) (0xF0 | (cp >> 18)); + out[at++] = (byte) (0x80 | ((cp >> 12) & 0x3F)); + out[at++] = (byte) (0x80 | ((cp >> 6) & 0x3F)); + out[at++] = (byte) (0x80 | (cp & 0x3F)); + } else if (c >= 0xD800 && c <= 0xDFFF) { + out[at++] = (byte) ('?'); + } else if (c < 0x80) { + out[at++] = (byte) (c); + } else if (c < 0x800) { + out[at++] = (byte) (0xC0 | (c >> 6)); + out[at++] = (byte) (0x80 | (c & 0x3F)); + } else { + out[at++] = (byte) (0xE0 | (c >> 12)); + out[at++] = (byte) (0x80 | ((c >> 6) & 0x3F)); + out[at++] = (byte) (0x80 | (c & 0x3F)); + } + } + byte[] result = new byte[at]; + System.arraycopy(out, 0, result, 0, at); + return result; + } +} diff --git a/vm/backend/demo/ormcheck/com/demo/OrmCheck.java b/vm/backend/demo/ormcheck/com/demo/OrmCheck.java index ffbb1b51959..d857ef91582 100644 --- a/vm/backend/demo/ormcheck/com/demo/OrmCheck.java +++ b/vm/backend/demo/ormcheck/com/demo/OrmCheck.java @@ -150,7 +150,10 @@ private static void generatedRepositories(EntityManager em, String dialect) { "repository",0,new com.codename1.orm.repository.PageRequest(1,1)).get(0).id); s.beginTransaction();first.score=4; check("repository bulk rows", "2", ""+records.revise("repository",11)); - check("repository bulk clears", "false", ""+s.contains(first)); + // A @Modifying statement leaves the session as it is, as Spring Data does + // unless the method asks for clearAutomatically. + check("repository bulk keeps the session", "true", ""+s.contains(first)); + check("repository bulk leaves managed values", "4", ""+first.score); s.rollbackTransaction(); check("repository rollback", "1", ""+records.qualified(5)); s.beginTransaction(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 68f9529cea7..fe8806b1e66 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -241,9 +241,22 @@ HttpServer.Response dispatch(HttpServer.Request request) throws Exception { if (chain == null) { throw new IllegalStateException("This server is not serving"); } - HttpServer.Response response = chain.handle(request); + HttpServer.Response response; + try { + response = chain.handle(request); + } catch (ResponseStatusException chosen) { + // The listener turns this into the answer the handler chose, so a + // test that dispatches here has to see that answer and not the throw. + response = HttpServer.Response.text(chosen.getStatus(), chosen.getReason()); + chain.decorateFallback(request, response); + return response; + } if (response == null) { response = HttpServer.Response.text(404, "Not Found"); + // Decorated as the listener decorates its own answers, CORS and + // security headers included: a test that dispatches must see the + // headers a client would. + chain.decorateFallback(request, response); } return response; } @@ -978,8 +991,11 @@ public HttpServer.Response checkUpgrade(HttpServer.Request request) { /// tried, the one whose store call threw among them, and each of the /// others is still finished here -- one failing save must not leave the /// rest undeleted, or their beans undestroyed. + /// + /// `answered` is the status the client is sent and `err` what went + /// wrong, null when the handler chose that status as its answer. private void failed(HttpServer.Request request, List attempted, - long startedMillis, Throwable err) { + long startedMillis, Throwable err, int answered) { endRequestBeans(request); List ended = request.endedSessions(); for (int e = 0 ; ended != null && e < ended.size() ; e++) { @@ -1010,7 +1026,7 @@ private void failed(HttpServer.Request request, List attempted, + storeErr); } } - requestLog.record(request, 500, startedMillis, err); + requestLog.record(request, answered, startedMillis, err); } /// Destroy passes over request beans that destroying others created. @@ -1158,13 +1174,31 @@ public HttpServer.Response handle(HttpServer.Request request) } } } + } catch (ResponseStatusException chosen) { + if (response != null) { + response.discard(); + } + // The listener answers with the status the handler chose, + // so that is what the request log and the metrics record. + // Left to the clause below, a 409 the client received was + // logged and counted as a 500 with an error attached. Only + // a status of 500 and above is a failure of the handler. + status = chosen.getStatus(); + failed(request, attempted, startedMillis, + status >= 500 ? chosen : null, status); + // Thrown on past the policies below, and still answered + // with them: the listener builds the status-only answer + // and hands it to decorateFallback above, which puts the + // CORS and security headers on it. Compression is the one + // policy it does without, on a body of a few words. + throw chosen; } catch (Exception err) { // The handler's response is replaced by a 500; a // file it carried is closed here or never. if (response != null) { response.discard(); } - failed(request, attempted, startedMillis, err); + failed(request, attempted, startedMillis, err, 500); throw err; } catch (Error err) { if (response != null) { @@ -1173,7 +1207,7 @@ public HttpServer.Response handle(HttpServer.Request request) // The same clean-up: an invalidated session // must still be deleted, or the client's old // cookie keeps its signed-in state. - failed(request, attempted, startedMillis, err); + failed(request, attempted, startedMillis, err, 500); throw err; } if (response != null && (cors != null || compression != null || secured)) { @@ -1260,6 +1294,47 @@ static String advertised(String host) { return h.indexOf(':') >= 0 && !h.startsWith("[") ? "[" + h + "]" : h; } + /// The directory the app's web build is looked for in when + /// [Config#WEBAPP_ROOT] names none. + static final String DEFAULT_WEBAPP_ROOT = "webapp"; + + /// The handler serving the app's web build, or null when there is none to + /// serve. + /// + /// The build puts the browser version of the app in a directory, and the + /// server that finds one answers with it: a person opens the server's + /// address and is using the app, with nothing installed. Like Spring Boot's + /// `static/` it needs no setting when the directory is where it is expected + /// -- `webapp` beside the configuration, in the working directory -- and + /// like it an `index.html` is what makes a directory a site. + /// + /// A directory that is NAMED and is not one is a mistake, and a start-up + /// error: a server told where its app is and serving a 404 there instead + /// is a deployment that looks finished. The default directory being absent + /// is the ordinary state of a server with no web build, and says nothing. + /// + /// Every file is revalidated unless [Config#WEBAPP_CACHE_CONTROL] says + /// otherwise, because the files of a build keep their names: a cached + /// script from the last deployment running against this one's server is + /// the failure, and a 304 for each of a couple of dozen files is the cost. + static StaticFiles webApp(Config config) throws IOException { + if (!config.getBoolean(Config.WEBAPP_ENABLED, true)) { + return null; + } + String named = config.get(Config.WEBAPP_ROOT); + boolean chosen = named != null && named.length() > 0; + String root = chosen ? named : DEFAULT_WEBAPP_ROOT; + if (FileIo.realPath(root + "/index.html") == null) { + if (chosen) { + throw new IOException(Config.WEBAPP_ROOT + " is " + root + ", which holds no " + + "index.html; build the app for the web into it, or remove the setting"); + } + return null; + } + return new StaticFiles(root, config.get(Config.WEBAPP_PATH, "/"), "index.html", + config.get(Config.WEBAPP_CACHE_CONTROL, "no-cache"), true); + } + /// Whether `host` names this machine's loopback interface. /// /// Only what is loopback by definition: `localhost`, the IPv6 `::1`, and a @@ -1983,6 +2058,14 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { // would depend on what happened to be in a directory. routers.add(staticFiles); } + // The app's web build, after even those: it is mounted on / by + // default, where it would otherwise answer for every path nothing + // else claimed -- including a file under the static prefix that is + // simply not there. + StaticFiles webApp = webApp(config); + if (webApp != null) { + routers.add(webApp); + } boolean servesWebSockets = webSocketEndpoints != null || application != null; // The management and MCP endpoints are the server's own, not the // application's: a server whose only routes are those still answers diff --git a/vm/backend/src/com/codename1/backend/ConcurrencyFailureException.java b/vm/backend/src/com/codename1/backend/ConcurrencyFailureException.java new file mode 100644 index 00000000000..87289c093e9 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/ConcurrencyFailureException.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +/// A statement failed because another transaction got there first, and not +/// because anything is wrong with it: the transaction was chosen to end a +/// deadlock, could not be serialized with one that committed before it, or +/// waited for a lock longer than the engine allows. +/// +/// Named after Spring's, and meant for the same use. The statement is sound and +/// so is the data, so the caller may run its transaction again from the start, +/// or answer that somebody else made the change first. Every other +/// [DataAccessException] is a failure that running the work again would repeat. +/// +/// What an engine reports this way depends on the engine and on how it is +/// configured, which is the reason to recognize the whole family and not one +/// code. Two transactions that read a row and then update it with a condition +/// are settled by most engines quietly: the second update changes no rows. An +/// engine that keeps a transaction to the snapshot it first read from refuses +/// the second update instead, and that refusal is this exception. The failures +/// recognized are: +/// +/// - PostgreSQL: a serialization failure (SQLSTATE 40001), a deadlock (40P01) +/// and a lock that was not available (55P03). +/// - MySQL and MariaDB: a deadlock (error 1213), a lock wait that timed out +/// (1205), a lock refused under NOWAIT (3572), a record that changed since +/// the transaction read it (1020), and any other error of SQLSTATE class 40. +/// +/// SQLite reports a database that stayed locked past its busy timeout as a +/// plain [DataAccessException]. +/// +/// The engine has usually undone the whole transaction by the time this is +/// thrown, and PostgreSQL refuses every further statement in it. So let it +/// leave the `@Transactional` method, which rolls back for it as for any data +/// access failure, and decide what to do from outside that method. An ORM +/// session reports a database failure as an unchecked exception with this one +/// as its cause; [#isCauseOf] finds it there. +public class ConcurrencyFailureException extends DataAccessException { + /// How far down a chain of causes [#isCauseOf] looks. + private static final int DEPTH = 16; + + public ConcurrencyFailureException(String message) { + super(message); + } + + public ConcurrencyFailureException(String message, Throwable cause) { + super(message, cause); + } + + /// Whether `failure` is a concurrency failure or was caused by one, however + /// many exceptions it has been wrapped in since. + /// + /// @param failure what was caught; null answers false + public static boolean isCauseOf(Throwable failure) { + Throwable at = failure; + for (int iter = 0; iter < DEPTH && at != null; iter++) { + if (at instanceof ConcurrencyFailureException) { + return true; + } + at = at.getCause(); + } + return false; + } +} diff --git a/vm/backend/src/com/codename1/backend/Config.java b/vm/backend/src/com/codename1/backend/Config.java index 091e636ba14..c1fc38e50d9 100644 --- a/vm/backend/src/com/codename1/backend/Config.java +++ b/vm/backend/src/com/codename1/backend/Config.java @@ -125,6 +125,20 @@ public final class Config { /// The Cache-Control header those files carry. public static final String STATIC_CACHE_CONTROL = "cn1.static.cacheControl"; + /// The directory holding the app's web build, which the server answers with + /// at [#WEBAPP_PATH] after every route and every static file. Defaults to + /// `webapp` in the working directory, and is served only when it holds an + /// `index.html`; a directory named here that does not is a start-up error. + public static final String WEBAPP_ROOT = "cn1.webapp.root"; + /// The path the web build is served under. Defaults to /. + public static final String WEBAPP_PATH = "cn1.webapp.path"; + /// The Cache-Control header the web build's files carry. Defaults to + /// no-cache: the files keep their names from one build to the next, so a + /// browser has to ask whether its copy is still the current one. + public static final String WEBAPP_CACHE_CONTROL = "cn1.webapp.cacheControl"; + /// Set to false to keep the server from looking for a web build at all. + public static final String WEBAPP_ENABLED = "cn1.webapp.enabled"; + /// The database, as a SQLite path or a PostgreSQL or MySQL URL. Also read from /// DATABASE_URL. public static final String DATASOURCE_URL = "cn1.datasource.url"; diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 4ccac999a20..8ff2c67bb0f 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -6087,6 +6087,15 @@ private void serveOneRelease(int fd) { if (response == null) { response = fallback(request, 404, "not found"); } + } catch (ResponseStatusException chosen) { + // The handler chose this answer. Below 500 it is not a + // failure of the handler: nothing is logged and the span + // is not marked as an error. + if (chosen.getStatus() >= 500) { + System.err.println("handler failed: " + chosen); + handlerError = chosen; + } + response = fallback(request, chosen.getStatus(), chosen.getReason()); } catch (Throwable err) { rethrowIfFatal(err); System.err.println("handler failed: " + err); @@ -6505,6 +6514,15 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) if (response == null) { response = fallback(request, 404, "not found"); } + } catch (ResponseStatusException chosen) { + // The handler chose this answer. Below 500 it is not a + // failure of the handler: nothing is logged and the span + // is not marked as an error. + if (chosen.getStatus() >= 500) { + System.err.println("handler failed: " + chosen); + handlerError = chosen; + } + response = fallback(request, chosen.getStatus(), chosen.getReason()); } catch (Throwable err) { rethrowIfFatal(err); System.err.println("handler failed: " + err); diff --git a/vm/backend/src/com/codename1/backend/ResponseStatusException.java b/vm/backend/src/com/codename1/backend/ResponseStatusException.java new file mode 100644 index 00000000000..695dfced80c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/ResponseStatusException.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +/// Thrown by a handler to answer a request with a status of its choosing. +/// +/// A handler that returns a value has no other way to say "409, that e-mail is +/// taken": its return type is the body of a success. That is every method of a +/// server interface generated from a shared `@RestClient` contract, and any +/// controller method that returns its own type and not a `Response`. +/// +/// ```java +/// if (taken(email)) { +/// throw new ResponseStatusException(409, "That e-mail already has an account"); +/// } +/// ``` +/// +/// The reason is the body, as plain text, so it is what the caller reads: keep +/// it free of anything the caller should not learn. A status below 500 is an +/// answer and is neither logged nor recorded as a failed request; 500 and above +/// is reported as any other failure of the handler is, and answered with the +/// status given. +/// +/// Unchecked, as Spring's is, and for the same reason: it has to pass through +/// methods whose signatures were written without it. Thrown inside a +/// `@Transactional` method it rolls the transaction back, as every unchecked +/// exception does. +public class ResponseStatusException extends RuntimeException { + private final int status; + private final String reason; + + /// @param status the status to answer with, 400 to 599 + /// @param reason what to tell the caller; sent as the body + public ResponseStatusException(int status, String reason) { + this(status, reason, null); + } + + /// @param status the status to answer with, 400 to 599 + /// @param reason what to tell the caller; sent as the body + /// @param cause what led to it, for the log; never sent + public ResponseStatusException(int status, String reason, Throwable cause) { + super(status + " " + reason, cause); + if (status < 400 || status > 599) { + throw new IllegalArgumentException("not an error status: " + status); + } + this.status = status; + this.reason = reason == null ? "" : reason; + } + + /// The status to answer with. + public int getStatus() { + return status; + } + + /// What the caller is told. + public String getReason() { + return reason; + } +} diff --git a/vm/backend/src/com/codename1/backend/StaticFiles.java b/vm/backend/src/com/codename1/backend/StaticFiles.java index 76b836fb926..98c0f895553 100644 --- a/vm/backend/src/com/codename1/backend/StaticFiles.java +++ b/vm/backend/src/com/codename1/backend/StaticFiles.java @@ -53,11 +53,28 @@ public final class StaticFiles implements HttpServer.Handler { private final String prefix; private final String indexFile; private final String cacheControl; + private final boolean precompressed; /// - `root`: the document root; resolved once, and every request must land inside it /// - `prefix`: URL prefix to strip, "" or "/" for none /// - `cacheControl`: the Cache-Control value, or null to omit it public StaticFiles(String root, String prefix, String indexFile, String cacheControl) throws IOException { + this(root, prefix, indexFile, cacheControl, false); + } + + /// As above, and with `precompressed` a file that has a gzip copy beside it + /// -- `app.js.gz` next to `app.js` -- is answered with that copy to a client + /// that accepts gzip. + /// + /// This is how a large text file is compressed without leaving the zero-copy + /// path: compressing on the way out would mean reading the whole file into + /// this process for every request, and the copy made once at build time goes + /// from the page cache to the socket like any other file. It is off unless + /// asked for, because nothing but the build that wrote both can know that + /// `x.gz` is `x` compressed and not some other file. + public StaticFiles(String root, String prefix, String indexFile, String cacheControl, + boolean precompressed) throws IOException { + this.precompressed = precompressed; String resolved = FileIo.realPath(root); if (resolved == null) { throw new IOException("Document root does not exist: " + root); @@ -215,59 +232,23 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { decoded = stripTrailingSlash(decoded) + "/" + indexFile; } - // Only where the open could not prove it. Checking the request string - // instead is defeated by an encoded traversal or by a symlink out of the - // tree, so this resolves first -- but it is a second lookup, which is why - // the open above is preferred wherever the platform supports it. - if (!beneathProven) { - String real = FileIo.realPath(root + decoded); - if (real == null || !isInsideRoot(real)) { - return HttpServer.Response.text(403, "forbidden"); - } - // AND THE DESCRIPTOR HAS TO BE THAT FILE. The check above is a - // second lookup, so on its own it answers a question about the - // path rather than about the bytes: an attacker who can write - // symlinks into the document root points one outside for the open - // and back inside for the resolve, and what gets served is the - // outside file that the check never looked at. - // - // Binding the two by IDENTITY NARROWS it. It does not close it, - // and openBeneathImpl says why in its own comment: the second - // lookup is racy in the same way as the first. What the comparison - // buys is that the final component can no longer be swapped - // unseen -- the resolved path has no symlinks left in it, so an - // attacker has to swap a DIRECTORY component of it between the - // resolve and this open to still win. Closing it outright is what - // openat2 is for, which is why the open above is preferred - // wherever the kernel has it. - // - // TWO MORE LIMITS, both worth naming rather than implying. A hard - // link inside the root to a file outside it cannot be told apart - // -- and that file does have a name inside the document root, - // which is what the root is a statement about. And the comparison - // is only as good as the identity: the packaged arm reads it from - // an fstat of the open descriptor, so it describes the served - // bytes, while the Java SE arm has no fstat for a channel and - // reads it from the pathname, so there it is one more statement - // about the path rather than about what was opened. - // - // Only where openBeneath could not settle it; a proven open needs - // none of this and pays for none of it. - if (info[3] != 0) { - int verify = FileIo.openRead(real); - if (verify < 0) { - return HttpServer.Response.text(403, "forbidden"); - } - long[] resolved = new long[4]; - int rc = FileIo.stat(verify, resolved); - FileIo.close(verify); - if (rc != 0 || resolved[3] != info[3]) { - return HttpServer.Response.text(403, "forbidden"); - } + if (!beneathProven && !resolvesInside(decoded, info)) { + return HttpServer.Response.text(403, "forbidden"); + } + + // The file is settled; which BYTES answer for it is the next question. + // Only after the checks above, so the copy is looked for beside a + // path that is already known to be inside the root. + boolean encoded = false; + if (precompressed) { + long[] packedInfo = new long[4]; + int packed = openPrecompressed(request, decoded, beneathProven, packedInfo); + if (packed >= 0) { + FileIo.close(fd); + fd = packed; + info = packedInfo; + encoded = true; } - // A platform that reports no identity at all -- a filesystem with - // no file key behind it -- keeps the older, weaker answer rather - // than refusing every request it cannot bind. } long size = info[0]; @@ -297,7 +278,20 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { Map headers = new LinkedHashMap(); headers.put("ETag", etag); headers.put("Last-Modified", Http1Date.format(modified)); - headers.put("Accept-Ranges", "bytes"); + if (precompressed) { + // Either answer was chosen by Accept-Encoding, so both say so: a + // shared cache that stored one would otherwise hand it to a + // client that asked for the other. + headers.put("Vary", "Accept-Encoding"); + } + if (encoded) { + // No Accept-Ranges: an offset into these bytes is an offset into + // the compressed stream, which is why a request carrying a Range + // was never given this copy in the first place. + headers.put("Content-Encoding", "gzip"); + } else { + headers.put("Accept-Ranges", "bytes"); + } if (cacheControl != null) { headers.put("Cache-Control", cacheControl); } @@ -343,6 +337,95 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { } } + /// Opens the gzip copy beside `path` for a request that can take it, filling + /// `out` the way [FileIo#stat] does, or answers -1 to send the file itself. + /// + /// The validators of the response then come from the copy, which is right: + /// they name the bytes on the wire, and the two representations must not + /// share an ETag or a cache revalidating one would be told 304 about the + /// other. + private int openPrecompressed(HttpServer.Request request, String path, boolean beneathProven, + long[] out) { + // A range is an offset into the file the client knows about, never into + // a compressed stream of it. + if (request.getHeader("range") != null || !Compression.acceptsGzip(request)) { + return -1; + } + String packedPath = path + ".gz"; + int packed = beneathProven ? FileIo.openBeneath(root, packedPath) + : FileIo.openRead(root + packedPath); + if (packed < 0) { + return -1; + } + // A directory named like the copy, an empty file, or -- where the open + // could not prove containment -- a link out of the root: none of them is + // the copy, and the file itself is still a correct answer. + if (FileIo.stat(packed, out) != 0 || out[2] != 0 || out[0] <= 0 + || (!beneathProven && !resolvesInside(packedPath, out))) { + FileIo.close(packed); + return -1; + } + return packed; + } + + /// Whether the descriptor described by `info`, opened for `path` without + /// the kernel proving where it resolved, is a file inside the root. + private boolean resolvesInside(String path, long[] info) { + // Only where the open could not prove it. Checking the request string + // instead is defeated by an encoded traversal or by a symlink out of the + // tree, so this resolves first -- but it is a second lookup, which is why + // the open above is preferred wherever the platform supports it. + String real = FileIo.realPath(root + path); + if (real == null || !isInsideRoot(real)) { + return false; + } + // AND THE DESCRIPTOR HAS TO BE THAT FILE. The check above is a + // second lookup, so on its own it answers a question about the + // path rather than about the bytes: an attacker who can write + // symlinks into the document root points one outside for the open + // and back inside for the resolve, and what gets served is the + // outside file that the check never looked at. + // + // Binding the two by IDENTITY NARROWS it. It does not close it, + // and openBeneathImpl says why in its own comment: the second + // lookup is racy in the same way as the first. What the comparison + // buys is that the final component can no longer be swapped + // unseen -- the resolved path has no symlinks left in it, so an + // attacker has to swap a DIRECTORY component of it between the + // resolve and this open to still win. Closing it outright is what + // openat2 is for, which is why the open above is preferred + // wherever the kernel has it. + // + // TWO MORE LIMITS, both worth naming rather than implying. A hard + // link inside the root to a file outside it cannot be told apart + // -- and that file does have a name inside the document root, + // which is what the root is a statement about. And the comparison + // is only as good as the identity: the packaged arm reads it from + // an fstat of the open descriptor, so it describes the served + // bytes, while the Java SE arm has no fstat for a channel and + // reads it from the pathname, so there it is one more statement + // about the path rather than about what was opened. + // + // Only where openBeneath could not settle it; a proven open needs + // none of this and pays for none of it. + if (info[3] != 0) { + int verify = FileIo.openRead(real); + if (verify < 0) { + return false; + } + long[] resolved = new long[4]; + int rc = FileIo.stat(verify, resolved); + FileIo.close(verify); + if (rc != 0 || resolved[3] != info[3]) { + return false; + } + } + // A platform that reports no identity at all -- a filesystem with + // no file key behind it -- keeps the older, weaker answer rather + // than refusing every request it cannot bind. + return true; + } + private boolean isInsideRoot(String real) { if (real.equals(root)) { return true; diff --git a/vm/backend/src/com/codename1/backend/push/CloudPush.java b/vm/backend/src/com/codename1/backend/push/CloudPush.java new file mode 100644 index 00000000000..ac989c97640 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/push/CloudPush.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.push; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Sends push notifications through the Codename One push service. +/// +/// ```java +/// CloudPush push = new CloudPush(appId, serverApiKey); +/// PushResult result = push.send(message, PushTarget.fromDeviceKey(deviceKey)); +/// if (!result.isAccepted()) { +/// log("push refused: " + result.getError()); +/// } +/// ``` +/// +/// `appId` is the application UUID the Console shows for the push application, +/// and `serverApiKey` a server API key created with the `push` scope. The key is +/// a credential: keep it in configuration, not in source, and never ship it in +/// the app. +/// +/// Each notification is one HTTPS request, made with [Web] on the calling thread. Every +/// answer the service gives comes back as a [PushResult], refusals included; +/// only a request that got no answer at all throws. Nothing here logs, so the +/// key and the device tokens reach no log through this class. +/// +/// Instances are immutable and can be shared between threads. +public final class CloudPush { + /// The production endpoint of the push service. + public static final String DEFAULT_ENDPOINT = + "https://cloud.codenameone.com/api/v3/push/messages"; + + private final String appId; + private final String serverApiKey; + private final String endpoint; + + /// A sender for one push application. + /// + /// @param appId the application UUID from the Console's push overview + /// @param serverApiKey a server API key with the `push` scope + /// @throws IllegalArgumentException if either is null or blank + public CloudPush(String appId, String serverApiKey) { + this(appId, serverApiKey, DEFAULT_ENDPOINT); + } + + private CloudPush(String appId, String serverApiKey, String endpoint) { + PushNotification.requireText(appId, "The push application id"); + PushNotification.requireText(serverApiKey, "The server API key"); + this.appId = appId; + this.serverApiKey = serverApiKey; + this.endpoint = endpoint; + } + + /// A copy that sends to another URL: a stub in a test, or a gateway that + /// forwards to the service. + /// + /// @param url the full URL of the messages endpoint + /// @return a sender with the same application and key + /// @throws IllegalArgumentException if the URL is null or blank + public CloudPush withEndpoint(String url) { + PushNotification.requireText(url, "The push endpoint"); + return new CloudPush(appId, serverApiKey, url); + } + + /// @return the URL messages are sent to + public String getEndpoint() { + return endpoint; + } + + /// @return the application UUID messages are sent for + public String getAppId() { + return appId; + } + + /// Sends one message to one or more devices. + /// + /// @param message the message + /// @param targets the devices + /// @return the service's answer, accepted or not + /// @throws IllegalArgumentException if the message is null or has no body, + /// data or surface command, or if there are no targets; checked before + /// any request is made + /// @throws IOException if the request failed without an answer: the service + /// couldn't be reached, the connection broke, or it timed out + public PushResult send(PushNotification message, PushTarget... targets) throws IOException { + if (targets == null) { + throw new IllegalArgumentException("A push needs at least one target"); + } + return send(message, Arrays.asList(targets)); + } + + /// Sends one message to one or more devices. + /// + /// @param message the message + /// @param targets the devices, each a [PushTarget] + /// @return the service's answer, accepted or not + /// @throws IllegalArgumentException if the message is null or has no body, + /// data or surface command, or if the list is empty or holds something + /// other than a [PushTarget]; checked before any request is made + /// @throws IOException if the request failed without an answer: the service + /// couldn't be reached, the connection broke, or it timed out + public PushResult send(PushNotification message, List targets) throws IOException { + if (message == null) { + throw new IllegalArgumentException("No push message"); + } + if (!message.hasContent()) { + throw new IllegalArgumentException("A push message needs a body, data or a " + + "surface command"); + } + if (targets == null || targets.isEmpty()) { + throw new IllegalArgumentException("A push needs at least one target"); + } + List targetList = new ArrayList(); + // Read as Object and tested: a raw List can hold anything, and on the + // native runtime a cast that fails is not an exception, it is a crash. + for (Object entry : (List) targets) { + if (!(entry instanceof PushTarget)) { + throw new IllegalArgumentException("Push targets must be PushTarget " + + "instances; found " + (entry == null ? "null" + : entry.getClass().getName())); + } + PushTarget target = (PushTarget) entry; + Map json = new LinkedHashMap(); + json.put("provider", target.getProvider()); + json.put("token", target.getToken()); + targetList.add(json); + } + Map request = new LinkedHashMap(); + request.put("appId", appId); + request.put("targets", targetList); + request.put("message", message.envelope()); + + List headers = new ArrayList(); + headers.add("Authorization: Bearer " + serverApiKey); + headers.add("Content-Type: application/json"); + headers.add("Accept: application/json"); + Web.Result response = Web.request("POST", endpoint, headers, + Json.write(request).getBytes("UTF-8")); + int status = response.getStatus(); + if (status < 0) { + // Both arms of Web throw for this already; the check keeps the + // contract here rather than in a comment about another class. + throw new IOException("The push service could not be reached"); + } + return toResult(status, response.getBodyAsString(), response.getHeader("Retry-After")); + } + + static PushResult toResult(int status, String body, String retryAfter) { + Map json = parseObject(body); + List deliveryIds = new ArrayList(); + String error = null; + boolean accepted = status >= 200 && status < 300; + if (json != null) { + Object ids = json.get("deliveryIds"); + if (ids instanceof List) { + for (Object id : (List) ids) { + if (id != null) { + deliveryIds.add(String.valueOf(id)); + } + } + } + if (!accepted) { + error = errorText(json); + } + } + if (!accepted && error == null) { + error = "HTTP " + status; + } + return new PushResult(status, deliveryIds, error, parseRetryAfter(retryAfter)); + } + + private static Map parseObject(String body) { + if (body == null || body.length() == 0) { + return null; + } + try { + Object value = Json.parse(body); + return value instanceof Map ? (Map) value : null; + } catch (IOException notJson) { + // A proxy's HTML error page, say. The status still says what + // happened; the body just has nothing to add. + return null; + } + } + + /// `error` is the service's code and `message` its explanation; either can + /// be missing. + private static String errorText(Map json) { + Object code = json.get("error"); + Object message = json.get("message"); + String codeText = code instanceof String && ((String) code).length() > 0 + ? (String) code : null; + String messageText = message instanceof String && ((String) message).length() > 0 + ? (String) message : null; + if (codeText != null && messageText != null && !codeText.equals(messageText)) { + return codeText + ": " + messageText; + } + return messageText != null ? messageText : codeText; + } + + /// Delta seconds only. The HTTP-date form is legal but the service sends + /// seconds, and reading a date would need a clock the caller's retry logic + /// is better placed to own; it answers -1 like an absent header. + static int parseRetryAfter(String value) { + if (value == null) { + return -1; + } + String text = value.trim(); + if (text.length() == 0 || text.length() > 9) { + return -1; + } + int seconds = 0; + for (int iter = 0 ; iter < text.length() ; iter++) { + char c = text.charAt(iter); + if (c < '0' || c > '9') { + return -1; + } + seconds = seconds * 10 + (c - '0'); + } + return seconds; + } + + /// The application only. The key is a credential, and a gateway URL can carry + /// one in its query string, so neither goes anywhere a log might. + @Override + public String toString() { + return "CloudPush[appId=" + appId + "]"; + } +} diff --git a/vm/backend/src/com/codename1/backend/push/PushNotification.java b/vm/backend/src/com/codename1/backend/push/PushNotification.java new file mode 100644 index 00000000000..877bbc9ab6e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/push/PushNotification.java @@ -0,0 +1,377 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.push; + +import com.codename1.backend.Json; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/// A push message in the schema-3 envelope the Codename One push service +/// delivers and the app's `PushClient` receives. +/// +/// ```java +/// PushNotification message = PushNotification.builder() +/// .title("Your driver is here") +/// .body("Look for the blue car") +/// .deepLink("app://trip/42") +/// .data("tripId", "42") +/// .build(); +/// ``` +/// +/// A message carries at least a body, data, or a surface command; [CloudPush] +/// refuses one that has none of them, because the service would. +/// +/// This is the server's counterpart of the app's `com.codename1.push.PushMessage` +/// and writes the same fields. It isn't that class shared with the backend: +/// `PushMessage` parses and writes through `com.codename1.io.JSONParser`, which +/// reaches `com.codename1.processing.Result` and through it the XML DOM, none of +/// which the server compiles. The two agree on the wire, which is the contract +/// that matters; the envelope is JSON. +/// +/// A built notification is immutable: [#toMap()] answers a copy. +public final class PushNotification { + /// The envelope schema this class writes. + public static final int SCHEMA_VERSION = 3; + + static final String OPERATION_WIDGET = "widget"; + static final String OPERATION_LIVE_UPDATE = "live-update"; + static final String OPERATION_LIVE_END = "live-end"; + + private final Map values; + + private PushNotification(Map values) { + this.values = copyMap(values); + } + + /// @return a builder with the schema already set + public static Builder builder() { + return new Builder(); + } + + /// The envelope as JSON. + /// + /// @return the JSON object the service receives as `message` + public String toJson() { + return Json.write(values); + } + + /// The envelope as maps and lists, in the order the fields were set. + /// + /// @return a copy; changing it doesn't change this notification + public Map toMap() { + return copyMap(values); + } + + /// Whether the service would admit this as a message: it needs a body, + /// data, or a surface command. + boolean hasContent() { + return values.containsKey("body") || values.containsKey("data") + || values.containsKey("surface"); + } + + /// The live envelope, for [CloudPush] to write without a second copy. + Map envelope() { + return values; + } + + /// A surface command that updates a running Live Activity. + /// + /// The app applies it with `LiveActivity.updateRemote`, before any listener + /// sees the message, so a lock-screen activity moves on even when the app + /// isn't running. + /// + /// @param activityId the id the app started the activity under + /// @param state the activity's new state, a flat map of names to JSON + /// values; sent as a JSON string with its keys sorted, the way the + /// app serializes state itself + /// @return a command for [Builder#surface(Map)] + /// @throws IllegalArgumentException if the id is empty or a state key is null + public static Map liveUpdate(String activityId, Map state) { + requireText(activityId, "A Live Activity id"); + Map command = new LinkedHashMap(); + command.put("operation", OPERATION_LIVE_UPDATE); + command.put("id", activityId); + command.put("state", serializeState(state)); + return command; + } + + /// A surface command that ends a Live Activity. + /// + /// @param activityId the id the app started the activity under + /// @param finalState the state to show as the activity ends, or null to + /// leave the last one + /// @param dismissImmediately true to remove the activity now rather than + /// letting the system keep the final state on screen for a while + /// @return a command for [Builder#surface(Map)] + /// @throws IllegalArgumentException if the id is empty or a state key is null + public static Map liveEnd(String activityId, Map finalState, + boolean dismissImmediately) { + requireText(activityId, "A Live Activity id"); + Map command = new LinkedHashMap(); + command.put("operation", OPERATION_LIVE_END); + command.put("id", activityId); + if (finalState != null) { + command.put("state", serializeState(finalState)); + } + command.put("dismissImmediately", Boolean.valueOf(dismissImmediately)); + return command; + } + + /// A surface command that publishes new widget content. + /// + /// @param kind the widget kind the app declared + /// @param timelineJson the timeline, already serialized as JSON + /// @return a command for [Builder#surface(Map)] + /// @throws IllegalArgumentException if either argument is empty + public static Map widget(String kind, String timelineJson) { + requireText(kind, "A widget kind"); + requireText(timelineJson, "A widget timeline"); + Map command = new LinkedHashMap(); + command.put("operation", OPERATION_WIDGET); + command.put("kind", kind); + command.put("timeline", timelineJson); + return command; + } + + /// The state map as the JSON STRING the envelope carries, keys sorted. + /// + /// Sorted because the app's own serializer sorts, so the same state written + /// on either side is the same text. Only the top level is sorted, as the app + /// does: the map is meant to be flat. + static String serializeState(Map state) { + Map sorted = new TreeMap(); + if (state != null) { + Iterator> it = state.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + // Through Object, not the declared String: a caller with a raw + // Map can put any key in it, and a cast that fails is a crash on + // the native runtime rather than an exception. + Object key = entry.getKey(); + if (key == null) { + throw new IllegalArgumentException("A state key is null"); + } + sorted.put(String.valueOf(key), copyValue(entry.getValue())); + } + } + return Json.write(sorted); + } + + static void requireText(String value, String label) { + if (value == null || value.trim().length() == 0) { + throw new IllegalArgumentException(label + " must not be empty"); + } + } + + /// A deep copy of the maps and lists in a value, so nothing a caller keeps a + /// reference to can change a built notification. + static Object copyValue(Object value) { + if (value instanceof Map) { + return copyMap((Map) value); + } + if (value instanceof Collection) { + List copy = new ArrayList(); + Iterator it = ((Collection) value).iterator(); + while (it.hasNext()) { + copy.add(copyValue(it.next())); + } + return copy; + } + return value; + } + + static Map copyMap(Map map) { + Map copy = new LinkedHashMap(); + Iterator> it = map.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + copy.put(String.valueOf(entry.getKey()), copyValue(entry.getValue())); + } + return copy; + } + + /// Builds a [PushNotification]. A builder can be reused: [#build()] takes a + /// snapshot. + public static final class Builder { + private final Map values = new LinkedHashMap(); + private final Map data = new LinkedHashMap(); + private final Map platform = new LinkedHashMap(); + + private Builder() { + values.put("schema", Integer.valueOf(SCHEMA_VERSION)); + } + + /// @param value an id for the message, or null to clear it + /// @return this builder + public Builder id(String value) { + return put("id", value); + } + + /// @param value the visible title, or null to clear it + /// @return this builder + public Builder title(String value) { + return put("title", value); + } + + /// @param value the visible body, or null to clear it + /// @return this builder + public Builder body(String value) { + return put("body", value); + } + + /// @param value an HTTPS URL of an image to show, or null to clear it + /// @return this builder + public Builder imageUrl(String value) { + return put("image", value); + } + + /// @param value the deep link the app opens when the user taps the + /// notification, or null to clear it + /// @return this builder + public Builder deepLink(String value) { + return put("deepLink", value); + } + + /// @param value a key under which a newer message replaces an older + /// undelivered one, or null to clear it + /// @return this builder + public Builder collapseKey(String value) { + return put("collapseKey", value); + } + + /// How long the providers keep trying to deliver the message. + /// + /// @param value the lifetime in seconds, greater than zero + /// @return this builder + /// @throws IllegalArgumentException if the value isn't positive + public Builder ttlSeconds(int value) { + if (value <= 0) { + throw new IllegalArgumentException("Push TTL must be greater than zero"); + } + values.put("ttl", Integer.valueOf(value)); + return this; + } + + /// @param value true for a data-only message that shows nothing + /// @return this builder + public Builder silent(boolean value) { + values.put("silent", Boolean.valueOf(value)); + return this; + } + + /// Adds a value the app reads from the message's data, or removes it. + /// + /// @param key the name + /// @param value a JSON value (string, number, boolean, map or list), or + /// null to remove the key + /// @return this builder + /// @throws IllegalArgumentException if the key is empty or has + /// surrounding whitespace + public Builder data(String key, Object value) { + requireKey(key, "A data key"); + if (value == null) { + data.remove(key); + } else { + data.put(key, copyValue(value)); + } + return this; + } + + /// Adds options for one provider, which the other providers ignore. + /// + /// @param providerId `fcm`, `apns`, `huawei`, `wns` or `web`, or one of + /// the aliases [PushTarget#of(String, String)] accepts + /// @param options the provider's options, or null to remove them + /// @return this builder + /// @throws IllegalArgumentException if the provider is unknown + public Builder platform(String providerId, Map options) { + String canonical = PushTarget.canonicalProvider(providerId); + if (canonical == null) { + throw new IllegalArgumentException("Unknown push provider \"" + providerId + + "\"; expected fcm, apns, huawei, wns or web"); + } + if (options == null) { + platform.remove(canonical); + } else { + platform.put(canonical, copyMap(options)); + } + return this; + } + + /// Sets a widget or Live Activity command, which the app applies before + /// its listener sees the message. [PushNotification#liveUpdate], + /// [PushNotification#liveEnd] and [PushNotification#widget] build one. + /// + /// @param command the command, or null to remove it + /// @return this builder + /// @throws IllegalArgumentException if the command's `operation` isn't + /// `widget`, `live-update` or `live-end` + public Builder surface(Map command) { + if (command == null) { + values.remove("surface"); + return this; + } + Object operation = command.get("operation"); + if (!OPERATION_WIDGET.equals(operation) && !OPERATION_LIVE_UPDATE.equals(operation) + && !OPERATION_LIVE_END.equals(operation)) { + throw new IllegalArgumentException("A surface command's operation is widget, " + + "live-update or live-end"); + } + values.put("surface", copyMap(command)); + return this; + } + + /// @return an immutable snapshot of the message built so far + public PushNotification build() { + Map snapshot = new LinkedHashMap(values); + if (!data.isEmpty()) { + snapshot.put("data", data); + } + if (!platform.isEmpty()) { + snapshot.put("platform", platform); + } + return new PushNotification(snapshot); + } + + private Builder put(String key, String value) { + if (value == null) { + values.remove(key); + } else { + values.put(key, value); + } + return this; + } + + private static void requireKey(String value, String label) { + if (value == null || value.length() == 0 || !value.equals(value.trim())) { + throw new IllegalArgumentException(label + + " must be non-empty and must not have surrounding whitespace"); + } + } + } +} diff --git a/vm/backend/src/com/codename1/backend/push/PushResult.java b/vm/backend/src/com/codename1/backend/push/PushResult.java new file mode 100644 index 00000000000..1e083c582ac --- /dev/null +++ b/vm/backend/src/com/codename1/backend/push/PushResult.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.push; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// What the push service answered to one [CloudPush] send. +/// +/// Accepted means ADMITTED: the service has queued the message durably and +/// will hand it to the providers, but no provider or device has seen it yet. +/// Whether it arrived is reported later, through delivery feedback. +/// +/// A refusal is a result too, not an exception: [#getStatus()] says which kind +/// (400 for a bad request, 401 for a missing or wrong key, 402 when the account +/// doesn't include the capability, 429 when the caller should slow down) and +/// [#getError()] carries the service's explanation. +public final class PushResult { + private final int status; + private final List deliveryIds; + private final String error; + private final int retryAfterSeconds; + + /// A result, mostly for tests of code that sends; [CloudPush] makes the real + /// ones. + /// + /// @param status the HTTP status + /// @param deliveryIds the delivery ids, or null for none + /// @param error the service's error text, or null + /// @param retryAfterSeconds the seconds to wait before retrying, or -1 + public PushResult(int status, List deliveryIds, String error, int retryAfterSeconds) { + this.status = status; + this.deliveryIds = deliveryIds == null ? new ArrayList() + : new ArrayList(deliveryIds); + this.error = error; + this.retryAfterSeconds = retryAfterSeconds; + } + + /// @return the HTTP status the service answered with + public int getStatus() { + return status; + } + + /// @return true when the service admitted the message (202, or any 2xx) + public boolean isAccepted() { + return status >= 200 && status < 300; + } + + /// One id per target the service admitted, for matching delivery feedback + /// later. + /// + /// @return the ids, empty when there are none; never null + public List getDeliveryIds() { + return Collections.unmodifiableList(deliveryIds); + } + + /// @return the service's explanation of a refusal, or null when it was + /// accepted + public String getError() { + return error; + } + + /// How long a 429 asked the caller to wait. + /// + /// @return seconds from the `Retry-After` header, or -1 when there was none + public int getRetryAfterSeconds() { + return retryAfterSeconds; + } + + @Override + public String toString() { + return "PushResult[status=" + status + ", deliveries=" + deliveryIds.size() + + (error == null ? "" : ", error=" + error) + "]"; + } +} diff --git a/vm/backend/src/com/codename1/backend/push/PushTarget.java b/vm/backend/src/com/codename1/backend/push/PushTarget.java new file mode 100644 index 00000000000..63cacfff2a3 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/push/PushTarget.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.push; + +/// One device to deliver a push to: the provider that owns the device and the +/// token that provider issued for it. +/// +/// The push service names five providers: `fcm`, `apns`, `huawei`, `wns` and +/// `web`. A device reports its provider in older spellings too -- the app's +/// `PushSubscription.getTransportId()` can answer `gcm`, `ios` or `hms` -- so +/// [#of(String, String)] accepts those and stores the service's name. A device +/// key from the classic `Push.getPushKey()`, such as `cn1-fcm-TOKEN`, is read by +/// [#fromDeviceKey(String)]. +/// +/// [#toString()] names the provider only. A token identifies one person's +/// device, so it stays out of anything that might reach a log. +public final class PushTarget { + /// Firebase Cloud Messaging, for Android. + public static final String FCM = "fcm"; + + /// Apple Push Notification service, for iOS. + public static final String APNS = "apns"; + + /// Huawei Push Kit, for Huawei devices without Google services. + public static final String HUAWEI = "huawei"; + + /// Windows Push Notification Services. + public static final String WNS = "wns"; + + /// Web Push, for browsers. + public static final String WEB = "web"; + + private static final String DEVICE_KEY_PREFIX = "cn1-"; + + private final String provider; + private final String token; + + private PushTarget(String provider, String token) { + this.provider = provider; + this.token = token; + } + + /// A target for `token` on `provider`. + /// + /// @param provider the provider id: `fcm`, `apns`, `huawei`, `wns` or `web`, + /// or one of the aliases `gcm`, `ios` and `hms`, in any case + /// @param token the provider's token for the device + /// @return the target, with the provider in the service's spelling + /// @throws IllegalArgumentException if the provider is unknown or the token is + /// null or blank + public static PushTarget of(String provider, String token) { + String canonical = canonicalProvider(provider); + if (canonical == null) { + throw new IllegalArgumentException("Unknown push provider \"" + provider + + "\"; expected fcm, apns, huawei, wns or web"); + } + if (token == null || token.trim().length() == 0) { + throw new IllegalArgumentException("A push target needs a device token"); + } + return new PushTarget(canonical, token); + } + + /// A target from a classic device key, `cn1--`, as the app's + /// `Push.getPushKey()` reports it: `cn1-fcm-...`, `cn1-gcm-...`, + /// `cn1-ios-...` or `cn1-hms-...`. + /// + /// The prefix is removed and the provider normalized, so the result carries + /// the bare token the provider issued. + /// + /// @param deviceKey the key the device registered + /// @return the target + /// @throws IllegalArgumentException if the key doesn't have that form, names + /// an unknown provider, or has no token + public static PushTarget fromDeviceKey(String deviceKey) { + if (deviceKey == null + || !deviceKey.regionMatches(true, 0, DEVICE_KEY_PREFIX, 0, DEVICE_KEY_PREFIX.length())) { + throw new IllegalArgumentException("A device key has the form cn1--"); + } + int start = DEVICE_KEY_PREFIX.length(); + int dash = deviceKey.indexOf('-', start); + if (dash <= start) { + throw new IllegalArgumentException("A device key has the form cn1--"); + } + return of(deviceKey.substring(start, dash), deviceKey.substring(dash + 1)); + } + + /// The service's name for a provider id or alias, or null when it names none. + /// + /// Compared with equalsIgnoreCase, never by folding with toLowerCase(): that + /// is locale sensitive, and on a server whose default locale is Turkish the + /// `I` of `IOS` folds to a dotless i and matches nothing. + static String canonicalProvider(String id) { + if (id == null) { + return null; + } + if (FCM.equalsIgnoreCase(id) || "gcm".equalsIgnoreCase(id)) { + return FCM; + } + if (APNS.equalsIgnoreCase(id) || "ios".equalsIgnoreCase(id)) { + return APNS; + } + if (HUAWEI.equalsIgnoreCase(id) || "hms".equalsIgnoreCase(id)) { + return HUAWEI; + } + if (WNS.equalsIgnoreCase(id)) { + return WNS; + } + if (WEB.equalsIgnoreCase(id)) { + return WEB; + } + return null; + } + + /// @return the provider, always one of `fcm`, `apns`, `huawei`, `wns` and `web` + public String getProvider() { + return provider; + } + + /// @return the provider's token for the device + public String getToken() { + return token; + } + + /// The provider only; the token is left out so a logged target identifies no + /// device. + @Override + public String toString() { + return "PushTarget[" + provider + "]"; + } +} diff --git a/vm/backend/src/com/codename1/backend/push/package-info.java b/vm/backend/src/com/codename1/backend/push/package-info.java new file mode 100644 index 00000000000..303e9d04024 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/push/package-info.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Sending push notifications from a server through the Codename One push +/// service. +/// +/// [PushNotification] builds the message, in the same envelope the app's +/// `PushClient` receives, including the commands that update a widget or a Live +/// Activity. [PushTarget] names a device by provider and token, and reads the +/// device keys the app registers. [CloudPush] sends the message with the +/// application's server API key and answers a [PushResult]. +/// +/// Server code: this package is not available in the app. +package com.codename1.backend.push; diff --git a/vm/backend/src/com/codename1/backend/sql/MySql.java b/vm/backend/src/com/codename1/backend/sql/MySql.java index ac2163937cc..df0709e02a9 100644 --- a/vm/backend/src/com/codename1/backend/sql/MySql.java +++ b/vm/backend/src/com/codename1/backend/sql/MySql.java @@ -30,6 +30,7 @@ import java.util.List; import java.util.Map; +import com.codename1.backend.ConcurrencyFailureException; import com.codename1.backend.Crypto; import com.codename1.backend.Tcp; @@ -1024,6 +1025,23 @@ private static final class Packet { byte[] body; } + /// Whether an error is the server ending this transaction's statement in + /// favour of another transaction, which the caller may answer by running its + /// own again. + /// + /// By number where the SQLSTATE says nothing: a lock wait that timed out + /// (1205), a lock refused under NOWAIT (3572) and a record that changed since + /// the transaction read it (1020) all carry the general HY000. The last is + /// MariaDB's, raised where the server holds a REPEATABLE READ transaction to + /// its snapshot: an UPDATE of a row another transaction changed and committed + /// after the snapshot was taken is refused, where it would otherwise have + /// read the row as it now is. Class 40 is the standard's "transaction + /// rollback", which is where the deadlock (1213, 40001) is. + static boolean isConcurrencyFailure(int code, String state) { + return code == 1213 || code == 1205 || code == 3572 || code == 1020 + || (state != null && state.startsWith("40")); + } + private static IOException errorFrom(Packet packet, String sql) { try { Reader reader = new Reader(packet.body); @@ -1037,9 +1055,11 @@ private static IOException errorFrom(Packet packet, String sql) { state = Wire.fromUtf8(reader.bytes(5)); } String message = Wire.fromUtf8(reader.rest()); - return new IOException("MySQL error " + code + String text = "MySQL error " + code + (state.length() == 0 ? "" : " " + state) + ": " + message - + (sql == null ? "" : " [" + sql + "]")); + + (sql == null ? "" : " [" + sql + "]"); + return isConcurrencyFailure(code, state) ? new ConcurrencyFailureException(text) + : new IOException(text); } catch (IOException malformed) { // This method BUILDS the exception rather than throwing one, so a // malformed error packet cannot be reported by failing here -- and the diff --git a/vm/backend/src/com/codename1/backend/sql/Postgres.java b/vm/backend/src/com/codename1/backend/sql/Postgres.java index 472bcd6d410..8864f3e247c 100644 --- a/vm/backend/src/com/codename1/backend/sql/Postgres.java +++ b/vm/backend/src/com/codename1/backend/sql/Postgres.java @@ -29,6 +29,7 @@ import java.util.Map; import com.codename1.backend.Base64; +import com.codename1.backend.ConcurrencyFailureException; import com.codename1.backend.Crypto; import com.codename1.backend.Tcp; @@ -917,10 +918,20 @@ private static IOException errorFrom(Message message, String sql) { } at = end + 1; } - return new IOException("PostgreSQL error" + String text = "PostgreSQL error" + (state == null ? "" : " " + state) + ": " + (detail == null ? "unknown" : detail) - + (sql == null ? "" : " [" + sql + "]")); + + (sql == null ? "" : " [" + sql + "]"); + return isConcurrencyFailure(state) ? new ConcurrencyFailureException(text) + : new IOException(text); + } + + /// Whether a SQLSTATE is the server ending this transaction's statement in + /// favour of another transaction: a serialization failure (40001), a + /// deadlock (40P01) or a lock that was not available (55P03). The caller may + /// answer any of them by running its transaction again. + static boolean isConcurrencyFailure(String state) { + return "40001".equals(state) || "40P01".equals(state) || "55P03".equals(state); } private static final class Message { diff --git a/vm/selfhost/perf-baseline/pr/5984.json b/vm/selfhost/perf-baseline/pr/5984.json new file mode 100644 index 00000000000..05a0d782dbe --- /dev/null +++ b/vm/selfhost/perf-baseline/pr/5984.json @@ -0,0 +1,193 @@ +{ + "pr": 5984, + "rebaseline": { + "linux-arm64@neoverse-n2": { + "hello": { + "all": { + "from": { + "memory": 0.868, + "time": 0.931 + }, + "memory": 1.014, + "reason": "This change tightens the proof that lets the translator retire an object when its frame ends: an object whose only callee is a native method (Thread.start) is no longer retired early, because the root scan then read a live Thread as freed and the sweep finalized it. Fewer objects are retired early, so peak RAM can only rise, and hello is the one workload small enough for that to show. Run for 3d3119ed9d, five rounds: hello RAM 1.01 against 0.87 on Linux arm64 and 1.22 against 0.77 on Windows x64, the same direction Windows arm64 moved earlier. Timing and tolerances are kept.", + "runs": 1, + "time": 0.931 + } + }, + "stringBuilding": { + "all": { + "from": { + "memory": 0.262, + "time": 1.36, + "tolerance": { + "memory": 0.3 + } + }, + "memory": 0.262, + "reason": "The same cause as the hello and translator rows: the translator retires fewer objects when their frame ends, so the temporaries string building makes are left to the collector. Linux arm64 rounds 1.57 1.58 1.42 1.57 1.55 (median 1.57) against 1.36; the Intel Windows x64 runner moved the same way in both of its runs (1.63 and 1.58 against 1.42, inside its tolerance). Memory and tolerances are kept.", + "runs": 1, + "time": 1.567, + "tolerance": { + "memory": 0.3 + } + } + }, + "translator": { + "all": { + "from": { + "memory": 0.447, + "time": 0.698 + }, + "memory": 0.507, + "reason": "The same cause as the hello rows: fewer objects are retired when their frame ends, so the translator's own peak RAM rises. Linux arm64 runs for 3d3119ed9d (rounds 0.48 0.49 0.47 0.52 0.53, median 0.49) and c21af4429b (0.51 0.57 0.46 0.52 0.53, median 0.52) against 0.45, one inside the 15% tolerance and one just past it; Windows arm64 moved from 0.45 to 0.51 the same way. Timing and tolerances are kept.", + "runs": 2, + "time": 0.698 + } + } + }, + "macos-arm64": { + "objectAllocation": { + "all": { + "from": { + "memory": 0.814, + "time": 1.106, + "tolerance": { + "memory": 0.3, + "time": 0.5 + } + }, + "memory": 1.15, + "reason": "The translator retires fewer objects when their frame ends (an object whose only callee is a native method is no longer retired early), so more of objectAllocation's garbage waits for the collector and peak RAM rises, as hello and translator RAM did on the other runners. macOS arm64 run for 7f6b4ee9d7, rounds 1.50 0.87 1.19 0.94 1.15 (median 1.15) against 0.81; time is unchanged at 1.06 to 1.14. The rounds spread widely, so the memory tolerance is 0.35, as the bimodal hello rows on Windows carry.", + "runs": 1, + "time": 1.106, + "tolerance": { + "memory": 0.35, + "time": 0.5 + } + } + } + }, + "windows-arm64@armv8-64-bit-family-8-model-d49-microsoft-corporation": { + "hello": { + "all": { + "from": { + "memory": 0.975, + "time": 1.107, + "tolerance": { + "memory": 0.35 + } + }, + "memory": 1.277, + "reason": "This change tightens the proof that lets the translator retire an object when its frame ends: an object whose only callee is a native method (Thread.start) is no longer retired early, because the root scan then read a live Thread as freed and the sweep finalized it. Fewer objects are retired early, so peak RAM can only rise. Windows arm64 runs 38210243191 (8ed4eb60a3) and 38293968963 (1dc66ed703), five rounds each: hello RAM 1.32 and 1.24 against 0.97 (Windows x64 moved the same way in the second run, 1.10 against 0.94). Timing and tolerances are kept.", + "runs": 2, + "time": 1.107, + "tolerance": { + "memory": 0.35 + } + } + } + }, + "windows-x64@amd64-family-25-model-17-authenticamd": { + "hello": { + "all": { + "from": { + "memory": 0.828, + "time": 1.375, + "tolerance": { + "memory": 0.2, + "time": 0.5 + } + }, + "memory": 1.092, + "reason": "This change tightens the proof that lets the translator retire an object when its frame ends: an object whose only callee is a native method (Thread.start) is no longer retired early, because the root scan then read a live Thread as freed and the sweep finalized it. Fewer objects are retired early, so peak RAM can only rise, and hello is the one workload small enough for that to show. On this CPU the result is bimodal, as on the Intel Windows x64 runner: runs for 3d3119ed9d and 95738c7ece gave medians 1.216 and 1.092, and the run for 8ac2f37307 gave rounds 1.16 0.74 0.81 0.80 0.87 (median 0.81) against the 1.154 recorded from the first two. The baseline is the middle of the three run medians, 1.092, and the memory tolerance is 0.35, as hello's RAM on the Intel Windows x64 and the Windows arm64 runners already has; it covers 0.71 to 1.47. Timing is kept.", + "runs": 3, + "time": 1.375, + "tolerance": { + "memory": 0.35, + "time": 0.5 + } + } + }, + "translator": { + "all": { + "from": { + "memory": 0.551, + "time": 0.777, + "tolerance": { + "time": 0.2 + } + }, + "memory": 0.551, + "reason": "Not a change in the translator: on this runner its self-translation time comes out in two groups, about 0.79 and about 0.58, and a run's median is whichever group three of its five rounds fell in. Runs for 3d3119ed9d (rounds 0.79 0.82 0.79 0.52 0.58, median 0.79) and 95738c7ece (0.66 0.61 0.59 0.56 0.85, median 0.61) are the same code as far as the translator goes. The baseline of 0.78 sat on the upper group, so the lower one read as an improvement past tolerance; taken from both runs it sits between them. Tolerance is kept.", + "runs": 2, + "time": 0.701, + "tolerance": { + "time": 0.2 + } + } + } + }, + "windows-x64@amd64-family-26-model-2-authenticamd": { + "hello": { + "all": { + "from": { + "memory": 0.749, + "time": 1.41, + "tolerance": { + "memory": 0.3, + "time": 0.5 + } + }, + "memory": 0.999, + "reason": "Hello RAM is bimodal on every runner: one run's rounds read about 1.0 to 1.2, another's about 0.75 to 0.85. On this CPU (AMD family 26 model 2) runs 38688282220 (rounds 1.00 0.81 1.31 0.75 0.83, median 0.83) and 38758240099 (1.02 0.99 1.03 1.17 0.85, median 1.02) are the two modes. The baseline is the median of all ten rounds, 0.999, and the memory tolerance is 0.35, as hello's RAM on the other Windows runners already has; it covers 0.65 to 1.35. Timing and its tolerance are kept.", + "runs": 2, + "time": 1.41, + "tolerance": { + "memory": 0.35, + "time": 0.5 + } + } + } + }, + "windows-x64@intel64-family-6-model-207-genuineintel": { + "hello": { + "all": { + "from": { + "memory": 0.82, + "time": 1.069, + "tolerance": { + "memory": 0.2, + "time": 0.5 + } + }, + "memory": 0.857, + "reason": "The translator retires fewer objects when their frame ends, so hello's peak RAM rises, as on the other runners. On this Intel Family 6 Model 207 runner it is bimodal: two runs of this branch gave rounds 1.13 1.11 1.13 0.73 0.98 (median 1.11) and 0.72 0.66 0.71 0.68 1.02 (median 0.71), so no single run's median holds the next one within the 20 percent tolerance. The baseline is the median of all ten rounds, 0.857, and the memory tolerance is 0.35, as hello's RAM on the Windows arm64 runner already has; it covers 0.56 to 1.16. Timing is kept.", + "runs": 2, + "time": 1.069, + "tolerance": { + "memory": 0.35, + "time": 0.5 + } + } + }, + "translator": { + "all": { + "from": { + "memory": 0.526, + "time": 0.602, + "tolerance": { + "time": 0.2 + } + }, + "memory": 0.526, + "reason": "The first run of this change on the Intel Family 6 Model 207 Windows x64 runner, which the overlay had no rows for. hello RAM rises for the cause recorded for the other runners: the translator retires fewer objects when their frame ends, so peak RAM is higher (rounds 1.13 1.11 1.13 0.73 0.98, median 1.11 against 0.82; the AMD Windows x64 runner moved from 0.83 to 1.15). translator time rounds 0.76 0.75 0.69 0.58 0.80, median 0.75 against 0.60, with one round at the old baseline; on the AMD runner the same change measured 0.70 against 0.78. Tolerances are kept.", + "runs": 1, + "time": 0.753, + "tolerance": { + "time": 0.2 + } + } + } + } + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java index 3e8977b750e..ca6df4fe8c0 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java @@ -70,6 +70,12 @@ static Stream provideCompilerConfigs() { return CompilerHelper.getDiagonalCompilers().stream(); } + // For the tests of the runtime library, which do not depend on which javac + // compiled them. See CompilerHelper.getBookendCompilers. + static Stream provideRuntimeCompilerConfigs() { + return CompilerHelper.getBookendCompilers().stream(); + } + @ParameterizedTest @org.junit.jupiter.params.provider.MethodSource("provideCompilerConfigs") void translatesOptimizedBytecodeToLLVMExecutable(CompilerHelper.CompilerConfig config) throws Exception { diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java index 359ac28ff9e..7db9372648d 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java @@ -126,7 +126,7 @@ void generatesRunnableHelloWorldUsingCleanTarget(CompilerHelper.CompilerConfig c } @ParameterizedTest - @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideCompilerConfigs") + @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideRuntimeCompilerConfigs") void scientificFloatToStringOutputIsTerminated(CompilerHelper.CompilerConfig config) throws Exception { Parser.cleanup(); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CompilerHelper.java b/vm/tests/src/test/java/com/codename1/tools/translator/CompilerHelper.java index 995b00c5c43..0ae3a690a27 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CompilerHelper.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CompilerHelper.java @@ -310,6 +310,25 @@ public static List getDiagonalCompilers() { return out; } + // The oldest and the newest of the diagonal, for a test whose subject is the + // runtime and not the shape of the bytecode: a lock, a file, an uncaught + // exception behave the same whichever javac compiled the few lines that call + // them, and each configuration costs a whole translation and C build. The + // two ends are kept because they are where javac's output differs most + // (string concatenation, nest access), so a library that only works under + // one of them still fails here. Tests of what the translator makes of + // bytecode keep the whole diagonal. + public static List getBookendCompilers() { + List all = getDiagonalCompilers(); + if (all.size() <= 2) { + return all; + } + List out = new ArrayList<>(); + out.add(all.get(0)); + out.add(all.get(all.size() - 1)); + return out; + } + private static boolean canCompile(String compilerVersion, String targetVersion) { int compilerMajor = parseJavaMajor(compilerVersion); int targetMajor = parseJavaMajor(targetVersion); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/FileClassIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/FileClassIntegrationTest.java index 18f946b6ea8..1ed44945774 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/FileClassIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/FileClassIntegrationTest.java @@ -40,7 +40,7 @@ public class FileClassIntegrationTest { @ParameterizedTest - @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideCompilerConfigs") + @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideRuntimeCompilerConfigs") public void testFileClassMethods(CompilerHelper.CompilerConfig config) throws Exception { Parser.cleanup(); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/FrameRetireLiveReceiverIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/FrameRetireLiveReceiverIntegrationTest.java new file mode 100644 index 00000000000..5da8a45f631 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/FrameRetireLiveReceiverIntegrationTest.java @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * An object must not be retired at frame exit while something can still reach it. + * + *

The translator stamps an object dead when the frame that allocated it returns, if + * it has proved the object never left that frame. The collector does not second-guess + * the stamp: a retired object reachable only from a thread's roots is not marked again, + * and the next sweep finalizes and frees it. With a {@code Thread} that is the thread's + * own native state, released by {@code Thread.finalize()} while the thread is running on + * it -- an intermittent use-after-free in a server's scheduler thread, whose only crime + * was {@code Thread t = new Thread(r); t.setDaemon(true); t.start();}.

+ * + *

The proof had five holes, and {@code FrameRetireLiveReceiverApp} has one method for + * each: a native callee, an abstract one, an override the call site does not name, a + * callee that leaks {@code this} one call further down, and a member of a local cluster + * read back out of the object holding it. None of them may be retired. The generated C + * says so directly and deterministically, so that is asserted first; the program is then + * run, and counts every object finalized while it was still reachable.

+ * + *

The gate cannot pass by retirement having stopped altogether: a sixth method builds + * an object that really is frame local, and it must still be retired.

+ */ +class FrameRetireLiveReceiverIntegrationTest { + + private static final String APP = "FrameRetireLiveReceiverApp"; + + /** What the translator writes beside an allocation it retires at frame exit. */ + private static final String RETIRED = "frame-exit retired"; + + private static final long VM_RUN_TIMEOUT_SECONDS = 600; + + @Test + void aReachableObjectIsNotRetiredAtFrameExit() throws Exception { + Parser.cleanup(); + List tempDirs = new ArrayList<>(); + try { + runGate(tempDirs); + } finally { + for (Path dir : tempDirs) { + deleteRecursively(dir); + } + } + } + + private void runGate(List tempDirs) throws Exception { + Path sourceDir = Files.createTempDirectory("frame-retire-sources"); + Path classesDir = Files.createTempDirectory("frame-retire-classes"); + Path javaApiDir = Files.createTempDirectory("frame-retire-javaapi"); + tempDirs.add(sourceDir); + tempDirs.add(classesDir); + tempDirs.add(javaApiDir); + + Path source = sourceDir.resolve(APP + ".java"); + Files.write(source, loadResource(APP + ".java").getBytes(StandardCharsets.UTF_8)); + + CompilerHelper.CompilerConfig config = selectCompiler(); + if (config == null) { + fail("No compatible compiler available for the frame-exit retirement test"); + } + CompilerHelper.compileJavaAPI(javaApiDir, config); + + List compileArgs = new ArrayList<>(); + compileArgs.add("-source"); + compileArgs.add(config.targetVersion); + compileArgs.add("-target"); + compileArgs.add(config.targetVersion); + if (CompilerHelper.useClasspath(config)) { + compileArgs.add("-classpath"); + compileArgs.add(javaApiDir.toString()); + } else { + compileArgs.add("-bootclasspath"); + compileArgs.add(javaApiDir.toString()); + compileArgs.add("-Xlint:-options"); + } + compileArgs.add("-d"); + compileArgs.add(classesDir.toString()); + compileArgs.add(source.toString()); + assertEquals(0, CompilerHelper.compile(config.jdkHome, compileArgs), + APP + " should compile. " + CompilerHelper.getLastErrorLog()); + + CompilerHelper.copyDirectory(javaApiDir, classesDir); + Path outputDir = Files.createTempDirectory("frame-retire-output"); + tempDirs.add(outputDir); + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, APP); + Path distDir = outputDir.resolve("dist"); + Path cmakeLists = distDir.resolve("CMakeLists.txt"); + assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project"); + + // ---- 1. what the translator decided, read from the C it wrote ------------------- + String generated = new String( + Files.readAllBytes(distDir.resolve(APP + "-src").resolve(APP + ".c")), + StandardCharsets.UTF_8); + String[] reachable = {"nativeReceiver", "abstractReceiver", "overridden", "transitive", + "clusterRead"}; + for (String method : reachable) { + assertFalse(function(generated, method).contains(RETIRED), + method + "() allocates an object that is still reachable after it returns," + + " and the translator retires it at frame exit:\n" + + function(generated, method)); + } + assertTrue(function(generated, "control").contains(RETIRED), + "control() allocates an object nothing else can reach. If that is no longer" + + " retired, this gate has stopped testing the analysis it guards:\n" + + function(generated, "control")); + + // ---- 2. and what happens when it runs -------------------------------------------- + CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, APP + "-src"); + Path buildDir = Files.createTempDirectory("frame-retire-build"); + tempDirs.add(buildDir); + List cmake = new ArrayList<>(Arrays.asList( + "cmake", "-S", distDir.toString(), "-B", buildDir.toString(), + "-DCMAKE_BUILD_TYPE=Release")); + cmake.addAll(CompilerHelper.cmakeToolchainArgs()); + CleanTargetIntegrationTest.runCommand(cmake, distDir); + CleanTargetIntegrationTest.runCommand( + Arrays.asList("cmake", "--build", buildDir.toString()), distDir); + Path exe = buildDir.resolve(CompilerHelper.executableName(APP)); + assertTrue(Files.exists(exe), "ParparVM build should produce a runnable executable at " + exe); + + String output = run(exe, distDir); + String result = null; + for (String line : output.split("\\R")) { + if (line.startsWith("FRAME_RETIRE_LIVE ")) { + result = line; + } + } + assertNotNull(result, "The workload should report its counts. Output: " + tail(output)); + System.out.println("[FrameRetireLiveReceiver] " + result); + assertTrue(count(result, "started") > 0 && count(result, "finalizedDead") > 0, + "Threads must have been started and finalizers must have run, or the counts" + + " below prove nothing: " + result); + assertEquals(0, count(result, "finalizedLiveThread"), + "A Thread object was finalized while its thread was alive: " + result); + assertEquals(0, count(result, "finalizedLive"), + "An object was finalized while it was still reachable: " + result); + assertEquals(count(result, "started"), count(result, "finished"), + "Every started thread should run to completion: " + result); + } + + /** The generated C of one static method of the app, from its signature to its end. */ + private static String function(String generated, String method) { + String name = APP + "_" + method + "___"; + int at = -1; + for (int from = generated.indexOf(name); from >= 0; from = generated.indexOf(name, from + 1)) { + int lineStart = generated.lastIndexOf('\n', from) + 1; + int lineEnd = generated.indexOf('\n', from); + // The definition, not a call: it starts in column 0 with the return type + // and its line ends by opening the body. + if (generated.startsWith("JAVA_", lineStart) && lineEnd > 0 + && generated.substring(from, lineEnd).trim().endsWith("{")) { + at = lineStart; + break; + } + } + assertTrue(at >= 0, "The generated C should define " + method + "()"); + int end = generated.indexOf("\n}\n", at); + assertTrue(end > at, "The definition of " + method + "() should be closed"); + return generated.substring(at, end + 2); + } + + private static int count(String result, String key) { + for (String field : result.split(" ")) { + if (field.startsWith(key + "=")) { + return Integer.parseInt(field.substring(key.length() + 1).trim()); + } + } + fail("No " + key + " in: " + result); + return -1; + } + + private String run(Path executable, Path workingDir) throws Exception { + ProcessBuilder builder = new ProcessBuilder(executable.toString()); + builder.directory(workingDir.toFile()); + builder.environment().keySet().removeIf(key -> key.startsWith("CN1_")); + builder.redirectErrorStream(true); + final Process process = builder.start(); + final StringBuilder captured = new StringBuilder(); + Thread drain = new Thread(() -> { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + synchronized (captured) { + captured.append(line).append('\n'); + } + } + } catch (Exception e) { + // A destroyed child ends the stream abruptly; what was captured is reported. + } + }); + drain.setDaemon(true); + drain.start(); + boolean exited = process.waitFor(VM_RUN_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!exited) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + drain.join(10_000); + String output; + synchronized (captured) { + output = captured.toString(); + } + assertTrue(exited, "The workload did not finish within " + VM_RUN_TIMEOUT_SECONDS + + "s. Output so far:\n" + tail(output)); + return output; + } + + private static String tail(String output) { + String[] lines = output.split("\\R"); + int from = Math.max(0, lines.length - 25); + return String.join("\n", Arrays.copyOfRange(lines, from, lines.length)); + } + + private static String loadResource(String name) throws Exception { + InputStream in = FrameRetireLiveReceiverIntegrationTest.class + .getResourceAsStream("/com/codename1/tools/translator/" + name); + assertNotNull(in, name + " test resource should exist"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")) + "\n"; + } + } + + private static CompilerHelper.CompilerConfig selectCompiler() { + String[] preferredTargets = {"11", "17", "21", "25", "1.8"}; + for (String target : preferredTargets) { + for (CompilerHelper.CompilerConfig config : CompilerHelper.getAvailableCompilers(target)) { + if (CompilerHelper.isJavaApiCompatible(config)) { + return config; + } + } + } + return null; + } + + private static void deleteRecursively(Path root) { + if (root == null || !Files.exists(root)) { + return; + } + try (java.util.stream.Stream walk = Files.walk(root)) { + walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } catch (Exception ignore) { + // Best effort; a leftover temp dir is not a test failure. + } + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptNetworkBindingsTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptNetworkBindingsTest.java index f31eaf5f7ac..8525a92ebbf 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptNetworkBindingsTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptNetworkBindingsTest.java @@ -112,8 +112,16 @@ void responseHeadersScriptInvokesTheMethod() throws Exception { assertTrue(out.contains("NULL=null"), "the @JSBody must answer null without a request: " + out); } - /// The send `@JSBody` applies the request's timeout and reports a timeout -- which a - /// synchronous XHR throws -- as a failure string instead of letting it escape. + /// The send `@JSBody` applies the request's timeout and reports a failure to SEND as a + /// string instead of letting it escape. + /// + /// The request is asynchronous: a synchronous one stopped the worker, and with it the + /// event thread, until the server answered. So `send()` returns while the request is + /// still in flight, when `status` is 0 and nothing has been received for EVERY request. + /// The script must therefore not read that state as "no response" -- it did while the + /// request was synchronous, and kept here it would fail every request the port makes. + /// How a request ended is reported by its events, and `openInputStream()` makes the + /// no-response check after it has waited for one; the last assertion holds it there. @Test void sendScriptAppliesTheTimeoutAndReportsTimingOut() throws Exception { String source = read(NETWORK_CONNECTION); @@ -141,9 +149,14 @@ void sendScriptAppliesTheTimeoutAndReportsTimingOut() throws Exception { "a timeout must be applied and reported: " + out); assertTrue(out.contains("FAST=null SENT=payload TIMEOUT=0"), "a request without a timeout is sent as it was: " + out); - assertTrue(out.contains("QUIET=NetworkError: no response"), - "a request that came back with no response at all must be a failure: " + out); + assertTrue(out.contains("QUIET=null"), + "a request still in flight has status 0 and no response, and is not a failure: " + out); assertTrue(out.contains("ANSWERED=null"), "an answered request is not a failure: " + out); + assertTrue(Pattern.compile("outcome\\.wait\\(.*?req\\.getStatus\\(\\) == 0 && req\\.getResponse\\(\\) == null\\)" + + "\\s*\\{\\s*throw new IOException\\([^;]*NetworkError: no response", Pattern.DOTALL) + .matcher(source).find(), + "a request that ended with no response at all must still be a failure, decided in" + + " openInputStream() after the wait for the request's events"); } /// `ArrayBufferInputStream.read(byte[], int, int)` stores signed Java bytes. A raw 0..255 diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/LockIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/LockIntegrationTest.java index 8482a3ab2a7..23ad1230ffb 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/LockIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/LockIntegrationTest.java @@ -38,7 +38,7 @@ class LockIntegrationTest { @ParameterizedTest - @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideCompilerConfigs") + @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideRuntimeCompilerConfigs") void verifiesLockAndReentrantLockBehavior(CompilerHelper.CompilerConfig config) throws Exception { Parser.cleanup(); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/ReadWriteLockIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/ReadWriteLockIntegrationTest.java index a4f84a672b0..0a2bcd00602 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/ReadWriteLockIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/ReadWriteLockIntegrationTest.java @@ -38,7 +38,7 @@ class ReadWriteLockIntegrationTest { @ParameterizedTest - @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideCompilerConfigs") + @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideRuntimeCompilerConfigs") void verifiesReadWriteLockBehavior(CompilerHelper.CompilerConfig config) throws Exception { Parser.cleanup(); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/RecordMethodsTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/RecordMethodsTest.java new file mode 100644 index 00000000000..dc9b556a981 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/RecordMethodsTest.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * A record's equals, hashCode and toString, which javac does not write out: it + * emits an invokedynamic of java.lang.runtime.ObjectMethods for each, and + * ParparVM has no such bootstrap. A record used as a transfer object on a + * device -- a client DTO shared with the server -- must still compare, hash and + * print as it does on the JVM. Components of every primitive kind, a String, an + * enum, a nested record and a list are covered, including NaN and -0.0. + */ +class RecordMethodsTest { + + @Test + void implicitRecordMethodsAnswerAsTheJdkDoes() throws Exception { + Parser.cleanup(); + + Path sourceDir = Files.createTempDirectory("record-methods-sources"); + Path classesDir = Files.createTempDirectory("record-methods-classes"); + Path javaApiDir = Files.createTempDirectory("record-methods-japi"); + + Path source = sourceDir.resolve("RecordMethodsApp.java"); + Files.write(source, loadAppSource().getBytes(StandardCharsets.UTF_8)); + + CompilerHelper.CompilerConfig config = selectCompiler(); + if (config == null) { + fail("No compiler for Java 17 or newer is available"); + } + + assertTrue(CompilerHelper.isJavaApiCompatible(config), + "JDK " + config.jdkVersion + " must target matching bytecode level for JavaAPI"); + + CompilerHelper.compileJavaAPI(javaApiDir, config); + + List compileArgs = new ArrayList<>(); + compileArgs.add("-source"); + compileArgs.add(config.targetVersion); + compileArgs.add("-target"); + compileArgs.add(config.targetVersion); + if (CompilerHelper.useClasspath(config)) { + compileArgs.add("-classpath"); + compileArgs.add(javaApiDir.toString()); + } else { + compileArgs.add("-bootclasspath"); + compileArgs.add(javaApiDir.toString()); + compileArgs.add("-Xlint:-options"); + } + compileArgs.add("-d"); + compileArgs.add(classesDir.toString()); + compileArgs.add(source.toString()); + + int compileResult = CompilerHelper.compile(config.jdkHome, compileArgs); + assertEquals(0, compileResult, + "RecordMethodsApp should compile. " + CompilerHelper.getLastErrorLog()); + + String jdkResult = extractResultLine(runJavaMain(config, classesDir)); + assertTrue(jdkResult.startsWith("RESULT="), + "The JDK leg should produce a RESULT line"); + + CompilerHelper.copyDirectory(javaApiDir, classesDir); + + Path outputDir = Files.createTempDirectory("record-methods-output"); + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "RecordMethodsApp"); + + Path distDir = outputDir.resolve("dist"); + Path cmakeLists = distDir.resolve("CMakeLists.txt"); + assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project"); + + CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "RecordMethodsApp-src"); + + Path buildDir = distDir.resolve("build"); + Files.createDirectories(buildDir); + + CleanTargetIntegrationTest.runCommand(Arrays.asList( + "cmake", + "-S", distDir.toString(), + "-B", buildDir.toString(), + "-DCMAKE_C_COMPILER=clang", + "-DCMAKE_OBJC_COMPILER=clang" + ), distDir); + + CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir); + + Path executable = buildDir.resolve("RecordMethodsApp"); + assertTrue(Files.exists(executable), "ParparVM build should produce a runnable executable"); + + String vmResult = extractResultLine( + CleanTargetIntegrationTest.runCommand(Arrays.asList(executable.toString()), buildDir)); + + assertEquals(jdkResult, vmResult, firstDifference(jdkResult, vmResult)); + } + + /** Names the first entry that differs rather than dumping two long strings. */ + private String firstDifference(String expected, String actual) { + String[] want = expected.substring("RESULT=".length()).split(";", -1); + String[] got = actual.substring(actual.indexOf('=') + 1).split(";", -1); + for (int i = 0; i < Math.min(want.length, got.length); i++) { + if (!want[i].equals(got[i])) { + return "Entry " + i + " differs: JDK " + want[i] + ", ParparVM " + got[i]; + } + } + return "ParparVM should answer a record's equals, hashCode and toString as the JDK does"; + } + + private String loadAppSource() throws Exception { + java.io.InputStream in = RecordMethodsTest.class.getResourceAsStream( + "/com/codename1/tools/translator/RecordMethodsApp.java"); + assertNotNull(in, "RecordMethodsApp.java test resource should exist"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")) + "\n"; + } + } + + private String runJavaMain(CompilerHelper.CompilerConfig config, Path classesDir) throws Exception { + String javaExe = config.jdkHome.resolve("bin").resolve("java").toString(); + if (System.getProperty("os.name").toLowerCase().contains("win")) { + javaExe += ".exe"; + } + + ProcessBuilder pb = new ProcessBuilder(javaExe, "-cp", classesDir.toString(), "RecordMethodsApp"); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + + int exitCode = process.waitFor(); + assertEquals(0, exitCode, "JVM run should exit cleanly. Output: " + output); + return output; + } + + private String extractResultLine(String output) { + for (String line : output.split("\\R")) { + if (line.startsWith("RESULT=")) { + return line.trim(); + } + } + return ""; + } + + private CompilerHelper.CompilerConfig selectCompiler() { + // Records need Java 16 or newer bytecode. + String[] preferredTargets = {"17", "21", "25"}; + for (String target : preferredTargets) { + List configs = CompilerHelper.getAvailableCompilers(target); + for (CompilerHelper.CompilerConfig config : configs) { + if (CompilerHelper.isJavaApiCompatible(config)) { + return config; + } + } + } + return null; + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/StackOverflowIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/StackOverflowIntegrationTest.java index d6bb4287389..d2efd2ded74 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/StackOverflowIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/StackOverflowIntegrationTest.java @@ -37,7 +37,7 @@ class StackOverflowIntegrationTest { @ParameterizedTest - @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideCompilerConfigs") + @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideRuntimeCompilerConfigs") void throwsAndRecoversFromStackOverflow(CompilerHelper.CompilerConfig config) throws Exception { Parser.cleanup(); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/StampedLockIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/StampedLockIntegrationTest.java index 657d798f386..ec3baf87e43 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/StampedLockIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/StampedLockIntegrationTest.java @@ -38,7 +38,7 @@ class StampedLockIntegrationTest { @ParameterizedTest - @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideCompilerConfigs") + @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideRuntimeCompilerConfigs") void verifiesStampedLockBehavior(CompilerHelper.CompilerConfig config) throws Exception { Parser.cleanup(); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/UncaughtExceptionIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/UncaughtExceptionIntegrationTest.java index 053f19196dc..21938b48ffe 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/UncaughtExceptionIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/UncaughtExceptionIntegrationTest.java @@ -57,7 +57,7 @@ class UncaughtExceptionIntegrationTest { @ParameterizedTest - @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideCompilerConfigs") + @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideRuntimeCompilerConfigs") void uncaughtExceptionIsFatal(CompilerHelper.CompilerConfig config) throws Exception { Parser.cleanup(); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/UrlCodecTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/UrlCodecTest.java new file mode 100644 index 00000000000..b860f8a7d72 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/UrlCodecTest.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Pins vm/JavaAPI's java.net.URLEncoder and URLDecoder against the JDK. + * + * Backend code is written against the JDK and runs both on a JVM and compiled + * by ParparVM, so the same form body or query string has to come out byte for + * byte the same on both: a space as '+', upper case hex, UTF-8 for anything + * outside the unreserved set, and the same refusals for a broken escape. + */ +class UrlCodecTest { + + @Test + void urlEncoderAndDecoderAnswerAsTheJdkDoes() throws Exception { + Parser.cleanup(); + + Path sourceDir = Files.createTempDirectory("url-codec-sources"); + Path classesDir = Files.createTempDirectory("url-codec-classes"); + Path javaApiDir = Files.createTempDirectory("url-codec-japi"); + + Path source = sourceDir.resolve("UrlCodecApp.java"); + Files.write(source, loadAppSource().getBytes(StandardCharsets.UTF_8)); + + CompilerHelper.CompilerConfig config = selectCompiler(); + if (config == null) { + fail("No compatible compiler available for the URL codec test"); + } + + assertTrue(CompilerHelper.isJavaApiCompatible(config), + "JDK " + config.jdkVersion + " must target matching bytecode level for JavaAPI"); + + CompilerHelper.compileJavaAPI(javaApiDir, config); + + List compileArgs = new ArrayList<>(); + compileArgs.add("-source"); + compileArgs.add(config.targetVersion); + compileArgs.add("-target"); + compileArgs.add(config.targetVersion); + if (CompilerHelper.useClasspath(config)) { + compileArgs.add("-classpath"); + compileArgs.add(javaApiDir.toString()); + } else { + compileArgs.add("-bootclasspath"); + compileArgs.add(javaApiDir.toString()); + compileArgs.add("-Xlint:-options"); + } + compileArgs.add("-d"); + compileArgs.add(classesDir.toString()); + compileArgs.add(source.toString()); + + int compileResult = CompilerHelper.compile(config.jdkHome, compileArgs); + assertEquals(0, compileResult, + "UrlCodecApp should compile. " + CompilerHelper.getLastErrorLog()); + + String jdkResult = extractResultLine(runJavaMain(config, classesDir)); + assertTrue(jdkResult.startsWith("RESULT="), + "The JDK leg should produce a RESULT line"); + + CompilerHelper.copyDirectory(javaApiDir, classesDir); + + Path outputDir = Files.createTempDirectory("url-codec-output"); + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "UrlCodecApp"); + + Path distDir = outputDir.resolve("dist"); + Path cmakeLists = distDir.resolve("CMakeLists.txt"); + assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project"); + + CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "UrlCodecApp-src"); + + Path buildDir = distDir.resolve("build"); + Files.createDirectories(buildDir); + + CleanTargetIntegrationTest.runCommand(Arrays.asList( + "cmake", + "-S", distDir.toString(), + "-B", buildDir.toString(), + "-DCMAKE_C_COMPILER=clang", + "-DCMAKE_OBJC_COMPILER=clang" + ), distDir); + + CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir); + + Path executable = buildDir.resolve("UrlCodecApp"); + assertTrue(Files.exists(executable), "ParparVM build should produce a runnable executable"); + + String vmResult = extractResultLine( + CleanTargetIntegrationTest.runCommand(Arrays.asList(executable.toString()), buildDir)); + + assertEquals(jdkResult, vmResult, firstDifference(jdkResult, vmResult)); + } + + /** Names the first entry that differs rather than dumping two long strings. */ + private String firstDifference(String expected, String actual) { + String[] want = expected.substring("RESULT=".length()).split(";", -1); + String[] got = actual.substring(actual.indexOf('=') + 1).split(";", -1); + for (int i = 0; i < Math.min(want.length, got.length); i++) { + if (!want[i].equals(got[i])) { + return "Entry " + i + " differs: JDK " + want[i] + ", ParparVM " + got[i]; + } + } + return "ParparVM should encode and decode URLs as the JDK does"; + } + + private String loadAppSource() throws Exception { + java.io.InputStream in = UrlCodecTest.class.getResourceAsStream( + "/com/codename1/tools/translator/UrlCodecApp.java"); + assertNotNull(in, "UrlCodecApp.java test resource should exist"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")) + "\n"; + } + } + + private String runJavaMain(CompilerHelper.CompilerConfig config, Path classesDir) throws Exception { + String javaExe = config.jdkHome.resolve("bin").resolve("java").toString(); + if (System.getProperty("os.name").toLowerCase().contains("win")) { + javaExe += ".exe"; + } + + ProcessBuilder pb = new ProcessBuilder(javaExe, "-cp", classesDir.toString(), "UrlCodecApp"); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + + int exitCode = process.waitFor(); + assertEquals(0, exitCode, "JVM run should exit cleanly. Output: " + output); + return output; + } + + private String extractResultLine(String output) { + for (String line : output.split("\\R")) { + if (line.startsWith("RESULT=")) { + return line.trim(); + } + } + return ""; + } + + private CompilerHelper.CompilerConfig selectCompiler() { + String[] preferredTargets = {"11", "17", "21", "25", "1.8"}; + for (String target : preferredTargets) { + List configs = CompilerHelper.getAvailableCompilers(target); + for (CompilerHelper.CompilerConfig config : configs) { + if (CompilerHelper.isJavaApiCompatible(config)) { + return config; + } + } + } + return null; + } +} diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/FrameRetireLiveReceiverApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/FrameRetireLiveReceiverApp.java new file mode 100644 index 00000000000..2bf8cf5b1e7 --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/FrameRetireLiveReceiverApp.java @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Objects that are still reachable when the frame that allocated them returns, + * each by a route the allocating frame cannot see in its own bytecode. + * + *

The translator retires an object at frame exit when it has proved the + * object never left the frame. Every shape here keeps its object in a local, + * passes it to nothing, stores it nowhere and returns nothing -- and the object + * is live afterwards all the same:

+ * + *
    + *
  • {@code nativeReceiver}: {@code Thread.start()} is native. It has no + * bytecode to examine, and it hands the receiver to a new thread that + * runs on it.
  • + *
  • {@code transitive}: the method called on the object keeps {@code this} + * to itself, and calls another method on {@code this} that does not.
  • + *
  • {@code overridden}: the call names the superclass's method, which is + * harmless; the object's own class overrides it.
  • + *
  • {@code abstractReceiver}: the same, through a method with no body at + * all.
  • + *
  • {@code clusterRead}: one local object is stored into a field of + * another, which is allowed, and then read back out and published.
  • + *
+ * + *

The witness is {@code finalize()}: each object knows how to tell that it + * is still reachable, and counts itself when it is finalized in that state. The + * thread's finalizer releases nothing, so the defect is reported as a number + * rather than as a crash somewhere else.

+ */ +public class FrameRetireLiveReceiverApp { + private static final int RING = 2048; + static final Object[] KEEP = new Object[RING]; + static final AtomicInteger finalizedLive = new AtomicInteger(); + static final AtomicInteger finalizedLiveThread = new AtomicInteger(); + static final AtomicInteger finalizedDead = new AtomicInteger(); + static final AtomicInteger started = new AtomicInteger(); + static final AtomicInteger finished = new AtomicInteger(); + static Object sink; + + static final class Worker extends Thread { + private final int naps; + + Worker(int naps) { + this.naps = naps; + } + + public void run() { + for (int i = 0; i < naps; i++) { + try { + Thread.sleep(20); + } catch (InterruptedException err) { + break; + } + } + finished.incrementAndGet(); + } + + /** + * Counts, and releases nothing in either case. Calling up to + * Thread.finalize() is not possible here: this file is also compiled + * by JDKs that resolve java.lang.Thread from their own class library, + * where the call binds to a method the translated runtime does not + * have. The few hundred thread states this leaks do not matter to a + * program that exits seconds later. + */ + protected void finalize() { + if (isAlive()) { + finalizedLiveThread.incrementAndGet(); + } else { + finalizedDead.incrementAndGet(); + } + } + } + + static class Tracked { + int id; + + Tracked(int id) { + this.id = id; + } + + void touch() { + } + + void publish() { + register(); + } + + void register() { + KEEP[id] = this; + } + + protected void finalize() { + if (KEEP[id] == this) { + finalizedLive.incrementAndGet(); + } else { + finalizedDead.incrementAndGet(); + } + } + } + + static final class Overrider extends Tracked { + Overrider(int id) { + super(id); + } + + void touch() { + KEEP[id] = this; + } + } + + abstract static class Shape { + int id; + + abstract void touch(); + + protected void finalize() { + if (KEEP[id] == this) { + finalizedLive.incrementAndGet(); + } else { + finalizedDead.incrementAndGet(); + } + } + } + + static final class Square extends Shape { + Square(int id) { + this.id = id; + } + + void touch() { + KEEP[id] = this; + } + } + + static final class Node { + Node next; + int id; + + protected void finalize() { + if (KEEP[id] == this) { + finalizedLive.incrementAndGet(); + } else { + finalizedDead.incrementAndGet(); + } + } + } + + /** Never published by anything: the shape retirement exists for. */ + static final class Scratch { + int total; + + void add(int v) { + total += v; + } + } + + static void nativeReceiver(int naps) { + Thread t = new Worker(naps); + t.setDaemon(true); + t.start(); + } + + static void transitive(int id) { + Tracked t = new Tracked(id); + t.publish(); + } + + static void overridden(int id) { + Tracked t = new Overrider(id); + t.touch(); + } + + static void abstractReceiver(int id) { + Shape s = new Square(id); + s.touch(); + } + + static void clusterRead(int id) { + Node a = new Node(); + Node b = new Node(); + b.id = id; + a.next = b; + KEEP[id] = a.next; + } + + static int control(int v) { + Scratch s = new Scratch(); + s.add(v); + s.add(v + 1); + return v; + } + + private static int intEnv(String name, int def) { + String v = System.getenv(name); + if (v == null || v.length() == 0) { + return def; + } + return Integer.parseInt(v); + } + + public static void main(String[] args) { + int rounds = intEnv("CN1_FRL_ROUNDS", 6000); + int id = 0; + int acc = 0; + for (int r = 0; r < rounds; r++) { + if (started.get() - finished.get() < 24) { + started.incrementAndGet(); + nativeReceiver(6 + (r & 7)); + } + for (int k = 0; k < 4; k++) { + transitive(id); + id = (id + 1) % RING; + overridden(id); + id = (id + 1) % RING; + abstractReceiver(id); + id = (id + 1) % RING; + clusterRead(id); + id = (id + 1) % RING; + acc += control(r); + } + // Garbage, so the collector runs back to back and frame exits land + // in every phase of a cycle. + Object[] junk = new Object[64]; + for (int i = 0; i < 2000; i++) { + junk[i & 63] = new int[8 + (i & 15)]; + } + sink = junk; + } + long settle = System.currentTimeMillis() + 5000; + while (started.get() != finished.get() && System.currentTimeMillis() < settle) { + try { + Thread.sleep(5); + } catch (InterruptedException err) { + break; + } + } + for (int i = 0; i < 4; i++) { + System.gc(); + try { + Thread.sleep(50); + } catch (InterruptedException err) { + break; + } + } + System.out.println("FRAME_RETIRE_LIVE started=" + started.get() + + " finished=" + finished.get() + + " finalizedDead=" + finalizedDead.get() + + " finalizedLive=" + finalizedLive.get() + + " finalizedLiveThread=" + finalizedLiveThread.get() + + " acc=" + acc); + if (finalizedLive.get() != 0 || finalizedLiveThread.get() != 0 + || started.get() != finished.get()) { + System.exit(1); + } + } +} diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/RecordMethodsApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/RecordMethodsApp.java new file mode 100644 index 00000000000..2057730212e --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/RecordMethodsApp.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +import java.util.ArrayList; +import java.util.List; + +/** + * Prints one RESULT line of a record's implicit equals, hashCode and toString, + * entries separated by ';'. RecordMethodsTest runs it on the JDK and on + * ParparVM and requires the two lines to match. A hashCode is printed only for + * a record whose components hash by value: an enum hashes by identity, which + * differs between any two runs, so the record holding one prints equals and + * toString only. + */ +public class RecordMethodsApp { + enum Size { SMALL, LARGE } + + record Prims(boolean z, byte b, short s, char c, int i, long l, float f, double d) { } + + record Point(double lat, double lng) { } + + record Trip(String name, Point from, Point to, List stops) { } + + record Sized(Size size, String label) { } + + record Empty() { } + + private static StringBuilder out = new StringBuilder("RESULT="); + private static boolean first = true; + + private static void add(String entry) { + if (!first) { + out.append(';'); + } + first = false; + out.append(entry); + } + + private static void all(Object a, Object b) { + add(String.valueOf(a.equals(b))); + add(String.valueOf(a.hashCode())); + add(String.valueOf(b.hashCode())); + add(a.toString()); + } + + public static void main(String[] args) { + Prims p1 = new Prims(true, (byte) -3, (short) 300, 'q', -7, 1L << 40, 1.5f, -2.25); + Prims p2 = new Prims(true, (byte) -3, (short) 300, 'q', -7, 1L << 40, 1.5f, -2.25); + all(p1, p2); + all(p1, new Prims(false, (byte) -3, (short) 300, 'q', -7, 1L << 40, 1.5f, -2.25)); + all(p1, new Prims(true, (byte) -3, (short) 300, 'q', -7, 1L << 41, 1.5f, -2.25)); + + // equals compares float and double components by their bits, as + // Float.compare does: NaN equals NaN, and 0.0 differs from -0.0. + all(new Point(Double.NaN, 1), new Point(Double.NaN, 1)); + all(new Point(0.0, 1), new Point(-0.0, 1)); + all(new Prims(false, (byte) 0, (short) 0, 'a', 0, 0, Float.NaN, 0), + new Prims(false, (byte) 0, (short) 0, 'a', 0, 0, Float.NaN, 0)); + all(new Prims(false, (byte) 0, (short) 0, 'a', 0, 0, 0.0f, 0), + new Prims(false, (byte) 0, (short) 0, 'a', 0, 0, -0.0f, 0)); + + List stops = new ArrayList(); + stops.add("Main St"); + stops.add("Oak Ave"); + List sameStops = new ArrayList(stops); + Trip t1 = new Trip("work", new Point(32.5, 34.75), new Point(-1, 2), stops); + Trip t2 = new Trip("work", new Point(32.5, 34.75), new Point(-1, 2), sameStops); + all(t1, t2); + all(t1, new Trip("work", new Point(32.5, 34.75), new Point(-1, 3), stops)); + + // Null components hash to 0 and print as null. + Trip blank = new Trip(null, null, null, null); + all(blank, new Trip(null, null, null, null)); + all(blank, t1); + + Sized s1 = new Sized(Size.LARGE, "x"); + add(String.valueOf(s1.equals(new Sized(Size.LARGE, "x")))); + add(String.valueOf(s1.equals(new Sized(Size.SMALL, "x")))); + add(String.valueOf(s1.equals(null))); + add(String.valueOf(s1.equals("x"))); + add(s1.toString()); + + all(new Empty(), new Empty()); + + System.out.println(out.toString()); + } +} diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/UrlCodecApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/UrlCodecApp.java new file mode 100644 index 00000000000..4b34bbed362 --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/UrlCodecApp.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +import java.net.URLDecoder; +import java.net.URLEncoder; + +/** + * Prints what java.net.URLEncoder and URLDecoder answer for a set of inputs, + * including the ones that must be refused. + * + * UrlCodecTest runs this on the host JDK and through ParparVM and requires + * identical output. Non-ASCII characters are built from code points so the + * source stays ASCII, and decoded text is printed as code points so the + * output does too. + */ +public class UrlCodecApp { + + public static void main(String[] args) throws Exception { + String accented = "caf" + (char) 0xE9; + String hebrew = "" + (char) 0x05E9 + (char) 0x05DC + (char) 0x05D5 + (char) 0x05DD; + // U+1F600, a code point outside the BMP: one surrogate pair, four bytes. + String emoji = "" + (char) 0xD83D + (char) 0xDE00; + String[] inputs = { + "", "plain", "a b", "a+b", "x=1&y=2", "~tilde", "safe.-*_", "100%", + "/path/to?q=v#frag", accented, hebrew, emoji, "mix " + accented + emoji + "!", + "" + (char) 0x7F + (char) 0x80 + (char) 0x7FF + (char) 0x800 + (char) 0xFFFF, + // A lone surrogate, which the JDK's UTF-8 encoder writes as '?'. + "x" + (char) 0xD800 + "y" + }; + StringBuilder sb = new StringBuilder("RESULT="); + for (int i = 0; i < inputs.length; i++) { + String encoded = URLEncoder.encode(inputs[i], "UTF-8"); + sb.append(encoded).append('|'); + sb.append(codePoints(URLDecoder.decode(encoded, "UTF-8"))).append(';'); + } + sb.append(URLEncoder.encode("a b" + accented, java.nio.charset.StandardCharsets.UTF_8)).append(';'); + sb.append(URLEncoder.encode("a b" + accented, "ISO-8859-1")).append(';'); + String[] decodes = { + "a+b%20c", "%41%42c", "%e2%82%ac", "%E2%82%AC", "100%25", "plain", "%F0%9F%98%80" + }; + for (int i = 0; i < decodes.length; i++) { + sb.append(codePoints(URLDecoder.decode(decodes[i], "UTF-8"))).append(';'); + } + sb.append(codePoints(URLDecoder.decode("caf%E9", "ISO-8859-1"))).append(';'); + String[] bad = {"%", "%4", "abc%", "%G1", "%-1", "%4z"}; + for (int i = 0; i < bad.length; i++) { + try { + URLDecoder.decode(bad[i], "UTF-8"); + sb.append("accepted;"); + } catch (IllegalArgumentException expected) { + sb.append("refused;"); + } + } + try { + URLDecoder.decode("x", ""); + sb.append("accepted;"); + } catch (java.io.UnsupportedEncodingException expected) { + sb.append("unsupported;"); + } + System.out.println(sb.toString()); + } + + private static String codePoints(String s) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(Integer.toHexString(s.charAt(i))); + } + return sb.toString(); + } +}