diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index bd13b6d4b1a..0a7b8557278 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -21,6 +21,7 @@ on: - 'scripts/bastion/**' - 'scripts/skirmish/**' - 'scripts/parley/**' + - 'scripts/reel/**' # 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 @@ -138,6 +139,7 @@ jobs: - 'scripts/savor/**' - 'scripts/skirmish/**' - 'scripts/parley/**' + - 'scripts/reel/**' - 'scripts/bastion/**' workflow: - '.github/workflows/developer-guide-docs.yml' diff --git a/.github/workflows/initializr-e2e.yml b/.github/workflows/initializr-e2e.yml index a8d1845f684..8693d9bdbeb 100644 --- a/.github/workflows/initializr-e2e.yml +++ b/.github/workflows/initializr-e2e.yml @@ -23,6 +23,7 @@ on: - 'scripts/savor/common/src/test/**' - 'scripts/skirmish/common/src/test/**' - 'scripts/parley/common/src/test/**' + - 'scripts/reel/common/src/test/**' - 'scripts/bastion/common/src/test/**' schedule: - cron: '17 3 * * *' @@ -43,7 +44,7 @@ jobs: strategy: fail-fast: false matrix: - template: [BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY, BASTION] + template: [BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY, BASTION, REEL] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/reel.yml b/.github/workflows/reel.yml new file mode 100644 index 00000000000..25a04392c15 --- /dev/null +++ b/.github/workflows/reel.yml @@ -0,0 +1,579 @@ +name: Reel (full-stack sample) + +# scripts/reel 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/reel/**' + - '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/**' + # Streaming and DRM: the player the app opens every title through. + - 'CodenameOne/src/com/codename1/media/**' + - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/rest/**' + # The REST client maps its responses through the mapper registry. + - 'CodenameOne/src/com/codename1/mapping/**' + # The ORM validates every mapping against its migration when the + # server starts, and the backend-database job is where that runs + # against a real database. + - 'CodenameOne/src/com/codename1/impl/orm/**' + - 'CodenameOne/src/com/codename1/orm/**' + - 'CodenameOne/src/com/codename1/annotations/**' + - 'scripts/ci/sample-server-tests-on-database.sh' + # What the hosted web app runs on: the JavaScript port, both the + # browser side and the Java the app is compiled with (its network + # connection, its implementation class). The `web` job is the only + # test here that signs in through either. + - 'Ports/JavaScriptPort/src/main/**' + - 'scripts/build-android-app.sh' + - 'scripts/build-ios-app.sh' + - 'scripts/sync-initializr-template.py' + - 'scripts/tests/generate-initializr-fixtures.py' + - 'scripts/tests/GenerateInitializr.java' + # The generator and the template. + - 'scripts/initializr/common/src/**' + - '.github/workflows/reel.yml' + push: + branches: [ master ] + paths: + - 'scripts/reel/**' + - 'vm/backend/src/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + # The ORM validates every mapping against its migration when the + # server starts, and the backend-database job is where that runs + # against a real database. + - 'CodenameOne/src/com/codename1/media/**' + - 'CodenameOne/src/com/codename1/impl/orm/**' + - 'CodenameOne/src/com/codename1/orm/**' + - 'CodenameOne/src/com/codename1/annotations/**' + - 'scripts/ci/sample-server-tests-on-database.sh' + - 'scripts/sync-initializr-template.py' + - 'scripts/initializr/common/src/**' + - '.github/workflows/reel.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/reel 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/reel + run: python3 scripts/sync-initializr-template.py reel + + 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/reel + 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/reel/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: reel-backend-tests + path: scripts/reel/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + backend-database: + name: Server tests on ${{ matrix.database }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + # The backend job runs on in-memory SQLite, which accepts any declared + # column type for any value and has no collations to get wrong. PostgreSQL + # and MySQL are what a deployment runs on, and where the ORM's schema + # validation refuses a mapping that disagrees with its migration. + strategy: + fail-fast: false + matrix: + database: [postgresql, mysql] + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: cn1 + POSTGRES_PASSWORD: secret + POSTGRES_DB: cn1 + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cn1" --health-interval 5s + --health-timeout 5s --health-retries 20 + steps: + - uses: actions/checkout@v6 + + # The runner's own MySQL 8.0 rather than a service container: a service + # cannot be given server options, and MySQL 8.4 (mysql:8) turns + # mysql_native_password off. The backend does caching_sha2_password's + # full exchange only over verified TLS, which a throwaway server has no + # certificate for. + - name: Start MySQL + if: matrix.database == 'mysql' + run: | + sudo systemctl start mysql.service + sudo mysql -uroot -proot -e "CREATE USER 'cn1'@'%' IDENTIFIED WITH mysql_native_password BY 'secret'; GRANT ALL ON *.* TO 'cn1'@'%'; CREATE DATABASE cn1;" + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on ${{ matrix.database }} + run: | + if [ "${{ matrix.database }}" = mysql ]; then + export CN1_TEST_DATABASE_URL="mysql://cn1:secret@127.0.0.1:3306/cn1?sslmode=disable" + else + export CN1_TEST_DATABASE_URL="postgres://cn1:secret@localhost:5432/cn1?sslmode=disable" + fi + scripts/ci/sample-server-tests-on-database.sh scripts/reel \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + + - name: Upload the test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: reel-backend-${{ matrix.database }}-tests + path: scripts/reel/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/reel/check-template.sh "$RUNNER_TEMP/reel-template" + + # The Initializr's own tests, which cover this template too, run in + # wayline.yml: a change here reaches the generator's resources, which + # trigger it. + + - name: Run the application against the server + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/reel/run-e2e.sh + + - name: Upload the captures and the server log + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: reel-e2e + path: | + scripts/reel/target/e2e-shots/** + scripts/reel/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/reel/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: reel-web-e2e + path: scripts/reel/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/reel + 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-reel-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2-reel- + ${{ runner.os }}-m2- + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-reel-${{ hashFiles('Ports/Android/build.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle-reel- + ${{ 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/reel + 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 }}/reel-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/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 025e5cf6ebd..803e20b250a 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -5744,6 +5744,66 @@ public Media createMedia(String uri, boolean isVideo, Runnable onCompletion) thr return null; } + /// Creates the platform player for a streaming request. The default plays an + /// unprotected or [com.codename1.media.DrmScheme#AES_128] stream through + /// `#createMedia(String, boolean, Runnable)`, since the platform players that + /// play HLS decrypt AES-128 themselves, and seeks to the start position once the + /// player exists. A port that supports more schemes or headers overrides this + /// together with `#isDrmSupported` and `#isStreamHeadersSupported`. + /// + /// #### Parameters + /// + /// - `request`: the stream to play + /// + /// #### Returns + /// + /// the media, or null when the platform cannot create a player + /// + /// #### Throws + /// + /// - `java.io.IOException`: if the stream cannot be opened + public Media createStreamingMedia(com.codename1.media.StreamRequest request) throws IOException { + com.codename1.media.DrmConfig drm = request.getDrm(); + if (drm != null && drm.getScheme() != com.codename1.media.DrmScheme.AES_128) { + throw new IOException(drm.getScheme() + " is not supported on this platform"); + } + if (!request.getHeaders().isEmpty() && !isStreamHeadersSupported()) { + throw new IOException("Stream request headers are not supported on this platform"); + } + Media m = createMedia(request.getUrl(), true, request.getOnCompletion()); + if (m != null && request.getStartPosition() > 0) { + m.setTime(request.getStartPosition()); + } + return m; + } + + /// Answers whether the platform player can play streams protected by a scheme. + /// The default answers false for every scheme; a port whose player decrypts HLS + /// AES-128 answers true for [com.codename1.media.DrmScheme#AES_128]. + /// + /// #### Parameters + /// + /// - `scheme`: the protection scheme + /// + /// #### Returns + /// + /// true when the scheme is supported + public boolean isDrmSupported(com.codename1.media.DrmScheme scheme) { + // AES-128 HLS needs nothing beyond a player that reads the key URL in the + // playlist, which is what the default createStreamingMedia hands it to. + return scheme == com.codename1.media.DrmScheme.AES_128; + } + + /// Answers whether the platform player can attach HTTP headers to the manifest + /// and segment requests of a stream. The default answers false. + /// + /// #### Returns + /// + /// true when stream request headers are supported + public boolean isStreamHeadersSupported() { + return false; + } + /// Creates media asynchronously. /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/io/ConnectionRequest.java b/CodenameOne/src/com/codename1/io/ConnectionRequest.java index 5754340e66e..6f74cd1c645 100644 --- a/CodenameOne/src/com/codename1/io/ConnectionRequest.java +++ b/CodenameOne/src/com/codename1/io/ConnectionRequest.java @@ -3655,7 +3655,10 @@ public void run() { if (getDestinationFile() != null) { String file = getDestinationFile(); FileSystemStorage fs = FileSystemStorage.getInstance(); - if (fs.exists(file)) { + // An empty entry is a download still being written by + // another request for the same URL, or one that failed: + // loading it would hand back an image of no bytes. + if (fs.exists(file) && fs.getLength(file) > 0) { try { EncodedImage img = EncodedImage.create(fs.openInputStream(file), (int) fs.getLength(file)); CallbackDispatcher.dispatchSuccess(onSuccess, img); @@ -3668,7 +3671,7 @@ public void run() { } else if (getDestinationStorage() != null) { String file = getDestinationStorage(); Storage fs = Storage.getInstance(); - if (fs.exists(file)) { + if (fs.exists(file) && fs.entrySize(file) > 0) { try { EncodedImage img = EncodedImage.create(fs.createInputStream(file), fs.entrySize(file)); CallbackDispatcher.dispatchSuccess(onSuccess, img); diff --git a/CodenameOne/src/com/codename1/media/DrmConfig.java b/CodenameOne/src/com/codename1/media/DrmConfig.java new file mode 100644 index 00000000000..0805091741e --- /dev/null +++ b/CodenameOne/src/com/codename1/media/DrmConfig.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.media; + +import java.util.HashMap; +import java.util.Map; + +/// How a [StreamRequest] obtains the keys of a protected stream: the scheme, the +/// license server URL, and whatever that server needs to authorize the request. +/// +/// The license exchange is made by the platform player, or by the port on the +/// player's behalf. The request body is the opaque challenge the scheme defines and +/// the response goes back to the player unread, so the license server is the only +/// party that interprets either: +/// +/// ```java +/// String playbackToken = "token-from-your-server"; +/// DrmConfig drm = new DrmConfig(DrmScheme.WIDEVINE, "https://example.com/license/widevine") +/// .addLicenseHeader("Authorization", "Bearer " + playbackToken); +/// ``` +/// +/// [DrmScheme#AES_128] needs no license URL: the key URL is in the playlist, and any +/// token it needs belongs in that URL. +public final class DrmConfig { + private final DrmScheme scheme; + private final String licenseUrl; + private String certificateUrl; + private String contentId; + private final Map licenseHeaders = new HashMap(); + + /// Creates a configuration for the given scheme. + /// + /// #### Parameters + /// + /// - `scheme`: the protection scheme the stream is packaged with + /// + /// - `licenseUrl`: where the player posts its license challenge; may be null only + /// for [DrmScheme#AES_128], whose key URL is part of the playlist + public DrmConfig(DrmScheme scheme, String licenseUrl) { + if (scheme == null) { + throw new IllegalArgumentException("scheme is required"); + } + if (licenseUrl == null && scheme != DrmScheme.AES_128) { + throw new IllegalArgumentException(scheme + " needs a license URL"); + } + this.scheme = scheme; + this.licenseUrl = licenseUrl; + } + + /// The protection scheme. + /// + /// #### Returns + /// + /// the scheme this configuration was created with + public DrmScheme getScheme() { + return scheme; + } + + /// Where the player posts its license challenge. + /// + /// #### Returns + /// + /// the license URL, or null for [DrmScheme#AES_128] + public String getLicenseUrl() { + return licenseUrl; + } + + /// Sets the URL of the FairPlay application certificate, the DER file Apple + /// issues to the content owner. Required for [DrmScheme#FAIRPLAY] and ignored by + /// every other scheme. + /// + /// #### Parameters + /// + /// - `certificateUrl`: the certificate URL + /// + /// #### Returns + /// + /// this configuration, for chaining + public DrmConfig setCertificateUrl(String certificateUrl) { + this.certificateUrl = certificateUrl; + return this; + } + + /// The FairPlay application certificate URL. + /// + /// #### Returns + /// + /// the certificate URL, or null when none was set + public String getCertificateUrl() { + return certificateUrl; + } + + /// Sets the FairPlay content identifier passed to the key request. When none is + /// set, the host of the `skd://` key URL in the playlist is used, which is the + /// common convention. + /// + /// #### Parameters + /// + /// - `contentId`: the content identifier + /// + /// #### Returns + /// + /// this configuration, for chaining + public DrmConfig setContentId(String contentId) { + this.contentId = contentId; + return this; + } + + /// The FairPlay content identifier. + /// + /// #### Returns + /// + /// the content identifier, or null to derive it from the key URL + public String getContentId() { + return contentId; + } + + /// Adds an HTTP header sent with every license request, typically the + /// authorization the license server checks the viewer's entitlement with. + /// + /// #### Parameters + /// + /// - `name`: the header name + /// + /// - `value`: the header value + /// + /// #### Returns + /// + /// this configuration, for chaining + public DrmConfig addLicenseHeader(String name, String value) { + if (name == null || value == null || hasLineBreak(name) || hasLineBreak(value)) { + // Ports pass headers to native code as line separated pairs, so a line + // break would silently shift every header after it. + throw new IllegalArgumentException("Header names and values must be non-null and free of line breaks"); + } + licenseHeaders.put(name, value); + return this; + } + + /// The headers sent with every license request. + /// + /// #### Returns + /// + /// a copy of the headers, keyed by name + public Map getLicenseHeaders() { + return new HashMap(licenseHeaders); + } + private static boolean hasLineBreak(String s) { + return s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0; + } +} diff --git a/CodenameOne/src/com/codename1/media/DrmScheme.java b/CodenameOne/src/com/codename1/media/DrmScheme.java new file mode 100644 index 00000000000..f5df4a1611c --- /dev/null +++ b/CodenameOne/src/com/codename1/media/DrmScheme.java @@ -0,0 +1,54 @@ +/* + * 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.media; + +/// A content protection scheme a [StreamRequest] can ask the platform player to +/// honour. Not every platform plays every scheme; ask +/// [MediaManager#isDrmSupported(DrmScheme)] first and pick the strongest scheme the +/// device answers yes to. +/// +/// A streaming service usually packages each title once with CENC and serves it +/// with whichever scheme the requesting device supports: FairPlay to Apple +/// devices, Widevine to Android, and HLS AES-128 where neither is available. +public enum DrmScheme { + /// HLS segment encryption with the key fetched from the `URI` of the + /// playlist's `#EXT-X-KEY` tag. Every native player that plays HLS decrypts it, + /// so it works on the most devices. The key reaches the client in the clear + /// (over TLS), which makes it a deterrent rather than hardware-backed + /// protection: guard the key URL with a short-lived, per-session token. + AES_128, + + /// The W3C ClearKey key system: CENC-encrypted media with keys delivered as a + /// JSON Web Key set. The same packaging as Widevine without a license vendor, + /// which makes it the scheme to develop and test a license server against. + CLEARKEY, + + /// Google Widevine, the protection Android devices implement, with + /// hardware-backed keys (L1) on most phones. + WIDEVINE, + + /// Apple FairPlay Streaming, the protection iOS, iPadOS and tvOS implement. + /// Needs the application certificate from Apple's FairPlay program, set with + /// [DrmConfig#setCertificateUrl(String)]. + FAIRPLAY +} diff --git a/CodenameOne/src/com/codename1/media/MediaManager.java b/CodenameOne/src/com/codename1/media/MediaManager.java index 72ea3d66e1b..766b4158554 100644 --- a/CodenameOne/src/com/codename1/media/MediaManager.java +++ b/CodenameOne/src/com/codename1/media/MediaManager.java @@ -562,6 +562,30 @@ public static Media createMedia(String uri, boolean isVideo, Runnable onCompleti return Display.getInstance().createMedia(uri, isVideo, onCompletion); } + /// Answers whether this platform can play streams protected by a DRM scheme. + /// See [StreamRequest]. + /// + /// #### Parameters + /// + /// - `scheme`: the protection scheme + /// + /// #### Returns + /// + /// true when [StreamRequest#create()] can play a stream protected by the scheme + public static boolean isDrmSupported(DrmScheme scheme) { + return Display.getInstance().isDrmSupported(scheme); + } + + /// Answers whether this platform can attach HTTP headers to the requests a + /// stream's player makes. See [StreamRequest#addHeader(String, String)]. + /// + /// #### Returns + /// + /// true when stream request headers are supported + public static boolean isStreamHeadersSupported() { + return Display.getInstance().isStreamHeadersSupported(); + } + /// Creates a Media from a given URI asynchronously. /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/media/StreamRequest.java b/CodenameOne/src/com/codename1/media/StreamRequest.java new file mode 100644 index 00000000000..674123f861e --- /dev/null +++ b/CodenameOne/src/com/codename1/media/StreamRequest.java @@ -0,0 +1,253 @@ +/* + * 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.media; + +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/// Describes an adaptive (HLS) or progressive video stream, optionally +/// protected by DRM, and creates the platform player for it. +/// +/// ```java +/// String manifestUrl = "https://example.com/video/master.m3u8"; +/// String licenseUrl = "https://example.com/license/widevine"; +/// String token = "token-from-your-server"; +/// StreamRequest req = new StreamRequest(manifestUrl) +/// .setDrm(new DrmConfig(DrmScheme.WIDEVINE, licenseUrl) +/// .addLicenseHeader("Authorization", "Bearer " + token)) +/// .setStartPosition(30000); +/// if (!req.isSupported()) { +/// // ask the server for a scheme this device can play +/// } +/// Form form = new Form("Player", new BorderLayout()); +/// req.createAsync().ready(video -> { +/// form.add(BorderLayout.CENTER, video.getVideoComponent()); +/// form.revalidate(); +/// }); +/// form.show(); +/// ``` +/// +/// The player is the platform's own (AVPlayer on Apple platforms, the framework +/// media player on Android, ffmpeg in the simulator), so the protocols, codecs and +/// DRM schemes it plays are the platform's. [#isSupported()] answers for the scheme +/// and the headers; a codec the device cannot decode surfaces as a playback error. +/// +/// To hide a protected video from screenshots and screen recording while it is on +/// screen, call `Display.getInstance().setProperty("DisableScreenshots", "true")` +/// and set it back to `"false"` when the player closes. +public final class StreamRequest { + private final String url; + private DrmConfig drm; + private int startPosition; + private Runnable onCompletion; + private final Map headers = new HashMap(); + + /// Creates a request for the stream at the given URL. + /// + /// #### Parameters + /// + /// - `url`: the HLS (`.m3u8`) manifest, or a progressive file + public StreamRequest(String url) { + if (url == null || url.length() == 0) { + throw new IllegalArgumentException("url is required"); + } + this.url = url; + } + + /// The stream URL. + /// + /// #### Returns + /// + /// the manifest or file URL + public String getUrl() { + return url; + } + + /// Sets the content protection of the stream. + /// + /// #### Parameters + /// + /// - `drm`: the DRM configuration, or null for an unprotected stream + /// + /// #### Returns + /// + /// this request, for chaining + public StreamRequest setDrm(DrmConfig drm) { + this.drm = drm; + return this; + } + + /// The content protection of the stream. + /// + /// #### Returns + /// + /// the DRM configuration, or null for an unprotected stream + public DrmConfig getDrm() { + return drm; + } + + /// Sets where playback starts, for resuming a title the viewer left part way. + /// + /// #### Parameters + /// + /// - `millis`: the start position in milliseconds + /// + /// #### Returns + /// + /// this request, for chaining + public StreamRequest setStartPosition(int millis) { + this.startPosition = Math.max(0, millis); + return this; + } + + /// Where playback starts. + /// + /// #### Returns + /// + /// the start position in milliseconds + public int getStartPosition() { + return startPosition; + } + + /// Sets a callback invoked when playback reaches the end of the stream. + /// + /// #### Parameters + /// + /// - `onCompletion`: the callback, or null + /// + /// #### Returns + /// + /// this request, for chaining + public StreamRequest setOnCompletion(Runnable onCompletion) { + this.onCompletion = onCompletion; + return this; + } + + /// The completion callback. + /// + /// #### Returns + /// + /// the callback, or null + public Runnable getOnCompletion() { + return onCompletion; + } + + /// Adds an HTTP header to the manifest and segment requests. Only some platforms + /// can attach headers to the requests their player makes (see + /// [MediaManager#isStreamHeadersSupported()]); a URL that carries its own signed + /// token works everywhere and is the portable choice. + /// + /// #### Parameters + /// + /// - `name`: the header name + /// + /// - `value`: the header value + /// + /// #### Returns + /// + /// this request, for chaining + public StreamRequest addHeader(String name, String value) { + if (name == null || value == null || hasLineBreak(name) || hasLineBreak(value)) { + // Ports pass headers to native code as line separated pairs, so a line + // break would silently shift every header after it. + throw new IllegalArgumentException("Header names and values must be non-null and free of line breaks"); + } + headers.put(name, value); + return this; + } + + /// The headers for the manifest and segment requests. + /// + /// #### Returns + /// + /// a copy of the headers, keyed by name + public Map getHeaders() { + return new HashMap(headers); + } + + /// Answers whether this platform can play the request: its DRM scheme, and its + /// headers if it has any. + /// + /// #### Returns + /// + /// true when [#create()] can be expected to return a player + public boolean isSupported() { + if (drm != null && !MediaManager.isDrmSupported(drm.getScheme())) { + return false; + } + return headers.isEmpty() || MediaManager.isStreamHeadersSupported(); + } + + /// Creates the player. Blocks until the platform has created it, so call it off + /// the EDT or use [#createAsync()]. + /// + /// #### Returns + /// + /// the media, whose `getVideoComponent()` is the view to place in a form + /// + /// #### Throws + /// + /// - `IOException`: when the platform cannot play the request, which + /// [#isSupported()] predicts, or the stream cannot be opened + public Media create() throws IOException { + if (drm != null && !MediaManager.isDrmSupported(drm.getScheme())) { + throw new IOException(drm.getScheme() + " is not supported on this platform"); + } + if (!headers.isEmpty() && !MediaManager.isStreamHeadersSupported()) { + throw new IOException("Stream request headers are not supported on this platform;" + + " carry the credential in the URL instead"); + } + Media m = Display.getInstance().createStreamingMedia(this); + if (m == null) { + throw new IOException("The platform could not create a player for " + url); + } + return m; + } + + /// Creates the player without blocking the calling thread. + /// + /// #### Returns + /// + /// a resource completed with the media, or with the error [#create()] would throw + public AsyncResource createAsync() { + final AsyncResource out = new AsyncResource(); + Display.getInstance().startThread(new Runnable() { + @Override + public void run() { + try { + out.complete(create()); + } catch (Throwable t) { + out.error(t); + } + } + }, "StreamRequest").start(); + return out; + } + private static boolean hasLineBreak(String s) { + return s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0; + } +} diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 338f9f9dbf1..3e5f3cc49d9 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -6081,6 +6081,47 @@ public Media createMedia(String uri, boolean isVideo, Runnable onCompletion) thr return impl.createMedia(uri, isVideo, onCompletion); } + /// Creates the platform player for a streaming request. Applications call + /// `com.codename1.media.StreamRequest#create()`, which checks support first. + /// + /// #### Parameters + /// + /// - `request`: the stream to play + /// + /// #### Returns + /// + /// the media, or null when the platform cannot create a player + /// + /// #### Throws + /// + /// - `java.io.IOException`: if the stream cannot be opened + public Media createStreamingMedia(com.codename1.media.StreamRequest request) throws IOException { + return impl.createStreamingMedia(request); + } + + /// Answers whether the platform player can play streams protected by a scheme. + /// + /// #### Parameters + /// + /// - `scheme`: the protection scheme + /// + /// #### Returns + /// + /// true when the scheme is supported + public boolean isDrmSupported(com.codename1.media.DrmScheme scheme) { + return impl.isDrmSupported(scheme); + } + + /// Answers whether the platform player can attach HTTP headers to the manifest + /// and segment requests of a stream. + /// + /// #### Returns + /// + /// true when stream request headers are supported + public boolean isStreamHeadersSupported() { + return impl.isStreamHeadersSupported(); + } + /// Creates media asynchronously. /// /// #### Parameters diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 57761a6fa84..69ee7ac297e 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -4768,6 +4768,70 @@ public void cleanup() { } + @Override + public boolean isDrmSupported(com.codename1.media.DrmScheme scheme) { + if (scheme == com.codename1.media.DrmScheme.AES_128) { + // The framework player's HLS extractor decrypts #EXT-X-KEY METHOD=AES-128. + return true; + } + return DrmVideoView.isSupported(scheme); + } + + @Override + public boolean isStreamHeadersSupported() { + // VideoView.setVideoURI(Uri, Map) and MediaPlayer.setDataSource(Context, Uri, Map) + // send the headers with the manifest and every segment request. + return true; + } + + @Override + public Media createStreamingMedia(final com.codename1.media.StreamRequest request) throws IOException { + if (getActivity() == null) { + return null; + } + final com.codename1.media.DrmConfig drm = request.getDrm(); + final boolean cenc = drm != null && drm.getScheme() != com.codename1.media.DrmScheme.AES_128; + if (cenc && !DrmVideoView.isSupported(drm.getScheme())) { + throw new IOException(drm.getScheme() + " is not supported on this device"); + } + if (cenc && request.getUrl().toLowerCase(java.util.Locale.ROOT).indexOf(".mpd") > -1) { + // The framework MediaPlayer has no DASH extractor; failing here names + // the reason instead of a generic prepare error later. + throw new IOException("DASH is not supported by the framework player; serve HLS (fMP4) or MP4"); + } + final java.util.Map headers = request.getHeaders(); + final Uri videoUri = Uri.parse(request.getUrl()); + final AndroidImplementation.Video[] video = new AndroidImplementation.Video[1]; + final java.util.concurrent.CountDownLatch created = new java.util.concurrent.CountDownLatch(1); + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + VideoView v; + if (cenc) { + v = new DrmVideoView(getActivity(), videoUri, headers, drm, request.getStartPosition()); + } else { + v = new VideoView(getActivity()); + v.setVideoURI(videoUri, headers); + if (request.getStartPosition() > 0) { + v.seekTo(request.getStartPosition()); + } + } + v.setZOrderMediaOverlay(true); + video[0] = new AndroidImplementation.Video(v, getActivity(), request.getOnCompletion()); + } finally { + created.countDown(); + } + } + }); + try { + created.await(); + } catch (InterruptedException ex) { + throw new IOException("Interrupted while creating the player"); + } + return video[0]; + } + /** * @inheritDoc */ diff --git a/Ports/Android/src/com/codename1/impl/android/DrmVideoView.java b/Ports/Android/src/com/codename1/impl/android/DrmVideoView.java new file mode 100644 index 00000000000..05b469202b7 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/DrmVideoView.java @@ -0,0 +1,552 @@ +/* + * 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.impl.android; + +import android.content.Context; +import android.media.MediaDrm; +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.view.MotionEvent; +import android.view.SurfaceHolder; +import android.view.View; +import android.widget.MediaController; +import android.widget.VideoView; + +import com.codename1.media.DrmConfig; +import com.codename1.media.DrmScheme; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/// A video view that plays a CENC-protected stream (Widevine or ClearKey) through +/// the framework `MediaPlayer` DRM API (API 26). +/// +/// It extends `VideoView` only so `AndroidImplementation.Video` can host it +/// unchanged: every playback method `Video` calls is overridden here, and +/// `VideoView`'s own player is never opened (its URI stays null, so its surface +/// callback returns early). The view is the surface; the player is ours. +/// +/// The player is created on a private looper thread, which is the thread +/// `MediaPlayer` delivers its events on. `onDrmInfo` must call `prepareDrm` and +/// provide the key response before it returns, and the license request is a +/// network round trip -- on the main thread that would throw +/// `NetworkOnMainThreadException`. Listener callbacks are reposted to the main +/// thread, where `VideoView` would have delivered them. +/// +/// The framework player has no DASH extractor: it plays CENC progressive or +/// fragmented MP4 and HLS with fMP4 segments. `AndroidImplementation` refuses an +/// `.mpd` URL up front rather than failing in prepare; an application that needs +/// DASH with Widevine on Android uses a Media3 based player instead. +class DrmVideoView extends VideoView { + static final UUID WIDEVINE_UUID = new UUID(0xEDEF8BA979D64ACEL, 0xA3C827DCD51D21EDL); + static final UUID CLEARKEY_UUID = new UUID(0xE2719D58A985B3C9L, 0x781AB030AF78D30EL); + + private final Uri uri; + private final Map headers; + private final DrmConfig drm; + private final Handler main = new Handler(Looper.getMainLooper()); + // One looper thread for the life of the view; quit by stopPlayback. + private HandlerThread playerThread; + private Handler playerHandler; + // Written on the player thread and read on the main thread (and the reverse + // for the flags), so they are atomics rather than plain fields. + private final AtomicReference playerRef = new AtomicReference(); + private final AtomicBoolean prepared = new AtomicBoolean(); + private final AtomicBoolean startWhenPrepared = new AtomicBoolean(); + private final AtomicBoolean released = new AtomicBoolean(); + // A seek asked for before the player was prepared, or the position the + // player had reached when its surface went away; -1 for none. VideoView + // keeps the same thing in mSeekWhenPrepared. + private final AtomicInteger pendingSeek = new AtomicInteger(-1); + private final AtomicInteger videoWidth = new AtomicInteger(); + private final AtomicInteger videoHeight = new AtomicInteger(); + private MediaController controller; + private MediaPlayer.OnCompletionListener completionListener; + private MediaPlayer.OnErrorListener errorListener; + + DrmVideoView(Context ctx, Uri uri, Map headers, DrmConfig drm, int startPosition) { + super(ctx); + this.uri = uri; + this.headers = headers; + this.drm = drm; + if (startPosition > 0) { + pendingSeek.set(startPosition); + } + // The player lives exactly as long as the surface, as VideoView's does: + // a view that is removed without cleanup() still lets go of its decoder + // and its DRM session (a scarce resource with hardware-backed Widevine) + // when its surface is destroyed, and a reparented view reopens where it + // was. Re-attaching means a new license request, which is cheap. + getHolder().addCallback(new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(final SurfaceHolder holder) { + open(holder); + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + closePlayer(true); + } + }); + } + + /// Maps a scheme to the UUID `MediaDrm` knows it by. + static UUID uuidFor(DrmScheme scheme) { + if (scheme == DrmScheme.WIDEVINE) { + return WIDEVINE_UUID; + } + if (scheme == DrmScheme.CLEARKEY) { + return CLEARKEY_UUID; + } + return null; + } + + /// Answers whether this device's framework player can play the scheme. + static boolean isSupported(DrmScheme scheme) { + UUID id = uuidFor(scheme); + if (id == null || Build.VERSION.SDK_INT < 26) { + return false; + } + try { + return MediaDrm.isCryptoSchemeSupported(id); + } catch (Throwable t) { + return false; + } + } + + private void open(final SurfaceHolder holder) { + if (released.get()) { + return; + } + if (playerThread == null) { + playerThread = new HandlerThread("CN1DrmVideo"); + playerThread.start(); + playerHandler = new Handler(playerThread.getLooper()); + } + playerHandler.post(new Runnable() { + @Override + public void run() { + if (!released.get() && playerRef.get() == null) { + createPlayer(holder); + } + } + }); + } + + /// Releases the player on its own thread. From surfaceDestroyed this waits + /// (briefly) for that to finish, because the surface must not be used once + /// the callback returns. + private void closePlayer(boolean wait) { + if (playerHandler == null) { + return; + } + final CountDownLatch done = new CountDownLatch(1); + playerHandler.post(new Runnable() { + @Override + public void run() { + try { + MediaPlayer p = playerRef.getAndSet(null); + if (p != null) { + if (prepared.get() && pendingSeek.get() < 0) { + try { + pendingSeek.set(p.getCurrentPosition()); + } catch (IllegalStateException ignore) { + // the position is lost with the player + } + } + try { + p.setDisplay(null); + if (Build.VERSION.SDK_INT >= 26) { + p.releaseDrm(); + } + } catch (Throwable ignore) { + // no DRM session was opened + } + p.release(); + } + prepared.set(false); + } finally { + done.countDown(); + } + } + }); + if (wait) { + try { + if (!done.await(1, TimeUnit.SECONDS)) { + com.codename1.io.Log.p("DrmVideoView: the player did not release its surface within 1s"); + } + } catch (InterruptedException ignore) { + Thread.currentThread().interrupt(); + } + } + } + + private void createPlayer(SurfaceHolder holder) { + try { + final MediaPlayer p = new MediaPlayer(); + playerRef.set(p); + p.setDisplay(holder); + p.setScreenOnWhilePlaying(true); + p.setOnDrmInfoListener(new MediaPlayer.OnDrmInfoListener() { + @Override + public void onDrmInfo(MediaPlayer mp, MediaPlayer.DrmInfo info) { + try { + acquireLicense(mp, info); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + postError(MediaPlayer.MEDIA_ERROR_UNKNOWN, MediaPlayer.MEDIA_ERROR_IO); + } + } + }); + p.setOnVideoSizeChangedListener(new MediaPlayer.OnVideoSizeChangedListener() { + @Override + public void onVideoSizeChanged(MediaPlayer mp, final int width, final int height) { + videoWidth.set(width); + videoHeight.set(height); + main.post(new Runnable() { + @Override + public void run() { + if (width > 0 && height > 0) { + getHolder().setFixedSize(width, height); + } + requestLayout(); + } + }); + } + }); + p.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(MediaPlayer mp) { + prepared.set(true); + int seek = pendingSeek.getAndSet(-1); + if (seek > 0) { + mp.seekTo(seek); + } + if (startWhenPrepared.get()) { + mp.start(); + } + main.post(new Runnable() { + @Override + public void run() { + if (controller != null) { + controller.setEnabled(true); + } + } + }); + } + }); + p.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(final MediaPlayer mp) { + startWhenPrepared.set(false); + main.post(new Runnable() { + @Override + public void run() { + if (completionListener != null) { + completionListener.onCompletion(mp); + } + } + }); + } + }); + p.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(MediaPlayer mp, int what, int extra) { + postError(what, extra); + return true; + } + }); + p.setDataSource(getContext(), uri, headers); + p.prepareAsync(); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + postError(MediaPlayer.MEDIA_ERROR_UNKNOWN, MediaPlayer.MEDIA_ERROR_IO); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // VideoView sizes itself from its own player's video size, which is + // never set here; keep the aspect ratio of ours instead. + int vw = videoWidth.get(); + int vh = videoHeight.get(); + int width = getDefaultSize(vw, widthMeasureSpec); + int height = getDefaultSize(vh, heightMeasureSpec); + if (vw > 0 && vh > 0) { + if (vw * height > width * vh) { + height = width * vh / vw; + } else if (vw * height < width * vh) { + width = height * vw / vh; + } + } + setMeasuredDimension(width, height); + } + + private void acquireLicense(MediaPlayer mp, MediaPlayer.DrmInfo info) throws Exception { + UUID scheme = uuidFor(drm.getScheme()); + boolean offered = false; + for (UUID u : info.getSupportedSchemes()) { + if (u.equals(scheme)) { + offered = true; + break; + } + } + if (!offered) { + throw new IllegalStateException("The stream is not packaged for " + drm.getScheme()); + } + mp.prepareDrm(scheme); + byte[] initData = info.getPssh().get(scheme); + MediaDrm.KeyRequest req = mp.getKeyRequest(null, initData, "video/mp4", + MediaDrm.KEY_TYPE_STREAMING, null); + byte[] response = post(drm.getLicenseUrl(), req.getData(), drm.getLicenseHeaders()); + if (released.get() || playerRef.get() != mp) { + // The view was closed while the license was in flight; this player is + // being released and must not go on preparing against a dead surface. + throw new IllegalStateException("The player was closed during license acquisition"); + } + mp.provideKeyResponse(null, response); + } + + private static byte[] post(String url, byte[] body, Map headers) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); + try { + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setConnectTimeout(15000); + c.setReadTimeout(15000); + c.setRequestProperty("Content-Type", "application/octet-stream"); + for (Map.Entry h : headers.entrySet()) { + c.setRequestProperty(h.getKey(), h.getValue()); + } + OutputStream os = c.getOutputStream(); + try { + os.write(body); + } finally { + os.close(); + } + int code = c.getResponseCode(); + if (code < 200 || code >= 300) { + throw new java.io.IOException("License server answered " + code); + } + InputStream is = c.getInputStream(); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = is.read(buf)) > 0) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } finally { + is.close(); + } + } finally { + c.disconnect(); + } + } + + private void postError(final int what, final int extra) { + main.post(new Runnable() { + @Override + public void run() { + if (errorListener != null) { + errorListener.onError(playerRef.get(), what, extra); + } + } + }); + } + + private void onPlayer(final Runnable r) { + if (playerHandler != null) { + playerHandler.post(r); + } + } + + @Override + public void setMediaController(MediaController mc) { + if (controller != null) { + controller.hide(); + } + controller = mc; + if (mc != null) { + mc.setMediaPlayer(this); + View anchor = getParent() instanceof View ? (View) getParent() : this; + mc.setAnchorView(anchor); + mc.setEnabled(prepared.get()); + } + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN && prepared.get() && controller != null) { + if (controller.isShowing()) { + controller.hide(); + } else { + controller.show(); + } + } + return false; + } + + @Override + public void setOnCompletionListener(MediaPlayer.OnCompletionListener l) { + completionListener = l; + } + + @Override + public void setOnErrorListener(MediaPlayer.OnErrorListener l) { + errorListener = l; + } + + @Override + public void start() { + startWhenPrepared.set(true); + onPlayer(new Runnable() { + @Override + public void run() { + MediaPlayer p = playerRef.get(); + if (p != null && prepared.get()) { + p.start(); + } + } + }); + } + + @Override + public void pause() { + startWhenPrepared.set(false); + onPlayer(new Runnable() { + @Override + public void run() { + MediaPlayer p = playerRef.get(); + if (p != null && prepared.get() && p.isPlaying()) { + p.pause(); + } + } + }); + } + + @Override + public void stopPlayback() { + released.set(true); + closePlayer(false); + if (playerHandler != null) { + playerHandler.post(new Runnable() { + @Override + public void run() { + playerThread.quitSafely(); + } + }); + } + } + + @Override + public boolean canPause() { + return prepared.get(); + } + + @Override + public boolean canSeekBackward() { + return prepared.get(); + } + + @Override + public boolean canSeekForward() { + return prepared.get(); + } + + @Override + public int getCurrentPosition() { + MediaPlayer p = playerRef.get(); + if (p == null || !prepared.get()) { + return 0; + } + try { + return p.getCurrentPosition(); + } catch (IllegalStateException e) { + return 0; + } + } + + @Override + public int getDuration() { + MediaPlayer p = playerRef.get(); + if (p == null || !prepared.get()) { + return -1; + } + try { + return p.getDuration(); + } catch (IllegalStateException e) { + return -1; + } + } + + @Override + public boolean isPlaying() { + MediaPlayer p = playerRef.get(); + if (p == null || !prepared.get()) { + return false; + } + try { + return p.isPlaying(); + } catch (IllegalStateException e) { + return false; + } + } + + @Override + public void seekTo(final int msec) { + if (!prepared.get()) { + pendingSeek.set(msec); + } + onPlayer(new Runnable() { + @Override + public void run() { + MediaPlayer p = playerRef.get(); + if (p != null && prepared.get()) { + p.seekTo(msec); + } else { + pendingSeek.set(msec); + } + } + }); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index ae14988382d..201000f7dbf 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -17336,6 +17336,14 @@ public CN1JPanel() { + @Override + public boolean isDrmSupported(com.codename1.media.DrmScheme scheme) { + // The simulator plays through ffmpeg, whose HLS demuxer fetches the + // #EXT-X-KEY URI and decrypts AES-128 segments. It has no CDM, so the + // CENC schemes (ClearKey, Widevine, FairPlay) are answered honestly as no. + return scheme == com.codename1.media.DrmScheme.AES_128 && FFMPEGMedia.isConfigured(); + } + @Override public AsyncResource createMediaAsync(String uriAddress, final boolean isVideo, final Runnable onCompletion) { if (FFMPEGMedia.isConfigured() && !"cef".equalsIgnoreCase(System.getProperty("cn1.javase.mediaImplementation", ""))) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ffmpeg/FFMPEGMedia.java b/Ports/JavaSE/src/com/codename1/impl/javase/ffmpeg/FFMPEGMedia.java index c861e871f1b..670ca89804e 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/ffmpeg/FFMPEGMedia.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/ffmpeg/FFMPEGMedia.java @@ -402,6 +402,12 @@ private void startVideoProcess() throws IOException { } command.add("-i"); command.add(source); + // The frame size is the probe's. A stream with several variants + // (an HLS master playlist) may decode at another resolution than + // the one probed; scaling keeps every frame the size the reader + // expects instead of reading rows at the wrong stride. + command.add("-s"); + command.add(probeData.width + "x" + probeData.height); command.add("-f"); command.add("rawvideo"); command.add("-pix_fmt"); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index db360e2242e..860eefde2b7 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -23890,3 +23890,199 @@ JAVA_INT com_codename1_impl_ios_IOSNative_getFoldRegion___int_1ARRAY_R_int( } return (JAVA_INT)kind; } + +// --------------------------------------------------------------------------- +// FairPlay Streaming (com.codename1.media.StreamRequest with DrmScheme.FAIRPLAY). +// +// The asset is registered with an AVContentKeySession before the player item +// exists, so the session -- not the player -- answers the playlist's skd:// key +// tag. For each key request the delegate fetches the application certificate, +// asks the request for the SPC, posts it to the license server with the caller's +// headers and hands the CKC back. Both network calls run on NSURLSession's queue; +// nothing here blocks the Java thread beyond creating the view. +// +// The session and its delegate are attached to the player with an associated +// object, so they live exactly as long as the player the Java peer releases. +// Only the AVKit iOS/tvOS path implements it: the macOS port and the watch have +// no FairPlay client here, and the simulator has no FairPlay CDM at all. +// --------------------------------------------------------------------------- +#if defined(CN1_USE_AVKIT) && !TARGET_OS_OSX +static char cn1FairPlaySessionKey; + +// One serial queue for every session's key callbacks, created once, so no +// per-player queue has to be released by hand in the MRC build. +static dispatch_queue_t cn1FairPlayQueue(void) { + static dispatch_queue_t q; + static dispatch_once_t once; + dispatch_once(&once, ^{ + q = dispatch_queue_create("com.codename1.fairplay", DISPATCH_QUEUE_SERIAL); + }); + return q; +} + +API_AVAILABLE(ios(10.3), tvos(10.2)) +@interface CN1FairPlayKeyDelegate : NSObject +@property (nonatomic, retain) NSString* certificateUrl; +@property (nonatomic, retain) NSString* licenseUrl; +@property (nonatomic, retain) NSString* contentId; +@property (nonatomic, retain) NSDictionary* licenseHeaders; +@property (nonatomic, retain) AVContentKeySession* session; +@end + +@implementation CN1FairPlayKeyDelegate + +- (void)fail:(AVContentKeyRequest*)keyRequest reason:(NSString*)reason { + NSLog(@"CN1 FairPlay: %@", reason); + [keyRequest processContentKeyResponseError:[NSError errorWithDomain:@"CN1FairPlay" code:-1 + userInfo:@{NSLocalizedDescriptionKey: reason}]]; +} + +- (void)handleKeyRequest:(AVContentKeyRequest*)keyRequest { + NSString* cid = self.contentId; + id ident = keyRequest.identifier; + if (cid == nil && [ident isKindOfClass:[NSString class]]) { + NSString* s = (NSString*)ident; + NSURL* u = [NSURL URLWithString:s]; + cid = u.host != nil ? u.host : [s stringByReplacingOccurrencesOfString:@"skd://" withString:@""]; + } + if (cid == nil || self.certificateUrl == nil) { + [self fail:keyRequest reason:@"FairPlay needs a certificate URL and a content identifier"]; + return; + } + NSData* cidData = [cid dataUsingEncoding:NSUTF8StringEncoding]; + NSURLSession* http = [NSURLSession sharedSession]; + NSURL* certUrl = [NSURL URLWithString:self.certificateUrl]; + [[http dataTaskWithURL:certUrl completionHandler:^(NSData* cert, NSURLResponse* r, NSError* e) { + NSInteger certStatus = [r isKindOfClass:[NSHTTPURLResponse class]] ? ((NSHTTPURLResponse*)r).statusCode : 200; + if (cert == nil || certStatus < 200 || certStatus >= 300) { + [self fail:keyRequest reason:[NSString stringWithFormat:@"certificate request failed (%ld)", (long)certStatus]]; + return; + } + [keyRequest makeStreamingContentKeyRequestDataForApp:cert contentIdentifier:cidData options:nil + completionHandler:^(NSData* spc, NSError* spcError) { + if (spc == nil) { + [self fail:keyRequest reason:@"the key request produced no SPC"]; + return; + } + NSURL* licenseURL = [NSURL URLWithString:self.licenseUrl]; + if (licenseURL == nil) { + [self fail:keyRequest reason:@"the license URL is not a valid URL"]; + return; + } + NSMutableURLRequest* req = [NSMutableURLRequest requestWithURL:licenseURL]; + req.HTTPMethod = @"POST"; + req.HTTPBody = spc; + [req setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"]; + for (NSString* k in self.licenseHeaders) { + [req setValue:[self.licenseHeaders objectForKey:k] forHTTPHeaderField:k]; + } + [[http dataTaskWithRequest:req completionHandler:^(NSData* ckc, NSURLResponse* lr, NSError* le) { + NSInteger status = [lr isKindOfClass:[NSHTTPURLResponse class]] ? ((NSHTTPURLResponse*)lr).statusCode : 0; + if (ckc == nil || status < 200 || status >= 300) { + [self fail:keyRequest reason:[NSString stringWithFormat:@"license server answered %ld", (long)status]]; + return; + } + [keyRequest processContentKeyResponse: + [AVContentKeyResponse contentKeyResponseWithFairPlayStreamingKeyResponseData:ckc]]; + }] resume]; + }]; + }] resume]; +} + +- (void)contentKeySession:(AVContentKeySession*)session didProvideContentKeyRequest:(AVContentKeyRequest*)keyRequest { + [self handleKeyRequest:keyRequest]; +} + +- (void)contentKeySession:(AVContentKeySession*)session didProvideRenewingContentKeyRequest:(AVContentKeyRequest*)keyRequest { + [self handleKeyRequest:keyRequest]; +} + +#ifndef CN1_USE_ARC +- (void)dealloc { + [_certificateUrl release]; + [_licenseUrl release]; + [_contentId release]; + [_licenseHeaders release]; + [_session release]; + [super dealloc]; +} +#endif +@end + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isFairPlaySupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#if TARGET_OS_SIMULATOR + return JAVA_FALSE; +#else + if (@available(iOS 10.3, tvOS 10.2, *)) { + return useAVKit() ? JAVA_TRUE : JAVA_FALSE; + } + return JAVA_FALSE; +#endif +} + +static JAVA_LONG cn1CreateFairPlayVideo(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT url, JAVA_OBJECT certificateUrl, JAVA_OBJECT licenseUrl, JAVA_OBJECT contentId, JAVA_OBJECT licenseHeaders, JAVA_INT onCompletionCallbackId) API_AVAILABLE(ios(10.3), tvos(10.2)) { + NSString* sUrl = toNSString(CN1_THREAD_STATE_PASS_ARG url); + NSString* sCert = certificateUrl == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG certificateUrl); + NSString* sLicense = toNSString(CN1_THREAD_STATE_PASS_ARG licenseUrl); + NSString* sContent = contentId == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG contentId); + NSString* sHeaders = licenseHeaders == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG licenseHeaders); + __block AVPlayerViewController* moviePlayerInstance = nil; + dispatch_sync(dispatch_get_main_queue(), ^{ + POOL_BEGIN(); + // Headers arrive as name\nvalue\n pairs, which keeps the native signature + // free of arrays; header names and values cannot contain a newline. + NSMutableDictionary* headers = [NSMutableDictionary dictionary]; + if (sHeaders != nil) { + NSArray* parts = [sHeaders componentsSeparatedByString:@"\n"]; + for (NSUInteger i = 0; i + 1 < [parts count]; i += 2) { + [headers setObject:[parts objectAtIndex:i + 1] forKey:[parts objectAtIndex:i]]; + } + } + CN1FairPlayKeyDelegate* delegate = [[CN1FairPlayKeyDelegate alloc] init]; + delegate.certificateUrl = sCert; + delegate.licenseUrl = sLicense; + delegate.contentId = sContent; + delegate.licenseHeaders = headers; + AVContentKeySession* session = [AVContentKeySession contentKeySessionWithKeySystem:AVContentKeySystemFairPlayStreaming]; + delegate.session = session; + [session setDelegate:delegate queue:cn1FairPlayQueue()]; + AVURLAsset* asset = [AVURLAsset URLAssetWithURL:cn1URLForMediaString(sUrl) options:nil]; + [session addContentKeyRecipient:asset]; + AVPlayer* cn1Player = [[AVPlayer alloc] initWithPlayerItem:[AVPlayerItem playerItemWithAsset:asset]]; + // The session holds its delegate weakly; the player holds the delegate, + // and the delegate the session, so all three go when the player does. + objc_setAssociatedObject(cn1Player, &cn1FairPlaySessionKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +#ifndef CN1_USE_ARC + [delegate release]; +#endif + moviePlayerInstance = [[AVPlayerViewController alloc] init]; + moviePlayerInstance.player = cn1Player; +#ifndef CN1_USE_ARC + [cn1Player release]; +#endif + registerVideoCallbackAV(CN1_THREAD_GET_STATE_PASS_ARG moviePlayerInstance.player, onCompletionCallbackId); + moviePlayerInstance.showsPlaybackControls = YES; + POOL_END(); + }); + return (JAVA_LONG)((BRIDGE_CAST void*)moviePlayerInstance); +} + +JAVA_LONG com_codename1_impl_ios_IOSNative_createFairPlayVideoComponent___java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT url, JAVA_OBJECT certificateUrl, JAVA_OBJECT licenseUrl, JAVA_OBJECT contentId, JAVA_OBJECT licenseHeaders, JAVA_INT onCompletionCallbackId) { + if (!useAVKit()) { + return 0; + } + if (@available(iOS 10.3, tvOS 10.2, *)) { + return cn1CreateFairPlayVideo(CN1_THREAD_STATE_PASS_ARG url, certificateUrl, licenseUrl, contentId, + licenseHeaders, onCompletionCallbackId); + } + return 0; +} +#else +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isFairPlaySupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + return JAVA_FALSE; +} + +JAVA_LONG com_codename1_impl_ios_IOSNative_createFairPlayVideoComponent___java_lang_String_java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT url, JAVA_OBJECT certificateUrl, JAVA_OBJECT licenseUrl, JAVA_OBJECT contentId, JAVA_OBJECT licenseHeaders, JAVA_INT onCompletionCallbackId) { + return 0; +} +#endif diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index e54b513fbb9..af87a483956 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -7227,6 +7227,10 @@ class IOSMedia extends AbstractMedia { private boolean embedNativeControls=true; private List completionHandlers; private boolean prepareToPlay; + // Set for a FairPlay stream: getVideoComponent creates the player through + // the content key session instead of a plain URL. + private com.codename1.media.DrmConfig fairPlay; + private int pendingTime = -1; @@ -7444,6 +7448,10 @@ public void setTime(int time) { } else { nativeInstance.setAudioTime(moviePlayerPeer, time); } + } else if(isVideo) { + // The video peer is created lazily by getVideoComponent; a resume + // position set before that is applied once the peer exists. + pendingTime = time; } } @@ -7484,7 +7492,17 @@ public boolean isPlaying() { @Override public Component getVideoComponent() { if (component == null) { - if(uri != null) { + if(uri != null && fairPlay != null) { + moviePlayerPeer = nativeInstance.createFairPlayVideoComponent(uri, fairPlay.getCertificateUrl(), + fairPlay.getLicenseUrl(), fairPlay.getContentId(), encodeHeaders(fairPlay.getLicenseHeaders()), + onCompletionCallbackId); + if (moviePlayerPeer == 0) { + fireMediaError(new MediaException(MediaErrorType.SrcNotSupported, "FairPlay is not supported on this device")); + return null; + } + nativeInstance.setNativeVideoControlsEmbedded(moviePlayerPeer, embedNativeControls); + component = PeerComponent.create(new long[] { nativeInstance.getVideoViewPeer(moviePlayerPeer) }); + } else if(uri != null) { moviePlayerPeer = nativeInstance.createVideoComponent(uri, onCompletionCallbackId); nativeInstance.setNativeVideoControlsEmbedded(moviePlayerPeer, embedNativeControls); component = PeerComponent.create(new long[] { nativeInstance.getVideoViewPeer(moviePlayerPeer) }); @@ -7505,6 +7523,10 @@ public Component getVideoComponent() { if (prepareToPlay && isVideo && !isPlaying()) { prepare(); } + if (moviePlayerPeer != 0 && pendingTime > 0) { + nativeInstance.setMediaTimeMS(moviePlayerPeer, pendingTime); + pendingTime = -1; + } return component; } @@ -7582,6 +7604,43 @@ public Media createMedia(String uri, boolean isVideo, Runnable onCompletion) thr return new IOSMedia(uri, isVideo, onCompletion); } + @Override + public boolean isDrmSupported(com.codename1.media.DrmScheme scheme) { + if (scheme == com.codename1.media.DrmScheme.AES_128) { + // AVPlayer decrypts HLS #EXT-X-KEY METHOD=AES-128 by itself. + return true; + } + return scheme == com.codename1.media.DrmScheme.FAIRPLAY && nativeInstance.isFairPlaySupported(); + } + + @Override + public Media createStreamingMedia(com.codename1.media.StreamRequest request) throws IOException { + com.codename1.media.DrmConfig drm = request.getDrm(); + if (drm == null || drm.getScheme() != com.codename1.media.DrmScheme.FAIRPLAY) { + return super.createStreamingMedia(request); + } + if (!nativeInstance.isFairPlaySupported()) { + throw new IOException("FairPlay is not supported on this device"); + } + IOSMedia m = new IOSMedia(request.getUrl(), true, request.getOnCompletion()); + m.fairPlay = drm; + if (request.getStartPosition() > 0) { + m.setTime(request.getStartPosition()); + } + return m; + } + + private static String encodeHeaders(java.util.Map headers) { + if (headers == null || headers.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (java.util.Map.Entry e : headers.entrySet()) { + sb.append(e.getKey()).append('\n').append(e.getValue()).append('\n'); + } + return sb.toString(); + } + @Override public void addCompletionHandler(Media media, Runnable onCompletion) { super.addCompletionHandler(media, onCompletion); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 05c191d083e..9295389bab5 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -537,6 +537,10 @@ native void prepareNativeDrag(String mimeTypes, int allowedActions, byte[] dragI native long createVideoComponent(String url, int onCompletionCallbackId); native long createVideoComponent(byte[] video, int onCompletionCallbackId); native long createVideoComponentNSData(long video, int onCompletionCallbackId); + // FairPlay: licenseHeaders is name\nvalue\n pairs (null for none). + native long createFairPlayVideoComponent(String url, String certificateUrl, String licenseUrl, + String contentId, String licenseHeaders, int onCompletionCallbackId); + native boolean isFairPlaySupported(); native long createNativeVideoComponent(String url, int onCompletionCallbackId); native long createNativeVideoComponent(byte[] video, int onCompletionCallbackId); native long createNativeVideoComponentNSData(long video, int onCompletionCallbackId); diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/StreamingSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/StreamingSnippets.java new file mode 100644 index 00000000000..e24df23bd57 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/StreamingSnippets.java @@ -0,0 +1,54 @@ +/* + * 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.media.DrmConfig; +import com.codename1.media.DrmScheme; +import com.codename1.media.StreamRequest; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +/// Snippets of the streaming sections of the developer guide. +public final class StreamingSnippets { + private StreamingSnippets() { + } + + static void playProtected() { + // tag::reel-stream[] + String manifestUrl = "https://reel.example.com/media/42/master.m3u8"; + String licenseUrl = "https://reel.example.com/api/license/clearkey"; + String licenseToken = "token-from-playback-start"; + int resumeSeconds = 90; + Form player = new Form("Player", new BorderLayout()); + StreamRequest req = new StreamRequest(manifestUrl) + .setStartPosition(resumeSeconds * 1000) + .setDrm(new DrmConfig(DrmScheme.CLEARKEY, licenseUrl) + .addLicenseHeader("X-Reel-License", licenseToken)); + req.createAsync().ready(media -> { + player.add(BorderLayout.CENTER, media.getVideoComponent()); + player.revalidate(); + }); + player.show(); + // end::reel-stream[] + } +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/reel.sh b/docs/demos/common/src/main/snippets/developer-guide/reel.sh new file mode 100644 index 00000000000..3d0a854f6cd --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/reel.sh @@ -0,0 +1,28 @@ +// Snippets of the Reel chapters of the developer guide. Edit them here, not inline. + +// tag::reel-sh-run[] +cd scripts/reel +tools/fetch-demo-media.sh --small # once: download and package the films +CN1_PROFILE=dev backend/server.sh run +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +// end::reel-sh-run[] + +// tag::reel-sh-drm[] +reel.drm.widevine.url=https://license.example.com/widevine +reel.drm.widevine.header=Authorization: Bearer ... +reel.drm.fairplay.url=https://license.example.com/fairplay +reel.drm.fairplay.certificate=https://license.example.com/fairplay.cer +// end::reel-sh-drm[] + +// tag::reel-sh-worker[] +java -jar tools/packager/target/reel-packager-1.0-SNAPSHOT.jar \ + worker --server https://reel.example.com --token "$REEL_WORKER_TOKEN" +// end::reel-sh-worker[] + +// tag::reel-sh-tests[] +mvn -pl shared,backend -Dcodename1.platform=backend test +mvn -pl shared,backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true +./run-e2e.sh +./run-web-e2e.sh +./check-template.sh +// end::reel-sh-tests[] diff --git a/docs/developer-guide/Reel-Implementors-Guide.asciidoc b/docs/developer-guide/Reel-Implementors-Guide.asciidoc new file mode 100644 index 00000000000..f94ae2e8a11 --- /dev/null +++ b/docs/developer-guide/Reel-Implementors-Guide.asciidoc @@ -0,0 +1,208 @@ +[[reel-implementors-guide]] +== Reel: Building on the sample + +Reel is a streaming service, and most of what makes one is deciding who may +play what, on which device, for how long, and then making it hard to play +anywhere else. Reel keeps what it shares with +<>, <> and +<>: the module layout, the accounts and +sign-in, push notifications, the admin console and hosting the app in a +browser. This chapter covers what streaming changes. For the parts the samples +share, the Wayline chapter is true of Reel with the names swapped. + +=== Running it + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/reel.sh[tag=reel-sh-run,indent=0] +---- + +`fetch-demo-media.sh` needs `ffmpeg`. It downloads the Blender open movies into +`scripts/reel/sources`, packages each with fresh random keys into +`scripts/reel/media` and writes `media/seed.json`, which a development server +reads at start-up for every video's keys, length, and renditions. Neither +directory is committed. Without them the server writes the catalog anyway, with +keys of its own, so every screen works and only the picture is missing. + +=== The modules + +[cols="1,4"] +|=== +| `shared` +| The transfer objects and the `@RestClient` interfaces, one per area: + `AccountApi`, `CatalogApi`, `PlaybackApi`, `LibraryApi`, `BillingApi`, + `PaymentApi`, `StudioApi`, `ModerationApi`, `LearningApi`, `AdminApi`. The app + gets a generated client and the server a generated interface to implement, + so the two can't drift apart. + +| `common` +| The app. `viewer` is the catalog, the title pages, the library and the plans; + `player` is playback, the ad breaks and the interactive overlays; `studio`, + `moderation` and `admin` are the other roles' screens; `net` holds the + session and the device identity; `notify` registers for push. + +| `backend` +| The server. Each area is a package with its service, its controller and the + repositories beside it; the schema is the migrations in + `src/main/resources/db/migration`, and the server runs on SQLite, PostgreSQL, + MySQL and MariaDB. + +| `tools/packager` +| A command line tool with no dependencies beyond `ffmpeg`: it packages a video, + seeds the demo catalog, and runs as the worker that packages uploads. +|=== + +=== Playback + +The app asks for a playback session with the title, the profile, the device and +the DRM schemes the device supports, strongest first: + +[source,java] +---- +include::../../scripts/reel/common/src/main/java/com/codenameone/examples/reel/player/PlayerForm.java[tag=reel-schemes,indent=0] +---- + +The server (`Playbacks.start`) checks everything that decides whether the title +may play: the profile's rating, the plan or a rental, the device, and how many +streams the plan allows at once. It then answers a manifest URL and a license +URL, each carrying a token signed for this session alone, the scheme it chose, +the ad breaks, the chapters and the interactions. The app hands that to the +framework's streaming API, with a `DrmConfig` that sends the license token in +an `X-Reel-License` header (`PlayerForm.startMain` in the sample): + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/StreamingSnippets.java[tag=reel-stream,indent=0] +---- + +While the title plays, the app reports a heartbeat. A session whose heartbeats +stop is swept, so a crashed app doesn't hold one of the plan's streams, and a +session ended elsewhere (an admin, a suspension, a device signed out) is +answered `ok=false` at the next heartbeat and the player closes. Each heartbeat +also checks again that the title is published and that the plan or a rental +still covers it, so a rental that runs out or a title that's taken down stops +within one heartbeat. A download's license is held to the same checks. The heartbeat runs on its own timer rather than the form's, because the +chapters list and the quiz dialogs are other forms and a form's timer stops +while it's hidden. + +=== What protects the video, and from what + +Every video is packaged twice with one content key: an HLS AES-128 ladder and a +CENC MP4 for ClearKey. The origin (`MediaOrigin`) checks the media token on +every playlist, segment and key: a token names the session, the video and the +tallest rendition the plan allows, and expires, so a URL copied out of the +player is good for one video for a few hours. The license endpoints answer +ClearKey and AES-128 from the key table, only for a valid license token. + +That stops casual copying of links and files. It doesn't stop a determined +person with a debugger: AES-128 and ClearKey keys reach the device in the clear +over TLS. That's what Widevine and FairPlay are for. Reel offers them when a +license vendor is configured and the *hardware DRM* feature is on; the server +forwards the device's challenge to the vendor and passes the answer back: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/reel.sh[tag=reel-sh-drm,indent=0] +---- + +The FairPlay certificate is a URL the device downloads, not a file on the server. +A device then gets the strongest scheme it supports that the server can +license, and a device that can play none is told so before playback starts. +The app also blocks screenshots while protected video is on the screen, and draws +the account's name over the picture when the *watermark* feature is on. + +=== Uploads and packaging + +An upload starts with `POST /api/studio/uploads`, which answers the piece size, +and continues with `PUT /api/studio/uploads/{id}?offset=N`, one piece at a time. +A piece sent at the wrong offset is refused with 409 and the app asks where the +server is; a dropped connection is retried. When the last byte arrives the +server queues a packaging job. + +Packaging is a separate process so that the server never runs `ffmpeg`: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/reel.sh[tag=reel-sh-worker,indent=0] +---- + +The worker claims a job and is handed the source and the content key, which the +server generated and keeps; it encodes the ladder and the CENC file with that +key, uploads the outputs and reports. Run as many +workers as there is video to package; each needs `reel.worker.token`. + +=== Moderation + +Every upload is checked as it's packaged. The keyword blocklist is matched +against the title's words, and the frames of the new video, a perceptual hash +every ten seconds, are compared with the frames of video that was taken down +before, so a re-upload of removed content is marked in the queue even under a +new name. `ContentClassifier` is the place to call an outside classifier. +Nothing a creator submits is published until a moderator approves it; an +administrator's own titles publish directly. + +=== Billing and ads + +Plans, trials, coupons, rentals and invoices are in `billing`. Payments go +through `reel.payments.provider`: `simulated` on a development profile, or +`stripe` with `REEL_PAYMENTS_STRIPE_SECRET` in the environment, where cards are +added on Stripe's own page so a card number never reaches the app or the +server. App-store purchases are refused unless +`reel.billing.trust-store-receipts=true`, which grants the plan on the +product the app reports without asking the store; a real service verifies the +receipt with Apple or Google in `Billing.storePurchase` before granting. A renewal that fails puts the subscription past due and tells the +viewer by push. + +`ads` decides where the breaks go and which campaign fills each when the break +comes, so a campaign whose budget ran out meanwhile isn't shown. A campaign +pays per thousand impressions, prefers titles in its genres and stops at its +budget or its end date. The player reports impressions, skips, clicks and +completions. + +=== Features and presets + +`settings.Settings` holds the feature switches the app reads at start-up, so an +administrator reshapes the service without a release. The presets set them all +at once: + +[cols="1,3"] +|=== +| Streaming service | Subscriptions, rentals, ads, profiles, downloads, courses, + hardware DRM, sharing detection and reports; no uploads from strangers. +| Video platform | Open uploads, ads, rentals, reports and interactive video. +| Training academy | Subscriptions, interactive video, certificates, downloads, + sharing detection and the watermark. +|=== + +A feature that's off is hidden in the app and refused by the server, so +switching one off is safe while apps are running. + +=== Tests + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/reel.sh[tag=reel-sh-tests,indent=0] +---- + +The server's tests run on the JVM and again compiled to a native binary, which +is how the server ships. `run-e2e.sh` starts a server and drives the app through +its screens in the simulator as each role; `run-web-e2e.sh` does the same for the +browser build in a headless browser. `.github/workflows/reel.yml` runs them all, +the server tests on each database, and the Android and iOS builds. + +=== Making it yours + +* *Your catalog.* Replace `DemoData` with an import from your own library, and + run the packaging worker over your masters. +* *Real DRM.* Contract a Widevine and FairPlay license service and set the + `reel.drm.*` settings; keep AES-128 for browsers and older devices. +* *A CDN.* The media origin checks a token on every request. Put a CDN in front + of it with the token in the cache key, or move the check to the CDN's edge + with the same secret (`reel.media.secret`). +* *Store receipts.* Verify App Store and Google Play receipts on the server + before turning on `reel.billing.trust-store-receipts`. +* *Billing.* Point `reel.payments.provider` at Stripe, or implement the payment + provider interface for another processor. +* *The look.* `common/src/main/css/theme.css` starts with the colors and the + corner radius; the admin console's *Branding* sets the name, the tagline and + the logo at run time. diff --git a/docs/developer-guide/Reel-Users-Guide.asciidoc b/docs/developer-guide/Reel-Users-Guide.asciidoc new file mode 100644 index 00000000000..db19d02ae15 --- /dev/null +++ b/docs/developer-guide/Reel-Users-Guide.asciidoc @@ -0,0 +1,248 @@ +[[reel-users-guide]] +== Reel: A streaming service from four sides + +Reel is a sample app with a server behind it. Viewers watch films, series and +courses on a phone, a tablet, a desktop or in a browser, and pick up on one +screen where they left off on another. Creators upload video and publish it. +Moderators decide what gets published. An administrator runs the service. The +app and the server are one Maven project, written in Java on both sides. + +This chapter is the user's guide: what each of them sees and can do. +<> is the other half. It explains how the project is +built, how playback is protected and what to change to make the service yours. + +One app serves everyone. The server gives every account a set of roles, and the +app opens on the screen that fits them. + +[cols="1,3"] +|=== +| Role | What it adds + +| Viewer +| Every account is a viewer. Browse and search the catalog, watch with ads or + without, keep a list, download for offline viewing, take courses, and manage + the household's profiles and the plan. + +| Creator +| Upload video, describe it, add chapters, quizzes and polls, submit a title for + review and follow how it's watched. + +| Moderator +| Approve or reject what creators submit, take down what was published, and + keep the blocklists that check every upload. + +| Admin +| See the service in numbers and charts, manage accounts, plans, coupons and ad + campaigns, switch features on and off, and deal with account sharing. +|=== + +=== Trying it + +Start the server on its development profile and it creates a set of accounts, +all with the password `reel-demo`, the plans, two ad campaigns and a catalog of +the Blender open movies and a short course: + +[cols="2,3"] +|=== +| `viewer@reel.example`, `viewer2@reel.example` | viewers +| `viewer3@reel.example` | a viewer with an open account-sharing alert +| `creator@reel.example` | owns the uploaded titles +| `moderator@reel.example` | reviews what creators submit +| `admin@reel.example` | runs the service +|=== + +`scripts/reel/README.adoc` has the two commands that start the server and the +app, and the one that fetches the films. Without the films every screen works; +the artwork and the picture are missing. + +=== Watching + +A viewer with more than one profile in the household is asked who is watching. +A kids profile sees only titles rated for children, and a profile can be locked +with a PIN. + +image::img/reel-viewer-home.png["The home screen with a featured title, its Play and More info buttons, and a row of recommendations under it",scaledwidth=40%] + +The home screen leads with one title and then rows: continue watching, +recommendations based on what the profile watched, the list, and the catalog +by genre. Search finds titles by name, synopsis, genre, and creator. + +image::img/reel-viewer-home-desktop.png["The home screen in a desktop window: the featured title's artwork across the whole width, the sections of the app along the top bar, and rows of titles under the artwork",scaledwidth=90%] + +On a desktop or a tablet the sections of the app sit along the top bar, the +featured title's text moves to the left of its artwork, and the rows show as +many titles as the window has room for. + +image::img/reel-viewer-title.png["A title's page with its artwork, year, age rating, length and HD badge, its content advisories, genre chips, a Play button and buttons to add it to the list, share it and report it",scaledwidth=40%] + +A title's page shows its episodes or lessons, plays it from where the profile +stopped, adds it to the list, rates it, downloads it, shares it and reports it. +Under the name are the year, the age rating in its box, the length, the best +quality the title was packaged in and a CC mark when it has subtitles. The +content advisories say what the rating is for: violence, frightening scenes, +strong language, sexual content, drugs or alcohol, flashing lights. Further +down the page lists the audio and subtitle languages. A title that's not in +the plan offers the plan that has it, or a rental. + +image::img/reel-viewer-title-desktop.png["A title's page in a desktop window, its artwork across the top and its details in a centered column under the title",scaledwidth=90%] + +image::img/reel-viewer-share.png["The share screen: a preview card with the title's poster, name, year, rating and synopsis, the link, and buttons to share it and copy it",scaledwidth=40%] + +Share shows how the link will look where it's pasted and offers the device's +share sheet or a copy of the link. The link opens a public page on the server +with the poster, the rating, the advisories and the synopsis, which is what a +chat app or a social network shows in its preview. Its Watch button opens the +app on that title, after signing in where needed. A draft or a removed title has +no public page. + +==== The player + +image::img/reel-viewer-player.png["The player over the title's artwork: the title and episode at the top, back ten seconds, play or pause and forward ten seconds in the middle, and a scrub bar with the time played and the time left over buttons for chapters, audio and subtitles, and the next episode",scaledwidth=40%] + +The player draws its own controls over the picture: back and forward ten +seconds, play and pause, a scrub bar with the time played and the time left, +chapters, Audio & subtitles and, in a series or a course, the next episode. The +controls fade after a few seconds of playback and come back on a tap. When an +episode ends and autoplay is on, the next one starts. + +image::img/reel-viewer-player-tracks.png["The Audio and subtitles sheet over the bottom of the player, with choices for subtitles, the audio track and the streaming quality",scaledwidth=40%] + +Audio & subtitles picks the subtitles (or none), the audio track and the +quality: Auto, or one of the heights the title was packaged in, up to what the +plan allows. Subtitles change at once. A different audio track or quality +reopens the stream where it was. + +Subtitles are WebVTT files kept beside the video and drawn by the app, so they +look the same on every device and work with every kind of protection. The +audio track is chosen by the server, which marks it as the default in the +playlist the player reads. + +On a plan with ads, short ad breaks play before and during the title, and some +can be skipped after a few seconds. A course pauses for a quiz or a poll and, +where the course branches, moves on to the lesson the answer leads to. While a +protected title plays, screenshots and screen recording are blocked where the +device allows it, and a faint mark naming the account sits over the picture. + +A plan allows a number of screens at once. Starting one more ends with a +message saying so, and an administrator, a suspension or a sign-out on another +device can end a stream that's playing. + +==== Your library, settings and account + +image::img/reel-viewer-library.png["The library with the profile's list, its downloads and the titles it watched, each with how far it got",scaledwidth=40%] + +The library holds the profile's list, its downloads and what it watched. A +title can be taken out of the history, which also takes it out of what the +recommendations learn from. + +image::img/reel-viewer-settings-subtitles.png["Settings with autoplay, data saver and the streaming quality, then the subtitle language, the subtitle size and a sample of how subtitles look",scaledwidth=40%] + +Settings hold the playback preferences (autoplay, data saver, the streaming +quality), the subtitle language and size used whenever a title has a track in +that language, notifications, the language and the theme of the app, and +privacy. The account screen shows the devices signed in and what's playing on +each, and signs any of them out. + +image::img/reel-viewer-account.png["The account screen with the name, the streams playing now and the devices signed in, each with a button to sign it out",scaledwidth=40%] + +=== Downloads + +On a plan with downloads, a title can be kept on the device. The file stays +encrypted, and playing it asks the server for the key, so a download stops +playing when its license runs out, when the device is signed out or when the +account is suspended. The library lists downloads without a connection. +Signing out removes them from the device. + +=== Plans, rentals and the wallet + +The development profile offers three plans: Basic with ads (one screen, HD), +Standard (two screens in Full HD, downloads) and Premium (four screens, the best +quality there is, downloads). A plan starts with a trial where the +administrator gives one, a coupon takes money off, and the plan can be changed +or cancelled at any time; a cancelled plan runs to the end of what was paid. +Invoices are listed in the account. + +Cards are added in the wallet. On the development profile `4242 4242 4242 4242` +is accepted, `4000 0000 0000 0002` is declined and nothing is charged. On a phone +the plan can also be bought through the app store. + +=== Learning + +image::img/reel-viewer-course.png["A course's page explaining how it works: short lessons in order, quizzes inside the video and a certificate at the end, with the profile's progress under them",scaledwidth=40%] + +On a service with courses, a course is a title whose episodes are short lessons. +The Courses row on the home screen says so, and a course's page explains how it +works: the lessons are watched in order, the video pauses on a quiz and carries +on when it's answered, and finishing every lesson and passing the quizzes earns +a certificate. The page shows how far the profile got. Progress is kept per +lesson, and a certificate opens from the learning screen, where anyone can +verify it by its code. + +image::img/reel-viewer-lesson-subtitles.png["A lesson in the player with the Audio and subtitles sheet open and Spanish chosen among English, Spanish, French and German subtitles",scaledwidth=40%] + +The demo course has subtitles in English, Spanish, French, and German. + +=== Creating + +image::img/reel-studio-title.png["The studio's page for a new draft title, with its status, a button to submit it for review and a prompt to add its first video",scaledwidth=40%] + +The studio lists the creator's titles with their status: draft, in review, +published, changes needed or removed. A title has a name, a synopsis, genres, +a rating and its videos. A video is uploaded in pieces, so a dropped connection +costs one piece and not the file, and the server packages it into the protected +formats the players need. Its chapters are lines of `m:ss Title`, and its +quizzes, polls and branches are added at a time in the video. + +image::img/reel-studio-video-subtitles.png["The studio's page for a video, with its subtitle tracks and the fields to add one: the language, the name viewers see and the WebVTT text",scaledwidth=40%] + +A video's subtitles are added one language at a time, as WebVTT: a language +such as `en` or `pt-BR`, the name viewers see, and the text. A title's age +rating comes with its content advisories, which the creator ticks when making +or editing the title. + +A finished title is submitted for review. A rejected one comes back with the +moderator's note, and the creator can correct it and submit it again, or appeal. +The analytics show views, minutes watched and where viewers stop, over the last +thirty days. + +=== Moderating + +image::img/reel-moderation-queue.png["The moderation queue with filters for titles, videos, reports, appeals and blocklist matches, here with nothing waiting",scaledwidth=40%] + +The queue holds submitted titles, reports from viewers and appeals. Every upload +is checked on arrival against the keyword blocklist and against the frames of +video that were taken down before, and what matches is marked in the queue. +A moderator approves a title, which publishes it and tells whoever follows the +series, or rejects or removes it with a note the creator sees. Every decision +is kept on the account's record. + +=== Administering + +image::img/reel-admin-dashboard.png["The admin dashboard with accounts, subscriptions, monthly revenue, streams now, minutes today and ad figures, and a chart of sign-ups under them",scaledwidth=40%] + +The dashboard shows the service at a glance: accounts, subscriptions and trials, +monthly revenue, streams now, minutes watched today, titles, ad impressions and +ad revenue, with thirty days of sign-ups, minutes, revenue and cancellations +drawn as charts. + +image::img/reel-admin-user.png["An account's page in the admin console with its name, plan and dates, switches for its roles and the action to suspend it",scaledwidth=40%] + +An account's page grants and removes roles, suspends and reinstates the account +with a reason, flags it, and lists its devices, its sessions, its sharing alerts +and its invoices. *Watching now* lists every stream that's playing and ends +one. Account sharing raises an alert when an account streams from more places +than a household plausibly does; the administrator dismisses it or signs the +account out everywhere. + +*Features* switches the service between three presets, a streaming service, a +video platform and a training academy, and then turns single features on and +off: subscriptions, rentals, ads, open uploads, interactive video, +certificates, profiles, downloads, sharing detection, hardware DRM, the +watermark and reports. The app follows on its next start, with no new build. +*Branding* sets the name, the tagline, the logo and the colors. + +=== In a browser + +The server can host the app itself, so a browser opening the server's address +gets Reel with nothing installed. A browser plays the protected video through +its own media player, and offers neither downloads nor app-store purchases. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 1c4157334ae..c784e684c41 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -162,6 +162,10 @@ include::Parley-Users-Guide.asciidoc[] include::Parley-Implementors-Guide.asciidoc[] +include::Reel-Users-Guide.asciidoc[] + +include::Reel-Implementors-Guide.asciidoc[] + include::Bastion-Users-Guide.asciidoc[] include::Bastion-Implementors-Guide.asciidoc[] diff --git a/docs/developer-guide/img/reel-admin-dashboard.png b/docs/developer-guide/img/reel-admin-dashboard.png new file mode 100644 index 00000000000..ab226b498ad Binary files /dev/null and b/docs/developer-guide/img/reel-admin-dashboard.png differ diff --git a/docs/developer-guide/img/reel-admin-user.png b/docs/developer-guide/img/reel-admin-user.png new file mode 100644 index 00000000000..35c38596a60 Binary files /dev/null and b/docs/developer-guide/img/reel-admin-user.png differ diff --git a/docs/developer-guide/img/reel-moderation-queue.png b/docs/developer-guide/img/reel-moderation-queue.png new file mode 100644 index 00000000000..839fe3d11dc Binary files /dev/null and b/docs/developer-guide/img/reel-moderation-queue.png differ diff --git a/docs/developer-guide/img/reel-studio-title.png b/docs/developer-guide/img/reel-studio-title.png new file mode 100644 index 00000000000..afe0f3a4471 Binary files /dev/null and b/docs/developer-guide/img/reel-studio-title.png differ diff --git a/docs/developer-guide/img/reel-studio-video-subtitles.png b/docs/developer-guide/img/reel-studio-video-subtitles.png new file mode 100644 index 00000000000..ba6f1d123ce Binary files /dev/null and b/docs/developer-guide/img/reel-studio-video-subtitles.png differ diff --git a/docs/developer-guide/img/reel-viewer-account.png b/docs/developer-guide/img/reel-viewer-account.png new file mode 100644 index 00000000000..ba685471409 Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-account.png differ diff --git a/docs/developer-guide/img/reel-viewer-course.png b/docs/developer-guide/img/reel-viewer-course.png new file mode 100644 index 00000000000..1579c736c43 Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-course.png differ diff --git a/docs/developer-guide/img/reel-viewer-home-desktop.png b/docs/developer-guide/img/reel-viewer-home-desktop.png new file mode 100644 index 00000000000..a881a79a52d Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-home-desktop.png differ diff --git a/docs/developer-guide/img/reel-viewer-home.png b/docs/developer-guide/img/reel-viewer-home.png new file mode 100644 index 00000000000..cf9e8cc0166 Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-home.png differ diff --git a/docs/developer-guide/img/reel-viewer-lesson-subtitles.png b/docs/developer-guide/img/reel-viewer-lesson-subtitles.png new file mode 100644 index 00000000000..071d28358f4 Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-lesson-subtitles.png differ diff --git a/docs/developer-guide/img/reel-viewer-library.png b/docs/developer-guide/img/reel-viewer-library.png new file mode 100644 index 00000000000..45b5cf0490a Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-library.png differ diff --git a/docs/developer-guide/img/reel-viewer-player-tracks.png b/docs/developer-guide/img/reel-viewer-player-tracks.png new file mode 100644 index 00000000000..dd7f1089602 Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-player-tracks.png differ diff --git a/docs/developer-guide/img/reel-viewer-player.png b/docs/developer-guide/img/reel-viewer-player.png new file mode 100644 index 00000000000..c0f27e29de9 Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-player.png differ diff --git a/docs/developer-guide/img/reel-viewer-settings-subtitles.png b/docs/developer-guide/img/reel-viewer-settings-subtitles.png new file mode 100644 index 00000000000..e09a72efec0 Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-settings-subtitles.png differ diff --git a/docs/developer-guide/img/reel-viewer-share.png b/docs/developer-guide/img/reel-viewer-share.png new file mode 100644 index 00000000000..9acaaf7a631 Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-share.png differ diff --git a/docs/developer-guide/img/reel-viewer-title-desktop.png b/docs/developer-guide/img/reel-viewer-title-desktop.png new file mode 100644 index 00000000000..9461c20c8a6 Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-title-desktop.png differ diff --git a/docs/developer-guide/img/reel-viewer-title.png b/docs/developer-guide/img/reel-viewer-title.png new file mode 100644 index 00000000000..3734d40316f Binary files /dev/null and b/docs/developer-guide/img/reel-viewer-title.png differ diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index e7ce1f0c8f6..1490498ddbe 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -836,5 +836,8 @@ callee signalling coturn unconfigured +Widevine +blocklist +blocklists Sepolia ntfy diff --git a/maven/backend-parparvm/pom.xml b/maven/backend-parparvm/pom.xml index 2d5449e254f..b5dd5bc776c 100644 --- a/maven/backend-parparvm/pom.xml +++ b/maven/backend-parparvm/pom.xml @@ -86,6 +86,11 @@ + + 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 426e36a5a28..c5e06b4c0ea 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 @@ -168,6 +168,12 @@ private void runProcessors() throws BuildExecutionException { + outputDirectory + ": " + e.getMessage(), e); } + // What javac left, so the classes the processors write -- through the + // context or by compiling generated source straight into the output, as + // the mapper processor does -- can be told apart from it afterwards. + Map before = new java.util.HashMap(); + listClassFiles(outputDirectory, "", before); + ProcessorContext ctx = new ProcessorContext(outputDirectory, stubSourceDirectory, index, log, cn1ProjectDir, rawSettings, mainClass, // The roots the build is actually compiling, so a processor asking @@ -266,6 +272,17 @@ private void runProcessors() throws BuildExecutionException { } } + // Taken before the ORM enhancer rewrites entity classes in place: those + // are the project's own and javac writes them afresh every build. + Map after = new java.util.HashMap(); + listClassFiles(outputDirectory, "", after); + java.util.Set generated = new java.util.TreeSet(); + for (Map.Entry e : after.entrySet()) { + if (!e.getValue().equals(before.get(e.getKey()))) { + generated.add(e.getKey()); + } + } + for (AnnotationProcessor processor : processors) { if (processor instanceof com.codename1.maven.processors.OrmAnnotationProcessor) { try { ((com.codename1.maven.processors.OrmAnnotationProcessor) processor).enhance(ctx); } @@ -277,6 +294,7 @@ private void runProcessors() throws BuildExecutionException { log.info("cn1: emitted " + emitted.size() + " generated class(es) under " + outputDirectory); } + writeGeneratedList(outputDirectory, generated); // Flush generated resources. These ride the project jar to the native // builders -- including a cloud build server, which receives the whole @@ -400,4 +418,50 @@ private static List loadProcessors() { return Collections.unmodifiableList(out); } + + private static void listClassFiles(File dir, String prefix, Map out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + String name = child.getName(); + if (child.isDirectory()) { + listClassFiles(child, prefix + name + "/", out); + } else if (name.endsWith(".class")) { + out.put(prefix + name.substring(0, name.length() - 6), Long.valueOf(child.lastModified())); + } + } + } + + /// The file beside `classes` that names the classes the last run of the + /// processors wrote into it, one internal name a line. + public static File generatedList(File classes) { + return new File(classes.getParentFile(), classes.getName() + ".cn1-generated.txt"); + } + + /// Records what was generated, for [BytecodeCompliance], which runs before + /// this step on the next build and would otherwise check last build's + /// generated classes -- stale ones, when a shared class they were made + /// from has changed since. + private static void writeGeneratedList(File classes, java.util.Set names) { + if (classes == null || classes.getParentFile() == null) { + return; + } + StringBuilder out = new StringBuilder(); + for (String name : new java.util.TreeSet(names)) { + out.append(name).append('\n'); + } + try { + FileOutputStream fos = new FileOutputStream(generatedList(classes)); + try { + fos.write(out.toString().getBytes("UTF-8")); + } finally { + fos.close(); + } + } catch (IOException unwritable) { + // The next compliance check then reads last build's generated + // classes as it always did; nothing else depends on the list. + } + } } 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 306f454dd6f..a599178da95 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 @@ -400,6 +400,7 @@ public void execute() throws BuildExecutionException { beforeCheck(); File outputDir = new File(host.outputDirectory().getPath()); + regenerated = regeneratedClasses(outputDir); if (!outputDir.isDirectory()) { writeComplianceSuccess("No output classes found for compliance check in " + outputDir.getAbsolutePath(), 0); return; @@ -1332,12 +1333,41 @@ private static boolean isReferenceValue(BasicValue value) { return sort == Type.OBJECT || sort == Type.ARRAY; } + /// Class files under the output that the annotation processors wrote on + /// the last build and will write again after this check + /// ([AnnotationProcessing#generatedList(File)]). A clean build checks none + /// of them, as they do not exist yet; an incremental one used to check + /// last build's copies, and fail on a stale mapper that still called the + /// old constructor of a shared record that has since changed. + private Set regenerated = Collections.emptySet(); + + private static Set regeneratedClasses(File outputDir) { + File list = AnnotationProcessing.generatedList(outputDir); + if (!list.isFile()) { + return Collections.emptySet(); + } + Set out = new HashSet(); + try { + for (String line : FileUtils.readLines(list, "UTF-8")) { + String name = line.trim(); + if (name.length() > 0) { + out.add(new File(outputDir, name + ".class").getAbsolutePath()); + } + } + } catch (IOException unreadable) { + return Collections.emptySet(); + } + return out; + } + private void collectClassFiles(File file, List out) { if (file == null || !file.exists()) { return; } if (file.isFile() && file.getName().endsWith(".class")) { - out.add(file); + if (!regenerated.contains(file.getAbsolutePath())) { + out.add(file); + } return; } if (file.isDirectory()) { 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 17648252dce..e7cfc00b87d 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 @@ -377,11 +377,18 @@ private static String readExpr(MappedField f, boolean isRecord) { /// Returns a full Java statement (terminated with `;`) that writes /// `rhsExpr` into the field for the runtime instance `o`. Records /// can't mutate after construction, so we accumulate in a local of - /// the form `_name` for later canonical-constructor invocation; + /// the form `__name` for later canonical-constructor invocation; /// JavaBeans POJOs route through `o.setName(rhsExpr)`; plain POJOs /// assign the field directly. + /// The local a record component is gathered in before the constructor + /// call. Two underscores: the generated code's own locals (`_kids`, `_i`, + /// `_e`, ...) take one, so a component named `kids` cannot redeclare one. + private static String recordLocal(MappedField f) { + return "__" + f.name; + } + private static String writeStmt(MappedField f, boolean isRecord, String rhsExpr) { - if (isRecord) return "_" + f.name + " = " + rhsExpr + ";"; + if (isRecord) return recordLocal(f) + " = " + rhsExpr + ";"; // `_p_`, not `_`: a creator parameter called `v` would otherwise be the // `_v` every read already declares. if (f.viaLocal) return "_p_" + f.name + " = " + rhsExpr + ";"; @@ -606,7 +613,7 @@ private static void appendRecordCtorCall(StringBuilder sb, MappedClass mc) { boolean first = true; for (MappedField f : mc.fields) { if (!first) sb.append(", "); - sb.append("_").append(f.name); + sb.append(recordLocal(f)); first = false; } sb.append(")"); @@ -1678,8 +1685,8 @@ private static void emitReadBody(StringBuilder sb, MappedClass mc, boolean json) } if (mc.isRecord) { for (MappedField f : mc.fields) { - sb.append(" ").append(fieldType(f)).append(" _") - .append(f.name).append(" = ") + sb.append(" ").append(fieldType(f)).append(" ") + .append(recordLocal(f)).append(" = ") .append(f.isArray ? "null" : defaultLiteral(f.kind)).append(";\n"); } sb.append(" if (").append(src).append(" == null) return "); 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 af580878181..62d2fa01071 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 @@ -136,6 +136,30 @@ void detectsForbiddenMethodReferenceWithSourceDetails(@TempDir Path tempDir) thr assertEquals("forbidden/Api#m()V", field(violation, "referencedMember")); } + @Test + void classesTheProcessorIsAboutToRegenerateAreNotChecked(@TempDir Path tempDir) throws Exception { + // bytecode-compliance runs before process-annotations, so a generated class + // left by the previous build is stale and replaced moments later. + Path outputDir = tempDir.resolve("classes"); + Files.createDirectories(outputDir); + writeClass(outputDir, "app/StaleMapper", "forbidden/Api", "m", "()V"); + Files.write(AnnotationProcessing.generatedList(outputDir.toFile()).toPath(), + "app/StaleMapper\n" + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + Path runtimeDir = tempDir.resolve("runtime"); + Files.createDirectories(runtimeDir); + writeJavaLangObject(runtimeDir); + + BytecodeCompliance mojo = new BytecodeCompliance(TestProjectHost.empty()); + java.lang.reflect.Method load = BytecodeCompliance.class.getDeclaredMethod("regeneratedClasses", java.io.File.class); + load.setAccessible(true); + java.lang.reflect.Field f = BytecodeCompliance.class.getDeclaredField("regenerated"); + f.setAccessible(true); + f.set(mojo, load.invoke(null, outputDir.toFile())); + Map runtimeIndex = buildClassIndex(mojo, Collections.singletonList(runtimeDir.toFile())); + assertEquals(0, scanProjectClasses(mojo, outputDir, runtimeIndex, Collections.emptyMap()).size()); + } + @Test void allowsMethodReferenceWhenPresentInAllowedApiIndex(@TempDir Path tempDir) throws Exception { Path outputDir = tempDir.resolve("classes"); 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 index daf64f32464..7fc6bb33248 100644 --- 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 @@ -246,6 +246,13 @@ private File compileContract() throws Exception { 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"); + // Components named after the generated mapper's own locals (`_kids`, + // `_i`, `_e`, `_ch`, `_v`): a record's components are gathered in + // locals too, and these once redeclared them, so the module failed to + // build on the generated source. + contract.put("com.example.Household", PKG + "import com.codename1.annotations.Mapped;\n" + + "@Mapped public record Household(java.util.List kids, int i, String e,\n" + + " String ch, String v, String m, String o) { }\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" diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/RecordMappingTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RecordMappingTest.java index d31b9d5bd96..944b3c02d18 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/RecordMappingTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RecordMappingTest.java @@ -99,7 +99,7 @@ public void recordRoundTripsThroughGeneratedMapper() throws Exception { String generated = invokeGenerateMapperSource(proc); assertNotNull("expected generated source for Pet record", generated); assertTrue("fromMap should construct via canonical ctor: " + generated, - generated.contains("new com.example.Pet(_name, _age)")); + generated.contains("new com.example.Pet(__name, __age)")); assertTrue("toMap should read via record accessor o.name(): " + generated, generated.contains("o.name()")); assertTrue("toMap should read via record accessor o.age(): " + generated, diff --git a/maven/core-unittests/src/test/java/com/codename1/io/ImageCacheEmptyEntryTest.java b/maven/core-unittests/src/test/java/com/codename1/io/ImageCacheEmptyEntryTest.java new file mode 100644 index 00000000000..49d4710fafa --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/io/ImageCacheEmptyEntryTest.java @@ -0,0 +1,63 @@ +/* + * 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.io; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.testing.TestCodenameOneImplementation; +import com.codename1.ui.EncodedImage; + +import java.io.OutputStream; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// An empty cache entry -- a download another request is still writing, or +/// one that failed -- must not be handed back as the image. +class ImageCacheEmptyEntryTest extends UITestBase { + @FormTest + void anEmptyStorageEntryIsDownloadedAgain() throws Exception { + String url = "http://example.com/empty-entry.png"; + String key = "cn1_image_cache_test_empty"; + OutputStream out = Storage.getInstance().createOutputStream(key); + out.close(); + byte[] body = {1, 2, 3, 4, 5, 6, 7, 8}; + TestCodenameOneImplementation.getInstance().addNetworkMockResponse(url, 200, "OK", body); + final AtomicReference result = new AtomicReference(); + try { + Util.downloadImageToStorage(url, key, img -> result.set(img), (sender, err, code, msg) -> result.set(err)); + long start = System.currentTimeMillis(); + while (result.get() == null && System.currentTimeMillis() - start < 5000) { + com.codename1.ui.DisplayTest.flushEdt(); + Thread.sleep(10); + } + Object got = result.get(); + assertTrue(got instanceof EncodedImage, String.valueOf(got)); + assertEquals(body.length, ((EncodedImage) got).getImageData().length); + } finally { + Storage.getInstance().deleteStorageFile(key); + implementation.clearNetworkMocks(); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/media/StreamRequestTest.java b/maven/core-unittests/src/test/java/com/codename1/media/StreamRequestTest.java new file mode 100644 index 00000000000..8f3d546d394 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/media/StreamRequestTest.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.media; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.BeforeEach; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class StreamRequestTest extends UITestBase { + private Media media; + + @BeforeEach + void configureImplementation() { + media = mock(Media.class); + implementation.setMedia(media); + } + + @FormTest + void unprotectedStreamPlaysThroughCreateMediaAndSeeks() throws IOException { + StreamRequest req = new StreamRequest("https://example.com/v/master.m3u8").setStartPosition(42000); + assertTrue(req.isSupported()); + assertSame(media, req.create()); + verify(media).setTime(42000); + } + + @FormTest + void zeroStartPositionDoesNotSeek() throws IOException { + new StreamRequest("https://example.com/v.mp4").setStartPosition(-5).create(); + verify(media, never()).setTime(0); + } + + @FormTest + void defaultImplementationSupportsOnlyAes128() { + // AES-128 HLS only needs the key URL in the playlist, which any HLS player reads. + for (DrmScheme s : DrmScheme.values()) { + assertEquals(s == DrmScheme.AES_128, MediaManager.isDrmSupported(s), s.name()); + } + StreamRequest req = new StreamRequest("https://example.com/v.m3u8") + .setDrm(new DrmConfig(DrmScheme.WIDEVINE, "https://example.com/license")); + assertFalse(req.isSupported()); + IOException e = assertThrows(IOException.class, req::create); + assertTrue(e.getMessage().contains("WIDEVINE")); + assertThrows(IllegalArgumentException.class, + () -> new DrmConfig(DrmScheme.CLEARKEY, "https://example.com/license").addLicenseHeader("X", "a\nb")); + assertThrows(IllegalArgumentException.class, () -> req.addHeader("X\r", "v")); + } + + @FormTest + void headersNeedPlatformSupport() { + assertFalse(MediaManager.isStreamHeadersSupported()); + StreamRequest req = new StreamRequest("https://example.com/v.m3u8").addHeader("Authorization", "Bearer x"); + assertFalse(req.isSupported()); + assertThrows(IOException.class, req::create); + } + + @FormTest + void drmConfigValidatesAndCopies() { + assertThrows(IllegalArgumentException.class, () -> new DrmConfig(null, "u")); + assertThrows(IllegalArgumentException.class, () -> new DrmConfig(DrmScheme.FAIRPLAY, null)); + assertNull(new DrmConfig(DrmScheme.AES_128, null).getLicenseUrl()); + DrmConfig c = new DrmConfig(DrmScheme.FAIRPLAY, "https://l").setCertificateUrl("https://c") + .setContentId("title-7").addLicenseHeader("X-Token", "t"); + assertEquals("https://c", c.getCertificateUrl()); + assertEquals("title-7", c.getContentId()); + c.getLicenseHeaders().clear(); + assertEquals("t", c.getLicenseHeaders().get("X-Token")); + assertThrows(IllegalArgumentException.class, () -> new StreamRequest("")); + } +} diff --git a/scripts/check-copyright-headers.sh b/scripts/check-copyright-headers.sh index b2e59b69840..3c28aabb62e 100755 --- a/scripts/check-copyright-headers.sh +++ b/scripts/check-copyright-headers.sh @@ -39,10 +39,10 @@ is_excluded() { # 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, scripts/savor/LICENSE, scripts/skirmish/LICENSE, scripts/parley/LICENSE, - # scripts/bastion/LICENSE), and carry that header + # scripts/bastion/LICENSE, scripts/reel/LICENSE), and carry that header # or none. case "$candidate" in - scripts/wayline/*|scripts/savor/*|scripts/skirmish/*|scripts/parley/*|scripts/bastion/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; + scripts/wayline/*|scripts/savor/*|scripts/skirmish/*|scripts/parley/*|scripts/bastion/*|scripts/reel/*|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/developer-guide/validate-guide-snippets.py b/scripts/developer-guide/validate-guide-snippets.py index a42cbe00389..611be0b12fe 100755 --- a/scripts/developer-guide/validate-guide-snippets.py +++ b/scripts/developer-guide/validate-guide-snippets.py @@ -15,7 +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/", "../../scripts/savor/", "../../scripts/skirmish/", "../../scripts/parley/") +INCLUDE_ROOTS = ("../demos/", "../../scripts/wayline/", "../../scripts/savor/", "../../scripts/skirmish/", "../../scripts/parley/", "../../scripts/reel/") def parse_args(): 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 1a6f3e4d94f..3b589547bb3 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 @@ -468,7 +468,7 @@ private Container createLanguageSelector(Template[] selectedTemplate, Runnable o return empty; } Template[] apps = {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.PARLEY, - Template.BASTION}; + Template.BASTION, Template.REEL}; int offered = 0; for (Template app : apps) { if (GeneratorModel.isTemplateOffered(app)) { @@ -512,6 +512,7 @@ static String templateTitle(Template template) { return template == Template.SAVOR ? "Food delivery" : template == Template.SKIRMISH ? "Card battle game" : template == Template.PARLEY ? "Messaging" : template == Template.WAYLINE ? "Ride hailing" : template == Template.BASTION ? "Banking" + : template == Template.REEL ? "Video streaming" : template.IS_KOTLIN ? "Empty app, Kotlin" : "Empty app, Java"; } 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 3fc169861e2..6b6f1b2439d 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 @@ -80,6 +80,11 @@ public static boolean isMavenLayoutChoiceOffered() { /// neither offered nor generated for a release before this one. static final String FULL_STACK_SINCE = "7.0.277"; + /// The first release whose core has the protected-playback API the streaming + /// template plays its titles through (`com.codename1.media.drm`). Before it the + /// template's app does not compile, so it is neither offered nor generated. + static final String REEL_SINCE = "7.0.280"; + /// The first release whose framework has what the banking template's app calls: /// timed clipboard clearing, the P-256 key and signature API it signs requests /// with, and the `BigDecimal` arithmetic its ledger rounds with. Against an @@ -89,7 +94,8 @@ public static boolean isMavenLayoutChoiceOffered() { /// The first release a template can be generated against. static String templateSince(Template template) { - return template == Template.BASTION ? BASTION_SINCE : FULL_STACK_SINCE; + return template == Template.BASTION ? BASTION_SINCE + : template == Template.REEL ? REEL_SINCE : FULL_STACK_SINCE; } /// The brand colour of the full-stack template as it ships: the `--brand` its @@ -109,12 +115,18 @@ static String templateSince(Template template) { /// The messaging template's `--brand`, held to its theme.css the same way. public static final int PARLEY_BRAND = 0x0a7d6b; + /// The streaming template's `--brand`, held to its theme.css the same way. + public static final int REEL_BRAND = 0xd93a42; + /// The brand colour `template` ships with: the one its icon is painted with /// when the developer keeps the template's own scheme. public static int brandOf(Template template) { if (template == Template.PARLEY) { return PARLEY_BRAND; } + if (template == Template.REEL) { + return REEL_BRAND; + } if (template == Template.BASTION) { return BASTION_BRAND; } @@ -2045,9 +2057,13 @@ private String buildReadmeMarkdown() { } else if (template.isFullStack()) { boolean food = template == Template.SAVOR; boolean chat = template == Template.PARLEY; + boolean video = template == Template.REEL; String lower = toLowerCaseInvariant(appName); out.append("## The app and its server\n\n") - .append(chat + .append(video + ? "This project is a working video streaming service: a viewer, a creator studio, " + + "moderation and an admin console in one " + : chat ? "This project is a working messaging app: chats, groups, media, calls and an admin " + "console in one " : food @@ -2057,7 +2073,11 @@ private String buildReadmeMarkdown() { .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(chat + .append(video + ? "- `backend/` is the server: accounts and profiles, the catalog, playback and " + + "licences, subscriptions and payments, uploads, moderation, the live channel and " + + "the database migrations.\n" + : chat ? "- `backend/` is the server: accounts and devices, contacts, conversations and " + "messages, media, calls, the live channel and the database migrations.\n" : food @@ -2078,7 +2098,11 @@ private String buildReadmeMarkdown() { .append("Bash or WSL.\n\n") .append("On the `dev` profile the server creates demonstration accounts, all with the ") .append("password `").append(lower).append("-demo`: ") - .append(chat + .append(video + ? "`viewer@" + lower + ".example`, `viewer2@" + lower + ".example`, `creator@" + lower + + ".example`, who uploads the catalog, `moderator@" + lower + ".example`, `admin@" + + lower + ".example` and `viewer3@" + lower + ".example`, who has been flagged " + : chat ? "`alice@" + lower + ".example`, `bob@" + lower + ".example`, `carol@" + lower + ".example` and `dan@" + lower + ".example`, who already share chats and a group, " + "`admin@" + lower + ".example`, who sees the console, and `mallory@" + lower @@ -2089,7 +2113,7 @@ private String buildReadmeMarkdown() { + ".example` and `applicant@" + lower + ".example`, who has applied to deliver " : "`rider@" + lower + ".example`, `driver@" + lower + ".example`, `admin@" + lower + ".example` and `applicant@" + lower + ".example`, who has applied to drive ") - .append(chat ? "for the admin to act on. " : "and waits for the admin's decision. ") + .append(chat || video ? "for the admin to act on. " : "and waits for the admin's decision. ") .append("Verification codes are written to the server's log until an SMS provider is ") .append(chat ? "configured. Calls use a public STUN server until the server is given a TURN " 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 65e5e5850d5..4d401d00368 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 @@ -88,7 +88,16 @@ public enum Template { "com.codenameone.examples.bastion", "Bastion", "/bastion-preview.jpg", "The banking template is an app, a server and the contract module they share; " + "an Initializr Gradle project is a single project.", - new String[0], "/bastion-modules.zip", "/bastion-settings.properties"); + new String[0], "/bastion-modules.zip", "/bastion-settings.properties"), + // The same kind of template as WAYLINE: a video streaming service (catalog, + // player, creator studio, moderation, admin) and its server. It is the + // project in scripts/reel, and its resources come from + // `scripts/sync-initializr-template.py reel`. + REEL(false, "/reel-src.zip", "/reel-css.zip", "/reel-pom.xml", + "com.codenameone.examples.reel", "Reel", "/reel-preview.jpg", + "The streaming template is an app, a server and the contract module they share; " + + "an Initializr Gradle project is a single project.", + new String[0], "/reel-modules.zip", "/reel-settings.properties"); /// The resource holding the picture shown for a template that has no live /// preview, or null for one that has. diff --git a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md index 16ab5f03da6..c8e96e5c397 100644 --- a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md +++ b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md @@ -1,6 +1,6 @@ --- name: codename-one -description: Build and modify Codename One cross-platform apps (Java 17, Maven or Gradle; iOS, Android, desktop, web) and their Codename One Backend. Use when the project has `codenameone_settings.properties`, applies the `com.codenameone` Gradle plugin or depends on `codenameone-core`; when editing `src/main/css/` or code using `com.codename1.*`; when running `cn1:run`, `cn1:test`, `cn1:build`, `cn1:backend` or `./gradlew run`; when the user asks for a UI, screen tests, screenshots, a server or a full-stack feature; when porting or running existing Android, Swing, JavaFX, Flutter/Dart or Unity code (`src/main/android|desktop|flutter|unity`, `import-*-project`, `transcode-flutter`); or when asking how a Codename One app is best structured (the Wayline, Savor, Parley and Skirmish demos). +description: Build and modify Codename One cross-platform apps (Java 17, Maven or Gradle; iOS, Android, desktop, web) and their Codename One Backend. Use when the project has `codenameone_settings.properties`, applies the `com.codenameone` Gradle plugin or depends on `codenameone-core`; when editing `src/main/css/` or code using `com.codename1.*`; when running `cn1:run`, `cn1:test`, `cn1:build`, `cn1:backend` or `./gradlew run`; when the user asks for a UI, screen tests, screenshots, a server or a full-stack feature; when porting or running existing Android, Swing, JavaFX, Flutter/Dart or Unity code (`src/main/android|desktop|flutter|unity`, `import-*-project`, `transcode-flutter`); or when asking how a Codename One app is best structured (the Wayline, Savor, Parley, Bastion, Reel and Skirmish demos). metadata: type: skill --- diff --git a/scripts/initializr/common/src/main/resources/reel-css.zip b/scripts/initializr/common/src/main/resources/reel-css.zip new file mode 100644 index 00000000000..c7109d10f95 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/reel-css.zip differ diff --git a/scripts/initializr/common/src/main/resources/reel-modules.zip b/scripts/initializr/common/src/main/resources/reel-modules.zip new file mode 100644 index 00000000000..df16535924a Binary files /dev/null and b/scripts/initializr/common/src/main/resources/reel-modules.zip differ diff --git a/scripts/initializr/common/src/main/resources/reel-pom.xml b/scripts/initializr/common/src/main/resources/reel-pom.xml new file mode 100644 index 00000000000..5e088517a28 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/reel-pom.xml @@ -0,0 +1,406 @@ + + + 4.0.0 + + com.example.myapp + myappname + 1.0-SNAPSHOT + + com.example.myapp + myappname-common + 1.0-SNAPSHOT + jar + + + + + + ${project.groupId} + myappname-shared + ${project.version} + + + com.codenameone + codenameone-core + + + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + 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 + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + + + + diff --git a/scripts/initializr/common/src/main/resources/reel-preview.jpg b/scripts/initializr/common/src/main/resources/reel-preview.jpg new file mode 100644 index 00000000000..4d327554d14 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/reel-preview.jpg differ diff --git a/scripts/initializr/common/src/main/resources/reel-settings.properties b/scripts/initializr/common/src/main/resources/reel-settings.properties new file mode 100644 index 00000000000..70fa8d1a21e --- /dev/null +++ b/scripts/initializr/common/src/main/resources/reel-settings.properties @@ -0,0 +1,13 @@ +# 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 a creator picks a video or a poster from the library. +codename1.arg.ios.NSPhotoLibraryUsageDescription=Your photo library is where you pick the videos and posters you publish. +# 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 diff --git a/scripts/initializr/common/src/main/resources/reel-src.zip b/scripts/initializr/common/src/main/resources/reel-src.zip new file mode 100644 index 00000000000..d1d7b844f79 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/reel-src.zip differ diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 2c82a95b10e..6ba214dc493 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: codename-one -description: Build and modify Codename One cross-platform apps (Java 17, Maven or Gradle; iOS, Android, desktop, web) and their Codename One Backend. Use when the project has `codenameone_settings.properties`, applies the `com.codenameone` Gradle plugin or depends on `codenameone-core`; when editing `src/main/css/` or code using `com.codename1.*`; when running `cn1:run`, `cn1:test`, `cn1:build`, `cn1:backend` or `./gradlew run`; when the user asks for a UI, screen tests, screenshots, a server or a full-stack feature; when porting or running existing Android, Swing, JavaFX, Flutter/Dart or Unity code (`src/main/android|desktop|flutter|unity`, `import-*-project`, `transcode-flutter`); or when asking how a Codename One app is best structured (the Wayline, Savor, Parley and Skirmish demos). +description: Build and modify Codename One cross-platform apps (Java 17, Maven or Gradle; iOS, Android, desktop, web) and their Codename One Backend. Use when the project has `codenameone_settings.properties`, applies the `com.codenameone` Gradle plugin or depends on `codenameone-core`; when editing `src/main/css/` or code using `com.codename1.*`; when running `cn1:run`, `cn1:test`, `cn1:build`, `cn1:backend` or `./gradlew run`; when the user asks for a UI, screen tests, screenshots, a server or a full-stack feature; when porting or running existing Android, Swing, JavaFX, Flutter/Dart or Unity code (`src/main/android|desktop|flutter|unity`, `import-*-project`, `transcode-flutter`); or when asking how a Codename One app is best structured (the Wayline, Savor, Parley, Bastion, Reel and Skirmish demos). metadata: type: skill --- @@ -31,7 +31,7 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/html-css-cheatsheet.md` — Converting common HTML/CSS snippets to CN1 components + CSS. - `references/android-to-cn1.md` — Porting Android (XML + Kotlin/Java) screens to Codename One. - `references/react-to-cn1.md` — Bringing a **React / JSX + CSS design** (e.g. a Claude-generated bundle: `*.jsx` + `tokens.css` + `styles.css`) into Codename One: the component/hook/CSS mapping, the `tokens.css` -> `theme.css` token workflow, dark-mode re-skinning, and the **render-on-desktop-then-inspect** loop. Read this when the user hands you a React/HTML design to rebuild in CN1. -- `references/demos.md` — The five full-stack demo applications (Wayline ride hailing, Savor food delivery, Parley encrypted messenger, Bastion secure bank and exchange, Skirmish 3D card game): where their source is, which one to read for which feature, and the structural patterns they all share. **Read this before inventing an app structure, networking layer, live-update channel or theme layout** -- copy how a demo does it. +- `references/demos.md` — The six full-stack demo applications (Wayline ride hailing, Savor food delivery, Parley encrypted messenger, Bastion secure bank and exchange, Reel video streaming, Skirmish 3D card game): where their source is, which one to read for which feature, and the structural patterns they all share. **Read this before inventing an app structure, networking layer, live-update channel or theme layout** -- copy how a demo does it. - `references/compatibility.md` — The build-time compatibility tools: run an existing **Android** app (`src/main/android`), **Swing** or **JavaFX** app (`src/main/desktop`), **Flutter** code (`src/main/flutter`) or **Unity 2D** project (`src/main/unity`) on every Codename One target without rewriting it, the `import-*-project` goals, how to mix ported and new code, limits, and sample inputs. Read this first whenever the user has an existing app in one of those frameworks. - `references/flutter-to-cn1.md` — Bringing **Dart / Flutter widget source** into Codename One. Unlike the other porting guides this is mostly not a rewrite: the `transcode-flutter` Maven goal compiles Dart widgets to Java at build time (no Dart SDK involved), and `FlutterUI.wrap` / `FlutterUI.runApp` mount the result. Read this when the user has Dart or Flutter code, asks to embed a Flutter screen in a CN1 app, or asks whether a Flutter app can be ported. - `references/testing-and-screenshots.md` — `AbstractTest`, `TestUtils`, `screenshotTest`, the `cn1:test` Maven goal, the screenshot tolerance algorithm. @@ -381,7 +381,7 @@ If you cannot run the simulator (e.g. headless environment), **say so explicitly | "I have a React/JSX design (jsx + tokens.css), rebuild it in CN1" / "match this Claude design" | `references/react-to-cn1.md` | | "I have Android XML/Kotlin/Java, convert it" | `references/android-to-cn1.md` | | "Run my existing Android / Swing / JavaFX / Flutter / Unity app" / `import-*-project` / `src/main/android`, `desktop`, `flutter`, `unity` | `references/compatibility.md` | -| "How should I structure this app" / "show me a real example" / best practice for networking, live updates, maps, chat, payments, security, banking, games | `references/demos.md` (then read the demo's source) | +| "How should I structure this app" / "show me a real example" / best practice for networking, live updates, maps, chat, payments, video, security, banking, games | `references/demos.md` (then read the demo's source) | | "Generate a client for this OpenAPI spec / `.proto` / GraphQL schema" / `@RestClient`, `@GrpcClient`, `@GraphQLClient` | `references/api-clients.md` | | "Write a test for this screen" / "Compare to a baseline" | `references/testing-and-screenshots.md` | | "Match this mockup" / "Compare to a Figma/Sketch/XD design" / "Import this HTML/CSS (Claude) design's tokens" / "How close is this screen to the design" | `references/mockup-comparison.md` | diff --git a/scripts/initializr/common/src/main/resources/skill/references/demos.md b/scripts/initializr/common/src/main/resources/skill/references/demos.md index ec3c92cf2e9..42c4fd0d813 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/demos.md +++ b/scripts/initializr/common/src/main/resources/skill/references/demos.md @@ -1,6 +1,6 @@ # The demo applications: where to learn idiomatic Codename One -Codename One ships five complete, production-shaped applications. Each is an app +Codename One ships six complete, production-shaped applications. Each is an app **and** its server, written by the Codename One team, tested end to end, and kept building against the current framework. When you are unsure how something is "meant" to be done -- a screen, a navigation pattern, a live channel, a REST @@ -21,7 +21,7 @@ pattern.** Copy the shape, not the domain. It explains *why* the code is laid out the way it is -- read it before making a structural change modeled on a demo. -All five share this layout, which is itself the first best practice (Skirmish adds a +All six share this layout, which is itself the first best practice (Skirmish adds a `rules` module, the deterministic game simulation the app, server and tests all compile): | Module | Holds | @@ -41,12 +41,14 @@ README of each lists the demo accounts. | `scripts/wayline` | Ride hailing: rider, driver, admin | Role-based start screens, maps and live markers (`map/`), bottom sheets, wallet and card entry (`pay/`), admin dashboards and charts (`admin/`, `ui/Chart.java`), the reference implementation of the shared-contract full-stack pattern | | `scripts/savor` | Food delivery: customer, merchant, courier, admin | Catalog/menu browsing, cart and checkout (`customer/`), merchant order queues and menu editing (`merchant/`), two state machines (order + delivery) mirrored between `shared` enums and the server | | `scripts/parley` | End-to-end encrypted messenger with calls | Chat UI and media (`chat/`, `ui/ChatForm.java`), crypto with core APIs (`crypto/`), WebRTC calls (`com.codename1.webrtc`, `ui/CallScreen.java`), local store (`store/`), push and WebSocket delivery | +| `scripts/reel` | Video streaming: viewer, creator studio, moderation, admin | Video playback and its controls, audio and subtitle tracks (`player/`), protected (DRM) playback and offline downloads, uploads and transcoding jobs (`studio/`), plans, coupons and ads (`pay/`, `admin/`), households, profiles and parental ratings (`viewer/`) | | `scripts/bastion` | Security-hardened bank and crypto exchange: customer, staff console | Security end to end: sealed key storage, biometric/PIN app lock, certificate pinning, secure windows (`security/`), device-key request signing, passkeys, TOTP and step-up (`security/RequestSigner.java`, server `money/RequestSigning.java`, `account/`), a Security Center that reports each protection's live state (`ui/SecurityCenterForm.java`); a double-entry ledger with idempotency and conditional updates (server `ledger/`), a hash-chained audit log (`audit/`), accounting reports and PDF printing (`books/`, `ui/Printout.java`), an order-matching exchange (`exchange/`), and real Ether custody on an Ethereum node (server `chain/`). Its implementor's guide ends with a security checklist | | `scripts/skirmish` | Real-time 3D card battler | Games: `GameView`/3D rendering (`battle/`), a deterministic `rules` module shared by app, server and tests, replays, in-app purchases and rewarded ads (`engage/`), matchmaking over sockets, in-app editors (`forge/`) | ## Patterns the app demos share (copy these) -Wayline, Savor and Parley follow all of these. Skirmish is a game and differs: +Wayline, Savor, Parley and Reel follow all of these, except that Reel has no live +channel: playback reports progress over REST heartbeats instead. Skirmish is a game and differs: its socket is `net/GameLink.java` rather than `live/LiveChannel.java`, and it has no `Prefs`, `Telemetry` or l10n bundles. Bastion has no live channel or `Telemetry` (a bank sends no analytics and pulls its data on demand), and its 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 5f8fedd02cd..fc14f7c7253 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 @@ -104,6 +104,7 @@ public boolean runTest() throws Exception { validateMavenProjectTypeBeforeTheGate(); validateMavenRefusals(); validateFullStackTemplate(); + validateReelGate(); for (Template template : Template.values()) { for (ProjectOptions.ProjectType type : ProjectOptions.ProjectType.values()) { if (template.isFullStack() && type == ProjectOptions.ProjectType.APP) { @@ -376,6 +377,7 @@ private void assertFullStackProject(Template template, Map entri boolean rides = template == Template.WAYLINE; boolean bank = template == Template.BASTION; boolean game = template == Template.SKIRMISH; + boolean video = template == Template.REEL; String name = template.SOURCE_MAIN_CLASS; String upper = template.name(); String lower = GeneratorModel.toLowerCaseInvariant(name); @@ -390,12 +392,12 @@ private void assertFullStackProject(Template template, Map entri // project once got the ride-hailing accounts and chapters. String readme = getText(entries, "README.md"); boolean chat = template == Template.PARLEY; - assertContains(readme, game ? "card battler" : rides ? "ride-hailing app" : chat ? "messaging app" : bank ? "working bank" : "food-delivery app", + assertContains(readme, game ? "card battler" : rides ? "ride-hailing app" : chat ? "messaging app" : bank ? "working bank" : video ? "video streaming service" : "food-delivery app", label + "README names the app"); if (!game) { - assertContains(readme, (rides ? "`rider@" : chat ? "`alice@" : bank ? "`customer2@" : "`customer@") + artifact + ".example`", + assertContains(readme, (rides ? "`rider@" : chat ? "`alice@" : bank ? "`customer2@" : video ? "`viewer@" : "`customer@") + artifact + ".example`", label + "README lists the template's own demo accounts"); - assertFalse(readme.indexOf(rides || chat || bank ? "courier@" : "rider@") >= 0, + assertFalse(readme.indexOf(rides || chat || bank || video ? "courier@" : "rider@") >= 0, label + "README carries no other template's accounts"); assertFalse(chat && readme.indexOf("rider@") >= 0, label + "README carries no ride-hailing accounts"); } @@ -416,15 +418,15 @@ private void assertFullStackProject(Template template, Map entri 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 + (game ? "/api/AccountApi.java" : rides ? "/api/RiderApi.java" : chat ? "/api/MessageApi.java" : bank ? "/api/BankingApi.java" : "/api/OrderApi.java")), + assertContains(getText(entries, "shared/src/main/java/" + packagePath + (game ? "/api/AccountApi.java" : rides ? "/api/RiderApi.java" : chat ? "/api/MessageApi.java" : bank ? "/api/BankingApi.java" : video ? "/api/CatalogApi.java" : "/api/OrderApi.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/" + (game ? "V1__game.sql" : bank ? "V4__accounts.sql" : "V1__accounts.sql")), label + "the server keeps its migrations"); - assertNotNull(entries.get("backend/src/test/java/" + packagePath + (game ? "/GameSocketTest.java" : rides ? "/RideFlowTest.java" : chat ? "/MessagingTest.java" : bank ? "/LedgerTest.java" : "/OrderFlowTest.java")), + assertNotNull(entries.get("backend/src/test/java/" + packagePath + (game ? "/GameSocketTest.java" : rides ? "/RideFlowTest.java" : chat ? "/MessagingTest.java" : bank ? "/LedgerTest.java" : video ? "/PlaybackTest.java" : "/OrderFlowTest.java")), label + "the server keeps its tests"); - assertNotNull(entries.get("common/src/main/java/" + packagePath + (game ? "/ui/HomeForm.java" : rides ? "/rider/RiderForm.java" : chat ? "/ui/ChatForm.java" : bank ? "/ui/HomeForm.java" : "/customer/CustomerForm.java")), + assertNotNull(entries.get("common/src/main/java/" + packagePath + (game ? "/ui/HomeForm.java" : rides ? "/rider/RiderForm.java" : chat ? "/ui/ChatForm.java" : bank ? "/ui/HomeForm.java" : video ? "/ui/WelcomeForm.java" : "/customer/CustomerForm.java")), label + "the app's screens move to the project's package"); if (!game) { assertNotNull(entries.get("common/src/main/l10n/Bundle.properties"), label + "the app keeps its bundle"); @@ -444,7 +446,12 @@ private void assertFullStackProject(Template template, Map entri assertContains(settings, "codename1.arg.android.playIntegrity=true", label + "the template's Android hint"); } else { assertContains(settings, "codename1.arg.android.xapplication_attr=", label + "the template's Android hint"); - if (!game) { + if (video) { + // Streaming asks for no location; a creator picks videos and posters + // from the library. + assertContains(settings, "codename1.arg.ios.NSPhotoLibraryUsageDescription=", + label + "the template's iOS photo library prompt"); + } else if (!game) { assertContains(settings, "codename1.arg.ios.NSLocationWhenInUseUsageDescription=", label + "the template's iOS location prompt"); } @@ -570,6 +577,18 @@ private void assertRefused(GeneratorModel model, String expectedReason, String m /// The version the full-stack combinations are generated for. private static final String FULL_STACK_VERSION = GeneratorModel.FULL_STACK_SINCE; + /// The streaming template needs a newer core than the other complete apps: + /// below its own gate it is refused, and the page does not list it. + private void validateReelGate() throws Exception { + assertFalse(GeneratorModel.isVersionAtLeast(FULL_STACK_VERSION, GeneratorModel.REEL_SINCE), + "Reel's gate is past the other complete apps'"); + assertRefused(GeneratorModel.createForPluginVersion(IDE.INTELLIJ, Template.REEL, "OldReel", + "com.acme.oldreel", ProjectOptions.defaults().forFullStack(), FULL_STACK_VERSION), + GeneratorModel.REEL_SINCE, "Reel is not generated before its gate"); + assertEqual(Boolean.valueOf(GeneratorModel.isVersionAtLeast(GeneratorModel.cn1PluginVersion(), + GeneratorModel.REEL_SINCE)), Boolean.valueOf(GeneratorModel.isTemplateOffered(Template.REEL)), "The page offers Reel from its gate on"); + } + private static ProjectOptions mavenOptions(ProjectOptions.ProjectType type, boolean allModules) { return ProjectOptions.defaults().withBuild(ProjectOptions.BuildTool.MAVEN, type).withPlatformModules(allModules); } diff --git a/scripts/initializr/e2e-shipped.sh b/scripts/initializr/e2e-shipped.sh index 2be2f5f259f..a9e25607860 100755 --- a/scripts/initializr/e2e-shipped.sh +++ b/scripts/initializr/e2e-shipped.sh @@ -12,7 +12,7 @@ # # e2e-shipped.sh [--require-latest] [--no-ui] [TEMPLATE ...] # -# TEMPLATE is BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY or BASTION; all of them +# TEMPLATE is BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, PARLEY, BASTION or REEL; all of them # by default. Every other check of the templates builds them against this # checkout's 8.0-SNAPSHOT, which no developer has: the generated pom names the # release the Initializr pins, and that is the only thing this script builds @@ -59,7 +59,7 @@ for arg in "$@"; do *) TEMPLATES+=("$arg") ;; esac done -[ ${#TEMPLATES[@]} -gt 0 ] || TEMPLATES=(BAREBONES KOTLIN WAYLINE SAVOR SKIRMISH PARLEY BASTION) +[ ${#TEMPLATES[@]} -gt 0 ] || TEMPLATES=(BAREBONES KOTLIN WAYLINE SAVOR SKIRMISH PARLEY BASTION REEL) log() { echo "[e2e-shipped] $*" >&2; } fail() { echo "[e2e-shipped] FAIL: $*" >&2; exit 1; } @@ -73,12 +73,15 @@ since() { } FULL_STACK_SINCE="$(since FULL_STACK_SINCE)" BASTION_SINCE="$(since BASTION_SINCE)" -[ -n "$FULL_STACK_SINCE" ] && [ -n "$BASTION_SINCE" ] || fail "could not read the template floors from $GENERATOR" +REEL_SINCE="$(since REEL_SINCE)" +[ -n "$FULL_STACK_SINCE" ] && [ -n "$BASTION_SINCE" ] && [ -n "$REEL_SINCE" ] || fail "could not read the template floors from $GENERATOR" # The oldest release a template's generated code compiles against: Bastion -# calls framework API that first shipped in BASTION_SINCE. +# calls framework API that first shipped in BASTION_SINCE, Reel the protected +# playback API of REEL_SINCE. template_since() { case "$1" in BASTION) echo "$BASTION_SINCE" ;; + REEL) echo "$REEL_SINCE" ;; *) echo "$FULL_STACK_SINCE" ;; esac } diff --git a/scripts/reel/.gitignore b/scripts/reel/.gitignore new file mode 100644 index 00000000000..a689c4956a3 --- /dev/null +++ b/scripts/reel/.gitignore @@ -0,0 +1,3 @@ +# The demo video, made by tools/fetch-demo-media.sh; never committed. +/media/ +/sources/ diff --git a/scripts/reel/LICENSE b/scripts/reel/LICENSE new file mode 100644 index 00000000000..e998a652408 --- /dev/null +++ b/scripts/reel/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 Reel 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/reel/README.adoc b/scripts/reel/README.adoc new file mode 100644 index 00000000000..c7fff8d284f --- /dev/null +++ b/scripts/reel/README.adoc @@ -0,0 +1,184 @@ += Reel + +A video streaming service and its server, in one Maven project and one language. +A viewer picks a profile, browses the catalog, watches with resume, chapters and +downloads, and pays for a plan or rents a title; a creator uploads and packages +video and submits titles for review; a moderator approves, rejects and takes down +what is published; and an administrator runs the service. Which of these a person +sees is decided by the roles the server gives their account. + +The same project is a subscription service, a creator platform or an academy: +an administrator switches presets and features (ads, rentals, downloads, +uploads, courses, quizzes and polls) without a rebuild. + +It is a sample of how a Codename One application and a Codename One Backend fit +together, and of the streaming and DRM API (`com.codename1.media.StreamRequest`). + +== Start here + +Two chapters of the developer guide cover this project: + +* https://www.codenameone.com/developer-guide/#reel-users-guide[The user's guide] + (link:../../docs/developer-guide/Reel-Users-Guide.asciidoc[source]) shows the + application as a viewer, a creator, a moderator and an administrator see it. +* https://www.codenameone.com/developer-guide/#reel-implementors-guide[The implementor's guide] + (link:../../docs/developer-guide/Reel-Implementors-Guide.asciidoc[source]) + explains how it is built and what to change to make it yours: DRM license + services, billing, ads, the packaging worker and the database. + +To see it running first, the 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 and profiles, the catalog, the player (ads, + chapters, quizzes, polls and branches on the timeline), downloads, plans and + rentals, the creator studio, the moderation queue and the admin console. + Styled in `src/main/css/theme.css`. The text is in `src/main/l10n`, in + English, Spanish, French, German and Hebrew; the translations are + machine-generated samples that no native speaker has reviewed. + What it reports to Codename One analytics, once the user has agreed, is all + in `Telemetry`. + +| `backend` +| The server: accounts, households and profiles, devices and concurrent + streams, playback sessions with heartbeats, media and license tokens, DRM key + delivery, plans, coupons, rentals and invoices, ad decisioning and + impressions, uploads, packaging jobs, moderation with keyword and frame + blocklists, recommendations, sharing alerts and push notifications. + The schema is the migration scripts in `src/main/resources/db/migration`, and + the same server runs on SQLite, PostgreSQL, MySQL and MariaDB. + +| `tools/packager` +| A command line tool (needs `ffmpeg`) that packages video into AES-128 HLS and + ClearKey CENC, and the worker that does it for uploads. +|=== + +There are no platform modules: `common` builds the application for the +simulator, the desktop, Android, iOS and the browser itself. + +== Running it + +The video is not in the repository. Fetch and package the Blender open movies +once (`--small` keeps a minute of each): + +[source,bash] +---- +tools/fetch-demo-media.sh --small +---- + +Without it every screen still works; only the picture is missing. Then start +the server on the development profile, which needs nothing else installed: + +[source,bash] +---- +CN1_PROFILE=dev backend/server.sh run +---- + +and 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 +`reel-demo`, the plans, two ad campaigns and the catalog: + +[cols="2,3"] +|=== +| `viewer@reel.example`, `viewer2@reel.example` | watch +| `viewer3@reel.example` | a viewer with a sharing alert and a flag +| `creator@reel.example` | owns the uploaded titles +| `moderator@reel.example` | reviews what creators submit +| `admin@reel.example` | runs the service +|=== + +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 + +[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. + +== What is real and what is simulated + +Every packaged video is AES-128 HLS and ClearKey CENC, with keys the server +holds and hands out only against a session's license token; that is what the +demo plays everywhere. Widevine and FairPlay need a license service of your +own: set `reel.drm.widevine.url` or `reel.drm.fairplay.url` and the server +forwards the challenge to it. Unset, the scheme is not offered, and a device +that needs it is told so before playback starts. + +Payments are simulated on a development profile: `4242 4242 4242 4242` is +accepted, `4000 0000 0000 0002` is declined, and no money moves. With +`reel.payments.provider=stripe` and `REEL_PAYMENTS_STRIPE_SECRET` set, cards are +added on Stripe's own page. Store purchases are verified with the store except +on a development profile. + +Push notifications go through the Codename One push service with +`reel.push.app` and `reel.push.key`; on a development profile they are printed +on the console. + +`backend/application.properties` lists every setting a deployment supplies, +each also read from the environment in upper case with `_` for every dot. + +== 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 sign in and pick a profile, browse and + play, upload and submit a title, moderate it and administer the service, + 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 viewer, picks a profile and reaches the home + screen, 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/reel.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/reel`. + +== The Initializr template + +`scripts/sync-initializr-template.py reel --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 + +Reel 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/reel/backend/application-dev.properties b/scripts/reel/backend/application-dev.properties new file mode 100644 index 00000000000..981dbb98bc5 --- /dev/null +++ b/scripts/reel/backend/application-dev.properties @@ -0,0 +1,40 @@ +# 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::reel-properties-dev-providers[] +reel.payments.provider=simulated +reel.push.provider=console +# end::reel-properties-dev-providers[] + +# Packaged media and finished uploads. tools/fetch-demo-media.sh fills the +# first with the Blender open movies. +reel.media.dir=../media +reel.upload.dir=target/uploads +# The packaging worker's shared secret. Unset in production means the worker +# API answers 503, so a real deployment has to choose one. +reel.worker.token=dev-worker-token +# Store purchases are accepted without asking the store, which only a +# development server may do. +reel.billing.trust-store-receipts=true diff --git a/scripts/reel/backend/application-test.properties b/scripts/reel/backend/application-test.properties new file mode 100644 index 00000000000..a652cf4cf88 --- /dev/null +++ b/scripts/reel/backend/application-test.properties @@ -0,0 +1,24 @@ +# 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. +reel.signin.per-minute=1000 + +# Cards +# simulated: no test reaches a payment processor. +reel.payments.provider=simulated + +# Push notifications are kept for the tests to read, not sent. +reel.push.provider=recording + +reel.media.dir=target/test-media +reel.upload.dir=target/test-uploads +reel.worker.token=test-worker-token +reel.billing.trust-store-receipts=true diff --git a/scripts/reel/backend/application.properties b/scripts/reel/backend/application.properties new file mode 100644 index 00000000000..fadfa711cff --- /dev/null +++ b/scripts/reel/backend/application.properties @@ -0,0 +1,77 @@ +# 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, simulated cards, the demo accounts +# and the Blender open movies tools/fetch-demo-media.sh packaged. 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 +# reel.admin.email, reel.admin.password the first admin, who grants the rest +# reel.media.dir where packaged video is served from +# reel.upload.dir where finished uploads wait for the worker +# reel.worker.token the packaging worker's shared secret +# reel.media.secret the key media and license tokens are signed with +# reel.payments.provider and its settings below +# reel.push.app, reel.push.key push notifications, below +# +# and may change the currency (reel.currency). +# +# DRM. Every packaged video is AES-128 HLS and ClearKey CENC, served with keys +# this server holds; that is enough to stop a link being shared and is what +# the demo plays. Widevine and FairPlay need a license service of your own -- +# this server forwards the challenge to it and passes the answer back: +# +# reel.drm.widevine.url, reel.drm.widevine.header +# reel.drm.fairplay.url, reel.drm.fairplay.header, +# reel.drm.fairplay.certificate +# +# Unset, the scheme is simply not offered, and a device that needs it is told +# so before playback starts. +# +# Payments. reel.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 works over Stripe's REST API: cards are added on Stripe's +# own page, so a card number never reaches the app or this server. Keep the +# key out of this file and out of the repository; set it in the environment of +# the deployment instead, where each setting is read in upper case with _ for +# every dot: +# +# REEL_PAYMENTS_STRIPE_SECRET a restricted or secret key (sk_...) +# +# Store purchases (reel.billing.trust-store-receipts) are accepted unverified +# only when that is true, which only the development profiles set; a real +# deployment verifies receipts with the store before granting anything. +# +# Push notifications. reel.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 and needs reel.push.app and reel.push.key (REEL_PUSH_KEY). + +# ${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/reel/backend/pom.xml b/scripts/reel/backend/pom.xml new file mode 100644 index 00000000000..6362ff0b0a8 --- /dev/null +++ b/scripts/reel/backend/pom.xml @@ -0,0 +1,160 @@ + + + 4.0.0 + + com.codenameone.examples.reel + reel + 1.0-SNAPSHOT + + com.codenameone.examples.reel + reel-backend + 1.0-SNAPSHOT + jar + + reel-backend + + + + + com.codenameone + codenameone-backend + ${cn1.version} + + + + ${project.groupId} + reel-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/reel/backend/server.sh b/scripts/reel/backend/server.sh new file mode 100755 index 00000000000..ca09414b8d4 --- /dev/null +++ b/scripts/reel/backend/server.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# +# Builds and runs the Reel 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 Reel +# 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 "[reel-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/reel-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/reel-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" + # The JDK the build used, not whichever java is first on the PATH: the + # classes are Java 17, and an older java there fails every request. + JAVA="java" + if [ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/java" ]; then JAVA="$JAVA_HOME/bin/java"; fi + 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/reel/backend/src/main/java/com/codenameone/examples/reel/Caller.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Caller.java new file mode 100644 index 00000000000..d3bbc5ac4f2 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/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.reel; + +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/reel/backend/src/main/java/com/codenameone/examples/reel/Days.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Days.java new file mode 100644 index 00000000000..84e9b1552f3 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Days.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.reel; + +/// 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 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/reel/backend/src/main/java/com/codenameone/examples/reel/DemoData.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/DemoData.java new file mode 100644 index 00000000000..f9123660c36 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/DemoData.java @@ -0,0 +1,364 @@ +/* + * 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.reel; + +import com.codename1.backend.Crypto; +import com.codename1.backend.Json; +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.reel.account.DemoAccounts; +import com.codenameone.examples.reel.ads.Ads; +import com.codenameone.examples.reel.billing.Billing; +import com.codenameone.examples.reel.billing.Entitlements; +import com.codenameone.examples.reel.domain.AdCampaign; +import com.codenameone.examples.reel.domain.Chapter; +import com.codenameone.examples.reel.domain.Genre; +import com.codenameone.examples.reel.domain.Interaction; +import com.codenameone.examples.reel.domain.Plan; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.domain.Video; +import com.codenameone.examples.reel.domain.VideoKey; +import com.codenameone.examples.reel.learning.Learning; +import com.codenameone.examples.reel.playback.Subtitles; +import com.codenameone.examples.reel.playback.Tracks; +import com.codenameone.examples.reel.repository.AdCampaignRepository; +import com.codenameone.examples.reel.repository.ChapterRepository; +import com.codenameone.examples.reel.repository.GenreRepository; +import com.codenameone.examples.reel.repository.InteractionRepository; +import com.codenameone.examples.reel.repository.PlanRepository; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.repository.VideoKeyRepository; +import com.codenameone.examples.reel.repository.VideoRepository; + +import java.time.Instant; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// What a development server starts with: the plans, the genres, the demo +/// accounts, two ad campaigns and a catalog of the Blender open movies plus a +/// short course. +/// +/// The video itself is not in the repository. `tools/fetch-demo-media.sh` +/// downloads the films, packages them with fresh random keys and writes +/// `seed.json` beside them; this reads that file for each video's keys, +/// duration and renditions. Without it -- a fresh checkout, the tests -- the +/// catalog is written anyway, with keys made here, so every screen works and +/// only the picture is missing. +@Component +public class DemoData { + /// The catalog: id, kind, name, year, rating, genres, access, synopsis. + private static final String[][] TITLES = { + {"big-buck-bunny", Kinds.MOVIE, "Big Buck Bunny", "2008", "G", "animation,comedy", Entitlements.FREE, + "A gentle giant rabbit takes a stand against three bullying rodents."}, + {"sintel", Kinds.MOVIE, "Sintel", "2010", "PG-13", "animation,fantasy", Entitlements.SUBSCRIPTION, + "A lonely girl searches the world for the baby dragon she raised."}, + {"tears-of-steel", Kinds.MOVIE, "Tears of Steel", "2012", "PG-13", "sci-fi,action", Entitlements.RENTAL, + "In a future Amsterdam, scientists replay one breakup to save the world from robots."}, + {"elephants-dream", Kinds.MOVIE, "Elephants Dream", "2006", "PG", "sci-fi,animation", + Entitlements.SUBSCRIPTION, "Two men explore a vast and strange machine that may only be in their heads."}, + {"streaming-101", Kinds.COURSE, "Streaming 101", "2026", "G", "learning", Entitlements.SUBSCRIPTION, + "How a streaming service protects its video: segments, keys, licences and downloads."}}; + private static final String[][] GENRES = { + {"animation", "Animation"}, {"comedy", "Comedy"}, {"fantasy", "Fantasy"}, {"sci-fi", "Science Fiction"}, + {"action", "Action"}, {"learning", "Learning"}}; + /// Each title's classification beyond its rating (see Advisories). + private static final String[][] ADVISORIES = { + {"big-buck-bunny", "violence"}, + {"sintel", "violence,frightening"}, + {"tears-of-steel", "violence,language"}, + {"elephants-dream", "frightening,flashing"}}; + private static final String[] LESSONS = { + "Welcome to Reel Academy", "Keys and licences", "Watching offline"}; + + private final DemoAccounts accounts; + private final PlanRepository plans; + private final GenreRepository genres; + private final TitleRepository titles; + private final VideoRepository videos; + private final VideoKeyRepository keys; + private final ChapterRepository chapters; + private final InteractionRepository interactions; + private final AdCampaignRepository campaigns; + private final Subtitles subtitles; + private final String mediaDir; + private final String currency; + + public DemoData(DemoAccounts accounts, PlanRepository plans, GenreRepository genres, TitleRepository titles, + VideoRepository videos, VideoKeyRepository keys, ChapterRepository chapters, + InteractionRepository interactions, AdCampaignRepository campaigns, Subtitles subtitles, + @Value("${reel.media.dir:media}") String mediaDir, + @Value("${reel.currency:USD}") String currency) { + this.accounts = accounts; + this.plans = plans; + this.genres = genres; + this.subtitles = subtitles; + this.titles = titles; + this.videos = videos; + this.keys = keys; + this.chapters = chapters; + this.interactions = interactions; + this.campaigns = campaigns; + this.mediaDir = mediaDir; + this.currency = currency; + } + + @PostConstruct + public void seed() throws IOException { + if (accounts.seed()) { + catalog(readSeed()); + } + } + + @Transactional(rollbackFor = IOException.class) + void catalog(Map seed) throws IOException { + Instant now = Instant.now(); + plan("basic", "Basic with ads", "Everything, with short ad breaks. One screen, HD.", 699, 1, "720", true, + false, 7, 0); + plan("standard", "Standard", "No ads, two screens in Full HD, downloads.", 1549, 2, "1080", false, true, + 7, 1); + plan("premium", "Premium", "No ads, four screens in the best quality there is, downloads.", 2299, 4, + "2160", false, true, 0, 2); + for (int iter = 0; iter < GENRES.length; iter++) { + Genre g = new Genre(); + g.setId(GENRES[iter][0]); + g.setName(GENRES[iter][1]); + g.setPosition(iter); + genres.insert(g); + } + for (int iter = 0; iter < TITLES.length; iter++) { + String[] t = TITLES[iter]; + Title row = new Title(); + row.setId(t[0]); + row.setKind(t[1]); + row.setName(t[2]); + row.setYear(Integer.parseInt(t[3])); + row.setRating(t[4]); + for (int a = 0; a < ADVISORIES.length; a++) { + if (ADVISORIES[a][0].equals(t[0])) { + row.setAdvisories(ADVISORIES[a][1]); + } + } + row.setGenres(t[5]); + row.setAccess(t[6]); + row.setRentalPriceCents(Entitlements.RENTAL.equals(t[6]) ? 399 : 0); + row.setSynopsis(t[7]); + row.setCreator(DemoAccounts.CREATOR); + row.setStatus(Kinds.PUBLISHED); + row.setFeatured(iter == 1); + row.setCreatedAt(now.minusMillis((TITLES.length - iter) * Days.MILLIS)); + row.setPublishedAt(row.getCreatedAt()); + if (Kinds.COURSE.equals(t[1])) { + row.setCertificate(true); + row.setPassPercent(70); + String first = "academy-lesson-1"; + row.setPosterUrl("/art/" + first + "/poster.jpg"); + row.setBackdropUrl(row.getPosterUrl()); + titles.insert(row); + for (int lesson = 0; lesson < LESSONS.length; lesson++) { + video("academy-lesson-" + (lesson + 1), t[0], Kinds.LESSON, lesson + 1, LESSONS[lesson], + seed, now); + for (int lang = 0; lang < DemoSubtitles.LANGUAGES.length; lang++) { + subtitles.put("academy-lesson-" + (lesson + 1), DemoSubtitles.LANGUAGES[lang][0], + DemoSubtitles.LANGUAGES[lang][1], DemoSubtitles.vtt(lang, lesson), now); + } + } + lessonContent(); + } else { + row.setPosterUrl("/art/" + t[0] + "/poster.jpg"); + row.setBackdropUrl(row.getPosterUrl()); + titles.insert(row); + video(t[0], t[0], Kinds.FEATURE, 1, t[2], seed, now); + } + } + campaign("ad-coffee", "Northside Roasters", "Fresh Coffee", "https://example.com/coffee", "", now, seed); + campaign("ad-bike", "Ride More Cycles", "Ride More", "https://example.com/bikes", "action,sci-fi", now, seed); + } + + private void plan(String code, String name, String description, long cents, int streams, String quality, + boolean ads, boolean downloads, int trialDays, int position) throws IOException { + Plan p = new Plan(); + p.setCode(code); + p.setName(name); + p.setDescription(description); + p.setPriceCents(cents); + p.setCurrency(currency); + p.setBillingInterval(Billing.MONTH); + p.setMaxStreams(streams); + p.setMaxQuality(quality); + p.setAdSupported(ads); + p.setDownloads(downloads); + p.setTrialDays(trialDays); + p.setActive(true); + p.setPosition(position); + plans.insert(p); + } + + private void video(String id, String titleId, String role, int number, String name, Map seed, + Instant now) throws IOException { + Map entry = seed.get(id); + Video v = new Video(); + v.setId(id); + v.setTitleId(titleId); + v.setRole(role); + v.setNumber(number); + v.setName(name); + v.setStatus(Kinds.READY); + v.setProgressPercent(100); + v.setDurationSeconds(entry == null ? 600 : (int) Rows.number(entry.get("durationSeconds"))); + v.setWidth(entry == null ? 1280 : (int) Rows.number(entry.get("width"))); + v.setHeight(entry == null ? 720 : (int) Rows.number(entry.get("height"))); + v.setRenditions(entry == null ? "360,720" : Rows.join(strings(entry.get("renditions")))); + // The same three as a video the worker packaged: see Jobs.done. + v.setSchemes("AES_128,CLEARKEY,WIDEVINE"); + // A packaged film names its audio tracks in seed.json; the generated + // clips have the one. + v.setAudio(entry == null || entry.get("audio") == null ? Tracks.ORIGINAL : Rows.text(entry.get("audio"))); + v.setThumbnailUrl("/art/" + id + "/poster.jpg"); + v.setCreatedAt(now); + videos.insert(v); + VideoKey k = new VideoKey(); + k.setVideoId(id); + k.setKeyId(entry == null ? Ids.hex(Crypto.randomBytes(16)) : Rows.text(entry.get("keyId"))); + k.setKeyHex(entry == null ? Ids.hex(Crypto.randomBytes(16)) : Rows.text(entry.get("keyHex"))); + k.setIvHex(entry == null ? Ids.hex(Crypto.randomBytes(16)) : Rows.text(entry.get("ivHex"))); + k.setCreatedAt(now); + keys.insert(k); + } + + /// Chapters, a quiz, a poll and a branch, at the points the generated + /// lessons show them. + private void lessonContent() throws IOException { + chapter("academy-lesson-1", 0, "Why segments"); + chapter("academy-lesson-1", 15, "Encryption"); + chapter("academy-lesson-2", 0, "Asking for a key"); + chapter("academy-lesson-3", 0, "Downloads"); + interaction("academy-lesson-1", Learning.QUIZ, 30, "What does a player download first?", + Arrays.asList("The master playlist", "The last segment", "The key"), "0", + "The master playlist lists the renditions, and each rendition lists its segments.", true, "", 0); + interaction("academy-lesson-2", Learning.POLL, 20, "Which device do you watch on most?", + Arrays.asList("Phone", "Tablet", "TV", "Computer"), "", "", false, "", 0); + interaction("academy-lesson-2", Learning.QUIZ, 35, "Who gets a decryption key?", + Arrays.asList("Anyone with the link", "Entitled viewers", "Only admins"), "1", + "The license server checks the session before it hands over a key.", true, "", 1); + interaction("academy-lesson-3", Learning.BRANCH, 40, "Where next?", + Arrays.asList("Review keys and licences", "Finish the course"), "", "", false, + "academy-lesson-2,", 0); + } + + private void chapter(String video, int at, String name) throws IOException { + Chapter c = new Chapter(); + c.setId(Ids.next()); + c.setVideoId(video); + c.setStartSeconds(at); + c.setTitle(name); + chapters.insert(c); + } + + private void interaction(String video, String kind, int at, String prompt, List options, + String answers, String explanation, boolean required, String targets, int position) throws IOException { + Interaction i = new Interaction(); + i.setId(Ids.next()); + i.setVideoId(video); + i.setKind(kind); + i.setAtSeconds(at); + i.setPrompt(prompt); + i.setOptions(Rows.joinLines(options)); + i.setAnswers(answers); + i.setExplanation(explanation); + i.setRequired(required); + i.setBranchTargets(targets); + i.setPosition(position); + interactions.insert(i); + } + + private void campaign(String video, String advertiser, String name, String click, String genreList, Instant now, + Map seed) throws IOException { + video(video, "", Kinds.AD, 1, name, seed, now); + AdCampaign c = new AdCampaign(); + c.setId(Ids.next()); + c.setAdvertiser(advertiser); + c.setName(name); + c.setCreativeUrl(Ads.VIDEO + video); + c.setDurationSeconds(15); + c.setSkippableAfterSeconds(5); + c.setClickUrl(click); + c.setCpmCents(1200); + c.setBudgetCents(50000); + c.setGenres(genreList); + c.setActive(true); + c.setStartsAt(now.minusMillis(Days.MILLIS)); + c.setEndsAt(now.plusMillis(90 * Days.MILLIS)); + c.setCreatedAt(now); + campaigns.insert(c); + } + + /// `seed.json` keyed by slug, or empty when there is none. + Map readSeed() throws IOException { + Map out = new HashMap(); + File f = new File(mediaDir, "seed.json"); + if (f.isFile()) { + Object list = Json.parseObject(new String(read(f), "UTF-8")).get("videos"); + if (list instanceof List) { + List rows = (List) list; + for (int iter = 0; iter < rows.size(); iter++) { + Object row = rows.get(iter); + if (row instanceof Map) { + out.put(Rows.text(((Map) row).get("slug")), (Map) row); + } + } + } + } + return out; + } + + private static List strings(Object value) { + List out = new ArrayList(); + if (value instanceof List) { + List list = (List) value; + for (int iter = 0; iter < list.size(); iter++) { + out.add(String.valueOf(Rows.number(list.get(iter)))); + } + } + return out; + } + + private static byte[] read(File f) throws IOException { + InputStream in = new FileInputStream(f); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + for (int n = in.read(buf); n > 0; n = in.read(buf)) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } finally { + in.close(); + } + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/DemoSubtitles.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/DemoSubtitles.java new file mode 100644 index 00000000000..77a748e067b --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/DemoSubtitles.java @@ -0,0 +1,77 @@ +/* + * 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.reel; + +/// The captions of the generated academy lessons, in four languages: the +/// three lines each lesson shows on screen, one every fifteen seconds (see +/// the packager's Seed.LESSONS, which draws them). Kept as text rather than +/// as files so a fresh checkout and the tests have them with no media. +final class DemoSubtitles { + /// Language tag and what the player's menu calls it. + static final String[][] LANGUAGES = { + {"en", "English"}, + {"es", "Espa\u00f1ol"}, + {"fr", "Fran\u00e7ais"}, + {"de", "Deutsch"}, + }; + + /// Per language of [#LANGUAGES], per lesson, the three lines. + static final String[][][] LINES = { + { + {"Welcome to Reel Academy.", "Streams are cut into small segments.", "Each segment is encrypted."}, + {"Keys and licences.", "The player asks the server for a key.", "Only entitled viewers get one."}, + {"Watching offline.", "Downloads keep their encryption.", "Licences expire on schedule."}, + }, + { + {"Bienvenido a Reel Academy.", "Las emisiones se cortan en segmentos peque\u00f1os.", "Cada segmento est\u00e1 cifrado."}, + {"Claves y licencias.", "El reproductor pide una clave al servidor.", "Solo los espectadores con derecho la reciben."}, + {"Ver sin conexi\u00f3n.", "Las descargas conservan su cifrado.", "Las licencias caducan seg\u00fan lo previsto."}, + }, + { + {"Bienvenue \u00e0 Reel Academy.", "Les flux sont d\u00e9coup\u00e9s en petits segments.", "Chaque segment est chiffr\u00e9."}, + {"Cl\u00e9s et licences.", "Le lecteur demande une cl\u00e9 au serveur.", "Seuls les spectateurs autoris\u00e9s en re\u00e7oivent une."}, + {"Regarder hors ligne.", "Les t\u00e9l\u00e9chargements restent chiffr\u00e9s.", "Les licences expirent comme pr\u00e9vu."}, + }, + { + {"Willkommen bei der Reel Academy.", "Streams werden in kleine Segmente zerlegt.", "Jedes Segment ist verschl\u00fcsselt."}, + {"Schl\u00fcssel und Lizenzen.", "Der Player fragt den Server nach einem Schl\u00fcssel.", "Nur berechtigte Zuschauer erhalten einen."}, + {"Offline ansehen.", "Downloads bleiben verschl\u00fcsselt.", "Lizenzen laufen planm\u00e4\u00dfig ab."}, + }, + }; + + private DemoSubtitles() { + } + + /// The WebVTT track of lesson `lesson` (from 0) in language `language` + /// (an index of [#LANGUAGES]). + static String vtt(int language, int lesson) { + String[] lines = LINES[language][lesson]; + StringBuilder out = new StringBuilder("WEBVTT\n\n"); + for (int iter = 0; iter < lines.length; iter++) { + int from = iter * 15 + 1; + out.append(iter + 1).append('\n').append(time(from)).append(" --> ").append(time(from + 13)).append('\n') + .append(lines[iter]).append("\n\n"); + } + return out.toString(); + } + + private static String time(int seconds) { + return "00:00:" + (seconds < 10 ? "0" : "") + seconds + ".000"; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Ids.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Ids.java new file mode 100644 index 00000000000..3b761268d01 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Ids.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.reel; + +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 title 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"))); + } + + /// Hex text as bytes; null for text that is not an even run of hex digits. + public static byte[] unhex(String text) { + if (text == null || (text.length() & 1) != 0) { + return null; + } + byte[] out = new byte[text.length() / 2]; + for (int iter = 0; iter < out.length; iter++) { + int hi = Character.digit(text.charAt(iter * 2), 16); + int lo = Character.digit(text.charAt(iter * 2 + 1), 16); + if (hi < 0 || lo < 0) { + return null; + } + out[iter] = (byte) ((hi << 4) | lo); + } + return out; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Kinds.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Kinds.java new file mode 100644 index 00000000000..9b8894e8b4a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Kinds.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.reel; + +/// The values of the catalog's status and kind columns, in one place so the +/// studio, the worker, moderation and playback agree on them. +public final class Kinds { + public static final String MOVIE = "MOVIE"; + public static final String SERIES = "SERIES"; + public static final String COURSE = "COURSE"; + + /// A title the creator is still editing; nobody else sees it. + public static final String DRAFT = "DRAFT"; + /// Submitted, waiting for a moderator. + public static final String IN_REVIEW = "IN_REVIEW"; + public static final String PUBLISHED = "PUBLISHED"; + public static final String REJECTED = "REJECTED"; + /// Taken down after publication (a report upheld, or by an admin). + public static final String REMOVED = "REMOVED"; + + public static final String FEATURE = "FEATURE"; + public static final String EPISODE = "EPISODE"; + public static final String LESSON = "LESSON"; + public static final String TRAILER = "TRAILER"; + public static final String AD = "AD"; + + public static final String UPLOADING = "UPLOADING"; + public static final String QUEUED = "QUEUED"; + public static final String PROCESSING = "PROCESSING"; + public static final String READY = "READY"; + public static final String FAILED = "FAILED"; + /// The worker's frame hashes matched the blocklist. + public static final String BLOCKED = "BLOCKED"; + + public static final String OPEN = "OPEN"; + public static final String UPHELD = "UPHELD"; + public static final String DISMISSED = "DISMISSED"; + + private Kinds() { + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Requests.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Requests.java new file mode 100644 index 00000000000..c5372850b04 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Requests.java @@ -0,0 +1,81 @@ +/* + * 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.reel; + +import com.codename1.backend.security.SecurityExchange; + +/// What the request being served says about where it came from. +/// +/// The app names its device in two headers it sends with every call: +/// `X-Reel-Device`, an id it made once and keeps, and `X-Reel-Device-Name`. +/// The country is the one a CDN in front of the server puts in `CF-IPCountry`; +/// without such a CDN it is empty and sharing detection works on addresses +/// alone. +public final class Requests { + public static final String DEVICE = "X-Reel-Device"; + public static final String DEVICE_NAME = "X-Reel-Device-Name"; + public static final String PLATFORM = "X-Reel-Platform"; + public static final String PROFILE_KEY = "X-Reel-Profile-Key"; + + private Requests() { + } + + public static String header(String name) { + SecurityExchange exchange = SecurityExchange.current(); + String value = exchange == null ? null : exchange.getRequest().getHeader(name); + return value == null ? "" : value.trim(); + } + + /// The device id, cut to what the column holds; empty when not sent. + public static String deviceId() { + return clean(header(DEVICE), 64); + } + + public static String deviceName() { + String name = clean(header(DEVICE_NAME), 80); + return name.length() == 0 ? "Unknown device" : name; + } + + public static String platform() { + return clean(header(PLATFORM), 32); + } + + public static String ip() { + SecurityExchange exchange = SecurityExchange.current(); + String address = exchange == null ? null : exchange.getRequest().getRemoteAddress(); + return address == null ? "" : clean(address, 64); + } + + public static String country() { + return clean(header("CF-IPCountry"), 8); + } + + /// Printable ASCII only, at most `max` characters: these go into columns, + /// logs and admin screens. + static String clean(String value, int max) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < value.length() && out.length() < max; iter++) { + char c = value.charAt(iter); + if (c >= ' ' && c <= '~') { + out.append(c); + } + } + return out.toString().trim(); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Roles.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Roles.java new file mode 100644 index 00000000000..153e6eda148 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Roles.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.reel; + +/// The roles, as the backend's user store names them (without `ROLE_`). The +/// shared [com.codenameone.examples.reel.api.Roles] holds the same names for the app. +public final class Roles { + public static final String VIEWER = com.codenameone.examples.reel.api.Roles.VIEWER; + public static final String CREATOR = com.codenameone.examples.reel.api.Roles.CREATOR; + public static final String MODERATOR = com.codenameone.examples.reel.api.Roles.MODERATOR; + public static final String ADMIN = com.codenameone.examples.reel.api.Roles.ADMIN; + + /// Whether the caller moderates or administers: staff can play any title, + /// published or not, without a plan, to review it. + public static boolean isStaff() { + return Caller.hasRole(ADMIN) || Caller.hasRole(MODERATOR); + } + + private Roles() { + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Rows.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Rows.java new file mode 100644 index 00000000000..86f64d64b5a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Rows.java @@ -0,0 +1,213 @@ +/* + * 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.reel; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Reading the rows of an aggregate query, whose columns come back as whatever +/// number type the database driver chose, and the comma lists some columns hold. +public final class Rows { + private Rows() { + } + + public static long number(Object value) { + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + public static long number(Long value) { + return value == null ? 0L : value.longValue(); + } + + public static String text(Object value) { + return value == null ? "" : String.valueOf(value); + } + + /// The first column of each row as a key and the second as its 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 comma-separated column as a list, without empty entries. + public static List split(String value) { + List out = new ArrayList(); + if (value == null || value.length() == 0) { + return out; + } + int start = 0; + while (start <= value.length()) { + int comma = value.indexOf(',', start); + int end = comma < 0 ? value.length() : comma; + String part = value.substring(start, end).trim(); + if (part.length() > 0) { + out.add(part); + } + if (comma < 0) { + break; + } + start = comma + 1; + } + return out; + } + + /// A list as a comma-separated column. Commas inside an entry are dropped, + /// since they would split it on the way back. + public static String join(List values) { + StringBuilder out = new StringBuilder(); + if (values == null) { + return ""; + } + for (int iter = 0; iter < values.size(); iter++) { + String v = values.get(iter); + if (v == null) { + continue; + } + v = v.replace(",", "").trim(); + if (v.length() == 0) { + continue; + } + if (out.length() > 0) { + out.append(','); + } + out.append(v); + } + return out.toString(); + } + + /// Comma-separated integers, skipping anything that is not one. + public static List ints(String value) { + List out = new ArrayList(); + List parts = split(value); + for (int iter = 0; iter < parts.size(); iter++) { + try { + out.add(Integer.valueOf(Integer.parseInt(parts.get(iter)))); + } catch (NumberFormatException skipped) { + // not a number: not an answer + } + } + return out; + } + + public static String joinInts(List values) { + List parts = new ArrayList(); + if (values != null) { + for (int iter = 0; iter < values.size(); iter++) { + if (values.get(iter) != null) { + parts.add(String.valueOf(values.get(iter).intValue())); + } + } + } + return join(parts); + } + + /// A list as one entry per line, for entries that may hold commas (the + /// options of a quiz question). Line breaks inside an entry become spaces. + public static String joinLines(List values) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; values != null && iter < values.size(); iter++) { + String v = values.get(iter) == null ? "" : values.get(iter).replace('\r', ' ').replace('\n', ' ').trim(); + if (v.length() == 0) { + continue; + } + if (out.length() > 0) { + out.append('\n'); + } + out.append(v); + } + return out.toString(); + } + + public static List splitLines(String value) { + List out = new ArrayList(); + if (value == null || value.length() == 0) { + return out; + } + List parts = split(value, '\n'); + for (int iter = 0; iter < parts.size(); iter++) { + if (parts.get(iter).trim().length() > 0) { + out.add(parts.get(iter).trim()); + } + } + return out; + } + + // The compiled server's class library has no regular expressions, so no + // String.split or replaceAll: these are the cuts the server needs, by hand. + + /// `value` cut at every `separator`, as `String.split` cuts it: inner + /// empty pieces kept, trailing ones dropped. + public static List split(String value, char separator) { + List out = new ArrayList(); + int from = 0; + for (;;) { + int at = value.indexOf(separator, from); + if (at < 0) { + out.add(value.substring(from)); + while (out.size() > 1 && out.get(out.size() - 1).length() == 0) { + out.remove(out.size() - 1); + } + return out; + } + out.add(value.substring(from, at)); + from = at + 1; + } + } + + /// The runs of ASCII letters and digits in `folded` (already lower case), + /// in order; everything else separates them. + public static List words(String folded) { + List out = new ArrayList(); + int start = -1; + for (int iter = 0; iter <= folded.length(); iter++) { + char c = iter < folded.length() ? folded.charAt(iter) : ' '; + boolean word = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + if (word && start < 0) { + start = iter; + } else if (!word && start >= 0) { + out.add(folded.substring(start, iter)); + start = -1; + } + } + return out; + } + + /// The UTC calendar day of `millis` as `yyyy-MM-dd`, computed rather than + /// formatted: the compiled runtime's date formatting has no time zones. + public static String utcDay(long millis) { + long days = Math.floorDiv(millis, 86400000L); + // Howard Hinnant's days-to-civil, for the proleptic Gregorian calendar. + long z = days + 719468; + long era = Math.floorDiv(z, 146097); + long doe = z - era * 146097; + long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + long doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + long mp = (5 * doy + 2) / 153; + long d = doy - (153 * mp + 2) / 5 + 1; + long m = mp < 10 ? mp + 3 : mp - 9; + long y = yoe + era * 400 + (m <= 2 ? 1 : 0); + return y + "-" + (m < 10 ? "0" : "") + m + "-" + (d < 10 ? "0" : "") + d; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SecurityConfig.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SecurityConfig.java new file mode 100644 index 00000000000..fa0d1e9638e --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SecurityConfig.java @@ -0,0 +1,415 @@ +/* + * 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.reel; + +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 VIEWER, CREATOR, MODERATOR and ADMIN ([Roles]). +/// `/api/admin` is for admins and `/api/moderation` for moderators and admins; +/// the studio checks the creator role itself, because a feature flag can open +/// uploads to everyone. The rest is for anyone signed in and acts on the +/// caller's own account, profiles, list and subscription. +@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 = "reel-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:reel: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("reel").clientId(CLIENT_ID) + .clientName("Reel") + .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("${reel.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 `reel.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. + // Opening accounts is the one thing a stranger can ask for that + // writes a row each time. One limiter per rule: a shared one would + // share its count. + .rateLimit("/api/signup", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(20, 3600)) + // Guessing the current password with a token in hand. + .rateLimit("/api/me/password", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 3600)) + // Certificate codes are short enough to type; guessing them in a + // loop is what this slows down. + .rateLimit("/api/verify/**", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(60, 60)) + // Every playback start mints tokens and may raise a sharing alert; + // a person starts a few a minute at most. + // Profile PINs are four digits. + .rateLimit("/api/profiles/*/unlock", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 900)) + .rateLimit("/api/playback/start", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(30, 60)) + // tag::reel-api-rules[] + // The media origin, the license server, the worker API, the + // certificate page and the shared-title page are outside this + // chain: each checks its own signed token (MediaTokens, the + // worker's bearer secret) or shows only public facts, and none of + // them takes the user's access token. + .authorizeHttpRequests(auth -> auth + .requestMatchers(AntPathRequestMatcher.antMatcher("POST", "/api/signup")).permitAll() + .requestMatchers(AntPathRequestMatcher.antMatcher("GET", "/api/config")).permitAll() + .requestMatchers(AntPathRequestMatcher.antMatcher("GET", "/api/plans")).permitAll() + .requestMatchers(AntPathRequestMatcher.antMatcher("GET", "/api/verify/**")).permitAll() + .requestMatchers("/api/admin/**").hasRole(Roles.ADMIN) + .requestMatchers("/api/moderation/**").hasAnyRole(Roles.MODERATOR, Roles.ADMIN) + .anyRequest().authenticated()) + // end::reel-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/reel/backend/src/main/java/com/codenameone/examples/reel/Services.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Services.java new file mode 100644 index 00000000000..00c8c0d0ebc --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Services.java @@ -0,0 +1,201 @@ +/* + * 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.reel; + +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.reel.moderation.ContentClassifier; +import com.codenameone.examples.reel.pay.MisconfiguredPayments; +import com.codenameone.examples.reel.playback.HttpLicenseProxy; +import com.codenameone.examples.reel.playback.LicenseProvider; +import com.codenameone.examples.reel.playback.Licenses; +import com.codenameone.examples.reel.repository.VideoKeyRepository; +import com.codenameone.examples.reel.settings.Settings; +import com.codenameone.examples.reel.pay.PaymentProvider; +import com.codenameone.examples.reel.pay.SimulatedPayments; +import com.codenameone.examples.reel.pay.StripePayments; +import com.codenameone.examples.reel.push.CloudPushSender; +import com.codenameone.examples.reel.push.ConsolePushSender; +import com.codenameone.examples.reel.push.MisconfiguredPushSender; +import com.codenameone.examples.reel.push.PushSender; +import com.codenameone.examples.reel.push.RecordingPushSender; + +import java.util.ArrayList; +import java.util.List; + +/// 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 -- `reel.payments.provider` and +/// `reel.push.provider` -- and, when that is not set, a development profile +/// (dev, test, local) gets the one that needs no account: 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 `.` (`REEL_PAYMENTS_STRIPE_SECRET`), which is where secrets belong. +@Configuration +public class Services { + // tag::reel-payment-choice[] + /// `reel.payments.provider` is one of: + /// + /// - `stripe`: [StripePayments], an untested stub over Stripe's REST API. + /// It needs `reel.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 `reel.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("${reel.payments.provider:}") String chosen, + @Value("${reel.payments.stripe.secret:}") String secret, + @Value("${reel.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, "reel.payments.provider is '" + kind + + "', which is neither stripe nor simulated"); + } + String address = publicUrl.length() > 0 ? publicUrl : issuer; + String missing = missing("", new String[] {"reel.payments.stripe.secret", secret, + "reel.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::reel-payment-choice[] + + // tag::reel-push-choice[] + /// `reel.push.provider` is one of: + /// + /// - `cloud`: the Codename One push service. It needs `reel.push.app`, + /// the app's push id, and `reel.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 video: it logs a configuration + /// error at start-up and again for every notification it could not send. + @Bean + public PushSender pushSender(Config config, + @Value("${reel.push.provider:}") String chosen, + @Value("${reel.push.app:}") String app, + @Value("${reel.push.key:}") String key, + @Value("${reel.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("reel.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("reel.push.provider is '" + kind + + "', which is none of cloud, console or recording"); + } + String missing = missing("reel.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::reel-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(); + } + + /// The license servers. ClearKey and AES-128 are answered by Reel itself + /// from the keys in `video_key`; Widevine and FairPlay need a vendor + /// license service (a DRM provider's proxy endpoint), so each is offered + /// only when its URL is configured. + @Bean + public Licenses licenses(VideoKeyRepository keys, Settings settings, + @Value("${reel.drm.widevine.url:}") String widevineUrl, + @Value("${reel.drm.widevine.header:}") String widevineHeader, + @Value("${reel.drm.fairplay.url:}") String fairplayUrl, + @Value("${reel.drm.fairplay.header:}") String fairplayHeader, + @Value("${reel.drm.fairplay.certificate:}") String fairplayCertificate) { + LicenseProvider widevine = widevineUrl.trim().length() == 0 ? null + : new HttpLicenseProxy("WIDEVINE", widevineUrl.trim(), widevineHeader, null); + LicenseProvider fairplay = fairplayUrl.trim().length() == 0 ? null + : new HttpLicenseProxy("FAIRPLAY", fairplayUrl.trim(), fairplayHeader, fairplayCertificate.trim()); + return new Licenses(keys, settings, widevine, fairplay); + } + + /// No classifier: see [ContentClassifier] for plugging one in. + @Bean + public ContentClassifier contentClassifier() { + return new ContentClassifier() { + @Override + public List classify(String name, String synopsis, List tags) { + return new ArrayList(); + } + }; + } + + private static String unset(String service, String provider, String missing) { + return service + " provider '" + provider + "' is not configured; set " + missing; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SignInCodePage.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/SignInCodePage.java new file mode 100644 index 00000000000..c2cddf61505 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/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.reel; + +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/reel/backend/src/main/java/com/codenameone/examples/reel/Text.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/Text.java new file mode 100644 index 00000000000..8a71161c40e --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/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.reel; + +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 Viewer" is "DV". + 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/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountCleanup.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountCleanup.java new file mode 100644 index 00000000000..942c02391b7 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountCleanup.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.reel.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.reel.domain.Household; +import com.codenameone.examples.reel.repository.AnswerRepository; +import com.codenameone.examples.reel.repository.CertificateRepository; +import com.codenameone.examples.reel.repository.DeviceRepository; +import com.codenameone.examples.reel.repository.DownloadRepository; +import com.codenameone.examples.reel.repository.HouseholdRepository; +import com.codenameone.examples.reel.repository.ListEntryRepository; +import com.codenameone.examples.reel.repository.PlaybackRepository; +import com.codenameone.examples.reel.repository.ProgressRepository; +import com.codenameone.examples.reel.repository.PushDeviceRepository; +import com.codenameone.examples.reel.repository.RentalRepository; +import com.codenameone.examples.reel.repository.SharingAlertRepository; +import com.codenameone.examples.reel.repository.SubscriptionRepository; +import com.codenameone.examples.reel.repository.TitleRatingRepository; + +import java.util.List; + +/// Deletes what a closed account leaves in the viewing tables, inside the +/// closing transaction ([Accounts#close]). Invoices are not touched: they are the +/// service's accounts, and stay under the retired name. Titles the account +/// published stay too, unpublished by the studio when their creator is gone. +@Component +public class AccountCleanup { + private final HouseholdRepository households; + private final ListEntryRepository lists; + private final DeviceRepository devices; + private final ProgressRepository progress; + private final TitleRatingRepository ratings; + private final DownloadRepository downloads; + private final AnswerRepository answers; + private final PlaybackRepository playbacks; + private final CertificateRepository certificates; + private final SharingAlertRepository alerts; + private final SubscriptionRepository subscriptions; + private final RentalRepository rentals; + private final PushDeviceRepository pushes; + + public AccountCleanup(HouseholdRepository households, ListEntryRepository lists, + DeviceRepository devices, ProgressRepository progress, TitleRatingRepository ratings, + DownloadRepository downloads, AnswerRepository answers, PlaybackRepository playbacks, + CertificateRepository certificates, SharingAlertRepository alerts, + SubscriptionRepository subscriptions, RentalRepository rentals, + PushDeviceRepository pushes) { + this.households = households; + this.lists = lists; + this.devices = devices; + this.progress = progress; + this.ratings = ratings; + this.downloads = downloads; + this.answers = answers; + this.playbacks = playbacks; + this.certificates = certificates; + this.alerts = alerts; + this.subscriptions = subscriptions; + this.rentals = rentals; + this.pushes = pushes; + } + + public void removeEverythingOf(String username) { + List mine = households.findByUsernameOrderByCreatedAtAsc(username); + for (int iter = 0; iter < mine.size(); iter++) { + lists.deleteByProfileId(mine.get(iter).getId()); + } + households.deleteByUsername(username); + devices.deleteByUsername(username); + progress.deleteByUsername(username); + ratings.deleteByUsername(username); + downloads.deleteByUsername(username); + answers.deleteByUsername(username); + playbacks.deleteByUsername(username); + certificates.deleteByUsername(username); + alerts.deleteByUsername(username); + rentals.deleteByUsername(username); + pushes.deleteByUsername(username); + if (subscriptions.findById(username) != null) { + subscriptions.deleteById(username); + } + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountEndpoint.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountEndpoint.java new file mode 100644 index 00000000000..1359efdb1af --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/AccountEndpoint.java @@ -0,0 +1,145 @@ +/* + * 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.reel.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.reel.Caller; +import com.codenameone.examples.reel.api.AccountApiServer; +import com.codenameone.examples.reel.api.AccountClosureDto; +import com.codenameone.examples.reel.api.AccountDto; +import com.codenameone.examples.reel.api.DeviceDto; +import com.codenameone.examples.reel.api.HouseholdProfileDto; +import com.codenameone.examples.reel.api.PasswordChangeDto; +import com.codenameone.examples.reel.api.PinDto; +import com.codenameone.examples.reel.api.ProfileDto; +import com.codenameone.examples.reel.api.ProfileEditDto; +import com.codenameone.examples.reel.api.PushDeviceDto; +import com.codenameone.examples.reel.api.SessionDto; +import com.codenameone.examples.reel.api.SignupDto; +import com.codenameone.examples.reel.playback.Playbacks; +import com.codenameone.examples.reel.push.PushDevices; + +import java.util.List; + +@Component +public class AccountEndpoint implements AccountApiServer { + private final Accounts accounts; + private final Households households; + private final Devices devices; + private final PushDevices pushes; + private final Playbacks playbacks; + + public AccountEndpoint(Accounts accounts, Households households, Devices devices, + PushDevices pushes, Playbacks playbacks) { + this.accounts = accounts; + this.households = households; + this.devices = devices; + this.pushes = pushes; + this.playbacks = playbacks; + } + + @Override + public AccountDto signup(SignupDto signup) throws Exception { + return accounts.register(signup); + } + + /// Also where a device learns it was signed out: [Devices#touch] answers 401. + @Override + public AccountDto me() throws Exception { + String who = Caller.name(); + devices.touch(who); + accounts.seen(who); + return accounts.describe(who); + } + + @Override + public AccountDto updateProfile(ProfileDto profile) throws Exception { + return accounts.saveProfile(Caller.name(), profile); + } + + @Override + public String changePassword(PasswordChangeDto change) throws Exception { + accounts.changePassword(Caller.name(), change == null ? null : change.current(), + change == null ? null : change.replacement()); + return "ok"; + } + + @Override + public String close(AccountClosureDto closure) throws Exception { + accounts.close(Caller.name(), closure == null ? null : closure.password()); + return "closed"; + } + + @Override + public List devices() throws Exception { + return devices.list(Caller.name()); + } + + @Override + public String signOutDevice(String id) throws Exception { + String who = Caller.name(); + devices.signOut(who, id); + playbacks.endForDevice(who, id); + return "ok"; + } + + @Override + public List sessions() throws Exception { + return playbacks.liveFor(Caller.name()); + } + + @Override + public String registerPush(PushDeviceDto device) throws Exception { + pushes.register(Caller.name(), device); + return "ok"; + } + + @Override + public String unregisterPush(PushDeviceDto device) throws Exception { + pushes.unregister(Caller.name(), device); + return "ok"; + } + + @Override + public List profiles() throws Exception { + String who = Caller.name(); + return households.list(who, accounts.displayName(who)); + } + + @Override + public HouseholdProfileDto createProfile(ProfileEditDto profile) throws Exception { + return households.create(Caller.name(), profile); + } + + @Override + public HouseholdProfileDto updateHouseholdProfile(String id, ProfileEditDto profile) throws Exception { + return households.update(Caller.name(), id, profile); + } + + @Override + public String deleteProfile(String id) throws Exception { + households.delete(Caller.name(), id); + return "ok"; + } + + @Override + public HouseholdProfileDto unlockProfile(String id, PinDto pin) throws Exception { + return households.unlock(Caller.name(), id, pin == null ? null : pin.pin()); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Accounts.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Accounts.java new file mode 100644 index 00000000000..df77cd72859 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Accounts.java @@ -0,0 +1,407 @@ +/* + * 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.reel.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.crypto.PasswordEncoder; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.Roles; +import com.codenameone.examples.reel.api.AccountDto; +import com.codenameone.examples.reel.api.ProfileDto; +import com.codenameone.examples.reel.api.SignupDto; +import com.codenameone.examples.reel.api.UserDto; +import com.codenameone.examples.reel.billing.Entitlements; +import com.codenameone.examples.reel.domain.ClosedAccount; +import com.codenameone.examples.reel.domain.Profile; +import com.codenameone.examples.reel.pay.Payments; +import com.codenameone.examples.reel.repository.ClosedAccountRepository; +import com.codenameone.examples.reel.repository.ProfileRepository; + +import java.time.Instant; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/// 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 +/// is this application's table. The name is the e-mail address, in lower case. +@Component +public class Accounts { + private final ProfileRepository profiles; + private final AccountCleanup cleanup; + private final JdbcUserDetailsManager users; + private final PasswordEncoder encoder; + private final Moderation moderation; + private final Payments payments; + private final ClosedAccountRepository closed; + private final Grants grants; + private final Entitlements entitlements; + + public Accounts(ProfileRepository profiles, AccountCleanup cleanup, JdbcUserDetailsManager users, + PasswordEncoder encoder, Moderation moderation, Payments payments, + ClosedAccountRepository closed, Grants grants, Entitlements entitlements) { + this.profiles = profiles; + this.cleanup = cleanup; + this.users = users; + this.encoder = encoder; + this.moderation = moderation; + this.payments = payments; + this.closed = closed; + this.grants = grants; + this.entitlements = entitlements; + } + + /// Opens an account. Answers 400 for a request that cannot be one and 409 + /// when the e-mail already has one. Not a transaction itself, so that after + /// a failure it can look again: two sign-ups racing for one e-mail both + /// pass the check, the unique key refuses the second, and the 409 is the + /// honest answer whatever the database called the error. + public AccountDto register(SignupDto request) throws IOException { + try { + return open(request); + } catch (ResponseStatusException answered) { + throw answered; + } catch (IOException | RuntimeException failed) { + if (request != null && request.email() != null && users.userExists(email(request.email()))) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + throw failed; + } + } + + @Transactional(rollbackFor = IOException.class) + private AccountDto open(SignupDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Nothing to register"); + } + String username = email(request.email()); + password(request.password()); + String name = required(request.name(), 120, "your name"); + if (users.userExists(username)) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + // A closed account's e-mail is never handed out again: its invoices and + // certificates are still filed under it. + if (closed.existsById(Ids.sha256(username))) { + throw new ResponseStatusException(409, + "That e-mail belonged to an account that was closed. Use another"); + } + create(username, request.password(), name, new String[] {Roles.VIEWER}, + Instant.now()); + return describe(username); + } + + /// Creates the sign-in and the profile. The caller has checked the name is free. + @Transactional(rollbackFor = IOException.class) + public void create(String username, String password, String name, String[] roles, Instant openedAt) + throws IOException { + users.createUser(User.withUsername(username).password(encoder.encode(password)) + .roles(roles).build()); + Profile profile = new Profile(); + profile.setUsername(username); + profile.setDisplayName(name); + profile.setCreatedAt(openedAt); + profile.setLanguage("en"); + profiles.insert(profile); + } + + public boolean exists(String username) { + return username != null && users.userExists(username); + } + + /// The account as its owner sees it. Answers 404 for a name with no account. + @Transactional(readOnly = true) + public AccountDto 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); + Entitlements.Allowance plan = entitlements.allowance(username); + return new AccountDto(username, profile.getDisplayName(), username, roles(signIn), + plan.planCode, plan.planName, plan.status, plan.periodEndsAt, plan.ads, plan.maxStreams, + plan.maxQuality, plan.downloads, !signIn.isEnabled()); + } + + /// The account as the admin console lists it, without the counts the + /// console adds. + @Transactional(readOnly = true) + public UserDto summary(String username, int devices, int streamsNow) 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); + Entitlements.Allowance plan = entitlements.allowance(username); + return new UserDto(username, profile.getDisplayName(), username, roles(signIn), plan.planCode, + plan.status, !signIn.isEnabled(), profile.isFlagged(), devices, streamsNow, + profile.getCreatedAt().toEpochMilli(), profile.getLastSeenAt().toEpochMilli()); + } + + @Transactional(readOnly = true) + public List newestFirst() { + List rows = profiles.newestFirst(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(rows.get(iter).getUsername()); + } + return out; + } + + @Transactional(readOnly = true) + public String displayName(String username) { + Profile profile = username == null ? null : profiles.findById(username); + return profile == null ? "" : profile.getDisplayName(); + } + + /// Whether the account holds `role`, read from the user store now. + public boolean hasRole(String username, String role) { + if (!exists(username)) { + return false; + } + return roles(users.loadUserByUsername(username)).contains(role); + } + + /// Replaces an account's roles. VIEWER is always kept. An admin cannot take + /// the admin role from themselves, so the service is never left with none. + public UserDto setRoles(String by, String username, List wanted) throws IOException { + List roles = new ArrayList(); + roles.add(Roles.VIEWER); + String[] known = {Roles.CREATOR, Roles.MODERATOR, Roles.ADMIN}; + for (int iter = 0; iter < known.length; iter++) { + if (wanted != null && wanted.contains(known[iter])) { + roles.add(known[iter]); + } + } + if (by.equals(username) && !roles.contains(Roles.ADMIN)) { + throw new ResponseStatusException(409, "You cannot take the admin role from yourself"); + } + changeRoles(by, username, roles); + return summary(username, 0, 0); + } + + @Transactional(rollbackFor = IOException.class) + private void changeRoles(String by, String username, List roles) throws IOException { + actingAdmin(by, username); + UserDetails signIn = load(username); + users.updateUser(User.withUsername(username).password(signIn.getPassword()) + .disabled(!signIn.isEnabled()).roles(roles.toArray(new String[roles.size()])).build()); + moderation.record(username, by, Moderation.ROLES, String.join(", ", roles)); + } + + /// Blocks an account or lifts the block. A blocked account's tokens stop + /// working at its next request: the roles are read on every one. + public UserDto suspend(String by, String username, boolean suspended, String reason) + throws IOException { + if (by.equals(username)) { + throw new ResponseStatusException(409, "You cannot block yourself"); + } + changeSuspension(by, username, suspended, reason); + if (suspended) { + grants.endAll(username); + } + return summary(username, 0, 0); + } + + @Transactional(rollbackFor = IOException.class) + private void changeSuspension(String by, String username, boolean suspended, String reason) + throws IOException { + actingAdmin(by, username); + UserDetails signIn = load(username); + List roles = roles(signIn); + users.updateUser(User.withUsername(username).password(signIn.getPassword()).disabled(suspended) + .roles(roles.toArray(new String[roles.size()])).build()); + moderation.record(username, by, suspended ? Moderation.BLOCK : Moderation.UNBLOCK, reason); + } + + @Transactional(rollbackFor = IOException.class) + public UserDto flag(String by, String username, boolean flagged, String reason) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + String why = reason == null ? "" : reason.trim(); + profile.setFlagged(flagged); + profile.setFlagReason(flagged ? (why.length() > 255 ? why.substring(0, 255) : why) : ""); + profiles.save(profile); + moderation.record(username, by, flagged ? Moderation.FLAG : Moderation.UNFLAG, why); + return summary(username, 0, 0); + } + + @Transactional(rollbackFor = IOException.class) + public AccountDto saveProfile(String username, ProfileDto wanted) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + profile.setDisplayName(required(wanted == null ? null : wanted.name(), 120, "your name")); + profiles.save(profile); + return describe(username); + } + + /// Notes that the account was used, at most once a minute. + @Transactional + public void seen(String username) { + Profile profile = profiles.findById(username); + Instant now = Instant.now(); + if (profile != null && profile.getLastSeenAt().plusMillis(60000L).isBefore(now)) { + profile.setLastSeenAt(now); + profiles.save(profile); + } + } + + /// Changes the password, and ends every other sign-in: a password is changed + /// as often as not because someone else may have the old one. + @Transactional(rollbackFor = IOException.class) + public AccountDto changePassword(String username, String current, String replacement) + throws IOException { + // Held first so two changes at once queue; the second then 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)); + grants.endOthers(username); + return describe(username); + } + + /// Closes an account for good: the sign-in, the profile, the household, + /// the devices, the saved cards and the viewing history are deleted. The + /// invoices stay, as the service's accounts, under a retired name. + /// `password` is asked for again: a token alone must not be enough for + /// something that cannot be undone. + @Transactional(rollbackFor = IOException.class) + public void close(String username, String password) throws IOException { + 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"); + } + if (roles(signIn).contains(Roles.ADMIN)) { + throw new ResponseStatusException(409, "Hand the admin role on before closing"); + } + payments.forget(username); + moderation.forget(username); + cleanup.removeEverythingOf(username); + if (profiles.findById(username) != null) { + profiles.deleteById(username); + } + String hash = Ids.sha256(username); + if (!closed.existsById(hash)) { + ClosedAccount row = new ClosedAccount(); + row.setUsernameHash(hash); + row.setClosedAt(Instant.now()); + closed.insert(row); + } + users.deleteUser(username); + grants.endAll(username); + } + + /// Holds both accounts, in name order so two admins cannot deadlock, and + /// checks `by` still is an admin: otherwise two admins could each strip the + /// other at the same moment and leave the service with none. + private void actingAdmin(String by, String username) { + 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() || !roles(acting).contains(Roles.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); + } + + /// The roles, without their `ROLE_` prefix. + private static List roles(UserDetails signIn) { + List held = new ArrayList(); + Iterator it = signIn.getAuthorities().iterator(); + while (it.hasNext()) { + String name = it.next().getAuthority(); + held.add(name.startsWith("ROLE_") ? name.substring(5) : name); + } + return held; + } + + 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"); + } + } + + /// An e-mail address as an account name: trimmed, and its ASCII letters + /// folded by hand, because `toLowerCase()` follows the server's locale and + /// a key must not. + 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); + } + + 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/reel/backend/src/main/java/com/codenameone/examples/reel/account/DemoAccounts.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/DemoAccounts.java new file mode 100644 index 00000000000..79b7309b3cd --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/DemoAccounts.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.reel.account; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.reel.Days; +import com.codenameone.examples.reel.Roles; +import com.codenameone.examples.reel.domain.Profile; +import com.codenameone.examples.reel.repository.ProfileRepository; + +import java.time.Instant; +import java.io.IOException; + +/// The accounts a development server starts with, one per role, all with the +/// password [#PASSWORD]. A server on any other profile makes only the first +/// admin, from `reel.admin.email` and `reel.admin.password`, and nobody else. +/// +/// [com.codenameone.examples.reel.DemoData] calls [#seed()] before it writes +/// the catalog, because the uploaded titles belong to [#CREATOR]. +@Component +public class DemoAccounts { + public static final String VIEWER = "viewer@reel.example"; + public static final String SECOND_VIEWER = "viewer2@reel.example"; + /// A viewer with an open sharing alert and a flag, for the admin console. + public static final String FLAGGED_VIEWER = "viewer3@reel.example"; + public static final String CREATOR = "creator@reel.example"; + public static final String MODERATOR = "moderator@reel.example"; + public static final String ADMIN = "admin@reel.example"; + public static final String PASSWORD = "reel-demo"; + + private final Accounts accounts; + private final Moderation moderation; + private final ProfileRepository profiles; + private final Config config; + private final String adminEmail; + private final String adminPassword; + + public DemoAccounts(Accounts accounts, Moderation moderation, ProfileRepository profiles, Config config, + @Value("${reel.admin.email:}") String adminEmail, + @Value("${reel.admin.password:}") String adminPassword) { + this.accounts = accounts; + this.moderation = moderation; + this.profiles = profiles; + this.config = config; + this.adminEmail = adminEmail; + this.adminPassword = adminPassword; + } + + /// Makes the accounts that do not exist yet. Returns whether this start + /// made the development set, so the caller seeds what goes with it once. + public boolean seed() throws IOException { + if (!config.isDevelopmentProfile()) { + if (adminEmail.length() > 0 && adminPassword.length() >= 8) { + add(Accounts.email(adminEmail), "Administrator", 0, Roles.VIEWER, Roles.ADMIN); + } + return false; + } + boolean first = !accounts.exists(ADMIN); + add(VIEWER, "Riley Viewer", 0, Roles.VIEWER); + add(SECOND_VIEWER, "Robin Viewer", 40, Roles.VIEWER); + add(CREATOR, "Casey Creator", 120, Roles.VIEWER, Roles.CREATOR); + add(MODERATOR, "Morgan Moderator", 200, Roles.VIEWER, Roles.MODERATOR); + add(ADMIN, "Alex Admin", 365, Roles.VIEWER, Roles.ADMIN); + boolean flag = !accounts.exists(FLAGGED_VIEWER); + add(FLAGGED_VIEWER, "Rae Viewer", 9, Roles.VIEWER); + if (flag) { + flag(FLAGGED_VIEWER, "Streams from five cities in one evening", 1); + } + return first; + } + + @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); + profiles.save(profile); + moderation.record(username, ADMIN, Moderation.FLAG, why, + Instant.now().minusMillis(daysAgo * Days.MILLIS)); + } + + private void add(String username, String name, int daysAgo, String... roles) throws IOException { + if (accounts.exists(username)) { + return; + } + String password = config.isDevelopmentProfile() ? PASSWORD : adminPassword; + try { + accounts.create(username, password, name, roles, Instant.now().minusMillis(daysAgo * Days.MILLIS)); + } 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. + if (!accounts.exists(username)) { + throw failed; + } + } + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Devices.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Devices.java new file mode 100644 index 00000000000..31ac6e9286b --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Devices.java @@ -0,0 +1,190 @@ +/* + * 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.reel.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Requests; +import com.codenameone.examples.reel.api.DeviceDto; +import com.codenameone.examples.reel.push.Notifier; +import com.codenameone.examples.reel.domain.Device; +import com.codenameone.examples.reel.repository.DeviceRepository; + +import java.time.Instant; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The devices an account has signed in on. +/// +/// A device is known by the id the app made for itself and sends with every +/// call. Its owner can sign one out from another: the row is marked revoked, +/// and a revoked device is refused playback and told to sign in again on its +/// next call to `/api/me`. Signing out every device at once is changing the +/// password. +@Component +public class Devices { + private final Grants grants; + /// More than this and the oldest is forgotten, so a script making up ids + /// cannot grow the table without end. + private static final int MAX_DEVICES = 20; + + private final DeviceRepository devices; + private final Notifier notifier; + + public Devices(DeviceRepository devices, Grants grants, Notifier notifier) { + this.grants = grants; + this.devices = devices; + this.notifier = notifier; + } + + /// Records that the calling device was used. Answers 401 for a device its + /// owner signed out. + @Transactional + public void touch(String username) { + String id = Requests.deviceId(); + if (id.length() == 0) { + return; + } + Instant now = Instant.now(); + Device row = devices.findFirstByIdAndUsername(id, username); + if (row == null) { + Device elsewhere = devices.findById(id); + if (elsewhere != null) { + // One device, one account: the account that signed in on it last. + devices.deleteById(id); + } + makeRoom(username); + // The account's other devices hear of a new one: a sign-in the + // owner did not make is the first sign of a shared password. + boolean known = !devices.findByUsernameOrderByLastSeenAtDesc(username).isEmpty(); + row = new Device(); + row.setId(id); + row.setUsername(username); + row.setCreatedAt(now); + fill(row, now); + row.setGrantId(Grants.current()); + devices.insert(row); + if (known) { + notifier.account(username, "New sign-in", "Reel was opened on " + row.getName() + + ". If it was not you, change your password.", null); + } + return; + } + if (row.getRevokedAt().isAfter(Instant.EPOCH)) { + throw new ResponseStatusException(401, "This device was signed out"); + } + String grant = Grants.current(); + if (row.getLastSeenAt().plusMillis(60000L).isBefore(now) || !Requests.ip().equals(row.getLastIp()) + || !grant.equals(row.getGrantId())) { + fill(row, now); + row.setGrantId(grant); + devices.save(row); + } + } + + /// Whether a device may play: known, not signed out. + @Transactional(readOnly = true) + public boolean usable(String username, String id) { + if (id == null || id.length() == 0) { + return true; + } + Device row = devices.findFirstByIdAndUsername(id, username); + return row == null || row.getRevokedAt().equals(Instant.EPOCH); + } + + /// Whether a download's device is still signed in. Stricter than + /// [#usable]: a row that is gone was pushed out by newer sign-ins (or was + /// a signed-out row they replaced), and must not bring a revoked device's + /// downloads back to life. + @Transactional(readOnly = true) + public boolean signedIn(String username, String id) { + if (id == null || id.length() == 0) { + return false; + } + Device row = devices.findFirstByIdAndUsername(id, username); + return row != null && row.getRevokedAt().equals(Instant.EPOCH); + } + + @Transactional(readOnly = true) + public List list(String username) { + List rows = devices.findByUsernameOrderByLastSeenAtDesc(username); + String current = Requests.deviceId(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Device d = rows.get(iter); + if (d.getRevokedAt().isAfter(Instant.EPOCH)) { + continue; + } + out.add(new DeviceDto(d.getId(), d.getName(), d.getPlatform(), d.getLastIp(), + d.getLastCountry(), d.getLastSeenAt().toEpochMilli(), d.getId().equals(current))); + } + return out; + } + + @Transactional(readOnly = true) + public int count(String username) { + return (int) devices.countByUsernameAndRevokedAt(username, Instant.EPOCH); + } + + @Transactional(rollbackFor = IOException.class) + /// Revokes every device the account is signed in on. + public void signOutAll(String username) throws IOException { + List rows = devices.findByUsernameOrderByLastSeenAtDesc(username); + Instant now = Instant.now(); + for (int iter = 0; iter < rows.size(); iter++) { + Device row = rows.get(iter); + if (row.getRevokedAt().equals(Instant.EPOCH)) { + row.setRevokedAt(now); + devices.save(row); + grants.endGrant(row.getGrantId()); + } + } + } + + @Transactional(rollbackFor = IOException.class) + public void signOut(String username, String id) throws IOException { + Device row = id == null ? null : devices.findFirstByIdAndUsername(id, username); + if (row == null) { + throw new ResponseStatusException(404, "No such device"); + } + row.setRevokedAt(Instant.now()); + devices.save(row); + // The device id is the app's own claim; what really signs the device + // out is ending the sign-in it last used, so its refresh token stops + // working and its access token runs out within minutes. + grants.endGrant(row.getGrantId()); + } + + private void makeRoom(String username) { + List rows = devices.findByUsernameOrderByLastSeenAtDesc(username); + for (int iter = MAX_DEVICES - 1; iter < rows.size(); iter++) { + devices.deleteById(rows.get(iter).getId()); + } + } + + private static void fill(Device row, Instant now) { + row.setName(Requests.deviceName()); + row.setPlatform(Requests.platform()); + row.setLastIp(Requests.ip()); + row.setLastCountry(Requests.country()); + row.setLastSeenAt(now); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Grants.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Grants.java new file mode 100644 index 00000000000..f9ddfe0601e --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Grants.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.reel.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, ""); + } + + /// Ends one sign-in, by the grant id [#current()] answered when it was + /// made. Its refresh token goes with it. + public void endGrant(String id) throws IOException { + if (id != null && id.length() > 0) { + authorizations.remove(id); + } + } + + 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. + public 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/reel/backend/src/main/java/com/codenameone/examples/reel/account/Households.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Households.java new file mode 100644 index 00000000000..5cfe2e04855 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Households.java @@ -0,0 +1,275 @@ +/* + * 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.reel.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.Requests; +import com.codenameone.examples.reel.api.Features; +import com.codenameone.examples.reel.api.HouseholdProfileDto; +import com.codenameone.examples.reel.api.ProfileEditDto; +import com.codenameone.examples.reel.domain.Household; +import com.codenameone.examples.reel.playback.MediaTokens; +import com.codenameone.examples.reel.repository.HouseholdRepository; +import com.codenameone.examples.reel.settings.Settings; + +import java.time.Instant; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The viewers inside an account: each with its own list, history, +/// recommendations and rating limit, a kids profile among them. +/// +/// Every account has at least one, made the first time it is asked for. With +/// the profiles switch off the account has that one and no more. A PIN is +/// stored as a password hash and checked by [#unlock]; the PIN gates choosing +/// the profile in the app, which is a household lock, not a security boundary +/// against the account's own owner. +@Component +public class Households { + public static final int MAX_PROFILES = 5; + /// Ratings from mildest to strongest; a profile sees up to its limit. + public static final String[] RATINGS = {"G", "PG", "PG-13", "R", "NC-17"}; + + private final HouseholdRepository rows; + private final PasswordEncoder encoder; + private final Settings settings; + private final MediaTokens tokens; + + public Households(HouseholdRepository rows, PasswordEncoder encoder, Settings settings, MediaTokens tokens) { + this.tokens = tokens; + this.rows = rows; + this.encoder = encoder; + this.settings = settings; + } + + @Transactional(rollbackFor = IOException.class) + public List list(String username, String ownerName) throws IOException { + List all = ensure(username, ownerName); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + out.add(dto(all.get(iter))); + } + return out; + } + + /// The profile `id` of the account, or its first when `id` is empty. Answers + /// 404 for a profile that is not the account's, and 403 for a PIN-protected + /// one the request carries no key for: the PIN is checked here, on every + /// call, never only on the app's profile picker. + @Transactional(rollbackFor = IOException.class) + public Household resolve(String username, String id) throws IOException { + Household row = owned(username, id); + if (row.getPinHash().length() > 0 + && !tokens.verifyProfile(Requests.header(Requests.PROFILE_KEY), username, row.getId())) { + throw new ResponseStatusException(403, "Enter this profile's PIN first"); + } + return row; + } + + private Household owned(String username, String id) throws IOException { + List all = ensure(username, ""); + if (id == null || id.length() == 0) { + return all.get(0); + } + for (int iter = 0; iter < all.size(); iter++) { + if (all.get(iter).getId().equals(id)) { + return all.get(iter); + } + } + throw new ResponseStatusException(404, "No such profile"); + } + + /// Profile edits change the PINs and rating limits themselves, so once any + /// grown-up profile has a PIN they need the key of an unlocked grown-up + /// profile. Without one there is no parental control to get around. + private void requireAdult(String username) throws IOException { + List all = ensure(username, ""); + boolean guarded = false; + String key = Requests.header(Requests.PROFILE_KEY); + for (int iter = 0; iter < all.size(); iter++) { + Household h = all.get(iter); + if (h.isKids()) { + continue; + } + if (h.getPinHash().length() > 0) { + guarded = true; + } + if (tokens.verifyProfile(key, username, h.getId())) { + return; + } + } + if (guarded) { + throw new ResponseStatusException(403, "Unlock a grown-up profile to change profiles"); + } + } + + @Transactional(rollbackFor = IOException.class) + public HouseholdProfileDto create(String username, ProfileEditDto wanted) throws IOException { + settings.require(Features.PROFILES); + requireAdult(username); + List all = ensure(username, ""); + if (all.size() >= MAX_PROFILES) { + throw new ResponseStatusException(409, "An account has at most " + MAX_PROFILES + " profiles"); + } + Household row = new Household(); + row.setId(Ids.next()); + row.setUsername(username); + row.setCreatedAt(Instant.now()); + apply(row, wanted); + rows.insert(row); + return dto(row); + } + + @Transactional(rollbackFor = IOException.class) + public HouseholdProfileDto update(String username, String id, ProfileEditDto wanted) throws IOException { + requireAdult(username); + Household row = owned(username, id); + apply(row, wanted); + rows.save(row); + return dto(row); + } + + @Transactional(rollbackFor = IOException.class) + public void delete(String username, String id) throws IOException { + requireAdult(username); + List all = ensure(username, ""); + Household row = owned(username, id); + if (all.size() == 1) { + throw new ResponseStatusException(409, "An account keeps at least one profile"); + } + rows.deleteById(row.getId()); + } + + /// Checks a profile's PIN and answers the profile with its key. A profile + /// without one unlocks with anything. Guessing is rate limited per account + /// in [com.codenameone.examples.reel.SecurityConfig]: four digits are ten + /// thousand tries. + @Transactional(rollbackFor = IOException.class) + public HouseholdProfileDto unlock(String username, String id, String pin) throws IOException { + Household row = owned(username, id); + if (row.getPinHash().length() > 0 && (pin == null || !encoder.matches(pin, row.getPinHash()))) { + throw new ResponseStatusException(403, "That PIN is not right"); + } + HouseholdProfileDto d = dto(row); + return new HouseholdProfileDto(d.id(), d.name(), d.avatar(), d.kids(), d.maxRating(), d.pinProtected(), + d.language(), tokens.profile(username, row.getId())); + } + + /// Whether a profile may see a title of this rating. + /// A profile by id, whoever owns it; for the admin views. + @Transactional(readOnly = true) + public Household find(String id) { + return rows.findById(id); + } + + public static boolean allows(Household profile, String rating) { + return rank(rating) <= rank(profile.getMaxRating()); + } + + static int rank(String rating) { + for (int iter = 0; iter < RATINGS.length; iter++) { + if (RATINGS[iter].equals(rating)) { + return iter; + } + } + // Unrated counts as the strongest: a kids profile does not see it. + return RATINGS.length - 1; + } + + private List ensure(String username, String ownerName) throws IOException { + List all = rows.findByUsernameOrderByCreatedAtAsc(username); + if (!all.isEmpty()) { + return all; + } + Household first = new Household(); + first.setId(Ids.next()); + first.setUsername(username); + first.setName(ownerName == null || ownerName.trim().length() == 0 ? "Me" : first(ownerName)); + first.setAvatar("red"); + first.setMaxRating("NC-17"); + first.setLanguage("en"); + first.setCreatedAt(Instant.now()); + rows.insert(first); + all = new ArrayList(); + all.add(first); + return all; + } + + private void apply(Household row, ProfileEditDto wanted) { + if (wanted == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + String name = wanted.name() == null ? "" : wanted.name().trim(); + if (name.length() == 0 || name.length() > 60) { + throw new ResponseStatusException(400, "A profile name is 1 to 60 characters"); + } + row.setName(name); + row.setAvatar(safe(wanted.avatar(), 32, "red")); + row.setKids(wanted.kids()); + String rating = wanted.kids() ? "PG" : safe(wanted.maxRating(), 8, "NC-17"); + if (rank(rating) == RATINGS.length - 1 && !"NC-17".equals(rating)) { + rating = "NC-17"; + } + row.setMaxRating(rating); + row.setLanguage(safe(wanted.language(), 8, "en")); + if (wanted.clearPin()) { + row.setPinHash(""); + } else if (wanted.pin() != null && wanted.pin().length() > 0) { + String pin = wanted.pin(); + if (pin.length() != 4 || !digits(pin)) { + throw new ResponseStatusException(400, "A PIN is four digits"); + } + row.setPinHash(encoder.encode(pin)); + } + } + + private static boolean digits(String text) { + for (int iter = 0; iter < text.length(); iter++) { + if (text.charAt(iter) < '0' || text.charAt(iter) > '9') { + return false; + } + } + return true; + } + + private static String safe(String value, int max, String fallback) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + return fallback; + } + return text.length() > max ? text.substring(0, max) : text; + } + + private static String first(String name) { + String text = name.trim(); + int space = text.indexOf(' '); + String out = space > 0 ? text.substring(0, space) : text; + return out.length() > 60 ? out.substring(0, 60) : out; + } + + public static HouseholdProfileDto dto(Household row) { + return new HouseholdProfileDto(row.getId(), row.getName(), row.getAvatar(), row.isKids(), + row.getMaxRating(), row.getPinHash().length() > 0, row.getLanguage(), null); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Moderation.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Moderation.java new file mode 100644 index 00000000000..d34d11604f6 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/account/Moderation.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.reel.account; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.api.ModerationEventDto; +import com.codenameone.examples.reel.domain.ModerationEvent; +import com.codenameone.examples.reel.repository.ModerationEventRepository; + +import java.time.Instant; +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"; + /// Account suspension; the column holds 16 characters. + public static final String SUSPEND = "suspend"; + public static final String UNSUSPEND = "unsuspend"; + /// A title of the account's taken down or restored. + public static final String TAKEDOWN = "takedown"; + public static final String RESTORE = "restore"; + + 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. + Instant newest = events.newest(username); + Instant after = newest == null ? Instant.EPOCH : newest.plusMillis(1L); + Instant now = Instant.now(); + record(username, by, action, reason, now.isAfter(after) ? now : 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, Instant 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().toEpochMilli(), row.getActor(), row.getAction(), + row.getReason())); + } + return out; + } + + @Transactional + public void forget(String username) throws IOException { + events.deleteByUsername(username); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/AdminEndpoint.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/AdminEndpoint.java new file mode 100644 index 00000000000..4d9bb31980d --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/AdminEndpoint.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.reel.admin; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.reel.Caller; +import com.codenameone.examples.reel.account.Accounts; +import com.codenameone.examples.reel.account.Devices; +import com.codenameone.examples.reel.account.Moderation; +import com.codenameone.examples.reel.ads.Ads; +import com.codenameone.examples.reel.api.AdCampaignDto; +import com.codenameone.examples.reel.api.AdminApiServer; +import com.codenameone.examples.reel.api.AdminStatsDto; +import com.codenameone.examples.reel.api.AppConfigDto; +import com.codenameone.examples.reel.api.BrandingDto; +import com.codenameone.examples.reel.api.CouponDto; +import com.codenameone.examples.reel.api.FeatureFlagDto; +import com.codenameone.examples.reel.api.FlagDto; +import com.codenameone.examples.reel.api.JobDto; +import com.codenameone.examples.reel.api.PlanDto; +import com.codenameone.examples.reel.api.PresetDto; +import com.codenameone.examples.reel.api.RoleChangeDto; +import com.codenameone.examples.reel.api.SessionDto; +import com.codenameone.examples.reel.api.SharingAlertDto; +import com.codenameone.examples.reel.api.SharingDecisionDto; +import com.codenameone.examples.reel.api.StatsSeriesDto; +import com.codenameone.examples.reel.api.SuspendDto; +import com.codenameone.examples.reel.api.UserDetailDto; +import com.codenameone.examples.reel.api.UserDto; +import com.codenameone.examples.reel.billing.Billing; +import com.codenameone.examples.reel.catalog.Catalog; +import com.codenameone.examples.reel.pay.Payments; +import com.codenameone.examples.reel.playback.Playbacks; +import com.codenameone.examples.reel.settings.Settings; +import com.codenameone.examples.reel.studio.Jobs; + +import java.util.ArrayList; +import java.util.List; + +/// The admin console. Everything under `/api/admin/` is limited to admins in +/// [com.codenameone.examples.reel.SecurityConfig]; `/api/config` is the one +/// public call, what every app reads at start to theme itself and to know +/// which features to show. +@Component +public class AdminEndpoint implements AdminApiServer { + private final Accounts accounts; + private final Devices devices; + private final Playbacks playbacks; + private final Settings settings; + private final Billing billing; + private final Ads ads; + private final Jobs jobs; + private final Stats stats; + private final Sharing sharing; + private final Moderation history; + private final Payments payments; + + public AdminEndpoint(Accounts accounts, Devices devices, Playbacks playbacks, Settings settings, + Billing billing, Ads ads, Jobs jobs, Stats stats, Sharing sharing, Moderation history, + Payments payments) { + this.accounts = accounts; + this.devices = devices; + this.playbacks = playbacks; + this.settings = settings; + this.billing = billing; + this.ads = ads; + this.jobs = jobs; + this.stats = stats; + this.sharing = sharing; + this.history = history; + this.payments = payments; + } + + @Override + public AppConfigDto config() throws Exception { + return new AppConfigDto(settings.branding(), settings.preset(), settings.flags(), + payments.config(settings.currency())); + } + + @Override + public AdminStatsDto stats() throws Exception { + return stats.now(); + } + + @Override + public StatsSeriesDto statsSeries(int days) throws Exception { + return stats.series(days == 0 ? 30 : days); + } + + /// Accounts newest first, filtered by a fragment of the address or name. + @Override + public List users(String q) throws Exception { + String wanted = q == null ? "" : Catalog.fold(q.trim()); + List all = accounts.newestFirst(); + List out = new ArrayList(); + for (int iter = 0; iter < all.size() && out.size() < 100; iter++) { + String username = all.get(iter); + if (wanted.length() > 0 && Catalog.fold(username).indexOf(wanted) < 0 + && Catalog.fold(accounts.displayName(username)).indexOf(wanted) < 0) { + continue; + } + out.add(summary(username)); + } + return out; + } + + @Override + public UserDetailDto user(String username) throws Exception { + UserDto user = summary(username); + return new UserDetailDto(user, devices.list(username), playbacks.liveFor(username), + billing.invoices(username), history.events(username), sharing.forUser(username)); + } + + @Override + public UserDto setRoles(String username, RoleChangeDto roles) throws Exception { + accounts.setRoles(Caller.name(), username, roles == null ? null : roles.roles()); + return summary(username); + } + + /// Suspending also ends whatever the account is streaming now. + @Override + public UserDto suspend(String username, SuspendDto suspend) throws Exception { + boolean on = suspend != null && suspend.suspended(); + accounts.suspend(Caller.name(), username, on, suspend == null ? "" : suspend.reason()); + if (on) { + playbacks.endAll(username, "SUSPENDED"); + } + return summary(username); + } + + @Override + public UserDto flag(String username, FlagDto flag) throws Exception { + accounts.flag(Caller.name(), username, flag != null && flag.flagged(), flag == null ? "" : flag.reason()); + return summary(username); + } + + @Override + public List sessions() throws Exception { + return playbacks.allLive(); + } + + @Override + public String endSession(String id) throws Exception { + playbacks.endSession(id); + return "ok"; + } + + @Override + public List flags() throws Exception { + return settings.flags(); + } + + @Override + public FeatureFlagDto setFlag(String key, FeatureFlagDto flag) throws Exception { + return settings.setFlag(Caller.name(), key, flag != null && flag.enabled()); + } + + @Override + public List presets() throws Exception { + return settings.presets(); + } + + @Override + public List applyPreset(String id) throws Exception { + return settings.applyPreset(Caller.name(), id); + } + + @Override + public BrandingDto setBranding(BrandingDto branding) throws Exception { + return settings.setBranding(Caller.name(), branding); + } + + @Override + public List plans() throws Exception { + return billing.plans(true); + } + + @Override + public PlanDto savePlan(String code, PlanDto plan) throws Exception { + return billing.savePlan(code, plan); + } + + @Override + public List coupons() throws Exception { + return billing.coupons(); + } + + @Override + public CouponDto saveCoupon(String code, CouponDto coupon) throws Exception { + return billing.saveCoupon(code, coupon); + } + + @Override + public List campaigns() throws Exception { + return ads.list(); + } + + @Override + public AdCampaignDto saveCampaign(AdCampaignDto campaign) throws Exception { + return ads.save(campaign); + } + + @Override + public List sharingAlerts(String status) throws Exception { + return sharing.list(status); + } + + @Override + public SharingAlertDto decideSharing(String id, SharingDecisionDto decision) throws Exception { + return sharing.decide(Caller.name(), id, decision); + } + + @Override + public List jobs(String status) throws Exception { + return jobs.list(status); + } + + @Override + public JobDto retryJob(String id) throws Exception { + return jobs.retry(id); + } + + private UserDto summary(String username) throws Exception { + return accounts.summary(username, devices.count(username), playbacks.liveFor(username).size()); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Sharing.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Sharing.java new file mode 100644 index 00000000000..e58dab65004 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Sharing.java @@ -0,0 +1,111 @@ +/* + * 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.reel.admin; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Kinds; +import com.codenameone.examples.reel.account.Devices; +import com.codenameone.examples.reel.account.Grants; +import com.codenameone.examples.reel.api.SharingAlertDto; +import com.codenameone.examples.reel.api.SharingDecisionDto; +import com.codenameone.examples.reel.domain.SharingAlert; +import com.codenameone.examples.reel.playback.Playbacks; +import com.codenameone.examples.reel.repository.SharingAlertRepository; + +import java.time.Instant; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Password-sharing alerts ([Playbacks] raises them) and what an admin does +/// about one: dismiss it (a family on holiday), or sign the account out +/// everywhere, which ends its streams and its sign-ins so the owner has to +/// sign in again -- and whoever they shared the password with does too. +@Component +public class Sharing { + public static final String DISMISS = "DISMISS"; + public static final String SIGN_OUT_ALL = "SIGN_OUT_ALL"; + + private final SharingAlertRepository alerts; + private final Playbacks playbacks; + private final Grants grants; + private final Devices devices; + + public Sharing(SharingAlertRepository alerts, Playbacks playbacks, Grants grants, Devices devices) { + this.alerts = alerts; + this.playbacks = playbacks; + this.grants = grants; + this.devices = devices; + } + + @Transactional(readOnly = true) + public List list(String status) { + List rows = status == null || status.length() == 0 + ? alerts.findByCreatedAtGreaterThanOrderByCreatedAtDesc(Instant.EPOCH) + : alerts.findByStatusOrderByCreatedAtDesc(status); + return dtos(rows); + } + + @Transactional(readOnly = true) + public List forUser(String username) { + return dtos(alerts.findByUsernameOrderByCreatedAtDesc(username)); + } + + @Transactional(rollbackFor = IOException.class) + public SharingAlertDto decide(String by, String id, SharingDecisionDto decision) throws IOException { + SharingAlert a = alerts.findById(id); + if (a == null) { + throw new ResponseStatusException(404, "No such alert"); + } + if (!Kinds.OPEN.equals(a.getStatus())) { + throw new ResponseStatusException(409, "Already decided"); + } + String action = decision == null ? "" : decision.action(); + if (SIGN_OUT_ALL.equals(action)) { + playbacks.endAll(a.getUsername(), "SIGNED_OUT"); + grants.endAll(a.getUsername()); + devices.signOutAll(a.getUsername()); + a.setStatus(Kinds.UPHELD); + } else if (DISMISS.equals(action)) { + a.setStatus(Kinds.DISMISSED); + } else { + throw new ResponseStatusException(400, "Dismiss, or sign the account out everywhere"); + } + a.setDecidedBy(by); + String note = decision.note() == null ? "" : decision.note().trim(); + a.setNote(note.length() > 255 ? note.substring(0, 255) : note); + alerts.save(a); + return dto(a); + } + + private static List dtos(List rows) { + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(dto(rows.get(iter))); + } + return out; + } + + static SharingAlertDto dto(SharingAlert a) { + return new SharingAlertDto(a.getId(), a.getUsername(), a.getKind(), a.getDetail(), a.getDistinctIps(), + a.getDistinctCountries(), a.getStatus(), a.getCreatedAt().toEpochMilli()); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Stats.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Stats.java new file mode 100644 index 00000000000..4c19444214a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/admin/Stats.java @@ -0,0 +1,222 @@ +/* + * 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.reel.admin; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Kinds; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.ads.Ads; +import com.codenameone.examples.reel.api.AdminStatsDto; +import com.codenameone.examples.reel.api.NameCountDto; +import com.codenameone.examples.reel.api.SeriesPointDto; +import com.codenameone.examples.reel.api.StatsSeriesDto; +import com.codenameone.examples.reel.billing.Billing; +import com.codenameone.examples.reel.billing.Entitlements; +import com.codenameone.examples.reel.domain.AdCampaign; +import com.codenameone.examples.reel.domain.AdEvent; +import com.codenameone.examples.reel.domain.Invoice; +import com.codenameone.examples.reel.domain.Plan; +import com.codenameone.examples.reel.domain.Playback; +import com.codenameone.examples.reel.domain.Profile; +import com.codenameone.examples.reel.domain.Subscription; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.moderation.Reviews; +import com.codenameone.examples.reel.repository.AdCampaignRepository; +import com.codenameone.examples.reel.repository.AdEventRepository; +import com.codenameone.examples.reel.repository.InvoiceRepository; +import com.codenameone.examples.reel.repository.PlanRepository; +import com.codenameone.examples.reel.repository.PlaybackRepository; +import com.codenameone.examples.reel.repository.ProfileRepository; +import com.codenameone.examples.reel.repository.SharingAlertRepository; +import com.codenameone.examples.reel.repository.SubscriptionRepository; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.settings.Settings; +import com.codenameone.examples.reel.studio.Jobs; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The numbers on the admin dashboard, computed from the tables on request. +/// Days are UTC days. +@Component +public class Stats { + private static final long DAY = 86400000L; + + private final ProfileRepository profiles; + private final SubscriptionRepository subscriptions; + private final PlanRepository plans; + private final PlaybackRepository playbacks; + private final TitleRepository titles; + private final InvoiceRepository invoices; + private final AdEventRepository adEvents; + private final AdCampaignRepository campaigns; + private final SharingAlertRepository alerts; + private final Reviews reviews; + private final Jobs jobs; + private final Settings settings; + + public Stats(ProfileRepository profiles, SubscriptionRepository subscriptions, PlanRepository plans, + PlaybackRepository playbacks, TitleRepository titles, InvoiceRepository invoices, + AdEventRepository adEvents, AdCampaignRepository campaigns, SharingAlertRepository alerts, + Reviews reviews, Jobs jobs, Settings settings) { + this.profiles = profiles; + this.subscriptions = subscriptions; + this.plans = plans; + this.playbacks = playbacks; + this.titles = titles; + this.invoices = invoices; + this.adEvents = adEvents; + this.campaigns = campaigns; + this.alerts = alerts; + this.reviews = reviews; + this.jobs = jobs; + this.settings = settings; + } + + @Transactional(readOnly = true) + public AdminStatsDto now() { + Instant now = Instant.now(); + Instant today = Instant.ofEpochMilli(now.toEpochMilli() - now.toEpochMilli() % DAY); + List active = subscriptions.findByStatus(Entitlements.ACTIVE); + long mrr = 0; + for (int iter = 0; iter < active.size(); iter++) { + Plan p = plans.findById(active.get(iter).getPlanCode()); + if (p != null) { + mrr += Billing.YEAR.equals(p.getBillingInterval()) ? p.getPriceCents() / 12 : p.getPriceCents(); + } + } + List sinceMidnight = playbacks.findByStartedAtGreaterThan(today); + long seconds = 0; + int streams = 0; + for (int iter = 0; iter < sinceMidnight.size(); iter++) { + Playback p = sinceMidnight.get(iter); + Instant end = p.getEndedAt().isAfter(Instant.EPOCH) ? p.getEndedAt() : p.getLastHeartbeatAt(); + seconds += Math.max(0L, (end.toEpochMilli() - p.getStartedAt().toEpochMilli()) / 1000L); + } + streams = (int) playbacks.countByEndedAt(Instant.EPOCH); + long impressions = Rows.number(adEvents.countSince(Ads.IMPRESSION, today)); + return new AdminStatsDto((int) profiles.count(), active.size(), + (int) subscriptions.countByStatus(Entitlements.TRIALING), mrr, settings.currency(), streams, + seconds / 60L, (int) titles.countByStatus(Kinds.PUBLISHED), reviews.pending(), + jobs.count(Kinds.QUEUED), jobs.count(Kinds.FAILED), alerts.findByStatusOrderByCreatedAtDesc(Kinds.OPEN).size(), + impressions, adRevenueSince(today)); + } + + @Transactional(readOnly = true) + public StatsSeriesDto series(int days) { + int n = Math.max(1, Math.min(90, days)); + Instant now = Instant.now(); + long firstMillis = now.toEpochMilli() - now.toEpochMilli() % DAY - (n - 1) * DAY; + Instant first = Instant.ofEpochMilli(firstMillis); + Map signups = buckets(firstMillis, n); + Map minutes = buckets(firstMillis, n); + Map revenue = buckets(firstMillis, n); + Map churn = buckets(firstMillis, n); + List people = profiles.findAll(); + for (int iter = 0; iter < people.size(); iter++) { + add(signups, people.get(iter).getCreatedAt().toEpochMilli(), 1); + } + Map plays = new HashMap(); + List sessions = playbacks.findByStartedAtGreaterThan(first); + for (int iter = 0; iter < sessions.size(); iter++) { + Playback p = sessions.get(iter); + Instant end = p.getEndedAt().isAfter(Instant.EPOCH) ? p.getEndedAt() : p.getLastHeartbeatAt(); + add(minutes, p.getStartedAt().toEpochMilli(), + Math.max(0L, (end.toEpochMilli() - p.getStartedAt().toEpochMilli()) / 60000L)); + Integer c = plays.get(p.getTitleId()); + plays.put(p.getTitleId(), Integer.valueOf(c == null ? 1 : c.intValue() + 1)); + } + List paid = invoices.findByCreatedAtGreaterThan(first); + for (int iter = 0; iter < paid.size(); iter++) { + if (Billing.PAID.equals(paid.get(iter).getStatus())) { + add(revenue, paid.get(iter).getCreatedAt().toEpochMilli(), paid.get(iter).getAmountCents()); + } + } + List ended = subscriptions.findByStatusAndUpdatedAtGreaterThan(Entitlements.CANCELED, first); + for (int iter = 0; iter < ended.size(); iter++) { + add(churn, ended.get(iter).getUpdatedAt().toEpochMilli(), 1); + } + List top = new ArrayList(); + for (Map.Entry e : plays.entrySet()) { + Title t = titles.findById(e.getKey()); + top.add(new NameCountDto(t == null ? e.getKey() : t.getName(), e.getValue().intValue())); + } + for (int i = 1; i < top.size(); i++) { + NameCountDto d = top.get(i); + int j = i - 1; + while (j >= 0 && top.get(j).count() < d.count()) { + top.set(j + 1, top.get(j)); + j--; + } + top.set(j + 1, d); + } + return new StatsSeriesDto(points(signups), points(minutes), points(revenue), points(churn), + top.size() > 10 ? new ArrayList(top.subList(0, 10)) : top); + } + + /// What the day's counted impressions earned: each campaign's CPM over a + /// thousand, summed. + private long adRevenueSince(Instant since) { + Map cpm = new HashMap(); + List all = campaigns.findAll(); + for (int iter = 0; iter < all.size(); iter++) { + cpm.put(all.get(iter).getId(), Long.valueOf(all.get(iter).getCpmCents())); + } + long millicents = 0; + List events = adEvents.findByEventAndCreatedAtGreaterThan(Ads.IMPRESSION, since); + for (int iter = 0; iter < events.size(); iter++) { + Long c = cpm.get(events.get(iter).getCampaignId()); + millicents += c == null ? 0 : c.longValue(); + } + return millicents / 1000L; + } + + private static Map buckets(long first, int n) { + Map out = new LinkedHashMap(); + for (int iter = 0; iter < n; iter++) { + out.put(day(first + iter * DAY), Long.valueOf(0)); + } + return out; + } + + private static void add(Map buckets, long at, long by) { + String key = day(at); + Long v = buckets.get(key); + if (v != null) { + buckets.put(key, Long.valueOf(v.longValue() + by)); + } + } + + private static List points(Map buckets) { + List out = new ArrayList(); + for (Map.Entry e : buckets.entrySet()) { + out.add(new SeriesPointDto(e.getKey(), e.getValue().longValue())); + } + return out; + } + + static String day(long at) { + return com.codenameone.examples.reel.Rows.utcDay(at); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/ads/Ads.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/ads/Ads.java new file mode 100644 index 00000000000..d170eb08fa8 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/ads/Ads.java @@ -0,0 +1,250 @@ +/* + * 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.reel.ads; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.api.AdBreakDto; +import com.codenameone.examples.reel.api.AdCampaignDto; +import com.codenameone.examples.reel.api.AdDto; +import com.codenameone.examples.reel.api.AdEventDto; +import com.codenameone.examples.reel.domain.AdCampaign; +import com.codenameone.examples.reel.domain.AdEvent; +import com.codenameone.examples.reel.repository.AdCampaignRepository; +import com.codenameone.examples.reel.repository.AdEventRepository; + +import java.time.Instant; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Ads: where the breaks go in a stream, which campaign fills each, and what +/// the player reports back. +/// +/// A campaign pays a CPM, so every counted impression adds a thousandth of it +/// to what the campaign has spent (kept in millicents, so one impression of a +/// $4 CPM is a whole number). A campaign stops being chosen when its budget is +/// spent, before its start or after its end, and prefers titles in its genres. +/// +/// The app plays the ads from the JSON this class answers and reports +/// impressions, clicks, skips and completions back through `/api/ads/event`, +/// signed in, so an event is tied to the session that showed the ad. +@Component +public class Ads { + public static final String IMPRESSION = "impression"; + public static final String CLICK = "click"; + public static final String SKIP = "skip"; + public static final String COMPLETE = "complete"; + /// Written by the server when it hands a campaign to a session, never + /// accepted from the player: the other events count only against it. + public static final String SERVED = "served"; + /// A mid-roll every this many seconds of a long title. + public static final int MIDROLL_EVERY = 600; + /// Prefix of a creative URL that names a packaged Reel video instead of an + /// external file: `video:ad-coffee`. + public static final String VIDEO = "video:"; + + private final AdCampaignRepository campaigns; + private final AdEventRepository events; + + public Ads(AdCampaignRepository campaigns, AdEventRepository events) { + this.campaigns = campaigns; + this.events = events; + } + + /// The breaks of a stream: a pre-roll, and a mid-roll every ten minutes of + /// a title long enough to have one, never in its last minute. + public List breaks(String sessionId, int durationSeconds, int resumeSeconds) { + List out = new ArrayList(); + out.add(new AdBreakDto(sessionId + "~0", 0, null, 5)); + int index = 1; + for (int at = MIDROLL_EVERY; at < durationSeconds - 60; at += MIDROLL_EVERY) { + if (at > resumeSeconds) { + out.add(new AdBreakDto(sessionId + "~" + index, at, null, 5)); + } + index++; + } + return out; + } + + /// The campaign that fills a break: the eligible one furthest from its + /// budget, preferring those that target one of the title's genres. One + /// creative per break keeps breaks short; null when nothing is eligible. + @Transactional(readOnly = true) + public AdCampaign choose(List genres, String breakId) { + Instant now = Instant.now(); + List all = campaigns.findByActiveTrue(); + AdCampaign best = null; + double bestScore = -1; + for (int iter = 0; iter < all.size(); iter++) { + AdCampaign c = all.get(iter); + if ((c.getStartsAt().isAfter(Instant.EPOCH) && c.getStartsAt().isAfter(now)) + || (c.getEndsAt().isAfter(Instant.EPOCH) && c.getEndsAt().isBefore(now))) { + continue; + } + if (c.getBudgetCents() > 0 && c.getSpentMillicents() >= c.getBudgetCents() * 1000L) { + continue; + } + double left = c.getBudgetCents() <= 0 ? 1.0 + : 1.0 - (double) c.getSpentMillicents() / (c.getBudgetCents() * 1000.0); + List targets = Rows.split(c.getGenres()); + boolean matches = targets.isEmpty(); + for (int g = 0; !matches && genres != null && g < genres.size(); g++) { + matches = targets.contains(genres.get(g)); + } + // Spread breaks across equally good campaigns rather than always + // the same one: the break id varies the tie break. + double score = (matches ? 2.0 : 1.0) + left + ((breakId.hashCode() ^ c.getId().hashCode()) & 7) / 100.0; + if (score > bestScore) { + bestScore = score; + best = c; + } + } + return best; + } + + /// Counts an ad event. An impression or a click is counted once per + /// campaign per session, whatever the player sends: a replayed request + /// must not spend an advertiser's budget twice. + @Transactional(rollbackFor = IOException.class) + public void record(String username, AdEventDto event) throws IOException { + if (event == null || event.campaignId() == null || event.sessionId() == null) { + throw new ResponseStatusException(400, "Not an ad event"); + } + String kind = event.event(); + if (!IMPRESSION.equals(kind) && !CLICK.equals(kind) && !SKIP.equals(kind) && !COMPLETE.equals(kind)) { + throw new ResponseStatusException(400, "Not an ad event"); + } + if (campaigns.findById(event.campaignId()) == null) { + throw new ResponseStatusException(404, "No such campaign"); + } + // Only a campaign this session was actually served can be reported, + // or a viewer could post impressions for every campaign and spend any + // advertiser's budget. + if (events.countBySessionIdAndCampaignIdAndEvent(event.sessionId(), event.campaignId(), SERVED) == 0 + || events.countBySessionIdAndCampaignIdAndEvent(event.sessionId(), event.campaignId(), kind) > 0) { + return; + } + insert(username, event.sessionId(), event.campaignId(), kind); + if (IMPRESSION.equals(kind)) { + campaigns.impression(event.campaignId()); + } else if (CLICK.equals(kind)) { + campaigns.click(event.campaignId()); + } + } + + /// Notes that a session was handed a campaign, once. + @Transactional(rollbackFor = IOException.class) + public void served(String username, String sessionId, String campaignId) throws IOException { + if (events.countBySessionIdAndCampaignIdAndEvent(sessionId, campaignId, SERVED) == 0) { + insert(username, sessionId, campaignId, SERVED); + } + } + + private void insert(String username, String sessionId, String campaignId, String kind) throws IOException { + AdEvent row = new AdEvent(); + row.setId(Ids.next()); + row.setCampaignId(campaignId); + row.setSessionId(sessionId); + row.setUsername(username); + row.setEvent(kind); + row.setCreatedAt(Instant.now()); + events.insert(row); + } + + public static AdDto dto(AdCampaign c, String mediaUrl, String breakId) { + return new AdDto(breakId + ":" + c.getId(), c.getId(), mediaUrl, c.getDurationSeconds(), + c.getSkippableAfterSeconds(), c.getClickUrl(), c.getAdvertiser(), new ArrayList()); + } + + // -------------------------------------------------------------- admin + + @Transactional(readOnly = true) + public List list() { + List all = campaigns.findAll(); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + out.add(campaign(all.get(iter))); + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public AdCampaignDto save(AdCampaignDto wanted) throws IOException { + if (wanted == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + AdCampaign row = wanted.id() == null || wanted.id().length() == 0 ? null : campaigns.findById(wanted.id()); + boolean fresh = row == null; + if (fresh) { + row = new AdCampaign(); + row.setId(Ids.next()); + row.setCreatedAt(Instant.now()); + } + row.setAdvertiser(text(wanted.advertiser(), 120, "the advertiser")); + row.setName(text(wanted.name(), 120, "the campaign name")); + String creative = wanted.creativeUrl() == null ? "" : wanted.creativeUrl().trim(); + if (!creative.startsWith(VIDEO) && !creative.startsWith("https://")) { + throw new ResponseStatusException(400, "The creative is video: or an https address"); + } + row.setCreativeUrl(creative.length() > 255 ? creative.substring(0, 255) : creative); + row.setDurationSeconds(clamp(wanted.durationSeconds(), 1, 120)); + row.setSkippableAfterSeconds(clamp(wanted.skippableAfterSeconds(), 0, 120)); + String click = wanted.clickUrl() == null ? "" : wanted.clickUrl().trim(); + if (click.length() > 0 && !click.startsWith("https://")) { + throw new ResponseStatusException(400, "The click address must be https"); + } + row.setClickUrl(click.length() > 255 ? click.substring(0, 255) : click); + row.setCpmCents(Math.max(0L, wanted.cpmCents())); + row.setBudgetCents(Math.max(0L, wanted.budgetCents())); + row.setGenres(Rows.join(wanted.genres())); + row.setActive(wanted.active()); + row.setStartsAt(Instant.ofEpochMilli(Math.max(0L, wanted.startsAt()))); + row.setEndsAt(Instant.ofEpochMilli(Math.max(0L, wanted.endsAt()))); + if (fresh) { + campaigns.insert(row); + } else { + campaigns.save(row); + } + return campaign(row); + } + + public static AdCampaignDto campaign(AdCampaign c) { + return new AdCampaignDto(c.getId(), c.getAdvertiser(), c.getName(), c.getCreativeUrl(), + c.getDurationSeconds(), c.getSkippableAfterSeconds(), c.getClickUrl(), c.getCpmCents(), + c.getBudgetCents(), c.getSpentMillicents() / 1000L, (int) c.getImpressions(), (int) c.getClicks(), + Rows.split(c.getGenres()), c.isActive(), c.getStartsAt().toEpochMilli(), + c.getEndsAt().toEpochMilli()); + } + + private static String text(String value, int max, String what) { + String t = value == null ? "" : value.trim(); + if (t.length() == 0 || t.length() > max) { + throw new ResponseStatusException(400, "Fill in " + what + " (at most " + max + " characters)"); + } + return t; + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Billing.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Billing.java new file mode 100644 index 00000000000..2b553f6a675 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Billing.java @@ -0,0 +1,663 @@ +/* + * 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.reel.billing; + +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.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.api.CouponCheckDto; +import com.codenameone.examples.reel.api.CouponDto; +import com.codenameone.examples.reel.api.Features; +import com.codenameone.examples.reel.api.InvoiceDto; +import com.codenameone.examples.reel.api.PlanDto; +import com.codenameone.examples.reel.api.PurchaseReceiptDto; +import com.codenameone.examples.reel.api.RentDto; +import com.codenameone.examples.reel.api.RentalDto; +import com.codenameone.examples.reel.api.SubscribeDto; +import com.codenameone.examples.reel.api.SubscriptionDto; +import com.codenameone.examples.reel.Kinds; +import com.codenameone.examples.reel.push.Notifier; +import com.codenameone.examples.reel.domain.Coupon; +import com.codenameone.examples.reel.domain.CouponRedemption; +import com.codenameone.examples.reel.domain.Invoice; +import com.codenameone.examples.reel.domain.Plan; +import com.codenameone.examples.reel.domain.Rental; +import com.codenameone.examples.reel.domain.Subscription; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.pay.PaymentDeclinedException; +import com.codenameone.examples.reel.pay.Payments; +import com.codenameone.examples.reel.repository.CouponRedemptionRepository; +import com.codenameone.examples.reel.repository.CouponRepository; +import com.codenameone.examples.reel.repository.InvoiceRepository; +import com.codenameone.examples.reel.repository.PlanRepository; +import com.codenameone.examples.reel.repository.RentalRepository; +import com.codenameone.examples.reel.repository.SubscriptionRepository; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.settings.Settings; + +import java.time.Instant; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Subscriptions, coupons, rentals and the invoices they leave. +/// +/// A subscription is one row per account. Subscribing charges the first +/// period at once -- or starts the plan's trial, once per account -- and the +/// renewal sweep charges each period after that. A charge that fails puts the +/// subscription PAST_DUE; [Entitlements] keeps it playing for a grace period +/// while the sweep retries, and it is canceled after [#MAX_ATTEMPTS] failures. +/// Changing plan takes effect at the end of the period already paid for, +/// so nobody is charged twice in one period and there is no proration to get +/// wrong. Canceling also runs to the end of the period. +/// +/// Every charge carries an idempotency key derived from what it pays for +/// (the account and the period, or the rental), so a retried request or two +/// servers running the sweep at once charge a card once. +@Component +public class Billing { + public static final String MONTH = "MONTH"; + public static final String YEAR = "YEAR"; + public static final int MAX_ATTEMPTS = 3; + /// How long after a declined renewal the card is tried again. Three + /// tries a day apart fit inside [Entitlements#GRACE_MILLIS]. + public static final long RETRY_MILLIS = 86400000L; + public static final long DAY = 86400000L; + /// How long a rental plays once paid for. + public static final long RENTAL_MILLIS = 2L * DAY; + public static final String PAID = "PAID"; + public static final String FAILED = "FAILED"; + public static final String KIND_SUBSCRIPTION = "SUBSCRIPTION"; + public static final String KIND_RENTAL = "RENTAL"; + public static final String KIND_STORE = "STORE"; + + private final SubscriptionRepository subscriptions; + private final PlanRepository plans; + private final CouponRepository coupons; + private final CouponRedemptionRepository redemptions; + private final InvoiceRepository invoices; + private final RentalRepository rentals; + private final TitleRepository titles; + private final Payments payments; + private final Settings settings; + private final boolean trustReceipts; + private final Notifier notifier; + + public Billing(SubscriptionRepository subscriptions, PlanRepository plans, CouponRepository coupons, + CouponRedemptionRepository redemptions, + InvoiceRepository invoices, RentalRepository rentals, TitleRepository titles, Payments payments, + Settings settings, @Value("${reel.billing.trust-store-receipts:false}") String trustReceipts, + Notifier notifier) { + this.subscriptions = subscriptions; + this.plans = plans; + this.coupons = coupons; + this.redemptions = redemptions; + this.invoices = invoices; + this.rentals = rentals; + this.titles = titles; + this.payments = payments; + this.settings = settings; + this.trustReceipts = "true".equalsIgnoreCase(trustReceipts.trim()); + this.notifier = notifier; + } + + // ------------------------------------------------------------ plans + + @Transactional(readOnly = true) + public List plans(boolean includeRetired) { + List all = sortedPlans(); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + if (includeRetired || all.get(iter).isActive()) { + out.add(plan(all.get(iter))); + } + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public PlanDto savePlan(String code, PlanDto wanted) throws IOException { + if (wanted == null || !code(code)) { + throw new ResponseStatusException(400, "A plan code is 2-32 letters, digits or dashes"); + } + Plan row = plans.findById(code); + boolean fresh = row == null; + if (fresh) { + row = new Plan(); + row.setCode(code); + row.setPosition(sortedPlans().size()); + } + String name = wanted.name() == null ? "" : wanted.name().trim(); + if (name.length() == 0 || name.length() > 60) { + throw new ResponseStatusException(400, "Name the plan (at most 60 characters)"); + } + row.setName(name); + row.setDescription(cut(wanted.description(), 255)); + row.setPriceCents(Math.max(0L, wanted.priceCents())); + row.setCurrency(settings.currency()); + row.setBillingInterval(YEAR.equals(wanted.interval()) ? YEAR : MONTH); + row.setMaxStreams(Math.max(1, Math.min(10, wanted.maxStreams()))); + row.setMaxQuality(String.valueOf(Entitlements.maxHeight(wanted.maxQuality()))); + row.setAdSupported(wanted.adSupported()); + row.setDownloads(wanted.downloads()); + row.setTrialDays(Math.max(0, Math.min(90, wanted.trialDays()))); + row.setActive(wanted.active()); + if (fresh) { + plans.insert(row); + } else { + plans.save(row); + } + return plan(row); + } + + // ------------------------------------------------------ subscriptions + + @Transactional(readOnly = true) + public SubscriptionDto subscription(String username) { + return dto(subscriptions.findById(username)); + } + + @Transactional(rollbackFor = IOException.class) + public SubscriptionDto subscribe(String username, SubscribeDto wanted) throws IOException { + settings.require(Features.SUBSCRIPTIONS); + if (wanted == null || wanted.planCode() == null) { + throw new ResponseStatusException(400, "Choose a plan"); + } + Plan plan = plans.findById(wanted.planCode()); + if (plan == null || !plan.isActive()) { + throw new ResponseStatusException(404, "No such plan"); + } + Instant now = Instant.now(); + Subscription sub = subscriptions.findById(username); + if (sub != null && Entitlements.isLive(sub, now) && sub.getStore().length() == 0) { + // Already paying: a change of plan waits for the period's end. + if (sub.getPlanCode().equals(plan.getCode())) { + sub.setPendingPlanCode(""); + } else { + sub.setPendingPlanCode(plan.getCode()); + } + sub.setCancelAtPeriodEnd(false); + sub.setUpdatedAt(now); + subscriptions.save(sub); + return dto(sub); + } + Coupon coupon = null; + if (wanted.coupon() != null && wanted.coupon().trim().length() > 0) { + coupon = coupons.findById(normalize(wanted.coupon())); + String why = couponProblem(coupon, now); + if (why != null) { + throw new ResponseStatusException(400, why); + } + if (redemptions.findById(CouponRedemption.key(coupon.getCode(), username)) != null) { + throw new ResponseStatusException(400, "You have already used this coupon"); + } + } + String method = payments.choose(username, wanted.paymentMethodId())[0]; + if (Payments.CASH.equals(method) && plan.getPriceCents() > 0) { + throw new ResponseStatusException(400, "Add a card to subscribe"); + } + boolean fresh = sub == null; + if (fresh) { + sub = new Subscription(); + sub.setUsername(username); + } + boolean trial = plan.getTrialDays() > 0 && (fresh || sub.getStartedAt().equals(Instant.EPOCH)); + sub.setPlanCode(plan.getCode()); + sub.setPendingPlanCode(""); + sub.setCancelAtPeriodEnd(false); + sub.setPaymentMethodId(method); + sub.setFailedAttempts(0); + sub.setStore(""); + sub.setStoreRef(""); + sub.setStartedAt(now); + sub.setUpdatedAt(now); + sub.setCouponCode(coupon == null ? "" : coupon.getCode()); + sub.setCouponPeriodsLeft(coupon == null ? 0 : Math.max(1, coupon.getPeriods())); + if (coupon != null) { + coupon.setRedemptions(coupon.getRedemptions() + 1); + coupons.save(coupon); + CouponRedemption used = new CouponRedemption(); + used.setId(CouponRedemption.key(coupon.getCode(), username)); + used.setRedeemedAt(now); + redemptions.insert(used); + } + if (trial) { + sub.setStatus(Entitlements.TRIALING); + sub.setPeriodEndsAt(now.plusMillis(plan.getTrialDays() * DAY)); + } else { + long amount = price(plan, sub); + charge(username, method, amount, plan, "start:" + now.toEpochMilli(), now); + sub.setStatus(Entitlements.ACTIVE); + sub.setPeriodEndsAt(periodEnd(plan, now)); + usedCouponPeriod(sub); + } + if (fresh) { + subscriptions.insert(sub); + } else { + subscriptions.save(sub); + } + return dto(sub); + } + + @Transactional(rollbackFor = IOException.class) + public SubscriptionDto cancel(String username) throws IOException { + Subscription sub = subscriptions.findById(username); + if (sub == null || !Entitlements.isLive(sub, Instant.now())) { + throw new ResponseStatusException(404, "No subscription to cancel"); + } + sub.setCancelAtPeriodEnd(true); + sub.setPendingPlanCode(""); + sub.setUpdatedAt(Instant.now()); + subscriptions.save(sub); + return dto(sub); + } + + @Transactional(rollbackFor = IOException.class) + public SubscriptionDto resume(String username) throws IOException { + Subscription sub = subscriptions.findById(username); + if (sub == null || !Entitlements.isLive(sub, Instant.now())) { + throw new ResponseStatusException(404, "No subscription to resume; subscribe again"); + } + sub.setCancelAtPeriodEnd(false); + if (Entitlements.CANCELED.equals(sub.getStatus())) { + sub.setStatus(Entitlements.ACTIVE); + } + sub.setUpdatedAt(Instant.now()); + subscriptions.save(sub); + return dto(sub); + } + + @Transactional(readOnly = true) + public CouponCheckDto checkCoupon(String code, String planCode) { + Plan plan = planCode == null ? null : plans.findById(planCode); + Coupon coupon = code == null ? null : coupons.findById(normalize(code)); + String why = couponProblem(coupon, Instant.now()); + long base = plan == null ? 0L : plan.getPriceCents(); + if (why != null) { + return new CouponCheckDto(code, false, why, base, 0); + } + return new CouponCheckDto(coupon.getCode(), true, "", base * (100 - coupon.getPercentOff()) / 100, + coupon.getPercentOff()); + } + + /// An App Store or Google Play purchase. A production server must verify + /// the receipt with the store (the App Store Server API, the Play + /// Developer API) before trusting it; that needs store credentials this + /// sample cannot ship, so receipts are refused unless + /// `reel.billing.trust-store-receipts` is on, which only the development + /// and test profiles set. + @Transactional(rollbackFor = IOException.class) + public SubscriptionDto storePurchase(String username, PurchaseReceiptDto receipt) throws IOException { + settings.require(Features.SUBSCRIPTIONS); + if (!trustReceipts) { + throw new ResponseStatusException(501, "Store purchases are not verified on this server"); + } + if (receipt == null || receipt.productId() == null || receipt.transactionId() == null) { + throw new ResponseStatusException(400, "Not a receipt"); + } + Plan plan = plans.findById(receipt.productId()); + if (plan == null) { + throw new ResponseStatusException(404, "No plan matches " + receipt.productId()); + } + Instant now = Instant.now(); + Subscription sub = subscriptions.findById(username); + boolean fresh = sub == null; + if (fresh) { + sub = new Subscription(); + sub.setUsername(username); + } + sub.setPlanCode(plan.getCode()); + sub.setStatus(Entitlements.ACTIVE); + sub.setStartedAt(now); + sub.setPeriodEndsAt(periodEnd(plan, now)); + sub.setCancelAtPeriodEnd(false); + sub.setPendingPlanCode(""); + sub.setStore(cut(receipt.store(), 16)); + sub.setStoreRef(cut(receipt.transactionId(), 128)); + sub.setUpdatedAt(now); + if (fresh) { + subscriptions.insert(sub); + } else { + subscriptions.save(sub); + } + invoice(username, KIND_STORE, plan.getName() + " (" + sub.getStore() + ")", plan.getPriceCents(), + PAID, sub.getStoreRef(), "", now); + return dto(sub); + } + + /// Charges the subscriptions whose period has ended, and ends those that + /// were canceled or ran out of retries. + @Scheduled(fixedDelay = 300000, initialDelay = 30000, lock = "reel-renewals") + @Transactional + public void renew() { + Instant now = Instant.now(); + List due = subscriptions.findByPeriodEndsAtLessThan(now); + for (int iter = 0; iter < due.size(); iter++) { + renewOne(due.get(iter), now); + } + } + + void renewOne(Subscription sub, Instant now) { + String status = sub.getStatus(); + if (Entitlements.CANCELED.equals(status) || Entitlements.NONE.equals(status)) { + return; + } + if (Entitlements.PAST_DUE.equals(status) && now.isBefore(sub.getNextRetryAt()) + && !now.isAfter(sub.getPeriodEndsAt().plusMillis(Entitlements.GRACE_MILLIS))) { + return; + } + if (sub.isCancelAtPeriodEnd() || sub.getStore().length() > 0) { + // A store subscription renews in the store, which tells us with a + // new receipt; without one the period simply ends. + end(sub, now); + return; + } + if (sub.getPendingPlanCode().length() > 0) { + sub.setPlanCode(sub.getPendingPlanCode()); + sub.setPendingPlanCode(""); + } + Plan plan = plans.findById(sub.getPlanCode()); + if (plan == null || !plan.isActive()) { + end(sub, now); + return; + } + // The period being paid for names the charge, so a retry of the same + // period reuses the key and the card is charged once. + String period = "renew:" + sub.getPeriodEndsAt().toEpochMilli(); + try { + charge(sub.getUsername(), sub.getPaymentMethodId(), price(plan, sub), plan, period, now); + sub.setStatus(Entitlements.ACTIVE); + sub.setFailedAttempts(0); + sub.setPeriodEndsAt(periodEnd(plan, sub.getPeriodEndsAt())); + usedCouponPeriod(sub); + } catch (IOException declined) { + sub.setFailedAttempts(sub.getFailedAttempts() + 1); + if (sub.getFailedAttempts() >= MAX_ATTEMPTS + || now.isAfter(sub.getPeriodEndsAt().plusMillis(Entitlements.GRACE_MILLIS))) { + end(sub, now); + return; + } + sub.setStatus(Entitlements.PAST_DUE); + sub.setNextRetryAt(now.plusMillis(RETRY_MILLIS)); + notifier.account(sub.getUsername(), "Payment failed", + "We will try again tomorrow. Check your card in Settings to keep watching.", null); + } + sub.setUpdatedAt(now); + subscriptions.save(sub); + } + + private void end(Subscription sub, Instant now) { + sub.setStatus(Entitlements.CANCELED); + sub.setCancelAtPeriodEnd(false); + sub.setUpdatedAt(now); + subscriptions.save(sub); + } + + // ------------------------------------------------------------ rentals + + @Transactional(rollbackFor = IOException.class) + public RentalDto rent(String username, RentDto wanted) throws IOException { + settings.require(Features.RENTALS); + Title title = wanted == null || wanted.titleId() == null ? null : titles.findById(wanted.titleId()); + if (title == null || !Entitlements.RENTAL.equals(title.getAccess()) + || !Kinds.PUBLISHED.equals(title.getStatus())) { + throw new ResponseStatusException(404, "This title is not for rent"); + } + Instant now = Instant.now(); + List mine = rentals.findByUsernameAndTitleId(username, title.getId()); + for (int iter = 0; iter < mine.size(); iter++) { + if (mine.get(iter).getExpiresAt().isAfter(now)) { + return rental(mine.get(iter), title); + } + } + String method = payments.choose(username, wanted.paymentMethodId())[0]; + if (Payments.CASH.equals(method)) { + throw new ResponseStatusException(400, "Add a card to rent"); + } + Rental row = new Rental(); + row.setId(Ids.next()); + row.setUsername(username); + row.setTitleId(title.getId()); + row.setPriceCents(title.getRentalPriceCents()); + row.setCurrency(settings.currency()); + row.setCreatedAt(now); + row.setExpiresAt(now.plusMillis(RENTAL_MILLIS)); + String ref = payments.chargeCard("rent:" + row.getId(), username, method, row.getPriceCents(), + row.getCurrency(), "Rental: " + title.getName()); + row.setInvoiceId(invoice(username, KIND_RENTAL, "Rental: " + title.getName(), row.getPriceCents(), PAID, + ref, title.getId(), now)); + rentals.insert(row); + return rental(row, title); + } + + @Transactional(readOnly = true) + public List rentals(String username) { + List all = rentals.findByUsernameOrderByCreatedAtDesc(username); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + out.add(rental(all.get(iter), titles.findById(all.get(iter).getTitleId()))); + } + return out; + } + + @Transactional(readOnly = true) + public List invoices(String username) { + List all = invoices.findByUsernameOrderByCreatedAtDesc(username); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + Invoice i = all.get(iter); + out.add(new InvoiceDto(i.getId(), i.getKind(), i.getDescription(), i.getAmountCents(), i.getCurrency(), + i.getStatus(), i.getCreatedAt().toEpochMilli(), i.getTitleId())); + } + return out; + } + + // ------------------------------------------------------------ coupons + + @Transactional(readOnly = true) + public List coupons() { + List all = coupons.findAll(); + List out = new ArrayList(); + for (int iter = 0; iter < all.size(); iter++) { + out.add(coupon(all.get(iter))); + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public CouponDto saveCoupon(String code, CouponDto wanted) throws IOException { + String key = normalize(code); + if (wanted == null || !code(key)) { + throw new ResponseStatusException(400, "A coupon code is 2-32 letters, digits or dashes"); + } + Coupon row = coupons.findById(key); + boolean fresh = row == null; + if (fresh) { + row = new Coupon(); + row.setCode(key); + row.setCreatedAt(Instant.now()); + } + row.setPercentOff(Math.max(1, Math.min(100, wanted.percentOff()))); + row.setPeriods(Math.max(1, Math.min(36, wanted.periods()))); + row.setMaxRedemptions(Math.max(0, wanted.maxRedemptions())); + row.setExpiresAt(Instant.ofEpochMilli(Math.max(0L, wanted.expiresAt()))); + row.setActive(wanted.active()); + if (fresh) { + coupons.insert(row); + } else { + coupons.save(row); + } + return coupon(row); + } + + // ------------------------------------------------------------ helpers + + private void charge(String username, String method, long amount, Plan plan, String period, Instant now) + throws IOException { + String description = plan.getName() + " plan"; + if (amount <= 0) { + invoice(username, KIND_SUBSCRIPTION, description, 0L, PAID, "", "", now); + return; + } + try { + String ref = payments.chargeCard("sub:" + username + ":" + period, username, method, amount, + plan.getCurrency().length() == 0 ? settings.currency() : plan.getCurrency(), description); + invoice(username, KIND_SUBSCRIPTION, description, amount, PAID, ref, "", now); + } catch (PaymentDeclinedException declined) { + invoice(username, KIND_SUBSCRIPTION, description + " (declined)", amount, FAILED, "", "", now); + throw declined; + } + } + + private String invoice(String username, String kind, String description, long amount, String status, + String ref, String titleId, Instant now) throws IOException { + Invoice row = new Invoice(); + row.setId(Ids.next()); + row.setUsername(username); + row.setKind(kind); + row.setDescription(cut(description, 255)); + row.setAmountCents(amount); + row.setCurrency(settings.currency()); + row.setStatus(status); + row.setProviderRef(ref == null ? "" : ref); + row.setTitleId(titleId == null ? "" : titleId); + row.setCreatedAt(now); + invoices.insert(row); + return row.getId(); + } + + private long price(Plan plan, Subscription sub) { + if (sub.getCouponPeriodsLeft() > 0 && sub.getCouponCode().length() > 0) { + Coupon c = coupons.findById(sub.getCouponCode()); + if (c != null) { + return plan.getPriceCents() * (100 - c.getPercentOff()) / 100; + } + } + return plan.getPriceCents(); + } + + private static void usedCouponPeriod(Subscription sub) { + if (sub.getCouponPeriodsLeft() > 0) { + sub.setCouponPeriodsLeft(sub.getCouponPeriodsLeft() - 1); + if (sub.getCouponPeriodsLeft() == 0) { + sub.setCouponCode(""); + } + } + } + + private static Instant periodEnd(Plan plan, Instant from) { + return from.plusMillis((YEAR.equals(plan.getBillingInterval()) ? 365L : 30L) * DAY); + } + + private static String couponProblem(Coupon c, Instant now) { + if (c == null || !c.isActive()) { + return "That code is not valid"; + } + if (c.getExpiresAt().isAfter(Instant.EPOCH) && c.getExpiresAt().isBefore(now)) { + return "That code has expired"; + } + if (c.getMaxRedemptions() > 0 && c.getRedemptions() >= c.getMaxRedemptions()) { + return "That code has been used up"; + } + return null; + } + + private List sortedPlans() { + List all = new ArrayList(plans.findAll()); + // Insertion sort by position: a handful of plans. + for (int i = 1; i < all.size(); i++) { + Plan p = all.get(i); + int j = i - 1; + while (j >= 0 && all.get(j).getPosition() > p.getPosition()) { + all.set(j + 1, all.get(j)); + j--; + } + all.set(j + 1, p); + } + return all; + } + + private SubscriptionDto dto(Subscription sub) { + if (sub == null) { + return new SubscriptionDto("", "", Entitlements.NONE, 0L, 0L, false, 0L, settings.currency(), "", 0, ""); + } + Plan plan = plans.findById(sub.getPlanCode()); + int percent = 0; + if (sub.getCouponCode().length() > 0) { + Coupon c = coupons.findById(sub.getCouponCode()); + percent = c == null ? 0 : c.getPercentOff(); + } + return new SubscriptionDto(sub.getPlanCode(), plan == null ? "" : plan.getName(), sub.getStatus(), + sub.getStartedAt().toEpochMilli(), sub.getPeriodEndsAt().toEpochMilli(), sub.isCancelAtPeriodEnd(), + plan == null ? 0L : plan.getPriceCents(), settings.currency(), sub.getCouponCode(), percent, + sub.getPendingPlanCode()); + } + + public static PlanDto plan(Plan p) { + return new PlanDto(p.getCode(), p.getName(), p.getDescription(), p.getPriceCents(), p.getCurrency(), + p.getBillingInterval(), p.getMaxStreams(), p.getMaxQuality(), p.isAdSupported(), p.isDownloads(), + p.getTrialDays(), p.isActive()); + } + + static CouponDto coupon(Coupon c) { + return new CouponDto(c.getCode(), c.getPercentOff(), c.getPeriods(), c.getMaxRedemptions(), + c.getRedemptions(), c.getExpiresAt().toEpochMilli(), c.isActive()); + } + + private RentalDto rental(Rental r, Title t) { + return new RentalDto(r.getId(), r.getTitleId(), t == null ? "" : t.getName(), r.getPriceCents(), + r.getCurrency(), r.getExpiresAt().toEpochMilli(), r.getStartedAt().isAfter(Instant.EPOCH)); + } + + /// Coupon and plan codes are compared in upper case, folded by hand + /// because they are ASCII by construction (see [#code]). + static String normalize(String code) { + if (code == null) { + return ""; + } + String t = code.trim(); + StringBuilder out = new StringBuilder(t.length()); + for (int iter = 0; iter < t.length(); iter++) { + char c = t.charAt(iter); + out.append(c >= 'a' && c <= 'z' ? (char) (c - 32) : c); + } + return out.toString(); + } + + static boolean code(String code) { + if (code == null || code.length() < 2 || code.length() > 32) { + return false; + } + for (int iter = 0; iter < code.length(); iter++) { + char c = code.charAt(iter); + if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '-') { + return false; + } + } + return true; + } + + private static String cut(String value, int max) { + String t = value == null ? "" : value.trim(); + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/BillingEndpoint.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/BillingEndpoint.java new file mode 100644 index 00000000000..160e3a52277 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/BillingEndpoint.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.reel.billing; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.reel.Caller; +import com.codenameone.examples.reel.api.BillingApiServer; +import com.codenameone.examples.reel.api.CouponCheckDto; +import com.codenameone.examples.reel.api.InvoiceDto; +import com.codenameone.examples.reel.api.PlanDto; +import com.codenameone.examples.reel.api.PurchaseReceiptDto; +import com.codenameone.examples.reel.api.RentDto; +import com.codenameone.examples.reel.api.RentalDto; +import com.codenameone.examples.reel.api.SubscribeDto; +import com.codenameone.examples.reel.api.SubscriptionDto; + +import java.util.List; + +@Component +public class BillingEndpoint implements BillingApiServer { + private final Billing billing; + + public BillingEndpoint(Billing billing) { + this.billing = billing; + } + + /// Public: the plan picker is shown before signing up. + @Override + public List plans() throws Exception { + return billing.plans(false); + } + + @Override + public SubscriptionDto subscription() throws Exception { + return billing.subscription(Caller.name()); + } + + @Override + public SubscriptionDto subscribe(SubscribeDto subscribe) throws Exception { + return billing.subscribe(Caller.name(), subscribe); + } + + @Override + public SubscriptionDto cancel() throws Exception { + return billing.cancel(Caller.name()); + } + + @Override + public SubscriptionDto resume() throws Exception { + return billing.resume(Caller.name()); + } + + @Override + public CouponCheckDto checkCoupon(String code, String plan) throws Exception { + Caller.name(); + return billing.checkCoupon(code, plan); + } + + @Override + public List invoices() throws Exception { + return billing.invoices(Caller.name()); + } + + @Override + public List rentals() throws Exception { + return billing.rentals(Caller.name()); + } + + @Override + public RentalDto rent(RentDto rent) throws Exception { + return billing.rent(Caller.name(), rent); + } + + @Override + public SubscriptionDto verifyReceipt(PurchaseReceiptDto receipt) throws Exception { + return billing.storePurchase(Caller.name(), receipt); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Entitlements.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Entitlements.java new file mode 100644 index 00000000000..9c73b09d502 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/billing/Entitlements.java @@ -0,0 +1,212 @@ +/* + * 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.reel.billing; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.api.Features; +import com.codenameone.examples.reel.domain.Plan; +import com.codenameone.examples.reel.domain.Rental; +import com.codenameone.examples.reel.domain.Subscription; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.repository.PlanRepository; +import com.codenameone.examples.reel.repository.RentalRepository; +import com.codenameone.examples.reel.repository.SubscriptionRepository; +import com.codenameone.examples.reel.settings.Settings; + +import java.time.Instant; +import java.util.List; + +/// What an account may watch, and how: the one place that turns a plan, a +/// rental and the feature switches into a yes or a no. +/// +/// The rules, in order: +/// +/// - With paid plans switched off every title is free, and ads play where ads +/// are switched on. +/// - A FREE title plays for anyone, with ads unless the plan is ad free. +/// - A SUBSCRIPTION title needs a live subscription: trialing or active, its +/// period not yet over. A cancelled subscription stays live to the end of +/// the period that was paid for. +/// - A RENTAL title needs a rental that has not expired, whatever the plan. +/// +/// The creator of a title, moderators and admins play it whatever it costs: +/// they have to see what they publish and what they judge. +@Component +public class Entitlements { + public static final String TRIALING = "TRIALING"; + public static final String ACTIVE = "ACTIVE"; + public static final String PAST_DUE = "PAST_DUE"; + public static final String CANCELED = "CANCELED"; + public static final String NONE = "NONE"; + + public static final String FREE = "FREE"; + public static final String SUBSCRIPTION = "SUBSCRIPTION"; + public static final String RENTAL = "RENTAL"; + + /// How long a renewal that failed keeps the subscription playing while + /// the card is retried. + public static final long GRACE_MILLIS = 3L * 86400000L; + + private final SubscriptionRepository subscriptions; + private final PlanRepository plans; + private final RentalRepository rentals; + private final Settings settings; + + public Entitlements(SubscriptionRepository subscriptions, PlanRepository plans, + RentalRepository rentals, Settings settings) { + this.subscriptions = subscriptions; + this.plans = plans; + this.rentals = rentals; + this.settings = settings; + } + + /// What an account's plan gives it, right now. + public static final class Allowance { + public final String planCode; + public final String planName; + public final String status; + public final long periodEndsAt; + public final boolean live; + public final boolean ads; + public final int maxStreams; + public final String maxQuality; + public final boolean downloads; + + Allowance(String planCode, String planName, String status, long periodEndsAt, boolean live, + boolean ads, int maxStreams, String maxQuality, boolean downloads) { + this.planCode = planCode; + this.planName = planName; + this.status = status; + this.periodEndsAt = periodEndsAt; + this.live = live; + this.ads = ads; + this.maxStreams = maxStreams; + this.maxQuality = maxQuality; + this.downloads = downloads; + } + } + + /// The decision for one title. + public static final class Access { + public final boolean allowed; + public final boolean ads; + /// Why not, in words the app shows; empty when allowed. + public final String reason; + /// 402 for something money fixes, 403 for something it does not. + public final int status; + + Access(boolean allowed, boolean ads, String reason, int status) { + this.allowed = allowed; + this.ads = ads; + this.reason = reason; + this.status = status; + } + } + + @Transactional(readOnly = true) + public Allowance allowance(String username) { + boolean adsOn = settings.enabled(Features.ADS); + boolean downloadsOn = settings.enabled(Features.DOWNLOADS); + if (!settings.enabled(Features.SUBSCRIPTIONS)) { + // No plans: one generous allowance for everybody, paid for by ads + // when ads are on. + return new Allowance("", "Free", NONE, 0L, true, adsOn, 4, "1080", downloadsOn); + } + Subscription sub = username == null ? null : subscriptions.findById(username); + Instant now = Instant.now(); + if (sub != null && isLive(sub, now)) { + Plan plan = plans.findById(sub.getPlanCode()); + if (plan != null) { + return new Allowance(plan.getCode(), plan.getName(), sub.getStatus(), + sub.getPeriodEndsAt().toEpochMilli(), true, adsOn && plan.isAdSupported(), + Math.max(1, plan.getMaxStreams()), plan.getMaxQuality(), + downloadsOn && plan.isDownloads()); + } + } + String status = sub == null ? NONE : sub.getStatus(); + return new Allowance("", "No plan", status, sub == null ? 0L : sub.getPeriodEndsAt().toEpochMilli(), false, + adsOn, 1, "480", false); + } + + /// Whether a subscription row is playing now. + public static boolean isLive(Subscription sub, Instant now) { + String status = sub.getStatus(); + if (TRIALING.equals(status) || ACTIVE.equals(status) || CANCELED.equals(status)) { + return sub.getPeriodEndsAt().isAfter(now); + } + if (PAST_DUE.equals(status)) { + return sub.getPeriodEndsAt().plusMillis(GRACE_MILLIS).isAfter(now); + } + return false; + } + + /// May `username` play `title`, and with ads? `staff` is true for the + /// title's creator and for moderators and admins. + @Transactional(readOnly = true) + public Access access(String username, Title title, boolean staff) { + if (staff) { + return new Access(true, false, "", 200); + } + Allowance allowance = allowance(username); + String kind = title.getAccess(); + if (RENTAL.equals(kind) && settings.enabled(Features.RENTALS)) { + if (hasRental(username, title.getId())) { + return new Access(true, false, "", 200); + } + return new Access(false, false, "Rent this title to watch it", 402); + } + if (FREE.equals(kind) || !settings.enabled(Features.SUBSCRIPTIONS)) { + return new Access(true, allowance.ads, "", 200); + } + if (allowance.live) { + return new Access(true, allowance.ads, "", 200); + } + return new Access(false, false, "Choose a plan to watch this", 402); + } + + @Transactional(readOnly = true) + public boolean hasRental(String username, String titleId) { + if (username == null) { + return false; + } + Instant now = Instant.now(); + List held = rentals.findByUsernameAndTitleId(username, titleId); + for (int iter = 0; iter < held.size(); iter++) { + if (held.get(iter).getExpiresAt().isAfter(now)) { + return true; + } + } + return false; + } + + /// The tallest rendition a quality cap allows. + public static int maxHeight(String quality) { + if ("2160".equals(quality)) { + return 2160; + } + if ("1080".equals(quality)) { + return 1080; + } + if ("720".equals(quality)) { + return 720; + } + return 480; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Catalog.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Catalog.java new file mode 100644 index 00000000000..4b68e703e62 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Catalog.java @@ -0,0 +1,455 @@ +/* + * 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.reel.catalog; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Kinds; +import com.codenameone.examples.reel.Roles; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.account.Accounts; +import com.codenameone.examples.reel.account.Households; +import com.codenameone.examples.reel.api.ChapterDto; +import com.codenameone.examples.reel.api.EpisodeDto; +import com.codenameone.examples.reel.api.Features; +import com.codenameone.examples.reel.api.GenreDto; +import com.codenameone.examples.reel.api.HomeDto; +import com.codenameone.examples.reel.api.HomeRowDto; +import com.codenameone.examples.reel.api.SearchResultDto; +import com.codenameone.examples.reel.api.TitleCardDto; +import com.codenameone.examples.reel.api.TitleDto; +import com.codenameone.examples.reel.billing.Entitlements; +import com.codenameone.examples.reel.domain.Chapter; +import com.codenameone.examples.reel.domain.Genre; +import com.codenameone.examples.reel.domain.Household; +import com.codenameone.examples.reel.domain.ListEntry; +import com.codenameone.examples.reel.domain.Progress; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.domain.TitleRating; +import com.codenameone.examples.reel.domain.Video; +import com.codenameone.examples.reel.playback.Tracks; +import com.codenameone.examples.reel.repository.ChapterRepository; +import com.codenameone.examples.reel.repository.GenreRepository; +import com.codenameone.examples.reel.repository.InteractionRepository; +import com.codenameone.examples.reel.repository.ListEntryRepository; +import com.codenameone.examples.reel.repository.ProgressRepository; +import com.codenameone.examples.reel.repository.TitleRatingRepository; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.repository.VideoRepository; +import com.codenameone.examples.reel.settings.Settings; + +import java.time.Instant; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// What a profile browses: the home rows, a title's page, genres and search. +/// +/// Everything is filtered by the profile's rating (a kids profile never sees +/// an R title, not even in search) and only published titles are shown. +/// +/// The recommendations ([Recommender]) mix what the profile watched and +/// rated with what other profiles who watched the same titles went on to +/// watch. They are computed on request from the database: a catalog of a few +/// thousand titles is a few milliseconds, and a larger service would precompute +/// the same scores in a scheduled job. +@Component +public class Catalog { + public static final int ROW = 20; + + private final TitleRepository titles; + private final VideoRepository videos; + private final GenreRepository genres; + private final ProgressRepository progress; + private final ListEntryRepository lists; + private final TitleRatingRepository ratings; + private final ChapterRepository chapters; + private final InteractionRepository interactions; + private final Households households; + private final Accounts accounts; + private final Entitlements entitlements; + private final Settings settings; + private final Recommender recommender; + private final Tracks tracks; + + public Catalog(TitleRepository titles, VideoRepository videos, GenreRepository genres, + ProgressRepository progress, ListEntryRepository lists, TitleRatingRepository ratings, + ChapterRepository chapters, InteractionRepository interactions, Households households, + Accounts accounts, Entitlements entitlements, Settings settings, Recommender recommender, + Tracks tracks) { + this.titles = titles; + this.videos = videos; + this.genres = genres; + this.progress = progress; + this.lists = lists; + this.ratings = ratings; + this.chapters = chapters; + this.interactions = interactions; + this.households = households; + this.accounts = accounts; + this.entitlements = entitlements; + this.settings = settings; + this.recommender = recommender; + this.tracks = tracks; + } + + @Transactional(rollbackFor = IOException.class) + public HomeDto home(String username, String profileId) throws IOException { + Household profile = households.resolve(username, profileId); + List visible = visible(profile); + Map<String, Title> byId = new HashMap<String, Title>(); + for (int iter = 0; iter < visible.size(); iter++) { + byId.put(visible.get(iter).getId(), visible.get(iter)); + } + Map<String, Integer> percent = new HashMap<String, Integer>(); + List<HomeRowDto> rows = new ArrayList<HomeRowDto>(); + + List<Progress> recent = progress.findTop50ByProfileIdOrderByUpdatedAtDesc(profile.getId()); + List<TitleCardDto> watching = new ArrayList<TitleCardDto>(); + Set<String> seen = new HashSet<String>(); + String lastWatched = null; + for (int iter = 0; iter < recent.size(); iter++) { + Progress p = recent.get(iter); + Title t = byId.get(p.getTitleId()); + if (t == null || !seen.add(t.getId())) { + continue; + } + if (lastWatched == null) { + lastWatched = t.getId(); + } + int pct = p.getDurationSeconds() <= 0 ? 0 : p.getPositionSeconds() * 100 / p.getDurationSeconds(); + percent.put(t.getId(), Integer.valueOf(p.isCompleted() ? 100 : pct)); + if (!p.isCompleted() && watching.size() < ROW) { + watching.add(card(t, pct, 0)); + } + } + if (!watching.isEmpty()) { + rows.add(new HomeRowDto("continue", "Continue watching", "CONTINUE", watching)); + } + + List<ListEntry> mine = lists.findByProfileIdOrderByAddedAtDesc(profile.getId()); + List<TitleCardDto> myList = new ArrayList<TitleCardDto>(); + for (int iter = 0; iter < mine.size() && myList.size() < ROW; iter++) { + Title t = byId.get(mine.get(iter).getTitleId()); + if (t != null) { + myList.add(card(t, pct(percent, t), 0)); + } + } + if (!myList.isEmpty()) { + rows.add(new HomeRowDto("my-list", "My List", "LIST", myList)); + } + + List<Recommender.Scored> picks = recommender.forProfile(profile.getId(), visible); + if (!picks.isEmpty()) { + rows.add(new HomeRowDto("for-you", "Recommended for you", "RECOMMENDED", cards(picks, percent))); + } + if (lastWatched != null) { + List<Recommender.Scored> similar = recommender.similar(byId.get(lastWatched), visible); + if (!similar.isEmpty()) { + rows.add(new HomeRowDto("because", "Because you watched " + byId.get(lastWatched).getName(), + "RECOMMENDED", cards(similar, percent))); + } + } + + List<Title> courses = new ArrayList<Title>(); + for (int iter = 0; iter < visible.size(); iter++) { + if (Kinds.COURSE.equals(visible.get(iter).getKind())) { + courses.add(visible.get(iter)); + } + } + if (!courses.isEmpty()) { + rows.add(new HomeRowDto("courses", settings.enabled(Features.CERTIFICATES) + ? "Courses with certificates" : "Courses", "COURSES", plain(courses, percent))); + } + + List<Title> trending = new ArrayList<Title>(visible); + sort(trending, true); + rows.add(new HomeRowDto("trending", "Trending now", "TRENDING", plain(trending, percent))); + List<Title> fresh = new ArrayList<Title>(visible); + sort(fresh, false); + rows.add(new HomeRowDto("new", "New on " + settings.branding().serviceName(), "NEW", plain(fresh, percent))); + + List<Genre> all = genres.findByPositionGreaterThanEqualOrderByPositionAsc(0); + for (int iter = 0; iter < all.size(); iter++) { + List<Title> in = inGenre(visible, all.get(iter).getId()); + if (in.size() >= 2) { + rows.add(new HomeRowDto("genre:" + all.get(iter).getId(), all.get(iter).getName(), "GENRE", + plain(in, percent))); + } + } + + Title hero = null; + for (int iter = 0; iter < visible.size() && hero == null; iter++) { + if (visible.get(iter).isFeatured()) { + hero = visible.get(iter); + } + } + if (hero == null && !trending.isEmpty()) { + hero = trending.get(0); + } + return new HomeDto(hero == null ? null : card(hero, pct(percent, hero), 0), rows); + } + + @Transactional(rollbackFor = IOException.class) + public TitleDto title(String username, String id, String profileId) throws IOException { + Household profile = households.resolve(username, profileId); + Title t = titles.findById(id); + boolean own = t != null && (t.getCreator().equals(username) || Roles.isStaff()); + if (t == null || (!Kinds.PUBLISHED.equals(t.getStatus()) && !own)) { + throw new ResponseStatusException(404, "No such title"); + } + if (!Households.allows(profile, t.getRating())) { + throw new ResponseStatusException(403, "This profile cannot watch " + t.getRating() + " titles"); + } + Entitlements.Access access = entitlements.access(username, t, own); + Entitlements.Allowance allowance = entitlements.allowance(username); + List<Video> all = videos.findByTitleIdOrderBySeasonAscNumberAsc(t.getId()); + List<EpisodeDto> episodes = new ArrayList<EpisodeDto>(); + String trailer = null; + Video first = null; + boolean interactive = false; + Map<String, Progress> marks = new HashMap<String, Progress>(); + List<Progress> mine = progress.findByProfileIdAndTitleId(profile.getId(), t.getId()); + for (int iter = 0; iter < mine.size(); iter++) { + marks.put(mine.get(iter).getVideoId(), mine.get(iter)); + } + int resume = 0; + for (int iter = 0; iter < all.size(); iter++) { + Video v = all.get(iter); + if (!Kinds.READY.equals(v.getStatus())) { + continue; + } + if (Kinds.TRAILER.equals(v.getRole())) { + trailer = v.getId(); + continue; + } + if (first == null) { + first = v; + } + interactive |= !interactions.findByVideoIdOrderByAtSecondsAsc(v.getId()).isEmpty(); + Progress p = marks.get(v.getId()); + int pct = p == null || v.getDurationSeconds() <= 0 ? 0 + : p.isCompleted() ? 100 : p.getPositionSeconds() * 100 / v.getDurationSeconds(); + if (p != null && !p.isCompleted() && resume == 0) { + resume = p.getPositionSeconds(); + } + episodes.add(new EpisodeDto(v.getId(), v.getSeason(), v.getNumber(), v.getName(), v.getSynopsis(), + v.getThumbnailUrl(), v.getDurationSeconds(), pct, p != null && p.isCompleted(), !access.allowed)); + } + List<ChapterDto> cs = new ArrayList<ChapterDto>(); + if (first != null) { + List<Chapter> rows = chapters.findByVideoIdOrderByStartSecondsAsc(first.getId()); + for (int iter = 0; iter < rows.size(); iter++) { + cs.add(new ChapterDto(rows.get(iter).getId(), rows.get(iter).getStartSeconds(), + rows.get(iter).getTitle())); + } + } + TitleRating mineRating = ratings.findFirstByProfileIdAndTitleId(profile.getId(), t.getId()); + boolean listed = lists.findFirstByProfileIdAndTitleId(profile.getId(), t.getId()) != null; + List<Recommender.Scored> similar = recommender.similar(t, visible(profile)); + Map<String, Integer> none = new HashMap<String, Integer>(); + return new TitleDto(t.getId(), t.getKind(), t.getName(), t.getSynopsis(), t.getPosterUrl(), + t.getBackdropUrl(), t.getYear(), t.getRating(), Rows.split(t.getAdvisories()), + first == null ? 0 : Tracks.tallest(first.getRenditions()), Tracks.audioLabels(first), + first == null ? new ArrayList<String>() : tracks.subtitleLabels(first.getId()), + Kinds.PUBLISHED.equals(t.getStatus()) ? WatchPage.path(t.getId()) : null, + Rows.split(t.getGenres()), Rows.split(t.getTags()), + t.getCreator(), accounts.displayName(t.getCreator()), first == null ? 0 : first.getDurationSeconds(), + average(t), t.getRatingCount(), mineRating == null ? 0 : mineRating.getStars(), listed, resume, + t.getAccess(), t.getRentalPriceCents(), settings.currency(), access.allowed, + access.allowed && allowance.downloads && settings.enabled(Features.DOWNLOADS), + interactive && settings.enabled(Features.INTERACTIVE), + t.isCertificate() && settings.enabled(Features.CERTIFICATES), trailer, cs, episodes, + cards(similar, none)); + } + + @Transactional(readOnly = true) + public List<GenreDto> genres() { + List<Title> published = titles.findByStatus(Kinds.PUBLISHED); + List<Genre> all = genres.findByPositionGreaterThanEqualOrderByPositionAsc(0); + List<GenreDto> out = new ArrayList<GenreDto>(); + for (int iter = 0; iter < all.size(); iter++) { + out.add(new GenreDto(all.get(iter).getId(), all.get(iter).getName(), + inGenre(published, all.get(iter).getId()).size())); + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public List<TitleCardDto> genre(String username, String id, String profileId) throws IOException { + Household profile = households.resolve(username, profileId); + List<Title> in = inGenre(visible(profile), id); + sort(in, true); + return plain(in, new HashMap<String, Integer>(), 200); + } + + /// Search over names, synopses, tags, genres and creators: every word of + /// the query must appear somewhere, and a name match ranks first. + @Transactional(rollbackFor = IOException.class) + public SearchResultDto search(String username, String q, String profileId) throws IOException { + Household profile = households.resolve(username, profileId); + String query = q == null ? "" : q.trim(); + if (query.length() == 0) { + return new SearchResultDto("", 0, new ArrayList<TitleCardDto>()); + } + String[] words = com.codenameone.examples.reel.Rows.words(fold(query)).toArray(new String[0]); + List<Title> visible = visible(profile); + List<Recommender.Scored> hits = new ArrayList<Recommender.Scored>(); + for (int iter = 0; iter < visible.size(); iter++) { + Title t = visible.get(iter); + String name = fold(t.getName()); + String rest = fold(t.getSynopsis() + " " + t.getTags() + " " + t.getGenres() + " " + + accounts.displayName(t.getCreator())); + double score = 0; + boolean all = true; + for (int w = 0; w < words.length && all; w++) { + if (words[w].length() == 0) { + continue; + } + if (name.indexOf(words[w]) >= 0) { + score += 3; + } else if (rest.indexOf(words[w]) >= 0) { + score += 1; + } else { + all = false; + } + } + if (all && score > 0) { + hits.add(new Recommender.Scored(t, score + Math.log(1 + t.getViewCount()) / 10.0)); + } + } + Recommender.sortScored(hits); + List<TitleCardDto> cards = cards(hits, new HashMap<String, Integer>()); + return new SearchResultDto(query, hits.size(), cards); + } + + // ------------------------------------------------------------ helpers + + List<Title> visible(Household profile) { + List<Title> published = titles.findByStatus(Kinds.PUBLISHED); + List<Title> out = new ArrayList<Title>(); + for (int iter = 0; iter < published.size(); iter++) { + Title t = published.get(iter); + if (Households.allows(profile, t.getRating()) && hasPlayable(t) + && (settings.enabled(Features.RENTALS) || !Entitlements.RENTAL.equals(t.getAccess()))) { + out.add(t); + } + } + return out; + } + + private boolean hasPlayable(Title t) { + List<Video> all = videos.findByTitleIdOrderBySeasonAscNumberAsc(t.getId()); + for (int iter = 0; iter < all.size(); iter++) { + if (Kinds.READY.equals(all.get(iter).getStatus()) && !Kinds.TRAILER.equals(all.get(iter).getRole()) + && !Kinds.AD.equals(all.get(iter).getRole())) { + return true; + } + } + return false; + } + + static List<Title> inGenre(List<Title> from, String genre) { + List<Title> out = new ArrayList<Title>(); + for (int iter = 0; iter < from.size(); iter++) { + if (Rows.split(from.get(iter).getGenres()).contains(genre)) { + out.add(from.get(iter)); + } + } + return out; + } + + private static void sort(List<Title> list, final boolean byViews) { + for (int i = 1; i < list.size(); i++) { + Title t = list.get(i); + int j = i - 1; + while (j >= 0 && (byViews ? list.get(j).getViewCount() < t.getViewCount() + : list.get(j).getPublishedAt().isBefore(t.getPublishedAt()))) { + list.set(j + 1, list.get(j)); + j--; + } + list.set(j + 1, t); + } + } + + private List<TitleCardDto> plain(List<Title> list, Map<String, Integer> percent) { + return plain(list, percent, ROW); + } + + private List<TitleCardDto> plain(List<Title> list, Map<String, Integer> percent, int max) { + List<TitleCardDto> out = new ArrayList<TitleCardDto>(); + for (int iter = 0; iter < list.size() && out.size() < max; iter++) { + out.add(card(list.get(iter), pct(percent, list.get(iter)), 0)); + } + return out; + } + + private List<TitleCardDto> cards(List<Recommender.Scored> list, Map<String, Integer> percent) { + List<TitleCardDto> out = new ArrayList<TitleCardDto>(); + for (int iter = 0; iter < list.size() && out.size() < ROW; iter++) { + Title t = list.get(iter).title; + out.add(card(t, pct(percent, t), list.get(iter).score)); + } + return out; + } + + private static int pct(Map<String, Integer> percent, Title t) { + Integer p = percent.get(t.getId()); + return p == null ? 0 : p.intValue(); + } + + public TitleCardDto card(Title t, int progressPercent, double score) { + int duration = 0; + List<Video> all = videos.findByTitleIdOrderBySeasonAscNumberAsc(t.getId()); + for (int iter = 0; iter < all.size(); iter++) { + if (Kinds.READY.equals(all.get(iter).getStatus()) && !Kinds.TRAILER.equals(all.get(iter).getRole())) { + duration = all.get(iter).getDurationSeconds(); + break; + } + } + String badge = ""; + Instant now = Instant.now(); + if (Kinds.COURSE.equals(t.getKind()) && t.isCertificate()) { + badge = "CERTIFICATE"; + } else if (t.getPublishedAt().isAfter(now.minusMillis(14L * 86400000L))) { + badge = "NEW"; + } + return new TitleCardDto(t.getId(), t.getKind(), t.getName(), t.getPosterUrl(), t.getBackdropUrl(), + t.getYear(), t.getRating(), duration, score, progressPercent, badge, + Entitlements.FREE.equals(t.getAccess()), Entitlements.RENTAL.equals(t.getAccess())); + } + + static double average(Title t) { + return t.getRatingCount() == 0 ? 0 : Math.round(t.getRatingSum() * 10.0 / t.getRatingCount()) / 10.0; + } + + public static String fold(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int iter = 0; iter < s.length(); iter++) { + char c = s.charAt(iter); + out.append(c >= 'A' && c <= 'Z' ? (char) (c + 32) : c); + } + return out.toString(); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/CatalogEndpoint.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/CatalogEndpoint.java new file mode 100644 index 00000000000..eee0d024fff --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/CatalogEndpoint.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.reel.catalog; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.reel.Caller; +import com.codenameone.examples.reel.api.CatalogApiServer; +import com.codenameone.examples.reel.api.GenreDto; +import com.codenameone.examples.reel.api.HomeDto; +import com.codenameone.examples.reel.api.ReportDto; +import com.codenameone.examples.reel.api.SearchResultDto; +import com.codenameone.examples.reel.api.TitleCardDto; +import com.codenameone.examples.reel.api.TitleDto; +import com.codenameone.examples.reel.moderation.Reviews; + +import java.util.List; + +@Component +public class CatalogEndpoint implements CatalogApiServer { + private final Catalog catalog; + private final Reviews reviews; + + public CatalogEndpoint(Catalog catalog, Reviews reviews) { + this.catalog = catalog; + this.reviews = reviews; + } + + @Override + public HomeDto home(String profile) throws Exception { + return catalog.home(Caller.name(), profile); + } + + @Override + public TitleDto title(String id, String profile) throws Exception { + return catalog.title(Caller.name(), id, profile); + } + + @Override + public List<GenreDto> genres() throws Exception { + Caller.name(); + return catalog.genres(); + } + + @Override + public List<TitleCardDto> genre(String id, String profile) throws Exception { + return catalog.genre(Caller.name(), id, profile); + } + + @Override + public SearchResultDto search(String q, String profile) throws Exception { + return catalog.search(Caller.name(), q, profile); + } + + @Override + public String report(String id, ReportDto report) throws Exception { + reviews.report(Caller.name(), id, report); + return "ok"; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Recommender.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Recommender.java new file mode 100644 index 00000000000..fc7fce97037 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/Recommender.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.reel.catalog; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.domain.Progress; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.domain.TitleRating; +import com.codenameone.examples.reel.repository.ProgressRepository; +import com.codenameone.examples.reel.repository.TitleRatingRepository; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// Recommendations, from two signals: +/// +/// - **Taste.** Each genre and tag of a title the profile watched counts +/// once; finishing it counts double; rating it adds or subtracts by the +/// stars (a one-star title pushes its genres down). A candidate scores the +/// sum of its genres' and tags' weights. +/// - **Co-watching.** Profiles that watched the same titles as this one -- +/// the titles they also watched score, weighted by how much they overlap. +/// This is item-to-item collaborative filtering at its simplest; it finds +/// what taste alone cannot, such as a course people take after another. +/// +/// Popularity and the average rating break ties, so a new profile with no +/// history still gets a sensible row. Titles the profile finished are left +/// out. +@Component +public class Recommender { + /// How many other profiles' histories one recommendation reads, at most. + static final int NEIGHBOURS = 200; + + private final ProgressRepository progress; + private final TitleRatingRepository ratings; + + public Recommender(ProgressRepository progress, TitleRatingRepository ratings) { + this.progress = progress; + this.ratings = ratings; + } + + public static final class Scored { + public final Title title; + public final double score; + + public Scored(Title title, double score) { + this.title = title; + this.score = score; + } + } + + @Transactional(readOnly = true) + public List<Scored> forProfile(String profileId, List<Title> candidates) { + Map<String, Title> byId = new HashMap<String, Title>(); + for (int iter = 0; iter < candidates.size(); iter++) { + byId.put(candidates.get(iter).getId(), candidates.get(iter)); + } + List<Progress> history = progress.findTop50ByProfileIdOrderByUpdatedAtDesc(profileId); + Map<String, Double> taste = new HashMap<String, Double>(); + Set<String> watched = new HashSet<String>(); + Set<String> finished = new HashSet<String>(); + for (int iter = 0; iter < history.size(); iter++) { + Progress p = history.get(iter); + watched.add(p.getTitleId()); + if (p.isCompleted()) { + finished.add(p.getTitleId()); + } + Title t = byId.get(p.getTitleId()); + if (t != null) { + weigh(taste, t, p.isCompleted() ? 2.0 : 1.0); + } + } + List<TitleRating> rated = ratings.findByProfileId(profileId); + for (int iter = 0; iter < rated.size(); iter++) { + Title t = byId.get(rated.get(iter).getTitleId()); + if (t != null) { + weigh(taste, t, rated.get(iter).getStars() - 3.0); + } + } + Map<String, Double> together = coWatched(profileId, watched); + List<Scored> out = new ArrayList<Scored>(); + for (int iter = 0; iter < candidates.size(); iter++) { + Title t = candidates.get(iter); + if (finished.contains(t.getId()) || watched.contains(t.getId())) { + continue; + } + double s = match(taste, t); + Double co = together.get(t.getId()); + if (co != null) { + s += 2.0 * co.doubleValue(); + } + s += Math.log(1 + t.getViewCount()) / 5.0 + Catalog.average(t) / 5.0; + out.add(new Scored(t, round(s))); + } + sortScored(out); + return out.size() > Catalog.ROW ? new ArrayList<Scored>(out.subList(0, Catalog.ROW)) : out; + } + + /// Titles like `seed`: shared genres and tags, and what its viewers also + /// watched. + @Transactional(readOnly = true) + public List<Scored> similar(Title seed, List<Title> candidates) { + List<Scored> out = new ArrayList<Scored>(); + if (seed == null) { + return out; + } + Map<String, Double> taste = new HashMap<String, Double>(); + weigh(taste, seed, 1.0); + Map<String, Double> together = new HashMap<String, Double>(); + List<Progress> viewers = progress.findByTitleId(seed.getId()); + Set<String> profiles = new HashSet<String>(); + for (int iter = 0; iter < viewers.size() && profiles.size() < NEIGHBOURS; iter++) { + profiles.add(viewers.get(iter).getProfileId()); + } + for (String p : profiles) { + Set<String> theirs = new HashSet<String>(); + List<Progress> rows = progress.findTop50ByProfileIdOrderByUpdatedAtDesc(p); + for (int iter = 0; iter < rows.size(); iter++) { + theirs.add(rows.get(iter).getTitleId()); + } + for (String t : theirs) { + Double d = together.get(t); + together.put(t, Double.valueOf((d == null ? 0 : d.doubleValue()) + 1.0 / profiles.size())); + } + } + for (int iter = 0; iter < candidates.size(); iter++) { + Title t = candidates.get(iter); + if (t.getId().equals(seed.getId())) { + continue; + } + double s = match(taste, t); + Double co = together.get(t.getId()); + if (co != null) { + s += 3.0 * co.doubleValue(); + } + if (s <= 0) { + continue; + } + out.add(new Scored(t, round(s + Catalog.average(t) / 10.0))); + } + sortScored(out); + return out.size() > 12 ? new ArrayList<Scored>(out.subList(0, 12)) : out; + } + + private Map<String, Double> coWatched(String profileId, Set<String> watched) { + Map<String, Double> out = new HashMap<String, Double>(); + if (watched.isEmpty()) { + return out; + } + Map<String, Integer> overlap = new HashMap<String, Integer>(); + for (String titleId : watched) { + List<Progress> rows = progress.findByTitleId(titleId); + for (int iter = 0; iter < rows.size() && overlap.size() < NEIGHBOURS; iter++) { + String other = rows.get(iter).getProfileId(); + if (!other.equals(profileId)) { + Integer n = overlap.get(other); + overlap.put(other, Integer.valueOf(n == null ? 1 : n.intValue() + 1)); + } + } + } + for (Map.Entry<String, Integer> e : overlap.entrySet()) { + double weight = e.getValue().doubleValue() / watched.size(); + List<Progress> rows = progress.findTop50ByProfileIdOrderByUpdatedAtDesc(e.getKey()); + Set<String> counted = new HashSet<String>(); + for (int iter = 0; iter < rows.size(); iter++) { + String t = rows.get(iter).getTitleId(); + if (counted.add(t)) { + Double d = out.get(t); + out.put(t, Double.valueOf((d == null ? 0 : d.doubleValue()) + weight)); + } + } + } + return out; + } + + private static void weigh(Map<String, Double> taste, Title t, double by) { + List<String> keys = Rows.split(t.getGenres()); + List<String> tags = Rows.split(t.getTags()); + for (int iter = 0; iter < tags.size(); iter++) { + keys.add("#" + tags.get(iter)); + } + for (int iter = 0; iter < keys.size(); iter++) { + Double d = taste.get(keys.get(iter)); + taste.put(keys.get(iter), Double.valueOf((d == null ? 0 : d.doubleValue()) + by)); + } + } + + private static double match(Map<String, Double> taste, Title t) { + double s = 0; + List<String> g = Rows.split(t.getGenres()); + for (int iter = 0; iter < g.size(); iter++) { + Double d = taste.get(g.get(iter)); + s += d == null ? 0 : d.doubleValue(); + } + List<String> tags = Rows.split(t.getTags()); + for (int iter = 0; iter < tags.size(); iter++) { + Double d = taste.get("#" + tags.get(iter)); + s += d == null ? 0 : 0.5 * d.doubleValue(); + } + return s; + } + + private static double round(double v) { + return Math.round(v * 100.0) / 100.0; + } + + static void sortScored(List<Scored> list) { + for (int i = 1; i < list.size(); i++) { + Scored s = list.get(i); + int j = i - 1; + while (j >= 0 && list.get(j).score < s.score) { + list.set(j + 1, list.get(j)); + j--; + } + list.set(j + 1, s); + } + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/WatchPage.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/WatchPage.java new file mode 100644 index 00000000000..902c13d43fb --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/catalog/WatchPage.java @@ -0,0 +1,147 @@ +/* + * 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.reel.catalog; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PathVariable; +import com.codename1.backend.annotations.RestController; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Kinds; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.api.Advisories; +import com.codenameone.examples.reel.api.BrandingDto; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.playback.MediaOrigin; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.settings.Settings; + +import java.util.List; + +/// What a shared link opens: `/watch/{id}`, a public page with the title's +/// poster, synopsis and classification, and a button that opens it in the app. +/// +/// The app shares this address and not an `/api` one because whoever receives +/// it may have no account and no app; a page is what every messenger can +/// preview (the `og:` tags) and every browser can open. Only a published title +/// has one -- a draft, a title under review and a removed one answer 404, so a +/// link shared before a takedown stops showing it. +@RestController +public class WatchPage { + private final TitleRepository titles; + private final Settings settings; + + public WatchPage(TitleRepository titles, Settings settings) { + this.titles = titles; + this.settings = settings; + } + + /// The path of a title's page, which the app resolves against its server. + public static String path(String titleId) { + return "/watch/" + titleId; + } + + @Transactional(readOnly = true) + @GetMapping("/watch/{id}") + public HttpServer.Response page(@PathVariable("id") String id) throws Exception { + Title t = MediaOrigin.safeId(id) ? titles.findById(id) : null; + BrandingDto brand = settings.branding(); + String service = escape(brand.serviceName()); + if (t == null || !Kinds.PUBLISHED.equals(t.getStatus())) { + return html(404, service, color(brand.primaryColor()), service, "", + "<main><h1>Not available</h1><p>This title is not on " + service + ".</p></main>"); + } + String name = escape(t.getName()); + String synopsis = escape(t.getSynopsis()); + String poster = escape(t.getPosterUrl()); + StringBuilder facts = new StringBuilder(); + facts.append("<span class=\"rating\">").append(escape(t.getRating())).append("</span>"); + if (t.getYear() > 0) { + facts.append("<span>").append(t.getYear()).append("</span>"); + } + List<String> advisories = Rows.split(t.getAdvisories()); + StringBuilder why = new StringBuilder(); + for (int iter = 0; iter < advisories.size(); iter++) { + String label = Advisories.label(advisories.get(iter)); + if (label != null) { + why.append(why.length() == 0 ? "" : ", ").append(label); + } + } + String body = "<main><img src=\"" + poster + "\" alt=\"\"><div><p class=\"by\">" + service + "</p><h1>" + + name + "</h1><p class=\"facts\">" + facts + "</p>" + + (why.length() == 0 ? "" : "<p class=\"why\">" + escape(why.toString()) + "</p>") + + "<p>" + synopsis + "</p><a class=\"open\" href=\"/?title=" + escape(t.getId()) + "\">Watch on " + + service + "</a></div></main>"; + return html(200, name + " - " + service, color(brand.primaryColor()), synopsis, poster, body); + } + + /// `value` when it is a `#rrggbb` colour, else the default red: branding is + /// typed by an admin and lands inside a style sheet. + static String color(String value) { + if (value == null || value.length() != 7 || value.charAt(0) != '#') { + return "#e50914"; + } + for (int iter = 1; iter < 7; iter++) { + char c = value.charAt(iter); + if (!(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F')) { + return "#e50914"; + } + } + return value; + } + + private static HttpServer.Response html(int status, String title, String brand, String description, + String image, String body) throws Exception { + String page = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">" + + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>" + title + + "" + + "" + + (image.length() == 0 ? "" : "") + + "" + + body + ""; + return new HttpServer.Response(status, "text/html; charset=utf-8", page.getBytes("UTF-8")); + } + + static String escape(String s) { + if (s == null) { + return ""; + } + StringBuilder out = new StringBuilder(s.length()); + for (int iter = 0; iter < s.length(); iter++) { + char ch = s.charAt(iter); + switch (ch) { + case '<': out.append("<"); break; + case '>': out.append(">"); break; + case '&': out.append("&"); break; + case '"': out.append("""); break; + case '\'': out.append("'"); break; + default: out.append(ch); + } + } + return out.toString(); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdCampaign.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdCampaign.java new file mode 100644 index 00000000000..d410938b79a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdCampaign.java @@ -0,0 +1,219 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// An ad campaign, its creative and its budget. +@Entity +public class AdCampaign { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String advertiser = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String creativeUrl = ""; + + @Column(nullable = false) + private int durationSeconds; + + @Column(nullable = false) + private int skippableAfterSeconds; + + @Column(nullable = false, type = "VARCHAR(255)") + private String clickUrl = ""; + + @Column(nullable = false) + private long cpmCents; + + @Column(nullable = false) + private long budgetCents; + + @Column(nullable = false) + private long spentMillicents; + + @Column(nullable = false) + private long impressions; + + @Column(nullable = false) + private long clicks; + + @Column(nullable = false, type = "VARCHAR(255)") + private String genres = ""; + + @Column(nullable = false) + private boolean active; + + @Column(nullable = false) + private Instant startsAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant endsAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public AdCampaign() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAdvertiser() { + return advertiser; + } + + public void setAdvertiser(String advertiser) { + this.advertiser = advertiser; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCreativeUrl() { + return creativeUrl; + } + + public void setCreativeUrl(String creativeUrl) { + this.creativeUrl = creativeUrl; + } + + public int getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(int durationSeconds) { + this.durationSeconds = durationSeconds; + } + + public int getSkippableAfterSeconds() { + return skippableAfterSeconds; + } + + public void setSkippableAfterSeconds(int skippableAfterSeconds) { + this.skippableAfterSeconds = skippableAfterSeconds; + } + + public String getClickUrl() { + return clickUrl; + } + + public void setClickUrl(String clickUrl) { + this.clickUrl = clickUrl; + } + + public long getCpmCents() { + return cpmCents; + } + + public void setCpmCents(long cpmCents) { + this.cpmCents = cpmCents; + } + + public long getBudgetCents() { + return budgetCents; + } + + public void setBudgetCents(long budgetCents) { + this.budgetCents = budgetCents; + } + + public long getSpentMillicents() { + return spentMillicents; + } + + public void setSpentMillicents(long spentMillicents) { + this.spentMillicents = spentMillicents; + } + + public long getImpressions() { + return impressions; + } + + public void setImpressions(long impressions) { + this.impressions = impressions; + } + + public long getClicks() { + return clicks; + } + + public void setClicks(long clicks) { + this.clicks = clicks; + } + + public String getGenres() { + return genres; + } + + public void setGenres(String genres) { + this.genres = genres; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public Instant getStartsAt() { + return startsAt; + } + + public void setStartsAt(Instant startsAt) { + this.startsAt = startsAt; + } + + public Instant getEndsAt() { + return endsAt; + } + + public void setEndsAt(Instant endsAt) { + this.endsAt = endsAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdEvent.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdEvent.java new file mode 100644 index 00000000000..607bc7b87c9 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/AdEvent.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// An ad tracking event. +@Entity +public class AdEvent { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String campaignId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String sessionId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String event = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public AdEvent() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getCampaignId() { + return campaignId; + } + + public void setCampaignId(String campaignId) { + this.campaignId = campaignId; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEvent() { + return event; + } + + public void setEvent(String event) { + this.event = event; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Answer.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Answer.java new file mode 100644 index 00000000000..f85f8a1529b --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Answer.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A viewer's answer to an interaction. +@Entity +public class Answer { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String interactionId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String profileId = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String choices = ""; + + @Column(nullable = false) + private boolean correct; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Answer() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getInteractionId() { + return interactionId; + } + + public void setInteractionId(String interactionId) { + this.interactionId = interactionId; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + public String getChoices() { + return choices; + } + + public void setChoices(String choices) { + this.choices = choices; + } + + public boolean isCorrect() { + return correct; + } + + public void setCorrect(boolean correct) { + this.correct = correct; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/BlockedItem.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/BlockedItem.java new file mode 100644 index 00000000000..d2a57348045 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/BlockedItem.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A banned frame hash or keyword. +@Entity +public class BlockedItem { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String value = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String note = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String createdBy = ""; + + public BlockedItem() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Certificate.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Certificate.java new file mode 100644 index 00000000000..8d9b0b04768 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Certificate.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A course completion certificate. +@Entity +public class Certificate { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String holderName = ""; + + @Column(nullable = false) + private int scorePercent; + + @Column(nullable = false) + private Instant issuedAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(16)") + private String verifyCode = ""; + + public Certificate() { + } + + 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 getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getHolderName() { + return holderName; + } + + public void setHolderName(String holderName) { + this.holderName = holderName; + } + + public int getScorePercent() { + return scorePercent; + } + + public void setScorePercent(int scorePercent) { + this.scorePercent = scorePercent; + } + + public Instant getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(Instant issuedAt) { + this.issuedAt = issuedAt; + } + + public String getVerifyCode() { + return verifyCode; + } + + public void setVerifyCode(String verifyCode) { + this.verifyCode = verifyCode; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Chapter.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Chapter.java new file mode 100644 index 00000000000..31234d242f9 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Chapter.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A named point in a video. +@Entity +public class Chapter { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false) + private int startSeconds; + + @Column(nullable = false, type = "VARCHAR(120)") + private String title = ""; + + public Chapter() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public int getStartSeconds() { + return startSeconds; + } + + public void setStartSeconds(int startSeconds) { + this.startSeconds = startSeconds; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ClosedAccount.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ClosedAccount.java new file mode 100644 index 00000000000..0979b323935 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ClosedAccount.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// 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, type = "VARCHAR(64)") + private String usernameHash = ""; + + @Column(nullable = false) + private Instant closedAt = Instant.EPOCH; + + public ClosedAccount() { + } + + public String getUsernameHash() { + return usernameHash; + } + + public void setUsernameHash(String usernameHash) { + this.usernameHash = usernameHash; + } + + public Instant getClosedAt() { + return closedAt; + } + + public void setClosedAt(Instant closedAt) { + this.closedAt = closedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Coupon.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Coupon.java new file mode 100644 index 00000000000..dce270e26f2 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Coupon.java @@ -0,0 +1,120 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A percent off for a number of billing periods. +@Entity +public class Coupon { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String code = ""; + + @Column(nullable = false) + private int percentOff; + + @Column(nullable = false) + private int periods; + + @Column(nullable = false) + private int maxRedemptions; + + @Column(nullable = false) + private int redemptions; + + @Column(nullable = false) + private Instant expiresAt = Instant.EPOCH; + + @Column(nullable = false) + private boolean active; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Coupon() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public int getPercentOff() { + return percentOff; + } + + public void setPercentOff(int percentOff) { + this.percentOff = percentOff; + } + + public int getPeriods() { + return periods; + } + + public void setPeriods(int periods) { + this.periods = periods; + } + + public int getMaxRedemptions() { + return maxRedemptions; + } + + public void setMaxRedemptions(int maxRedemptions) { + this.maxRedemptions = maxRedemptions; + } + + public int getRedemptions() { + return redemptions; + } + + public void setRedemptions(int redemptions) { + this.redemptions = redemptions; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/CouponRedemption.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/CouponRedemption.java new file mode 100644 index 00000000000..b47a0981542 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/CouponRedemption.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// That an account used a coupon. Keyed by the code and the account together, +/// so the insert itself refuses a second use. +@Entity +public class CouponRedemption { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(230)") + private String id = ""; + + @Column(nullable = false) + private Instant redeemedAt = Instant.EPOCH; + + public CouponRedemption() { + } + + public static String key(String code, String username) { + return code + ":" + username; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Instant getRedeemedAt() { + return redeemedAt; + } + + public void setRedeemedAt(Instant redeemedAt) { + this.redeemedAt = redeemedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Device.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Device.java new file mode 100644 index 00000000000..831f7eab2ff --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Device.java @@ -0,0 +1,142 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A device that signed in to an account, keyed by the id the app made for itself. +@Entity +public class Device { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String platform = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String lastIp = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String lastCountry = ""; + + @Column(nullable = false) + private Instant lastSeenAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant revokedAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String grantId = ""; + + public Device() { + } + + 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 getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getLastIp() { + return lastIp; + } + + public void setLastIp(String lastIp) { + this.lastIp = lastIp; + } + + public String getLastCountry() { + return lastCountry; + } + + public void setLastCountry(String lastCountry) { + this.lastCountry = lastCountry; + } + + public Instant getLastSeenAt() { + return lastSeenAt; + } + + public void setLastSeenAt(Instant lastSeenAt) { + this.lastSeenAt = lastSeenAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getRevokedAt() { + return revokedAt; + } + + public void setRevokedAt(Instant revokedAt) { + this.revokedAt = revokedAt; + } + + public String getGrantId() { + return grantId; + } + + public void setGrantId(String grantId) { + this.grantId = grantId; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Download.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Download.java new file mode 100644 index 00000000000..f308cc381e2 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Download.java @@ -0,0 +1,120 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A title licensed for offline play on one device. +@Entity +public class Download { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String deviceId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String scheme = ""; + + @Column(nullable = false) + private Instant licenseExpiresAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Download() { + } + + 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 getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getScheme() { + return scheme; + } + + public void setScheme(String scheme) { + this.scheme = scheme; + } + + public Instant getLicenseExpiresAt() { + return licenseExpiresAt; + } + + public void setLicenseExpiresAt(Instant licenseExpiresAt) { + this.licenseExpiresAt = licenseExpiresAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/FrameHash.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/FrameHash.java new file mode 100644 index 00000000000..021fe5f0c62 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/FrameHash.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A perceptual hash of one sampled frame, for matching against the blocklist. +@Entity +public class FrameHash { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String hash = ""; + + @Column(nullable = false) + private int position; + + public FrameHash() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getHash() { + return hash; + } + + public void setHash(String hash) { + this.hash = hash; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Genre.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Genre.java new file mode 100644 index 00000000000..950e8607186 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Genre.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A genre, or an academy subject. +@Entity +public class Genre { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String name = ""; + + @Column(nullable = false) + private int position; + + public Genre() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Household.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Household.java new file mode 100644 index 00000000000..4bb0ee05c49 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Household.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A viewer profile inside an account: its own list, history and rating limit. +@Entity +public class Household { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String avatar = ""; + + @Column(nullable = false) + private boolean kids; + + @Column(nullable = false, type = "VARCHAR(8)") + private String maxRating = ""; + + @Column(nullable = false, type = "VARCHAR(128)") + private String pinHash = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String language = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Household() { + } + + 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 getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public boolean isKids() { + return kids; + } + + public void setKids(boolean kids) { + this.kids = kids; + } + + public String getMaxRating() { + return maxRating; + } + + public void setMaxRating(String maxRating) { + this.maxRating = maxRating; + } + + public String getPinHash() { + return pinHash; + } + + public void setPinHash(String pinHash) { + this.pinHash = pinHash; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Interaction.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Interaction.java new file mode 100644 index 00000000000..3a5b3046e5a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Interaction.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A pause point in a video: a quiz, a poll, a branch or a note. `options` are one per line. +@Entity +public class Interaction { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false) + private int atSeconds; + + @Column(nullable = false, type = "VARCHAR(500)") + private String prompt = ""; + + @Column(nullable = false, type = "TEXT") + private String options = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String answers = ""; + + @Column(nullable = false, type = "VARCHAR(500)") + private String explanation = ""; + + @Column(nullable = false) + private boolean required; + + @Column(nullable = false, type = "VARCHAR(255)") + private String branchTargets = ""; + + @Column(nullable = false) + private int position; + + public Interaction() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public int getAtSeconds() { + return atSeconds; + } + + public void setAtSeconds(int atSeconds) { + this.atSeconds = atSeconds; + } + + public String getPrompt() { + return prompt; + } + + public void setPrompt(String prompt) { + this.prompt = prompt; + } + + public String getOptions() { + return options; + } + + public void setOptions(String options) { + this.options = options; + } + + public String getAnswers() { + return answers; + } + + public void setAnswers(String answers) { + this.answers = answers; + } + + public String getExplanation() { + return explanation; + } + + public void setExplanation(String explanation) { + this.explanation = explanation; + } + + public boolean isRequired() { + return required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public String getBranchTargets() { + return branchTargets; + } + + public void setBranchTargets(String branchTargets) { + this.branchTargets = branchTargets; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Invoice.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Invoice.java new file mode 100644 index 00000000000..d8b247ac4b4 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Invoice.java @@ -0,0 +1,142 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A billing record: a period, a rental, a proration, a refund. +@Entity +public class Invoice { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String description = ""; + + @Column(nullable = false) + private long amountCents; + + @Column(nullable = false, type = "VARCHAR(3)") + private String currency = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String providerRef = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Invoice() { + } + + 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 getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + 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 String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Job.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Job.java new file mode 100644 index 00000000000..21d2526411f --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Job.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A packaging job a worker claims, with the lease it holds it under. +@Entity +public class Job { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private int progressPercent; + + @Column(nullable = false, type = "VARCHAR(64)") + private String worker = ""; + + @Column(nullable = false, type = "VARCHAR(500)") + private String error = ""; + + @Column(nullable = false) + private int attempts; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant startedAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant finishedAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant leaseUntil = Instant.EPOCH; + + public Job() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public int getProgressPercent() { + return progressPercent; + } + + public void setProgressPercent(int progressPercent) { + this.progressPercent = progressPercent; + } + + public String getWorker() { + return worker; + } + + public void setWorker(String worker) { + this.worker = worker; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public int getAttempts() { + return attempts; + } + + public void setAttempts(int attempts) { + this.attempts = attempts; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getStartedAt() { + return startedAt; + } + + public void setStartedAt(Instant startedAt) { + this.startedAt = startedAt; + } + + public Instant getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(Instant finishedAt) { + this.finishedAt = finishedAt; + } + + public Instant getLeaseUntil() { + return leaseUntil; + } + + public void setLeaseUntil(Instant leaseUntil) { + this.leaseUntil = leaseUntil; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ListEntry.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ListEntry.java new file mode 100644 index 00000000000..08922d69fa4 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ListEntry.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A title on a profile's list. The id is the profile id and the title id. +@Entity +public class ListEntry { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(80)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String profileId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false) + private Instant addedAt = Instant.EPOCH; + + public ListEntry() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public Instant getAddedAt() { + return addedAt; + } + + public void setAddedAt(Instant addedAt) { + this.addedAt = addedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationEvent.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationEvent.java new file mode 100644 index 00000000000..acb75305c6a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationEvent.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One thing an admin did to an account, and why. Written once, never changed. +@Entity +public class ModerationEvent { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String actor = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String action = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + 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 Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant 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/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationItem.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationItem.java new file mode 100644 index 00000000000..8fb8fca247a --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/ModerationItem.java @@ -0,0 +1,186 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// Something waiting for a moderator: a title, a report or an appeal. +@Entity +public class ModerationItem { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String subjectId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String reason = ""; + + @Column(nullable = false, type = "VARCHAR(1000)") + private String detail = ""; + + @Column(nullable = false, type = "VARCHAR(500)") + private String signals = ""; + + @Column(nullable = false) + private double score; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String decidedBy = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String decision = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String note = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant decidedAt = Instant.EPOCH; + + public ModerationItem() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getSubjectId() { + return subjectId; + } + + public void setSubjectId(String subjectId) { + this.subjectId = subjectId; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getDetail() { + return detail; + } + + public void setDetail(String detail) { + this.detail = detail; + } + + public String getSignals() { + return signals; + } + + public void setSignals(String signals) { + this.signals = signals; + } + + public double getScore() { + return score; + } + + public void setScore(double score) { + this.score = score; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getDecidedBy() { + return decidedBy; + } + + public void setDecidedBy(String decidedBy) { + this.decidedBy = decidedBy; + } + + public String getDecision() { + return decision; + } + + public void setDecision(String decision) { + this.decision = decision; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getDecidedAt() { + return decidedAt; + } + + public void setDecidedAt(Instant decidedAt) { + this.decidedAt = decidedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentCustomer.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentCustomer.java new file mode 100644 index 00000000000..e9d77b9a7aa --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/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.reel.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, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + 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/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentMethod.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentMethod.java new file mode 100644 index 00000000000..0aa0310630f --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentMethod.java @@ -0,0 +1,132 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// 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, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(24)") + private String brand = ""; + + @Column(nullable = false, type = "VARCHAR(4)") + private String last4 = ""; + + @Column(nullable = false) + private int expMonth; + + @Column(nullable = false) + private int expYear; + + @Column(nullable = false, type = "VARCHAR(64)") + private String token = ""; + + @Column(nullable = false) + private boolean preferred; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + 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 Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentSetup.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentSetup.java new file mode 100644 index 00000000000..5c896578a8e --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PaymentSetup.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A card being added on the provider's page, until the browser comes back. +@Entity +public class PaymentSetup { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String reference = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @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 Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public boolean isCompleted() { + return completed; + } + + public void setCompleted(boolean completed) { + this.completed = completed; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Plan.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Plan.java new file mode 100644 index 00000000000..b985cd05bab --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Plan.java @@ -0,0 +1,174 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A subscription plan. +@Entity +public class Plan { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String description = ""; + + @Column(nullable = false) + private long priceCents; + + @Column(nullable = false, type = "VARCHAR(3)") + private String currency = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String billingInterval = ""; + + @Column(nullable = false) + private int maxStreams; + + @Column(nullable = false, type = "VARCHAR(8)") + private String maxQuality = ""; + + @Column(nullable = false) + private boolean adSupported; + + @Column(nullable = false) + private boolean downloads; + + @Column(nullable = false) + private int trialDays; + + @Column(nullable = false) + private boolean active; + + @Column(nullable = false) + private int position; + + public Plan() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public long getPriceCents() { + return priceCents; + } + + public void setPriceCents(long priceCents) { + this.priceCents = priceCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public String getBillingInterval() { + return billingInterval; + } + + public void setBillingInterval(String billingInterval) { + this.billingInterval = billingInterval; + } + + public int getMaxStreams() { + return maxStreams; + } + + public void setMaxStreams(int maxStreams) { + this.maxStreams = maxStreams; + } + + public String getMaxQuality() { + return maxQuality; + } + + public void setMaxQuality(String maxQuality) { + this.maxQuality = maxQuality; + } + + public boolean isAdSupported() { + return adSupported; + } + + public void setAdSupported(boolean adSupported) { + this.adSupported = adSupported; + } + + public boolean isDownloads() { + return downloads; + } + + public void setDownloads(boolean downloads) { + this.downloads = downloads; + } + + public int getTrialDays() { + return trialDays; + } + + public void setTrialDays(int trialDays) { + this.trialDays = trialDays; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Playback.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Playback.java new file mode 100644 index 00000000000..e53f7c0e335 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Playback.java @@ -0,0 +1,208 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A stream: who plays what where, and the lease its heartbeats renew. +@Entity +public class Playback { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String profileId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String deviceId = ""; + + @Column(nullable = false, type = "VARCHAR(80)") + private String deviceName = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String ip = ""; + + @Column(nullable = false, type = "VARCHAR(8)") + private String country = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String scheme = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String tokenHash = ""; + + @Column(nullable = false) + private Instant startedAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant lastHeartbeatAt = Instant.EPOCH; + + @Column(nullable = false) + private int positionSeconds; + + @Column(nullable = false) + private Instant endedAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(32)") + private String endedReason = ""; + + public Playback() { + } + + 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 getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public String getDeviceName() { + return deviceName; + } + + public void setDeviceName(String deviceName) { + this.deviceName = deviceName; + } + + public String getIp() { + return ip; + } + + public void setIp(String ip) { + this.ip = ip; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } + + public String getScheme() { + return scheme; + } + + public void setScheme(String scheme) { + this.scheme = scheme; + } + + public String getTokenHash() { + return tokenHash; + } + + public void setTokenHash(String tokenHash) { + this.tokenHash = tokenHash; + } + + public Instant getStartedAt() { + return startedAt; + } + + public void setStartedAt(Instant startedAt) { + this.startedAt = startedAt; + } + + public Instant getLastHeartbeatAt() { + return lastHeartbeatAt; + } + + public void setLastHeartbeatAt(Instant lastHeartbeatAt) { + this.lastHeartbeatAt = lastHeartbeatAt; + } + + public int getPositionSeconds() { + return positionSeconds; + } + + public void setPositionSeconds(int positionSeconds) { + this.positionSeconds = positionSeconds; + } + + public Instant getEndedAt() { + return endedAt; + } + + public void setEndedAt(Instant endedAt) { + this.endedAt = endedAt; + } + + public String getEndedReason() { + return endedReason; + } + + public void setEndedReason(String endedReason) { + this.endedReason = endedReason; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Profile.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Profile.java new file mode 100644 index 00000000000..3683bca6e68 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Profile.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// What Reel knows about an account beyond its sign-in, keyed by the same username. +@Entity +public class Profile { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String displayName = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(8)") + private String language = ""; + + @Column(nullable = false) + private boolean flagged; + + @Column(nullable = false, type = "VARCHAR(255)") + private String flagReason = ""; + + @Column(nullable = false) + private Instant lastSeenAt = Instant.EPOCH; + + 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 Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + 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 Instant getLastSeenAt() { + return lastSeenAt; + } + + public void setLastSeenAt(Instant lastSeenAt) { + this.lastSeenAt = lastSeenAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Progress.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Progress.java new file mode 100644 index 00000000000..1fbbf547bf3 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Progress.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// Where a profile is in a video. The id is the profile id and the video id. +@Entity +public class Progress { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(80)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String profileId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false) + private int positionSeconds; + + @Column(nullable = false) + private int durationSeconds; + + @Column(nullable = false) + private boolean completed; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public Progress() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public int getPositionSeconds() { + return positionSeconds; + } + + public void setPositionSeconds(int positionSeconds) { + this.positionSeconds = positionSeconds; + } + + public int getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(int durationSeconds) { + this.durationSeconds = durationSeconds; + } + + public boolean isCompleted() { + return completed; + } + + public void setCompleted(boolean completed) { + this.completed = completed; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PushDevice.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PushDevice.java new file mode 100644 index 00000000000..54ceaf28347 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/PushDevice.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// 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, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + /// `fcm`, `apns`, `huawei`, `wns` or `web`. + @Column(nullable = false, type = "VARCHAR(16)") + private String provider = ""; + + @Column(nullable = false, type = "VARCHAR(1024)") + private String token = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String platform = ""; + + @Column(nullable = false) + private boolean newEpisodes; + + @Column(nullable = false) + private boolean accountEvents; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + 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 Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + public boolean isNewEpisodes() { + return newEpisodes; + } + + public void setNewEpisodes(boolean newEpisodes) { + this.newEpisodes = newEpisodes; + } + + public boolean isAccountEvents() { + return accountEvents; + } + + public void setAccountEvents(boolean accountEvents) { + this.accountEvents = accountEvents; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Rental.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Rental.java new file mode 100644 index 00000000000..a98ca96d29d --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Rental.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A title an account may play until the rental expires. +@Entity +public class Rental { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false) + private long priceCents; + + @Column(nullable = false, type = "VARCHAR(3)") + private String currency = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant expiresAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant startedAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(32)") + private String invoiceId = ""; + + public Rental() { + } + + 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 getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public long getPriceCents() { + return priceCents; + } + + public void setPriceCents(long priceCents) { + this.priceCents = priceCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public Instant getStartedAt() { + return startedAt; + } + + public void setStartedAt(Instant startedAt) { + this.startedAt = startedAt; + } + + public String getInvoiceId() { + return invoiceId; + } + + public void setInvoiceId(String invoiceId) { + this.invoiceId = invoiceId; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Setting.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Setting.java new file mode 100644 index 00000000000..6a4a1ff9c15 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Setting.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One admin-controlled setting: a feature switch, the preset, the branding. +@Entity +public class Setting { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String name = ""; + + @Column(nullable = false, type = "TEXT") + private String value = ""; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String updatedBy = ""; + + public Setting() { + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public void setUpdatedBy(String updatedBy) { + this.updatedBy = updatedBy; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/SharingAlert.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/SharingAlert.java new file mode 100644 index 00000000000..58940a675a8 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/SharingAlert.java @@ -0,0 +1,142 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A sign an account is used beyond its household. +@Entity +public class SharingAlert { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String detail = ""; + + @Column(nullable = false) + private int distinctIps; + + @Column(nullable = false) + private int distinctCountries; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String decidedBy = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String note = ""; + + public SharingAlert() { + } + + 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 getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getDetail() { + return detail; + } + + public void setDetail(String detail) { + this.detail = detail; + } + + public int getDistinctIps() { + return distinctIps; + } + + public void setDistinctIps(int distinctIps) { + this.distinctIps = distinctIps; + } + + public int getDistinctCountries() { + return distinctCountries; + } + + public void setDistinctCountries(int distinctCountries) { + this.distinctCountries = distinctCountries; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public String getDecidedBy() { + return decidedBy; + } + + public void setDecidedBy(String decidedBy) { + this.decidedBy = decidedBy; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Subscription.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Subscription.java new file mode 100644 index 00000000000..fc2af84ad4b --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Subscription.java @@ -0,0 +1,197 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// An account's subscription. One row per account, kept after it ends. +@Entity +public class Subscription { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String planCode = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private Instant startedAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant periodEndsAt = Instant.EPOCH; + + @Column(nullable = false) + private boolean cancelAtPeriodEnd; + + @Column(nullable = false, type = "VARCHAR(32)") + private String pendingPlanCode = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String couponCode = ""; + + @Column(nullable = false) + private int couponPeriodsLeft; + + @Column(nullable = false, type = "VARCHAR(32)") + private String paymentMethodId = ""; + + @Column(nullable = false) + private int failedAttempts; + + @Column(nullable = false) + private Instant nextRetryAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(16)") + private String store = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String storeRef = ""; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public Subscription() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPlanCode() { + return planCode; + } + + public void setPlanCode(String planCode) { + this.planCode = planCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Instant getStartedAt() { + return startedAt; + } + + public void setStartedAt(Instant startedAt) { + this.startedAt = startedAt; + } + + public Instant getPeriodEndsAt() { + return periodEndsAt; + } + + public void setPeriodEndsAt(Instant periodEndsAt) { + this.periodEndsAt = periodEndsAt; + } + + public boolean isCancelAtPeriodEnd() { + return cancelAtPeriodEnd; + } + + public void setCancelAtPeriodEnd(boolean cancelAtPeriodEnd) { + this.cancelAtPeriodEnd = cancelAtPeriodEnd; + } + + public String getPendingPlanCode() { + return pendingPlanCode; + } + + public void setPendingPlanCode(String pendingPlanCode) { + this.pendingPlanCode = pendingPlanCode; + } + + public String getCouponCode() { + return couponCode; + } + + public void setCouponCode(String couponCode) { + this.couponCode = couponCode; + } + + public int getCouponPeriodsLeft() { + return couponPeriodsLeft; + } + + public void setCouponPeriodsLeft(int couponPeriodsLeft) { + this.couponPeriodsLeft = couponPeriodsLeft; + } + + public String getPaymentMethodId() { + return paymentMethodId; + } + + public void setPaymentMethodId(String paymentMethodId) { + this.paymentMethodId = paymentMethodId; + } + + public int getFailedAttempts() { + return failedAttempts; + } + + public void setFailedAttempts(int failedAttempts) { + this.failedAttempts = failedAttempts; + } + + public String getStore() { + return store; + } + + public void setStore(String store) { + this.store = store; + } + + public String getStoreRef() { + return storeRef; + } + + public void setStoreRef(String storeRef) { + this.storeRef = storeRef; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + public Instant getNextRetryAt() { + return nextRetryAt; + } + + public void setNextRetryAt(Instant nextRetryAt) { + this.nextRetryAt = nextRetryAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Subtitle.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Subtitle.java new file mode 100644 index 00000000000..623659f6de0 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Subtitle.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A subtitle track of a video, in one language. The WebVTT text is kept in +/// the row: a feature's subtitles are tens of kilobytes, and the player +/// fetches them once. +@Entity +public class Subtitle { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String language = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String label = ""; + + @Column(nullable = false, type = "TEXT") + private String vtt = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Subtitle() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getVtt() { + return vtt; + } + + public void setVtt(String vtt) { + this.vtt = vtt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Title.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Title.java new file mode 100644 index 00000000000..2e53c216a61 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Title.java @@ -0,0 +1,320 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A movie, a series, a course or a creator's video, with its listing and moderation state. +@Entity +public class Title { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(200)") + private String name = ""; + + @Column(nullable = false, type = "TEXT") + private String synopsis = ""; + + @Column(nullable = false) + private int year; + + @Column(nullable = false, type = "VARCHAR(8)") + private String rating = ""; + + /// What earned the rating -- `violence`, `language` -- comma separated. + @Column(nullable = false, type = "VARCHAR(255)") + private String advisories = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String genres = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String tags = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String creator = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String access = ""; + + @Column(nullable = false) + private long rentalPriceCents; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String moderationNote = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String posterUrl = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String backdropUrl = ""; + + @Column(nullable = false) + private boolean certificate; + + @Column(nullable = false) + private int passPercent; + + @Column(nullable = false) + private boolean featured; + + @Column(nullable = false) + private boolean ageRestricted; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant publishedAt = Instant.EPOCH; + + /// How many of its videos the viewers following it have been told about. + @Column(nullable = false) + private int announcedVideos; + + @Column(nullable = false) + private long ratingSum; + + @Column(nullable = false) + private int ratingCount; + + @Column(nullable = false) + private long viewCount; + + public Title() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSynopsis() { + return synopsis; + } + + public void setSynopsis(String synopsis) { + this.synopsis = synopsis; + } + + public int getYear() { + return year; + } + + public void setYear(int year) { + this.year = year; + } + + public String getAdvisories() { + return advisories; + } + + public void setAdvisories(String advisories) { + this.advisories = advisories; + } + + public String getRating() { + return rating; + } + + public void setRating(String rating) { + this.rating = rating; + } + + public String getGenres() { + return genres; + } + + public void setGenres(String genres) { + this.genres = genres; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getCreator() { + return creator; + } + + public void setCreator(String creator) { + this.creator = creator; + } + + public String getAccess() { + return access; + } + + public void setAccess(String access) { + this.access = access; + } + + public long getRentalPriceCents() { + return rentalPriceCents; + } + + public void setRentalPriceCents(long rentalPriceCents) { + this.rentalPriceCents = rentalPriceCents; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getModerationNote() { + return moderationNote; + } + + public void setModerationNote(String moderationNote) { + this.moderationNote = moderationNote; + } + + public String getPosterUrl() { + return posterUrl; + } + + public void setPosterUrl(String posterUrl) { + this.posterUrl = posterUrl; + } + + public String getBackdropUrl() { + return backdropUrl; + } + + public void setBackdropUrl(String backdropUrl) { + this.backdropUrl = backdropUrl; + } + + public boolean isCertificate() { + return certificate; + } + + public void setCertificate(boolean certificate) { + this.certificate = certificate; + } + + public int getPassPercent() { + return passPercent; + } + + public void setPassPercent(int passPercent) { + this.passPercent = passPercent; + } + + public boolean isFeatured() { + return featured; + } + + public void setFeatured(boolean featured) { + this.featured = featured; + } + + public boolean isAgeRestricted() { + return ageRestricted; + } + + public void setAgeRestricted(boolean ageRestricted) { + this.ageRestricted = ageRestricted; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getPublishedAt() { + return publishedAt; + } + + public void setPublishedAt(Instant publishedAt) { + this.publishedAt = publishedAt; + } + + public long getRatingSum() { + return ratingSum; + } + + public void setRatingSum(long ratingSum) { + this.ratingSum = ratingSum; + } + + public int getRatingCount() { + return ratingCount; + } + + public void setRatingCount(int ratingCount) { + this.ratingCount = ratingCount; + } + + public long getViewCount() { + return viewCount; + } + + public void setViewCount(long viewCount) { + this.viewCount = viewCount; + } + + public int getAnnouncedVideos() { + return announcedVideos; + } + + public void setAnnouncedVideos(int announcedVideos) { + this.announcedVideos = announcedVideos; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/TitleRating.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/TitleRating.java new file mode 100644 index 00000000000..555d97e6846 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/TitleRating.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A profile's rating of a title. The id is the profile id and the title id. +@Entity +public class TitleRating { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(80)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String profileId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false) + private int stars; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public TitleRating() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public int getStars() { + return stars; + } + + public void setStars(int stars) { + this.stars = stars; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Upload.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Upload.java new file mode 100644 index 00000000000..2d8ad6d9302 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Upload.java @@ -0,0 +1,142 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A resumable upload of a video's source file. +@Entity +public class Upload { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(200)") + private String fileName = ""; + + @Column(nullable = false) + private long totalBytes; + + @Column(nullable = false) + private long receivedBytes; + + @Column(nullable = false, type = "VARCHAR(64)") + private String sha256 = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public Upload() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public long getTotalBytes() { + return totalBytes; + } + + public void setTotalBytes(long totalBytes) { + this.totalBytes = totalBytes; + } + + public long getReceivedBytes() { + return receivedBytes; + } + + public void setReceivedBytes(long receivedBytes) { + this.receivedBytes = receivedBytes; + } + + public String getSha256() { + return sha256; + } + + public void setSha256(String sha256) { + this.sha256 = sha256; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Video.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Video.java new file mode 100644 index 00000000000..efd66497fd5 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/Video.java @@ -0,0 +1,232 @@ +/* + * 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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One playable file of a title: the feature, an episode or lesson, or the trailer. +@Entity +public class Video { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String titleId = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String role = ""; + + @Column(nullable = false) + private int season; + + @Column(nullable = false) + private int number; + + @Column(nullable = false, type = "VARCHAR(200)") + private String name = ""; + + @Column(nullable = false, type = "TEXT") + private String synopsis = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String status = ""; + + @Column(nullable = false) + private int progressPercent; + + @Column(nullable = false, type = "VARCHAR(255)") + private String error = ""; + + @Column(nullable = false) + private int durationSeconds; + + @Column(nullable = false) + private int width; + + @Column(nullable = false) + private int height; + + @Column(nullable = false, type = "VARCHAR(255)") + private String renditions = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String schemes = ""; + + /// The audio tracks, as `id:language:label` separated by commas; the + /// first is the original. + @Column(nullable = false, type = "VARCHAR(255)") + private String audio = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String thumbnailUrl = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Video() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTitleId() { + return titleId; + } + + public void setTitleId(String titleId) { + this.titleId = titleId; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public int getSeason() { + return season; + } + + public void setSeason(int season) { + this.season = season; + } + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSynopsis() { + return synopsis; + } + + public void setSynopsis(String synopsis) { + this.synopsis = synopsis; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public int getProgressPercent() { + return progressPercent; + } + + public void setProgressPercent(int progressPercent) { + this.progressPercent = progressPercent; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public int getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(int durationSeconds) { + this.durationSeconds = durationSeconds; + } + + public int getWidth() { + return width; + } + + public void setWidth(int width) { + this.width = width; + } + + public int getHeight() { + return height; + } + + public void setHeight(int height) { + this.height = height; + } + + public String getAudio() { + return audio; + } + + public void setAudio(String audio) { + this.audio = audio; + } + + public String getRenditions() { + return renditions; + } + + public void setRenditions(String renditions) { + this.renditions = renditions; + } + + public String getSchemes() { + return schemes; + } + + public void setSchemes(String schemes) { + this.schemes = schemes; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/VideoKey.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/VideoKey.java new file mode 100644 index 00000000000..578635b0e4d --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/domain/VideoKey.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.reel.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// The content key a video is encrypted with. Never written to the media directory. +@Entity +public class VideoKey { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String videoId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String keyId = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String keyHex = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String ivHex = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public VideoKey() { + } + + public String getVideoId() { + return videoId; + } + + public void setVideoId(String videoId) { + this.videoId = videoId; + } + + public String getKeyId() { + return keyId; + } + + public void setKeyId(String keyId) { + this.keyId = keyId; + } + + public String getKeyHex() { + return keyHex; + } + + public void setKeyHex(String keyHex) { + this.keyHex = keyHex; + } + + public String getIvHex() { + return ivHex; + } + + public void setIvHex(String ivHex) { + this.ivHex = ivHex; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/CertificatePage.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/CertificatePage.java new file mode 100644 index 00000000000..44794072a66 --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/CertificatePage.java @@ -0,0 +1,92 @@ +/* + * 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.reel.learning; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PathVariable; +import com.codename1.backend.annotations.RestController; +import com.codenameone.examples.reel.api.CertificateDto; +import com.codenameone.examples.reel.settings.Settings; + +import java.text.SimpleDateFormat; +import java.util.Date; + +/// The printable certificate page at `/verify/{code}`: what the code on a +/// certificate leads to, and what the app opens to print or share one. No +/// sign-in: the code is the credential, and it proves only that the named +/// person passed the named course. +@RestController +public class CertificatePage { + private final Learning learning; + private final Settings settings; + + public CertificatePage(Learning learning, Settings settings) { + this.learning = learning; + this.settings = settings; + } + + @GetMapping("/verify/{code}") + public HttpServer.Response page(@PathVariable("code") String code) throws Exception { + CertificateDto c; + try { + c = learning.verify(code); + } catch (ResponseStatusException missing) { + return html(404, "

Not found

No certificate has the code " + escape(code) + ".

"); + } + String service = escape(settings.branding().serviceName()); + String date = new SimpleDateFormat("yyyy-MM-dd").format(new Date(c.issuedAt())); + return html(200, "

" + service + " certifies that

" + + escape(c.holderName()) + "

completed

" + escape(c.titleName()) + "

with a score of " + + c.scorePercent() + "% on " + date + "

Verification code " + + escape(c.verifyCode()) + "

"); + } + + private static HttpServer.Response html(int status, String body) throws Exception { + String page = "" + + "Certificate" + + "" + body + + ""; + return new HttpServer.Response(status, "text/html; charset=utf-8", page.getBytes("UTF-8")); + } + + static String escape(String s) { + if (s == null) { + return ""; + } + StringBuilder out = new StringBuilder(s.length()); + for (int iter = 0; iter < s.length(); iter++) { + char ch = s.charAt(iter); + switch (ch) { + case '<': out.append("<"); break; + case '>': out.append(">"); break; + case '&': out.append("&"); break; + case '"': out.append("""); break; + case '\'': out.append("'"); break; + default: out.append(ch); + } + } + return out.toString(); + } +} diff --git a/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/Learning.java b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/Learning.java new file mode 100644 index 00000000000..358420d5d1d --- /dev/null +++ b/scripts/reel/backend/src/main/java/com/codenameone/examples/reel/learning/Learning.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.reel.learning; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.reel.Ids; +import com.codenameone.examples.reel.Kinds; +import com.codenameone.examples.reel.Rows; +import com.codenameone.examples.reel.account.Accounts; +import com.codenameone.examples.reel.account.Households; +import com.codenameone.examples.reel.api.AnswerDto; +import com.codenameone.examples.reel.api.AnswerResultDto; +import com.codenameone.examples.reel.api.CertificateDto; +import com.codenameone.examples.reel.api.CourseProgressDto; +import com.codenameone.examples.reel.api.Features; +import com.codenameone.examples.reel.domain.Answer; +import com.codenameone.examples.reel.domain.Certificate; +import com.codenameone.examples.reel.domain.Household; +import com.codenameone.examples.reel.domain.Interaction; +import com.codenameone.examples.reel.domain.Playback; +import com.codenameone.examples.reel.domain.Progress; +import com.codenameone.examples.reel.domain.Title; +import com.codenameone.examples.reel.domain.Video; +import com.codenameone.examples.reel.repository.AnswerRepository; +import com.codenameone.examples.reel.repository.CertificateRepository; +import com.codenameone.examples.reel.repository.InteractionRepository; +import com.codenameone.examples.reel.repository.PlaybackRepository; +import com.codenameone.examples.reel.repository.ProgressRepository; +import com.codenameone.examples.reel.repository.TitleRepository; +import com.codenameone.examples.reel.repository.VideoRepository; +import com.codenameone.examples.reel.settings.Settings; +import com.codename1.backend.Crypto; + +import java.time.Instant; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// The academy: answers to quizzes, polls and branches, course progress and +/// certificates. +/// +/// Answers are checked here, never in the app: the player receives the +/// questions without their answers ([com.codenameone.examples.reel.playback.Playbacks]). +/// A viewer's latest answer to a question is the one that counts, so a quiz +/// can be retaken. A course's score is the share of its quiz questions whose +/// latest answer is right; a certificate is issued once every lesson is +/// complete and the score reaches the course's pass mark. Each certificate has +/// a short public code anyone can check at `/verify/{code}`. +@Component +public class Learning { + public static final String QUIZ = "QUIZ"; + public static final String POLL = "POLL"; + public static final String BRANCH = "BRANCH"; + /// Unambiguous characters for a verification code. + private static final String CODE = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + private final AnswerRepository answers; + private final InteractionRepository interactions; + private final VideoRepository videos; + private final TitleRepository titles; + private final ProgressRepository progress; + private final CertificateRepository certificates; + private final PlaybackRepository sessions; + private final Households households; + private final Accounts accounts; + private final Settings settings; + + public Learning(AnswerRepository answers, InteractionRepository interactions, VideoRepository videos, + TitleRepository titles, ProgressRepository progress, CertificateRepository certificates, + PlaybackRepository sessions, Households households, Accounts accounts, Settings settings) { + this.answers = answers; + this.interactions = interactions; + this.videos = videos; + this.titles = titles; + this.progress = progress; + this.certificates = certificates; + this.sessions = sessions; + this.households = households; + this.accounts = accounts; + this.settings = settings; + } + + @Transactional(rollbackFor = IOException.class) + public AnswerResultDto answer(String username, AnswerDto dto) throws IOException { + settings.require(Features.INTERACTIVE); + Interaction q = dto == null || dto.interactionId() == null ? null : interactions.findById(dto.interactionId()); + if (q == null) { + throw new ResponseStatusException(404, "No such question"); + } + Video video = videos.findById(q.getVideoId()); + Title title = video == null ? null : titles.findById(video.getTitleId()); + if (title == null) { + throw new ResponseStatusException(404, "No such question"); + } + // The answer is tied to the profile of the session it was given in, so + // a household's profiles keep separate scores. + // It also proves the caller was entitled to the video: a question is + // only answered from inside a playback of it. + Playback s = dto.sessionId() == null ? null : sessions.findById(dto.sessionId()); + if (s == null || !s.getUsername().equals(username) || !s.getVideoId().equals(video.getId())) { + throw new ResponseStatusException(404, "No such session"); + } + String profileId = s.getProfileId(); + List options = Rows.splitLines(q.getOptions()); + List chosen = dto.choices() == null ? new ArrayList() : dto.choices(); + if (chosen.isEmpty()) { + throw new ResponseStatusException(400, "Choose an answer"); + } + Set picked = new HashSet(); + for (int iter = 0; iter < chosen.size(); iter++) { + Integer c = chosen.get(iter); + if (c == null || c.intValue() < 0 || c.intValue() >= options.size()) { + throw new ResponseStatusException(400, "That option does not exist"); + } + picked.add(c); + } + if ((POLL.equals(q.getKind()) || BRANCH.equals(q.getKind())) && picked.size() != 1) { + throw new ResponseStatusException(400, "Choose one option"); + } + Set right = new HashSet(Rows.ints(q.getAnswers())); + boolean correct = QUIZ.equals(q.getKind()) ? right.equals(picked) : true; + + // A quiz answer is final. Every answer is told whether it was right, + // so a quiz that took another would be passed by trying each option + // in turn -- and the certificate would say nothing. A poll or a + // branch can be changed. + List previous = answers.findByUsernameAndInteractionId(username, q.getId()); + for (int iter = 0; iter < previous.size(); iter++) { + if (previous.get(iter).getProfileId().equals(profileId)) { + if (QUIZ.equals(q.getKind())) { + throw new ResponseStatusException(409, "This question was already answered"); + } + answers.deleteById(previous.get(iter).getId()); + } + } + Answer row = new Answer(); + row.setId(Ids.next()); + row.setInteractionId(q.getId()); + row.setVideoId(video.getId()); + row.setTitleId(title.getId()); + row.setUsername(username); + row.setProfileId(profileId); + row.setChoices(Rows.joinInts(new ArrayList(picked))); + row.setCorrect(correct); + row.setCreatedAt(Instant.now()); + answers.insert(row); + + List tally = new ArrayList(); + if (POLL.equals(q.getKind())) { + int[] counts = new int[options.size()]; + List all = answers.findByInteractionId(q.getId()); + for (int iter = 0; iter < all.size(); iter++) { + List c = Rows.ints(all.get(iter).getChoices()); + for (int k = 0; k < c.size(); k++) { + if (c.get(k).intValue() < counts.length) { + counts[c.get(k).intValue()]++; + } + } + } + for (int iter = 0; iter < counts.length; iter++) { + tally.add(Integer.valueOf(counts[iter])); + } + } + String next = null; + if (BRANCH.equals(q.getKind())) { + List targets = Rows.split(q.getBranchTargets()); + int at = picked.iterator().next().intValue(); + next = at < targets.size() ? targets.get(at) : null; + } + int score = score(username, profileId, title.getId()); + if (QUIZ.equals(q.getKind())) { + maybeCertify(username, profileId, title); + } + return new AnswerResultDto(correct, QUIZ.equals(q.getKind()) ? q.getExplanation() : "", tally, next, score); + } + + @Transactional(rollbackFor = IOException.class) + public List progress(String username, String profileId) throws IOException { + Household profile = households.resolve(username, profileId); + List rows = progress.findTop50ByProfileIdOrderByUpdatedAtDesc(profile.getId()); + Set seen = new HashSet(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Title t = titles.findById(rows.get(iter).getTitleId()); + if (t == null || !Kinds.COURSE.equals(t.getKind()) || !seen.add(t.getId())) { + continue; + } + int[] lessons = lessons(profile.getId(), t.getId()); + Certificate cert = maybeCertify(username, profile.getId(), t); + out.add(new CourseProgressDto(t.getId(), t.getName(), lessons[0], lessons[1], + score(username, profile.getId(), t.getId()), cert != null, cert == null ? null : cert.getId())); + } + return out; + } + + @Transactional(readOnly = true) + public List certificates(String username) { + List rows = certificates.findByUsernameOrderByIssuedAtDesc(username); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(dto(rows.get(iter))); + } + return out; + } + + /// Public: anyone holding a code can check it. + @Transactional(readOnly = true) + public CertificateDto verify(String code) { + Certificate c = code == null ? null : certificates.findFirstByVerifyCode(code.trim()); + if (c == null) { + throw new ResponseStatusException(404, "No certificate has that code"); + } + return dto(c); + } + + /// Issues the certificate of a course once it is earned; answers the + /// existing one when it already was. + Certificate maybeCertify(String username, String profileId, Title t) throws IOException { + List have = certificates.findByUsernameAndTitleId(username, t.getId()); + if (!have.isEmpty()) { + return have.get(0); + } + if (!t.isCertificate() || !settings.enabled(Features.CERTIFICATES)) { + return null; + } + int[] lessons = lessons(profileId, t.getId()); + int score = score(username, profileId, t.getId()); + if (lessons[0] == 0 || lessons[1] < lessons[0] || score < t.getPassPercent()) { + return null; + } + Certificate c = new Certificate(); + c.setId(Ids.next()); + c.setUsername(username); + c.setTitleId(t.getId()); + c.setHolderName(accounts.displayName(username)); + c.setScorePercent(score); + c.setIssuedAt(Instant.now()); + c.setVerifyCode(code()); + certificates.insert(c); + return c; + } + + /// {total lessons, completed lessons} of a course for a profile. + private int[] lessons(String profileId, String titleId) { + List