Compare commits

...
Author SHA1 Message Date
CI 61618f3345 Bump version to 0.6.0-SNAPSHOT [skip ci] 2026-11-07 19:22:54 +00:00
james.maes 835c5add68 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#32) from deps/align-gitea into develop
build / maven (push) Successful in 2m37s
maven / build (push) Successful in 2m37s
2026-11-07 19:20:28 +00:00
james.maesandClaude Opus 5.5 a9fa2bc932 build: surefire 3.5.6 (3.5.2 POM is served brotli-encoded by maven.kof22.com and fails to parse)
build / maven (push) Successful in 1m26s
maven / build (push) Successful in 1m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:18:37 +00:00
james.maesandClaude Opus 5.5 d4b2b626ca build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Canceled after 36s
maven / build (push) Canceled after 36s
demo/pom.xml: qqq.version: 4.1.0-RC.21 -> 4.1.0-RC.26
demo/pom.xml: qqq-frontend-next: 1.0.0-RC.35 -> 1.0.0-RC.38
examples/echo/demo/pom.xml: qqq.version: 4.1.0-RC.21 -> 4.1.0-RC.26
examples/echo/demo/pom.xml: qqq-frontend-next: 1.0.0-RC.35 -> 1.0.0-RC.38
examples/echo/demo/pom.xml: generation.version: 0.1.0-SNAPSHOT -> 1.1.0-RC.1
examples/echo/demo/pom.xml: printing.version: 1.0.0 -> 1.2.0-RC.1
examples/echo/demo/pom.xml: recordfiles.version: 0.1.0-SNAPSHOT -> 0.4.0-RC.1
examples/echo/demo/pom.xml: collaboration.version: 0.1.0-record-files-SNAPSHOT -> 1.2.0-RC.1
examples/echo/pom.xml: qqq.version: 4.1.0-RC.21 -> 4.1.0-RC.26
examples/echo/pom.xml: qqq-frontend-next: 1.0.0-RC.35 -> 1.0.0-RC.38
examples/echo/pom.xml: generation.version: 0.1.0-SNAPSHOT -> 1.1.0-RC.1
examples/echo/pom.xml: printing.version: 1.0.0 -> 1.2.0-RC.1
integrations/record-files/pom.xml: collaboration.version: 1.1.0-RC.1 -> 1.2.0-RC.1
integrations/record-files/pom.xml: recordfiles.version: 0.1.0-RC.1 -> 0.4.0-RC.1
pom.xml: parent -> 2.2.1
pom.xml: qqq.version: 4.1.0-RC.22 -> 4.1.0-RC.26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:16:11 +00:00
james.maes 4f96071157 Merge pull request 'fix(echo): make approved scanner photos available immediately' (#22) from fix/approved-scanner-photo-availability into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Failing after 1m0s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Failing after 1m0s
2026-11-07 13:12:15 +00:00
CI ad0c5da85a Bump version to 0.5.0-SNAPSHOT [skip ci] 2026-11-06 23:46:11 +00:00
james.maes 631c86b244 Merge pull request 'build: publish the Record Files bridge with the base through one reactor' (#29) from kof22/scanners-reactor into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 3m49s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 3m49s
2026-11-06 23:42:35 +00:00
james.maesandClaude Sonnet 5.5 52c34a54e7 Merge develop into kof22/scanners-reactor (keep the 0.4.0-SNAPSHOT revision in the reactor root)
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 2m44s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 2m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 17:39:10 -06:00
CI df8b615c09 Bump version to 0.4.0-SNAPSHOT [skip ci] 2026-11-06 23:32:02 +00:00
james.maesandClaude Sonnet 5.5 6cb8e34c5d build: publish the Record Files bridge with the base through one reactor
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 7m17s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 7m12s
The root pom becomes the packaging-pom reactor (qbit-mobile-scanners-reactor), the single <revision> source and parent of base/ and
integrations/record-files, so one root mvn deploy publishes the reactor POM, qbit-mobile-scanners and qbit-mobile-scanners-record-files at one
version. Consumer POMs are flattened so the version is resolved. Pins: QQQ 4.1.0-RC.22, qbit-record-files 0.1.0-RC.1, qbit-collaboration 1.1.0-RC.1
(all verified in the Gitea registry); the SNAPSHOT/feature coordinates and the qqq.frozen.version override are gone. Checkstyle config stays at the
repository root. demo/ and examples/ stay standalone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 17:30:45 -06:00
james.maesandClaude Sonnet 5.5 4be0bfe7ba refactor: move the base qbit into base/ (pure rename, no content change)
Prepares the reactor layout; the root pom.xml is recreated as the aggregator in the next commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 17:28:39 -06:00
james.maes 0d4f6df850 Merge pull request 'fix: refresh device versions on heartbeat (mobile-scanners 1.6)' (#26) from fix/refresh-device-versions into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 10m37s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 12s
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 10m37s
2026-11-06 23:23:31 +00:00
CI 8e7fba0d65 Bump version to 0.3.0-SNAPSHOT [skip ci] 2026-11-06 16:53:27 +00:00
james.maesandClaude Fable 5.1 01b9705428 fix: refresh device versions on heartbeat (mobile-scanners 1.6)
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 4m42s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 4m42s
An in-place app or OS update left the device record and every later scan
reporting the enrollment-time appVersion, because only enrollment wrote
manufacturer/model/osVersion/appVersion. Extension 1.6 adds the same
optional fields to MobileScannersHeartbeatRequestV1; the heartbeat updates
the approved device record only when a sent value differs, and scans copy
the refreshed record. Omitted, null or blank values keep the enrolled
values. Adds the 1.6.0 OpenAPI baseline and contract tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 10:44:17 -06:00
james.maes 63f026dfc2 Merge pull request 'ci: pin shared workflow to v1' (#24) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 54m22s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Waiting to run
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 10s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 2m11s
2026-11-06 15:34:37 +00:00
james.maes 78ae9e0722 Merge pull request 'feat(echo): carry the GG Forge pg/nav/label/recovery layers upstream' (#25) from feat/echo-forge-delta-20261106 into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Failing after 4m23s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 9s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 10s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 6s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Failing after 4m23s
2026-11-06 15:32:41 +00:00
james.maesandClaude Fable 5.1 e7774e4dd0 feat(echo): carry the GG Forge pg/nav/label/recovery layers upstream
build / maven (push) Successful in 2m54s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 16s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 8s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 13s
maven / build (push) Successful in 2m54s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Upstream the cumulative local delta that GG Forge has been running as
com.kingsrook.examples:qqq-echo:0.1.0-forge.1874c64b0f86-pg3-nav2-label3-recovery1
so a published build can replace that pin. Rebased over the two later
develop commits that touch examples/echo (323b8d9, ae3fd7b); both only
change examples/echo/demo, so there were no conflicts.

Layers carried locally by GG Forge, now in source:

- pg1/pg2: typed PostgreSQL 17 bindings in EchoStore, db/echo-postgres.sql,
  EchoPostgresTest (opt-in via -Decho.postgres.url), app-navigation fix.
- nav2: authorized process descriptions only for MetaDataInput and
  ProcessMetaDataInput; device authority everywhere else
  (EchoPermissions, EchoAppVisibilityTest).
- label2/label3: media display-name formatting ("4 x 1 in label - 300 dpi"),
  centered GS1 HRI compatibility, EchoCenteredLabelTest.
- recovery1: rejected-scan recovery with EchoScanRejected; EchoService,
  EchoSteps, EchoStore and EchoPrintingDestinations changes; HTTP fixture
  regression for success/reject/success.

POM: only org.postgresql:postgresql 42.7.8 (test) is added; Forge's
-forge.* coordinate pins are not carried, develop's property-based
versions stay as they are.

Verification (offline, Forge qualification m2 cache, generation cohort
0.1.0-forge.ee782b7d6ea8 supplied on the command line because the
0.1.0-SNAPSHOT generation build in the cache predates print copies):
./mvnw -f examples/echo/pom.xml verify, Checkstyle 0 violations,
88 tests, 0 failures, 0 errors, 0 skipped with a disposable
postgres:17-alpine fixture (1 skipped without it).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 08:57:40 -06:00
james.maesandClaude Opus 5.5 1f7c3e63ba ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 2m53s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 2m53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:11:45 +00:00
CI 514c42b3f8 Bump version to 0.2.0-SNAPSHOT [skip ci] 2026-11-05 23:57:37 +00:00
james.maesandClaude Sonnet 5.5 1f8e3196fe fix(echo): make approved scanner photos available immediately
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 4m20s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 4m21s
Opt the Echo host into the scoped captured-photo policy while preserving
quarantine for other intake. Allow only the demo administrator to release
earlier captures through the normal audited action.

Cover approval, parent and retained-content access, and record the verified
device behavior and remaining public dependency and release gates.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 17:29:34 -06:00
james.maes 408686ea23 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#23) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 2m15s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 2m15s
2026-11-05 23:17:19 +00:00
CI ceb45dbf53 ci: rerun shared workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m49s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 12s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Failing after 2m6s
build / maven (push) Successful in 1m32s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 6s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 1m32s
2026-11-05 16:39:36 -06:00
james.maesandClaude Opus 5.5 507bca7729 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Failing after 1m58s
build / maven (push) Failing after 15m3s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 3s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 6s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Failing after 15m3s
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:06:34 +00:00
james.maes 59959247c7 Merge pull request 'fix: preserve stored file metadata and verify restore authorization' (#21) from fix/demo-object-store-integrity into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 3m27s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
Shared Java Maven CI / secrets (push) Failing after 23s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 2m2s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 3s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 3d75c629c2706be133505268b6696fe9b1ae420c] (push) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 29s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Failing after 14s
qrun-release / release (push) Failing after 3m43s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 11s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 2m13s
2026-11-05 14:16:07 +00:00
james.maes 7dc1121c3a Merge pull request 'Publish the scanner PostgreSQL schema with current raw scan fields' (#20) from feature/kof22-scanners-postgres into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 2m50s
qrun-release / postpublish (push) Failing after 10m45s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 19s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 2m16s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 3d75c629c2706be133505268b6696fe9b1ae420c] (push) Successful in 8s
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 24s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Successful in 13s
qrun-release / release (push) Successful in 3m5s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 18s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 2m37s
Shared QRun source version / version (push) Failing after 10m44s
2026-11-05 14:09:19 +00:00
james.maesandClaude Sonnet 5.5 ae3fd7be06 fix: preserve stored file metadata and verify restore authorization
Shared Java Maven CI / verify (push) Successful in 3m18s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m14s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 2m29s
Shared Java Maven CI / secrets (push) Successful in 1m14s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 26s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 29s
qrun-release / release (push) Successful in 4m1s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 3d75c629c2706be133505268b6696fe9b1ae420c] (pull_request) Successful in 7s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:37:05 -06:00
James MaesandClaude Sonnet 5.5 ef0af0e5f2 feat: add the PostgreSQL 17 schema and its opt-in test variant
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 31s
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 5m29s
Shared Java Maven CI / verify (push) Successful in 3m7s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m37s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 12s
Shared Java Maven CI / secrets (push) Successful in 57s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 44s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 43s
qrun-release / release (push) Successful in 5m14s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow cf802461ca4ebfcf5c500440715cb333d92ea606] (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Add db/mobile-scanners-postgres.sql, the fresh-install PostgreSQL 17 schema
that is the final state of mobile-scanners-h2.sql: quoted physical names that
match the QQQ metadata names, timestamptz columns and the Extension 1.5
optional mobileScan.aimIdentifier (VARCHAR(3)) and rawDataBase64
(VARCHAR(10924)) columns, matching MobileService.AIM_IDENTIFIER_LENGTH and
RAW_DATA_MAXIMUM_CHARACTERS.

Add the existing opt-in PostgreSQL variant to MobileScannersTest
(-Dmobile.pg=true, one Testcontainers database per test, schema from the
shipped PostgreSQL file) and its test-scope Postgres, Testcontainers and
driver dependencies. The default H2 run is unchanged. The scan-count helper
now uses the variant's login and identifier quoting.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:15:38 -06:00
james.maes 9e8685f6e0 Merge mobile test records and record UX support
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 5m21s
qrun-release / postpublish (push) Successful in 2m55s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 43s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 3m40s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 11s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 49s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Successful in 20s
qrun-release / release (push) Successful in 5m44s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 60e5a80ed7a5a67877bfb3e129b1929c83464b6e] (push) Successful in 9s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 28s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 4m9s
Shared QRun source version / version (push) Successful in 2m55s
Rich synthetic data and authorized photo previews, verified by the combined server gate and companion native acceptance. Paired Android review: QRun-IO/qqq-android#15.
2026-11-05 12:38:46 +00:00
james.maesandCodex 61b30cddc3 docs: record final integrated mobile verification and reviews
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 3m0s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m6s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 27s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 3m35s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 37s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 46s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 11s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 60e5a80ed7a5a67877bfb3e129b1929c83464b6e] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
Shared Java Maven CI / gate (push) Successful in 25s
qrun-release / release (push) Successful in 3m29s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-05 06:32:02 -06:00
james.maesandCodex c10edf4046 chore: integrate current scanner baseline and record UX evidence
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 3m16s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m20s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 17s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 2m41s
Shared Java Maven CI / secrets (push) Successful in 43s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 38s
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 5s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 60e5a80ed7a5a67877bfb3e129b1929c83464b6e] (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 12s
Shared Java Maven CI / gate (push) Successful in 21s
qrun-release / release (push) Successful in 4m47s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Preserve upstream publication metadata, contract baseline and recovery guidance while reconciling the completed local TC22 record UX work.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-05 06:26:29 -06:00
james.maesandCodex 323b8d9866 feat: add rich mobile record fixtures and photo previews
Add synthetic grouped orders, related items and history, parent-scoped photo grants, and isolated native fixture support.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-05 06:15:04 -06:00
james.maes 75f661490d Merge pull request 'docs: reconcile scanner release evidence and recovery guidance' (#18) from docs/release-readiness-reconciliation into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 13053bb397bf07f3eaacd6e51ef0ffad7f9f1eb3] (push) Successful in 10s
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 3m47s
qrun-release / postpublish (push) Successful in 4m53s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 40s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 2m24s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 7s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 42s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Successful in 17s
qrun-release / release (push) Successful in 4m8s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 19s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 2m48s
Shared QRun source version / version (push) Successful in 4m54s
2026-11-05 11:53:55 +00:00
james.maesandClaude 31f4823269 docs: reconcile scanner release evidence and recovery guidance
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 13053bb397bf07f3eaacd6e51ef0ffad7f9f1eb3] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 1m47s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m36s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 1m50s
Shared Java Maven CI / secrets (push) Successful in 28s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 23s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 15s
qrun-release / release (push) Successful in 2m5s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Freeze the mobile-scanners 1.5 contract baseline and align publication metadata with Gitea.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-05 05:49:28 -06:00
james.maes a9eae04d58 Merge pull request 'build(record-files): prepare standard publication metadata' (#17) from fix/record-files-publication-metadata into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 4m54s
qrun-release / postpublish (push) Successful in 3m34s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 42s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 2m20s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 9s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow f1d46718955078331817c6206b822a94bc7d769f] (push) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 48s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Successful in 23s
qrun-release / release (push) Successful in 5m20s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 26s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 2m52s
Shared QRun source version / version (push) Successful in 3m35s
2026-11-05 11:09:08 +00:00
james.maesandClaude e4233b315c build(record-files): prepare standard publication metadata
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 2m13s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m26s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 1m38s
Shared Java Maven CI / secrets (push) Successful in 29s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow f1d46718955078331817c6206b822a94bc7d769f] (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
Shared Java Maven CI / gate (push) Successful in 12s
qrun-release / release (push) Successful in 2m27s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Use the existing QBit parent and explicit Apache-2.0/Gitea metadata while preserving dependency pins and the independent module build. Publication still requires compatible immutable dependencies and hosted qualification.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-05 04:54:47 -06:00
james.maesandGPT-6 0b0875f40a docs: record GS1 source publication for Forge
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m7s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 24s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 5m0s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 38s
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Co-Authored-By: GPT-6 <noreply@openai.com>
2026-11-04 19:26:16 -06:00
104 changed files with 5201 additions and 288 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
-22
View File
@@ -1,22 +0,0 @@
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
name: qrun-release
on:
push: {}
workflow_dispatch: {}
permissions:
contents: read
actions: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1
with:
runner: linux-small
postpublish:
if: ${{ needs.release.outputs.capture == 'true' }}
needs: release
permissions:
contents: write
actions: write
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
with:
runner: linux-small
+1 -1
View File
@@ -1,6 +1,6 @@
# QQQ Mobile Scanners
This is an application QBit and a separate local demo, using Java 21 and QQQ 4.1.0-RC.21. Optional `examples/echo` (scan-to-print) uses released Printing 1.0.0; its Generation, Record Files and Scripting owner inputs are not yet public. Contract versions: UI API 1.10, `mobile-scanners` 1.5, `printing` 2.3, `mobile-record-files` 1.0.
This is an application QBit and a separate local demo, using Java 21 and QQQ 4.1.0-RC.21. Optional `examples/echo` (scan-to-print) uses released Printing 1.0.0; its Generation, Record Files and Scripting owner inputs are not yet public. Contract versions: UI API 1.10, `mobile-scanners` 1.6 (1.5 on the maintained local server), `printing` 2.3, `mobile-record-files` 1.0.
The Android host is maintained in [QRun-IO/qqq-android on Gitea](https://git.kof22.com/QRun-IO/qqq-android) (reviews pulls/8 and pulls/10 are merged into `main`). Present status is in `docs/SESSION-STATE.md`; older logs are in `docs/history/`.
## Build and verify
+2 -1
View File
@@ -52,7 +52,8 @@ Android host --(/qqq/v1 + mobile-scanners extension)--> QQQ server + this qbit -
## Project layout
- `src/main`, `src/test`: the reusable qbit (enrollment routes, approval/revocation, scan process, table metadata) and its H2 behavior and permission tests.
- `pom.xml`: the `qbit-mobile-scanners-reactor` aggregator (packaging pom, single `<revision>` source) of the two published modules, `base` and `integrations/record-files`; `demo` and `examples/echo` are not part of it. One root `mvn deploy` publishes the reactor POM, `qbit-mobile-scanners` and `qbit-mobile-scanners-record-files` at the same version.
- `base/src/main`, `base/src/test`: the reusable qbit (enrollment routes, approval/revocation, scan process, table metadata) and its H2 behavior and permission tests.
- `demo`: basic local QQQ server with persistent H2 storage, native administration UI and password login. QQQ `4.0.0` lacks the password session exchange it needs.
- `e2e`: browser acceptance tests against the packaged frontend and an isolated real demo server.
- `examples/echo` and `examples/echo/demo`: the optional Echo library and its runnable composed server.
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-mobile-scanners-reactor</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-mobile-scanners</artifactId>
<version>${revision}</version>
<name>QQQ Mobile Scanners</name>
<description>Approved mobile devices and native QQQ barcode capture processes.</description>
<url>https://git.kof22.com/QRun-IO/qbit-mobile-scanners</url>
<scm>
<connection>scm:git:https://git.kof22.com/QRun-IO/qbit-mobile-scanners.git</connection>
<developerConnection>scm:git:ssh://git@git.kof22.com:22/QRun-IO/qbit-mobile-scanners.git</developerConnection>
<url>https://git.kof22.com/QRun-IO/qbit-mobile-scanners</url>
<tag>HEAD</tag>
</scm>
<issueManagement>
<system>Gitea</system>
<url>https://git.kof22.com/QRun-IO/qbit-mobile-scanners/issues</url>
</issueManagement>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.224</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-postgres</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.21.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.14</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.6.0</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>14.3.0</version>
</dependency>
</dependencies>
<configuration>
<configLocation>${maven.multiModuleProjectDirectory}/checkstyle/checkstyle.xml</configLocation>
<consoleOutput>true</consoleOutput>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
<failOnViolation>true</failOnViolation>
<violationSeverity>warning</violationSeverity>
</configuration>
<executions>
<execution>
<id>checkstyle</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -69,11 +69,13 @@ final class MobileDeviceActivity
/***************************************************************************
** Preserve last known coordinates with explicit availability and timestamps.
** Preserve last known coordinates with explicit availability and timestamps,
** and refresh reported software versions in the same presence write.
***************************************************************************/
static void heartbeat(String id, Map<String, Object> input, boolean foreground) throws QException
static void heartbeat(QRecord device, Map<String, Object> input, boolean foreground) throws QException
{
QRecord changes = new QRecord().withValue("id", id).withValue("lastSeenAt", Instant.now());
QRecord changes = new QRecord().withValue("id", device.getValueString("id")).withValue("lastSeenAt", Instant.now());
MobileService.refreshDeviceInfo(device, input, changes);
String status = MobileService.optional(input, "locationStatus", 30);
if(status != null && !List.of("AVAILABLE", "PERMISSION_DENIED", "DISABLED", "UNAVAILABLE").contains(status))
{
@@ -46,7 +46,7 @@ public final class MobileScannersUiApi
***************************************************************************/
public static MiddlewareSpecExtension extension()
{
return new MiddlewareSpecExtension().withName("mobile-scanners").withSemanticVersion("1.5.0")
return new MiddlewareSpecExtension().withName("mobile-scanners").withSemanticVersion("1.6.0")
.withDescription("Native scanner devices, customer branding and SCANNER process widget contract.")
.withSpec(MobileBrandingSpec.class).withSpec(MobileStatusSpec.class)
.withSpec(MobileEnrollSpec.class).withSpec(MobileCurrentDeviceSpec.class)
@@ -44,6 +44,7 @@ public final class MobileService
{
static final ThreadLocal<QBackendTransaction> TRANSACTION = new ThreadLocal<>();
static final List<String> DEVICE_INFO_FIELDS = List.of("manufacturer", "model", "osVersion", "appVersion");
static final int AIM_IDENTIFIER_LENGTH = 3;
static final int RAW_DATA_MAXIMUM_BYTES = 8192;
static final int RAW_DATA_MAXIMUM_CHARACTERS = 10924;
@@ -78,7 +79,8 @@ public final class MobileService
QRecord credential = new QRecord().withValue("id", id).withValue("tokenHash", hash(token));
QRecord device = new QRecord().withValue("id", id).withValue("installationId", id).withValue("status", "PENDING")
.withValue("enrolledBy", userId()).withValue("enrolledAt", Instant.now());
for(String name : new String[] { "displayName", "manufacturer", "model", "osVersion", "appVersion" })
device.setValue("displayName", optional(input, "displayName", 200));
for(String name : DEVICE_INFO_FIELDS)
{
device.setValue(name, optional(input, name, 200));
}
@@ -567,6 +569,25 @@ public final class MobileService
/***************************************************************************
** An updated app or OS reports new versions after enrollment; copy only
** changed, bounded values so scans record the software that produced them.
** Absent, null or blank values never erase what enrollment recorded.
***************************************************************************/
static void refreshDeviceInfo(QRecord device, Map<String, ?> input, QRecord changes) throws QException
{
for(String name : DEVICE_INFO_FIELDS)
{
String value = optional(input, name, 200);
if(value != null && !value.isBlank() && !value.equals(device.getValueString(name)))
{
changes.setValue(name, value);
}
}
}
/***************************************************************************
** Enforce canonical UUIDs before using them as identifiers.
***************************************************************************/
@@ -24,7 +24,7 @@ public class MobileSessionHeartbeatSpec extends MobileDeviceJsonSpec
{
return new BasicOperation().withHttpMethod(HttpMethod.POST).withPath(MobileScannersUiApi.PREFIX + "/sessions/heartbeat")
.withOperationId("mobileScannersSessionHeartbeat").withShortSummary("Report current handheld session activity")
.withLongDescription("Binds the current QQQ sign-in to the verified device. Nullable display metadata never grants permissions. Ended sign-ins return 401 and cannot start another mobile session. screenAvailable=false immediately removes the retained frame. Lease duration is expiresAt minus serverTime, not the device clock.");
.withLongDescription("Binds the current QQQ sign-in to the verified device. Nullable display metadata never grants permissions. Ended sign-ins return 401 and cannot start another mobile session. screenAvailable=false immediately removes the retained frame. Lease duration is expiresAt minus serverTime, not the device clock. Optional manufacturer, model, osVersion and appVersion refresh the enrolled device record when they differ (1.6); scans recorded afterwards carry the refreshed values.");
}
@@ -52,10 +52,14 @@ final class MobileSessionSchemas
activity.put("traceId", nullableText(100));
activity.put("locationStatus", text(30).withEnumValues(List.of("AVAILABLE", "PERMISSION_DENIED", "DISABLED", "UNAVAILABLE")));
activity.put("location", ref("MobileScannersLocationV1").withNullable(true));
for(String field : MobileService.DEVICE_INFO_FIELDS)
{
activity.put(field, text(200));
}
schemas.put("MobileScannersLocationV1", object("Optional foreground observation with runtime permission. Coordinates are last known, not a guarantee of current position; the server separately records receipt time.", Map.of(
"latitude", new BoundedIntegerSchema(-90, 90).withType(Type.NUMBER), "longitude", new BoundedIntegerSchema(-180, 180).withType(Type.NUMBER),
"accuracyMeters", new BoundedIntegerSchema(0, 10000000).withType(Type.NUMBER), "capturedAt", timestamp()), "latitude", "longitude", "accuracyMeters", "capturedAt"));
schemas.put("MobileScannersHeartbeatRequestV1", object("Display activity only. Never include form values, barcodes, passwords or raw error messages. JSON maximum 4096 bytes. Omitted captureMode means APP_CONTENT, requiring foreground eligible app pixels. DEVICE_SCREEN requires explicit per-session Android operator consent and may remain available in background. False screenAvailable clears pixels and lease; changing mode invalidates old demand.", activity, "foreground", "screenAvailable"));
schemas.put("MobileScannersHeartbeatRequestV1", object("Display activity only. Never include form values, barcodes, passwords or raw error messages. JSON maximum 4096 bytes. Omitted captureMode means APP_CONTENT, requiring foreground eligible app pixels. DEVICE_SCREEN requires explicit per-session Android operator consent and may remain available in background. False screenAvailable clears pixels and lease; changing mode invalidates old demand. Optional manufacturer, model, osVersion and appVersion (1.6) refresh the enrolled device record when they differ, so later scans carry the installed software; omitted or blank values leave stored values unchanged.", activity, "foreground", "screenAvailable"));
schemas.put("MobileScannersScreenLeaseV1", object("Server-authorized capture demand. Compute remaining duration from expiresAt minus heartbeat serverTime. Sequence is scoped to this lease and resumes at nextSequence after client restart.", Map.of(
"leaseId", uuid(), "expiresAt", timestamp(), "frameIntervalMillis", integer(1000, 1000), "maxWidth", integer(640, 640),
"maxHeight", integer(1280, 1280), "maxBytes", integer(262144, 262144), "nextSequence", integer(0, 9007199254740990L), "captureMode", captureMode()),
@@ -153,7 +153,7 @@ final class MobileSessionService
String deviceId = currentDevice();
synchronized(current())
{
MobileService.requireApproved(deviceId);
QRecord device = MobileService.requireApproved(deviceId);
QRecord session = observe(deviceId);
boolean foreground = bool(input, "foreground");
MobileCaptureMode mode = MobileCaptureMode.parse(input.get("captureMode"));
@@ -173,7 +173,7 @@ final class MobileSessionService
changes.setValue(name, MobileService.optional(input, name, 80));
}
changes.setValue("traceId", MobileService.optional(input, "traceId", 100));
MobileDeviceActivity.heartbeat(deviceId, input, foreground);
MobileDeviceActivity.heartbeat(device, input, foreground);
update(changes);
String id = session.getValueString("id");
if(!available)
@@ -0,0 +1,92 @@
-- Copyright (c) 2026 Kingsrook, LLC
-- SPDX-License-Identifier: Apache-2.0
-- PostgreSQL 17 fresh-install schema for Mobile Scanners: the final state of db/mobile-scanners-h2.sql (the H2 file carries
-- the upgrade history of older installs, written with H2-only syntax). Physical names are the metadata names, quoted,
-- and quoted, because QQQ quotes every identifier it issues (case preserved). Idempotent, run by the schema owner
-- (never the runtime role), which then needs SELECT, INSERT, UPDATE and DELETE on these tables.
-- mobileScan.aimIdentifier and rawDataBase64 are the Extension 1.5 optional exact capture metadata (NULL = absent).
CREATE TABLE IF NOT EXISTS "mobileDevice" (
id VARCHAR(36) PRIMARY KEY,
"installationId" VARCHAR(200) NOT NULL,
"displayName" VARCHAR(200),
manufacturer VARCHAR(200),
model VARCHAR(200),
"osVersion" VARCHAR(200),
"appVersion" VARCHAR(200),
status VARCHAR(200) NOT NULL CHECK (status IN ('PENDING', 'APPROVED', 'REVOKED')),
"enrolledBy" VARCHAR(200) NOT NULL,
"approvedBy" VARCHAR(200),
"enrolledAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"approvedAt" TIMESTAMP WITH TIME ZONE,
"shortName" VARCHAR(32) NOT NULL,
description VARCHAR(2000),
"lastSeenAt" TIMESTAMP WITH TIME ZONE,
"locationStatus" VARCHAR(30),
latitude NUMERIC(12,8),
longitude NUMERIC(12,8),
"accuracyMeters" NUMERIC(18,4),
"locationCapturedAt" TIMESTAMP WITH TIME ZONE,
"locationReceivedAt" TIMESTAMP WITH TIME ZONE
);
CREATE UNIQUE INDEX IF NOT EXISTS mobileDevice_shortName ON "mobileDevice"("shortName");
CREATE INDEX IF NOT EXISTS mobileDevice_lastSeenAt ON "mobileDevice"("lastSeenAt");
CREATE TABLE IF NOT EXISTS "mobileDeviceCredential" (
id VARCHAR(36) PRIMARY KEY,
"tokenHash" VARCHAR(64) NOT NULL
);
CREATE TABLE IF NOT EXISTS "mobileScan" (
id VARCHAR(64) PRIMARY KEY,
"deviceId" VARCHAR(200) NOT NULL REFERENCES "mobileDevice"(id),
"eventId" VARCHAR(200) NOT NULL,
"userId" VARCHAR(200) NOT NULL,
"appId" VARCHAR(200) NOT NULL,
"processName" VARCHAR(200) NOT NULL,
barcode VARCHAR(4096) NOT NULL,
symbology VARCHAR(200),
"scanSource" VARCHAR(200),
manufacturer VARCHAR(200),
model VARCHAR(200),
"osVersion" VARCHAR(200),
"appVersion" VARCHAR(200),
"capturedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"receivedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"aimIdentifier" VARCHAR(3),
"rawDataBase64" VARCHAR(10924)
);
CREATE TABLE IF NOT EXISTS "mobileSession" (
id VARCHAR(36) PRIMARY KEY,
"deviceId" VARCHAR(36) NOT NULL REFERENCES "mobileDevice"(id),
"userId" VARCHAR(200) NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('ACTIVE', 'ENDED')),
"startedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"lastSeenAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"endedAt" TIMESTAMP WITH TIME ZONE,
"endedBy" VARCHAR(200),
"endReason" VARCHAR(200),
foreground BOOLEAN,
"screenAvailable" BOOLEAN,
"captureBlockedReason" VARCHAR(80),
"appName" VARCHAR(200),
"processName" VARCHAR(200),
"processInstanceId" VARCHAR(200),
"stepName" VARCHAR(200),
"screenLabel" VARCHAR(200),
"lastAction" VARCHAR(80),
"errorCode" VARCHAR(80),
"traceId" VARCHAR(100),
"authCleanupStatus" VARCHAR(80),
"authCleanupAt" TIMESTAMP WITH TIME ZONE,
"captureMode" VARCHAR(20) DEFAULT 'APP_CONTENT'
);
CREATE INDEX IF NOT EXISTS mobileSession_device ON "mobileSession"("deviceId", "startedAt");
CREATE TABLE IF NOT EXISTS "mobileSessionBinding" (
id VARCHAR(64) PRIMARY KEY,
"sessionId" VARCHAR(36) NOT NULL UNIQUE REFERENCES "mobileSession"(id),
"coreSessionUuid" VARCHAR(200) NOT NULL
);
File diff suppressed because it is too large. Load diff
@@ -64,9 +64,19 @@ class MobileScannersApiContractTest
assertTrue(model.getComponents().getSchemas().containsKey(schema), schema);
}
assertEquals(7, extension.getSpecs().size());
assertEquals("1.5.0", extension.getSemanticVersion());
assertEquals(java.util.List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(Path.of("src/test/resources/openapi/mobile-scanners-1.4.0.yaml")), 1,
"Regenerate the current extension contract."));
assertEquals("1.6.0", extension.getSemanticVersion());
for(String previous : java.util.List.of("1.4.0", "1.5.0", "1.6.0"))
{
assertEquals(java.util.List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(Path.of("src/test/resources/openapi/mobile-scanners-" + previous + ".yaml")), 1,
"Regenerate the current extension contract."));
}
var heartbeatRequest = model.getComponents().getSchemas().get("MobileScannersHeartbeatRequestV1");
for(String optional : MobileService.DEVICE_INFO_FIELDS)
{
assertTrue(heartbeatRequest.getProperties().containsKey(optional), optional);
assertEquals(200, heartbeatRequest.getProperties().get(optional).getMaxLength(), optional);
assertFalse(generated.contains("- \"" + optional + "\""), optional + " must stay optional, never a required key.");
}
var scanEvent = model.getComponents().getSchemas().get("MobileScannersScanEventV1");
var scanContext = model.getComponents().getSchemas().get("MobileScannersScanContextV1");
for(String optional : java.util.List.of("aimIdentifier", "rawDataBase64"))
@@ -26,6 +26,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
@@ -75,6 +76,86 @@ class MobileScannersTest
/***************************************************************************
** Each test receives an isolated database using the shipped schema.
***************************************************************************/
/***************************************************************************
** PostgreSQL 17 variant (opt-in with -Dmobile.pg=true): one fresh database per test in a
** Testcontainers PostgreSQL; the schema is the shipped PostgreSQL file.
***************************************************************************/
private static org.testcontainers.containers.PostgreSQLContainer<?> postgres;
private String dbUser = "sa";
private String dbPassword = "";
private String schemaResource = "/db/mobile-scanners-h2.sql";
private boolean pg;
/***************************************************************************
** QQQ quotes every identifier it issues, so direct SQL against the PostgreSQL schema
** must quote the camelCase names too (string literals are left alone).
***************************************************************************/
private String q(String statement)
{
if(!pg)
{
return (statement);
}
java.util.regex.Matcher literals = java.util.regex.Pattern.compile("('(?:[^']|'')*')|\\b([a-z]+(?:[A-Z][A-Za-z0-9]*)+)\\b").matcher(statement);
StringBuilder result = new StringBuilder();
while(literals.find())
{
literals.appendReplacement(result, java.util.regex.Matcher.quoteReplacement(literals.group(1) != null ? literals.group(1) : "\"" + literals.group(2) + "\""));
}
literals.appendTail(result);
return (result.toString());
}
/***************************************************************************
** Choose H2 or the PostgreSQL container for this test.
***************************************************************************/
private void selectDatabase() throws Exception
{
pg = "true".equals(System.getProperty("mobile.pg"));
if(!pg)
{
jdbcUrl = "jdbc:h2:mem:mobile_" + UUID.randomUUID().toString().replace("-", "") + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1";
return;
}
synchronized(MobileScannersTest.class)
{
if(postgres == null)
{
postgres = new org.testcontainers.containers.PostgreSQLContainer<>("postgres:17-alpine");
postgres.start();
}
}
String database = "mobile_" + UUID.randomUUID().toString().replace("-", "");
try(Connection admin = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()))
{
admin.createStatement().execute("CREATE DATABASE " + database);
}
jdbcUrl = postgres.getJdbcUrl().replaceFirst("/[^/?]+(\\?|$)", "/" + database + "$1");
dbUser = postgres.getUsername();
dbPassword = postgres.getPassword();
schemaResource = "/db/mobile-scanners-postgres.sql";
}
/***************************************************************************
** The backend metadata for the selected database.
***************************************************************************/
private com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData backendMetaData()
{
if(!pg)
{
return (new RDBMSBackendMetaData().withName("db").withVendor("h2").withJdbcUrl(jdbcUrl).withUsername("sa"));
}
var backend = new com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData();
backend.setName("db");
backend.setJdbcUrl(jdbcUrl);
backend.setUsername(dbUser);
backend.setPassword(dbPassword);
return (backend);
}
/***************************************************************************
** Each test receives an isolated database using the shipped schema.
***************************************************************************/
@@ -82,20 +163,20 @@ class MobileScannersTest
void setup() throws Exception
{
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
jdbcUrl = "jdbc:h2:mem:mobile_" + UUID.randomUUID().toString().replace("-", "") + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1";
selectDatabase();
instance = new QInstance();
instance.setEnforcePermissionsInCoreActions(true);
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), new QAuthenticationMetaData().withName("test").withType(QAuthenticationType.MOCK));
instance.addBackend(new RDBMSBackendMetaData().withName("db").withVendor("h2").withJdbcUrl(jdbcUrl).withUsername("sa"));
instance.addBackend(backendMetaData());
new MobileScannersQBitProducer().withConfig(new MobileScannersQBitConfig().withBackendName("db")).produce(instance, "mobile");
for(var table : instance.getTables().values())
{
table.setBackendDetails(new RDBMSTableBackendDetails().withTableName(table.getName()));
}
new QInstanceValidator().validate(instance);
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", ""))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword))
{
String schema = new String(getClass().getResourceAsStream("/db/mobile-scanners-h2.sql").readAllBytes(), StandardCharsets.UTF_8);
String schema = new String(getClass().getResourceAsStream(schemaResource).readAllBytes(), StandardCharsets.UTF_8);
for(String statement : schema.split(";"))
{
if(!statement.isBlank())
@@ -426,9 +507,9 @@ class MobileScannersTest
assertThrows(QException.class, () -> MobileService.capture(input));
input.put("eventId", UUID.randomUUID().toString());
assertNotEquals(first.getValueString("id"), MobileService.capture(input).getValueString("id"));
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", ""))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword))
{
var result = connection.createStatement().executeQuery("SELECT COUNT(*) FROM mobileScan");
var result = connection.createStatement().executeQuery(q("SELECT COUNT(*) FROM mobileScan"));
assertTrue(result.next());
assertEquals(2, result.getInt(1));
}
@@ -473,8 +554,8 @@ class MobileScannersTest
QRecord saved = MobileService.capture(input);
assertEquals("UNKNOWN", saved.getValueString("symbology"));
assertEquals("UNKNOWN", saved.getValueString("scanSource"));
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", "");
var statement = connection.prepareStatement("UPDATE mobileScan SET symbology = NULL, scanSource = NULL WHERE id = ?"))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword);
var statement = connection.prepareStatement(q("UPDATE mobileScan SET symbology = NULL, scanSource = NULL WHERE id = ?")))
{
statement.setString(1, saved.getValueString("id"));
assertEquals(1, statement.executeUpdate());
@@ -664,8 +745,8 @@ class MobileScannersTest
***************************************************************************/
private int scanRows() throws Exception
{
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", "");
var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM mobileScan"))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword);
var statement = connection.createStatement(); var rows = statement.executeQuery(q("SELECT COUNT(*) FROM mobileScan")))
{
assertTrue(rows.next());
return rows.getInt(1);
@@ -687,8 +768,8 @@ class MobileScannersTest
input.addValue("symbology", "QR_CODE");
assertTrue(assertThrows(QException.class, () -> MobileScannerActions.capture(input)).getMessage().contains("Restart the scanner process"));
assertThrows(QException.class, () -> MobileScannerActions.capture(input));
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", "");
var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM mobileScan"))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword);
var statement = connection.createStatement(); var rows = statement.executeQuery(q("SELECT COUNT(*) FROM mobileScan")))
{
assertTrue(rows.next());
assertEquals(0, rows.getInt(1));
@@ -811,9 +892,9 @@ class MobileScannersTest
{
assertEquals(savedId, future.get());
}
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", ""))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword))
{
var result = connection.createStatement().executeQuery("SELECT COUNT(*) FROM mobileScan");
var result = connection.createStatement().executeQuery(q("SELECT COUNT(*) FROM mobileScan"));
assertTrue(result.next());
assertEquals(1, result.getInt(1));
}
@@ -850,9 +931,9 @@ class MobileScannersTest
void interruptedEnrollmentRequiresNewInstallationIdentity() throws Exception
{
String id = UUID.randomUUID().toString();
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", ""))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword))
{
var statement = connection.prepareStatement("INSERT INTO mobileDeviceCredential(id, tokenHash) VALUES (?, ?)");
var statement = connection.prepareStatement(q("INSERT INTO mobileDeviceCredential(id, tokenHash) VALUES (?, ?)"));
statement.setString(1, id);
statement.setString(2, "0".repeat(64));
statement.executeUpdate();
@@ -862,6 +943,93 @@ class MobileScannersTest
/***************************************************************************
** Enrollment records the installed software; heartbeats without versions,
** or with blank ones, leave it unchanged and scans copy it.
***************************************************************************/
@Test
void enrollmentStoresVersionsAndHeartbeatWithoutThemLeavesThemUnchanged() throws Exception
{
String device = approvedDevice(Map.of("osVersion", "14", "appVersion", "1.0.0"));
QRecord enrolled = MobileService.get(MobileScannersQBitProducer.DEVICE, device);
assertEquals("Zebra", enrolled.getValueString("manufacturer"));
assertEquals("TC22", enrolled.getValueString("model"));
assertEquals("14", enrolled.getValueString("osVersion"));
assertEquals("1.0.0", enrolled.getValueString("appVersion"));
bind(device);
MobileSessionService.heartbeat(Map.of("foreground", true, "screenAvailable", false));
MobileSessionService.heartbeat(new HashMap<>(Map.of("foreground", true, "screenAvailable", false, "appVersion", "", "osVersion", " ")));
QRecord unchanged = MobileService.get(MobileScannersQBitProducer.DEVICE, device);
for(String field : MobileService.DEVICE_INFO_FIELDS)
{
assertEquals(enrolled.getValueString(field), unchanged.getValueString(field), field);
}
QRecord scan = MobileService.capture(scan());
assertEquals("1.0.0", scan.getValueString("appVersion"));
assertEquals("14", scan.getValueString("osVersion"));
assertEquals("Zebra", scan.getValueString("manufacturer"));
assertEquals("TC22", scan.getValueString("model"));
}
/***************************************************************************
** An in-place app update reports its new version on the next heartbeat and
** every later scan carries it; earlier scans keep the version that made them.
***************************************************************************/
@Test
void heartbeatRefreshesDeviceVersionsForLaterScans() throws Exception
{
String device = approvedDevice(Map.of("osVersion", "14", "appVersion", "1.0.0"));
bind(device);
QRecord before = MobileService.capture(scan());
assertEquals("1.0.0", before.getValueString("appVersion"));
MobileSessionService.heartbeat(Map.of("foreground", true, "screenAvailable", false, "appVersion", "1.0.1", "osVersion", "15"));
QRecord updated = MobileService.get(MobileScannersQBitProducer.DEVICE, device);
assertEquals("1.0.1", updated.getValueString("appVersion"));
assertEquals("15", updated.getValueString("osVersion"));
assertEquals("Zebra", updated.getValueString("manufacturer"));
assertEquals("TC22", updated.getValueString("model"));
assertEquals("APPROVED", updated.getValueString("status"));
QRecord after = MobileService.capture(scan());
assertEquals("1.0.1", after.getValueString("appVersion"));
assertEquals("15", after.getValueString("osVersion"));
assertEquals("1.0.0", MobileService.get(MobileScannersQBitProducer.SCAN, before.getValueString("id")).getValueString("appVersion"));
MobileSessionService.heartbeat(Map.of("foreground", true, "screenAvailable", false, "manufacturer", "Zebra Technologies", "model", "MC9400"));
QRecord hardware = MobileService.get(MobileScannersQBitProducer.DEVICE, device);
assertEquals("Zebra Technologies", hardware.getValueString("manufacturer"));
assertEquals("MC9400", hardware.getValueString("model"));
assertEquals("1.0.1", hardware.getValueString("appVersion"));
assertEquals("MC9400", MobileService.capture(scan()).getValueString("model"));
}
/***************************************************************************
** Heartbeat versions obey the enrollment limits: strings of at most 200
** characters; anything else is a 400 and the device record is untouched.
***************************************************************************/
@Test
void heartbeatRejectsInvalidDeviceVersionsLikeEnrollment() throws Exception
{
String tooLong = "v".repeat(201);
String longest = "v".repeat(200);
assertThrows(QBadRequestException.class, () -> MobileService.enroll(Map.of("installationId", UUID.randomUUID().toString(), "appVersion", tooLong)));
String device = approvedDevice(Map.of("osVersion", "14", "appVersion", "1.0.0"));
bind(device);
assertThrows(QBadRequestException.class, () -> MobileSessionService.heartbeat(Map.of("foreground", true, "screenAvailable", false, "appVersion", tooLong)));
assertThrows(QBadRequestException.class, () -> MobileSessionService.heartbeat(Map.of("foreground", true, "screenAvailable", false, "osVersion", 15)));
QRecord unchanged = MobileService.get(MobileScannersQBitProducer.DEVICE, device);
assertEquals("1.0.0", unchanged.getValueString("appVersion"));
assertEquals("14", unchanged.getValueString("osVersion"));
assertEquals("1.0.0", MobileService.capture(scan()).getValueString("appVersion"));
MobileSessionService.heartbeat(Map.of("foreground", true, "screenAvailable", false, "appVersion", longest));
assertEquals(longest, MobileService.get(MobileScannersQBitProducer.DEVICE, device).getValueString("appVersion"));
assertEquals(longest, MobileService.capture(scan()).getValueString("appVersion"));
}
/***************************************************************************
** A realistic installation, with no hardware identifier requirement.
***************************************************************************/
@@ -1066,9 +1234,9 @@ class MobileScannersTest
***************************************************************************/
private String deviceStatus(String id) throws Exception
{
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", ""))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword))
{
var statement = connection.prepareStatement("SELECT status FROM mobileDevice WHERE id = ?");
var statement = connection.prepareStatement(q("SELECT status FROM mobileDevice WHERE id = ?"));
statement.setString(1, id);
var rows = statement.executeQuery();
assertTrue(rows.next());
@@ -1083,7 +1251,19 @@ class MobileScannersTest
***************************************************************************/
private String approvedDevice() throws QException
{
String id = (String) enroll().get("deviceId");
return approvedDevice(Map.of());
}
/***************************************************************************
** Approve an enrollment that also reported its software versions.
***************************************************************************/
private String approvedDevice(Map<String, Object> versions) throws QException
{
Map<String, Object> enrollment = new HashMap<>(Map.of("installationId", UUID.randomUUID().toString(), "displayName", "Test TC22", "manufacturer", "Zebra", "model", "TC22"));
enrollment.putAll(versions);
String id = (String) MobileService.enroll(enrollment).get("deviceId");
QContext.init(instance, admin);
MobileService.setApproval(id, true);
QContext.init(instance, scanner);
@@ -1280,9 +1460,9 @@ class MobileScannersTest
var selected = new QueryInput().withTableName(MobileScannersQBitProducer.DEVICE)
.withFieldNamesToInclude(java.util.Set.of("id", "online"));
assertEquals(true, new QueryAction().execute(selected).getRecords().getFirst().getValueBoolean("online"));
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", ""))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword))
{
connection.createStatement().executeUpdate("UPDATE mobileDevice SET lastSeenAt = TIMESTAMP WITH TIME ZONE '2000-01-01 00:00:00+00:00'");
connection.createStatement().executeUpdate(q("UPDATE mobileDevice SET lastSeenAt = TIMESTAMP WITH TIME ZONE '2000-01-01 00:00:00+00:00'"));
}
QContext.init(instance, admin);
var query = new QueryInput().withTableName(MobileScannersQBitProducer.DEVICE)
@@ -1336,12 +1516,13 @@ class MobileScannersTest
@Test
void migrationRemovesOnlyOrphanedCredentials() throws Exception
{
org.junit.jupiter.api.Assumptions.assumeFalse(pg, "H2 upgrade-history migration; the PostgreSQL schema is a fresh install only");
Map<String, Object> valid = enroll();
String orphan = UUID.randomUUID().toString();
MobileService.insert(MobileScannersQBitProducer.CREDENTIAL, new QRecord().withValue("id", orphan).withValue("tokenHash", MobileService.hash("never-returned")));
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", ""))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword))
{
String schema = new String(getClass().getResourceAsStream("/db/mobile-scanners-h2.sql").readAllBytes(), StandardCharsets.UTF_8);
String schema = new String(getClass().getResourceAsStream(schemaResource).readAllBytes(), StandardCharsets.UTF_8);
for(String statement : schema.split(";"))
{
if(!statement.isBlank())
@@ -1363,17 +1544,18 @@ class MobileScannersTest
@Test
void generatedNamesAndMigrationSurvivePrefixCollisions() throws Exception
{
org.junit.jupiter.api.Assumptions.assumeFalse(pg, "H2 upgrade-history migration; the PostgreSQL schema is a fresh install only");
String firstId = "12345678-1234-4234-8234-123400000001";
String secondId = "12345678-1234-4234-8234-123400000002";
Map<String, Object> first = MobileService.enroll(Map.of("installationId", firstId));
Map<String, Object> second = MobileService.enroll(Map.of("installationId", secondId));
assertNotEquals(first.get("shortName"), second.get("shortName"));
Map<String, Object> custom = MobileService.enroll(Map.of("installationId", UUID.randomUUID().toString(), "shortName", "scanner-1"));
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", ""); var sql = connection.createStatement())
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword); var sql = connection.createStatement())
{
sql.execute("DROP INDEX mobileDevice_shortName");
sql.execute("UPDATE mobileDevice SET shortName = 'scanner-123456781234423482341234' WHERE id IN ('" + firstId + "', '" + secondId + "')");
String schema = new String(getClass().getResourceAsStream("/db/mobile-scanners-h2.sql").readAllBytes(), StandardCharsets.UTF_8);
sql.execute(q("DROP INDEX mobileDevice_shortName"));
sql.execute(q("UPDATE mobileDevice SET shortName = 'scanner-123456781234423482341234' WHERE id IN ('") + firstId + "', '" + secondId + "')");
String schema = new String(getClass().getResourceAsStream(schemaResource).readAllBytes(), StandardCharsets.UTF_8);
for(int pass = 0; pass < 2; pass++)
{
for(String statement : schema.split(";"))
@@ -1400,6 +1582,7 @@ class MobileScannersTest
@Test
void timestampRoundTripsOutsideUtcAndPresenceExpires() throws Exception
{
org.junit.jupiter.api.Assumptions.assumeFalse(pg, "changes the JVM default zone; a PostgreSQL deployment must run a UTC JVM (the owner PostgreSQL qualifiers require it) and the host refuses to start otherwise");
java.util.TimeZone previous = java.util.TimeZone.getDefault();
try
{
@@ -1413,9 +1596,9 @@ class MobileScannersTest
QRecord record = MobileService.get(MobileScannersQBitProducer.DEVICE, device);
assertEquals(captured, record.getValueInstant("locationCapturedAt"));
assertTrue(java.time.Duration.between(record.getValueInstant("lastSeenAt"), java.time.Instant.now()).abs().getSeconds() < 2);
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", ""))
try(Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword))
{
connection.createStatement().executeUpdate("UPDATE mobileDevice SET lastSeenAt = TIMESTAMP '2000-01-01 00:00:00'");
connection.createStatement().executeUpdate(q("UPDATE mobileDevice SET lastSeenAt = TIMESTAMP '2000-01-01 00:00:00'"));
}
assertEquals(false, MobileService.get(MobileScannersQBitProducer.DEVICE, device).getValueBoolean("online"));
}
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -7,7 +7,7 @@
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.1.0-RC.21</qqq.version>
<qqq.version>4.1.0-RC.26</qqq.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -34,7 +34,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.35</version>
<version>1.0.0-RC.38</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
@@ -78,7 +78,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<version>3.5.6</version>
<configuration>
<systemPropertyVariables>
<qqq.logger.logSessionId.disabled>true</qqq.logger.logSessionId.disabled>
@@ -773,7 +773,7 @@ class MobileHttpAcceptanceTest
}
}
assertNotNull(mobile);
assertEquals("1.5.0", mobile.getString("version"));
assertEquals("1.6.0", mobile.getString("version"));
assertEquals("/qbit/mobile-scanners", mobile.getString("pathPrefix"));
var openApiResponse = request("GET", "/qqq/v1/openapi.json", null, null, device);
assertEquals(200, openApiResponse.statusCode());
+4 -1
View File
@@ -45,4 +45,7 @@ Preserve the user's database, accounts, enrollment and printer setup. Use isolat
Before updating the maintained demo, stop its owning process cleanly and back up the closed database, attachment files and configuration together. Preserve accounts, device enrollment, printer settings and template revisions. Apply the additive nullable `rendererValue` and `humanReadable` columns in `echo-h2.sql`; existing scans keep their original values and are not backfilled or reprinted. Verify the backup can be restored into an isolated directory and start only that restored copy for the recovery check.
Publish a separately named GS1 document using `EchoTemplatePublisher.publishGs1` for the printer's reported media profile. Existing plain documents remain valid for their original profiles. Use the matching GS1 document for the sample label, print one copy, then scan that output and compare exact GS1 data and visible HRI. Do not declare physical acceptance from a synthetic broadcast or renderer-only result. Printer/media confirmation, deployment authorization, HTTPS configuration and the remaining readiness gates above are outstanding.
Publish a separately named GS1 document using `EchoTemplatePublisher.publishGs1` for the printer's reported media profile. Existing plain documents remain valid for their original profiles. Use the matching GS1 document for the sample label, print one copy, then scan that output and compare exact GS1 data and visible HRI. Do not declare physical acceptance from a synthetic broadcast or renderer-only result. Printer/media confirmation, deployment authorization, HTTPS configuration and the remaining readiness gates above are outstanding.
## Forge source publication
Server source commit `1874c64` merged through PR 15 as `f9b0eb2` after Gitea checks 4318 and 4319 succeeded. Signed tag `echo-gs1-source-20261104` pins that exact merged tree. The Gitea prerelease includes a source archive, checksum and verification receipt. Forge must include `examples/echo` explicitly; publishing the root scanner JAR alone does not include GS1 duplication. This is a source integration release, not an Echo Maven or APK release. No Forge or handheld deployment was performed.
+21
View File
@@ -0,0 +1,21 @@
# Mobile browsing test data
The composed Echo demo includes **Test Data → Test Orders** for testing the existing mobile app. These are synthetic records, separate from scanner captures and printer jobs.
| Table | Rows | Contents |
| --- | ---: | --- |
| Test Orders | 1,000 | 39 fields across Order overview, Customer, Shipping, Amounts, Warehouse, and Notes and audit |
| Order Items | 12,000 | 12 related items per order, grouped item and handling fields |
| Order History | 3,000 | Three related history entries per order |
Values exercise dates, timestamps, numbers, decimals, booleans, leading zeros, Unicode, multiline notes, long descriptions and empty optional fields. All customer information is fictional.
Search or filter the table, open an order, and use Details, Items, History or Photos. Details groups collapse and can be selected directly. Table pages contain 25 records; related tabs contain 10 records per page. Back preserves the table page and search/filter state.
Use **Actions → Add photo → Take photo / Choose photo** to attach an image to the displayed record. Photos lists saved attachments, including those awaiting server review; available images have thumbnails and previews. Normal Record Files permissions, quarantine and upload lifecycle still apply.
The native preview uses standard QQQ metadata sections and direct association joins through the existing semantic query/get API. It supports read-only browsing; arbitrary widgets, nested editing and unsupported relationship definitions are not added by this fixture.
`MobileTestData` belongs only to `examples/echo/demo`. Startup adds missing H2 tables and missing synthetic rows without replacing existing rows or attachments. The scanner account can read these tables and attach photos; raw table edits remain refused. The basic Scan Capture demo does not install this dataset.
Verification uses separate databases, accounts and disposable application IDs. Updating the maintained demo must preserve its database, accounts, printer configuration and existing device enrollment.
+15
View File
@@ -0,0 +1,15 @@
# Mobile record UX improvements
User direction: Add photo belongs in the record's Actions menu, alongside other actions. Address the full TC22 visual review in the existing app and maintained demo.
1. Preserve table page, scroll, search and filter state across record navigation and recreation.
2. Keep record identity visible; add Details, related-record and Photos navigation, collapsible field groups, compact summaries and formatted values.
3. Put capture/selection behind Actions → Add photo; retain pending-upload guards. Browse saved attachments with lifecycle status and authorized previews.
4. Use standard semantic metadata, query/count/process and owner attachment/download APIs. Preserve existing permissions and quarantine.
5. Verify unit/build/lint, isolated real native flows, and actual TC22 layout; update the same app/server with preserved data.
The user subsequently authorized committing, pushing and merging the completed scanner and Android work through Gitea. No new artifact release is requested.
## Progress
All five steps are complete. The maintained server and existing TC22 app were updated with preserved data and enrollment. Native search/filter/Back, related-record and photo acceptance passed. Actual TC22 inspection confirmed the Actions menu, capture chooser, tabs, formatting and page-two restoration. Evidence and remaining hardware/release limits are in [the completion record](history/MOBILE-UX-2026-11-05.md).
+56
View File
@@ -0,0 +1,56 @@
# Single-instance deployment and recovery
Written 2026-11-05 from the static final review of source base `a9eae04` (see [review reconciliation](REVIEW-RECONCILIATION-2026-11-05.md)). The procedure below is for the host/operator in a real incident. The only executed evidence is the automated [isolated local-demo recovery acceptance](#isolated-local-demo-recovery-acceptance-automated-passed), which covers the demo's stopped H2 database plus object snapshot and restore. No production storage, enterprise object-store or high-availability deployment is supported or qualified, and those are not tasks of this scanner release. The supported model is one server process per database.
## Topology
- Run exactly **one server JVM per database**, with no overlap during an upgrade (stop the old process before starting the new one). Revocation and capture-commit exclusion use a per-JVM monitor, screen-mirror frames are held in memory only, and Echo job admission relies on a row lock in that one database.
- H2 must be used single-process. Do not point two servers at one database.
- The host owns HTTPS and the identity provider; the loopback HTTP demo is not a deployment ([production readiness](PRODUCTION-READINESS.md)).
## Backup and restore
- Take one **consistent snapshot of the database and any file storage together** (same moment, or with the server stopped). A database restored without its matching files, or the reverse, leaves dangling references.
- Treat bindings and backups as **sensitive**. `mobileSessionBinding.coreSessionUuid` is stored in plaintext at rest (reads are blocked by the application, not by the backup). Anyone holding a backup holds live session identifiers until they expire or end. Backups also contain barcode payloads and last-known device location.
- After any restore, before reopening to users:
1. Stop all server processes, then restore the database and files from the same snapshot. Nothing can be done through the application while every server is stopped, so steps 2 and 3 need a running server.
2. **Restart in controlled, admin-only access** (for example, reachable only by administrators, with scanner clients and other users kept off the network path) and keep it that way through steps 3 to 6. Do not reopen to users first: a device revoked after the snapshot may be approved again and a session ended after it may be active again.
3. **Re-audit device approvals.** A restore reverts revocations, approvals and enrollments made after the snapshot, so a device revoked later may be approved again. Review every device and re-revoke as needed.
4. **End or reconcile active sessions.** Tombstones written after the snapshot are lost. With the server running in that controlled state, apply the End Session process to the restored sessions so cleanup runs against the restored state, and check the cleanup status below.
5. Devices enrolled after the snapshot no longer exist and must re-enroll.
6. **Echo:** jobs accepted after the snapshot may be absent from the database although a print was already queued. Manually reconcile the physical or Printing queue against the restored jobs **before authorizing any retry**, and retry only what that comparison shows was not printed. This is a manual check; there is **no exactly-once physical print guarantee**, and a retry can still produce a duplicate or a missing print if the queue or printer state is unknown.
7. Reopen to users only after the above are done.
### Isolated local-demo recovery acceptance (automated, passed)
`EchoDemoAcceptanceTest.restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited` (Echo composed demo) exercises this procedure on private temporary directories only.
**Verification (2026-11-05):** the combined five-module `verify` on the merged source (`develop` `9e8685f` plus the storage fixes) passed with qualified private owner dependencies: 196 tests (root 53, demo 8, Record Files 22, Echo 74, Echo demo 39), 0 failures, 0 errors, 0 skips, and zero Checkstyle violations in every module. The qualified owner inputs are private, so this is not public-dependency qualification.
The case restores H2 scan rows, the exact leading-zero value and symbology, and the Record Files owner's own `RecordFilesTables.FILE` row. It asserts that row's exact table, record and attachment relationship and the SHA-256 of the object bytes. It shows that a revocation and session end made after the snapshot are reverted by the restore, then reapplies the normal Revoke and End Session processes and shows that a valid in-flight run and a new initialization are denied. No physical printer, service or demo data was changed.
Flow: it enrolls and approves a synthetic device, scans over real HTTP, and lets the real Record Files service retain the generated original in the demo local-file adapter. With the servers stopped and H2 shut down, it copies the database and object directory together, makes later changes (a second scan and file, a revocation and an ended session), restores only those two temporary directories and reads them without the application. It then restarts on a new ephemeral loopback port and applies the existing Revoke and End Session processes.
What this is **not**: it covers the local demo's H2 database and local files only, using private coherent fixture inputs. It is not published-cohort qualification (that is repeated when the final public dependencies are adopted), and enterprise object-store and high-availability recovery are unsupported and unqualified. In a real incident the full procedure above still requires an operator-owned audit of approvals, sessions and the print queue before a restored service returns to users.
Storage regressions in `PrivateObjectFilesTest` (part of the Echo demo's 39 tests): `concurrentSameKeyWritersLeaveOneWinnersBytesAndMetadata` (concurrent same-key writers leave one winner's bytes and metadata), `dotBucketsAreRefusedAndValidBucketsStillWork` (`.` and `..` buckets refused), plus `existingObjectIsNeverReplaced` and `keysCannotTraverseOrAlias`.
Limits: the file store is the single-host demo adapter and the database is the demo's H2 file, so this does not qualify production storage, an object store, encryption or high availability. Other database backends are not supported or tested here. The renderer is controlled and the printer is a database fixture; no job is physically printed and no normal demo data is changed. The file check reads the Record Files rows and object bytes directly, not through a product service.
## Session cleanup status
Ending a session (`MobileSessionService`) first persists the ended state with `authCleanupStatus = PENDING`. Cleanup then sets `REQUESTED` (with `authCleanupAt`) **before** calling the logout executor and removing the stored core session. If that call throws a `QException`, the status becomes `RETRY_NEEDED`. So `REQUESTED` means cleanup was attempted and no failure was reported; it is not independent proof that the provider session is gone. Cleanup is skipped when a binding does not exist, and for a single end inside an open transaction; a status left at `PENDING` also needs a retry. The bulk End Session process commits all tombstones first and runs cleanup afterwards.
To retry: find sessions with `authCleanupStatus` of `RETRY_NEEDED` or `PENDING` (admin view of the session table), then run End Session again for them. Ending an already ended session does not change its end time and reruns cleanup when a binding exists. Confirm the status reaches `REQUESTED`. There is no automatic monitor in this repository; the operator must check for these statuses.
## Retention
Scans, sessions, bindings and Echo scans are **kept indefinitely** by default. Table deletes are blocked by permissions and foreign keys tie history to devices. Barcode payloads and last-known location are retained, so a different retention period is a host-owned policy, not a feature of this release: any purge is a host DBA procedure, taken with a snapshot first and respecting the foreign keys. Audit is current-state only; revoking overwrites the previous approver and time.
## Database migration beyond H2
The supplied, tested scripts are H2: `base/src/main/resources/db/mobile-scanners-h2.sql` and the Echo `echo-h2.sql`. Other backends are unsupported and untested here; a host that uses one **owns the equivalent migration**; do not assume `ADD COLUMN IF NOT EXISTS` or the column types. Additive columns that older databases need before running current servers: on `mobileScan`, nullable `aimIdentifier` (`VARCHAR(3)`) and `rawDataBase64`; on Echo, nullable `echo_scan.aimIdentifier`, `rendererValue` and `humanReadable`, and **`echo_run.copies`, which is `INT NOT NULL DEFAULT 1`** (existing runs keep one copy), so it is not nullable and the equivalent migration must supply the default. Echo also has nullable template columns (`templateId`, `templateRevisionId`) in `echo-h2.sql`. Test the migration on a copy, and snapshot first.
## Demo-only file adapters
`LocalFilesystemAttachmentStorage` and `LocalStagingChunkStorage` (Echo demo) are single-host local-file adapters for the demo. They are not production storage; photo and record-file storage for a deployment is not qualified here. `ECHO_RELEASE_GENERATED_FILES=true` turns off generated-file quarantine and exists for the demo only; leave it off unless every producer permitted to keep generated output is trusted ([Echo demo README](../examples/echo/demo/README.md)).
+17 -15
View File
@@ -1,32 +1,34 @@
# Production readiness
**Status (2026-11-04): not released as production 1.0.** The local demo works and is accepted by the operator; the gates below decide whether the project can be called production-ready. This is a checklist, not a score. The detailed evidence ledger, with per-gate tables and the architecture review findings A1–A11, is archived in [history](history/PRODUCTION-READINESS-through-2026-11-04.md); present status is in [session state](SESSION-STATE.md).
**Status (2026-11-05): not released as production 1.0.** The local demo works and is accepted by the operator; the gates below decide whether the project can be called production-ready. This is a checklist, not a score. The detailed evidence ledger, with per-gate tables and the architecture review findings A1–A11, is archived in [history](history/PRODUCTION-READINESS-through-2026-11-04.md); present status is in [session state](SESSION-STATE.md).
## Delivered and verified
| Area | Evidence (see session state for revisions and counts) |
| --- | --- |
| Server source and development publication | `develop` at the merge of PR 13; PR 12 snapshot `0.1.0-20261103.233448-3645` read back anonymously and matches reviewed source. |
| Android host libraries | `main` at `0ae2101`; SDK run 3634 passed and artifacts verified. Development libraries only. |
| Current cohort tests | 198 tests pass (scanner 53, base demo 8, mobile Record Files 18, Printing bridge 16, Echo 68, Echo demo 35); Checkstyle passes; no private QQQ backend in the cohort. |
| Local runtime | One server on public QQQ RC.21 and Printing 1.0.0; API 1.10, mobile 1.5, printing 2.3, mobile-record-files 1.0; existing data preserved. |
| Server source and development publication | PR 19 merged as `9e8685f`, including record/photo UX support and earlier PR 17/18 changes; the storage/recovery patch is based on that source. Earlier PR 12 snapshot `0.1.0-20261103.233448-3645` was read back anonymously and matched its reviewed source. |
| Android host libraries | Published development libraries from source `0ae2101`: SDK run 3634 passed and artifacts verified. The scanner application's newer source and signed candidate are recorded below. |
| Current cohort tests | Combined five-module `verify` (2026-11-05): 196 server tests (53 / 8 / 22 / 74 / 39), zero failures, errors and skips, zero Checkstyle violations, with qualified private owner dependencies. Android 232 unit tests and lint pass. Inherited report-only guardrail findings remain visible. |
| Storage integrity and recovery | `PrivateObjectFilesTest` concurrent-writer and dot-bucket regressions; `EchoDemoAcceptanceTest.restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited` is the isolated local-demo recovery acceptance (stopped H2 plus object snapshot/restore, `RecordFilesTables.FILE` relationship and byte hashes). See [operations](OPERATIONS.md). |
| Local runtime | One server using the tested development aggregate: public QQQ SNAPSHOT, current Collaboration, scoped local Record Files and qualified document inputs, plus Printing 1.0.0. API 1.10, mobile 1.5, printing 2.3, mobile-record-files 1.0; existing data preserved. Final immutable public-cohort qualification remains open. |
| Device and admin processes | Enrollment, approval and revocation work from a single record and from bulk selection (checked rows, select-all, first N), with confirmation and visible results, covered by HTTP and browser E2E tests. Earlier browser run: 15 cases. |
| Hardware | TC22 manual tests: operator-reported pass on November 4 (no per-case artifacts). MC9400 configured and approved; trigger check pending. |
| Approved-scanner photos | Host-only `quarantineCapturedFiles=false` makes an approved device's captured JPEG/PNG `AVAILABLE`; other intake stays `QUARANTINED`. `record-files.release` is admin-only. Root-reported: 69 owner and 197 scanner/Echo tests passed, 13 focused Android tests passed, Checkstyle passed. A synthetic gallery PNG from the signed MC9400 app was `AVAILABLE` with exact parent/type/size/SHA-256. No camera-hardware or new full-preview proof. Depends on the unmerged Record Files owner PR 1. |
| Hardware | TC22 manual tests: operator-reported pass on November 4 (no per-case artifacts). MC9400 physical trigger done: Saved scan with backend EMDK CODE128/AIM/raw bytes and attribution confirmed (the barcode was not the requested literal). |
## Open gates
Local GS1-128 and native record/photo changes passed 188 server tests, Android unit/lint/build gates, and disposable native Echo/record-photo/process-photo acceptance. They remain uncommitted and undeployed. Synthetic GS1 renderer/decoder evidence does not close physical print/rescan acceptance or the signed-build deployment gates below.
The maintained server and existing TC22 app now run the GS1, rich test-data and record UX changes. Actions → Add photo, grouped/related records, search/filter/Back state and the saved-photo gallery are implemented and verified by isolated native acceptance plus an actual TC22 visual pass. Existing data and enrollment were preserved. See [current evidence](history/MOBILE-UX-2026-11-05.md). This supersedes the earlier undeployed status; physical GS1 print/rescan and signed-build production deployment remain open. The user authorized the final release, signing and publication for this bounded goal; Android PR 15 (`c782aaaaba3f62422232436a9000af9d4b38fc82`) merged after all CI.
| Gate | What is still required |
| --- | --- |
| Public dependency cohort | Public, coherent Generation/bridges/Record Files/Scripting artifacts from their owner; adoption of the Core/Collaboration/Next UI foundations when released. Today these are qualified owner inputs. |
| Release | Normal Gitea CI on the final revisions, a reviewed release manifest, and release/RC publication, which stays disabled until then. Redesign of the shared CI pipeline is outside this scope. |
| Signing and distribution | Done: QRun key with backup, signed 1.0 APK, direct/MDM channel. Open: qualification of the signed build on a device against an HTTPS server, then distribution and update path. A signed preview is published; it is not a qualified production rollout. No signing CI. Deferred by the user to the very end. |
| Deployment | HTTPS and the host's identity provider, supported database migration beyond the supplied H2 schema, retention and recovery documentation. The HTTP loopback demo is not a production deployment. |
| Provider authentication | Native OAuth2 requires UI API 1.10 native-session and nonce-validation support on the server (verified in the Android source). Still open: acceptance against a configured provider (such as Authentik) on a native handheld, including logout and account switching. No such handheld test is claimed. |
| Further device qualification | MC9400 physical trigger check and MDM-applied managed configuration remain unverified. The TC22 camera, GPS and screen-viewing checks are covered by the accepted operator group report; no separate per-case evidence is claimed or required again for that demo. |
| Barcode fidelity | Echo's supported profiles are documented ([limits](../examples/echo/README.md#fidelity-and-limits)); The local branch supports bounded GS1-128; physical print/rescan remains open. Other GS1, ECI, structured and binary profiles remain refused. No universal barcode-reproduction claim. |
| Final reviews | Reconcile the dated security, architecture and standards reports to the final revision; they are scoped historical reviews, not a fresh exhaustive scan. |
| Public dependency cohort | Public, coherent Generation/bridges/Record Files/Scripting artifacts from their owner; adoption of the Core/Collaboration/Next UI foundations when released. Today these are qualified owner inputs, and the current local aggregate uses public Core SNAPSHOT, current Collaboration and a local Record Files change, not a final public cohort. |
| Release | Retest the full gate when the final public dependency cohort is adopted, with a reviewed release manifest; Maven publication stays disabled until then. Redesign of the shared CI pipeline is outside this scope. |
| Signing and distribution | Done: QRun key with backup, signed Android 1.0.1 (code 3) built from the merged PR 15 source tree and installed in place on an MC9400 (installed bytes match; login/enrollment preserved; Scan Capture reopened; HTTPS connected). Open: public dependencies, signing-APK download publication and final distribution. No signing CI. |
| Deployment | HTTPS and the host's identity provider. Single-instance procedure, recovery and default-indefinite, host-owned retention guidance is in [operations](OPERATIONS.md); isolated local-demo recovery acceptance has passed. Enterprise object-store/HA and non-H2 databases are unsupported and unqualified options, not release tasks. The HTTP loopback demo is not a production deployment. |
| Provider authentication | Native OAuth2 requires UI API 1.10 native-session and nonce-validation support on the server (verified in the Android source). Shared Authenticator server configuration is verified (trusted HTTPS, API 1.10, scanner and printing extensions and channels, exact callbacks, REQUIRED nonce). Still open: native Authentik login, logout and account switching on a handheld (needs the MC9400 wireless-debugging endpoint). No native-auth pass is claimed. The Printing client nonce fix (PR 11, `443f642`; 352 tests and build passed) awaits hosted Core. |
| Further device qualification | MDM-applied managed configuration remain unverified. Company profile/wallpaper is separate tracked scope, not a blocker for this release goal. The TC22 camera, GPS and screen-viewing checks are covered by the accepted operator group report; no separate per-case evidence is claimed or required again for that demo. |
| Barcode fidelity | Echo's supported profiles are documented ([limits](../examples/echo/README.md#fidelity-and-limits)); The committed source supports bounded GS1-128; physical print/rescan remains open. Other GS1, ECI, structured and binary profiles remain refused. No universal barcode-reproduction claim. |
| Final reviews | Reconcile the dated security, architecture and standards reports to the final revision; they are scoped historical reviews, not a fresh exhaustive scan. A dated static review of base `a9eae04`, its supplement and its scope and limits are in [review reconciliation](REVIEW-RECONCILIATION-2026-11-05.md); it is not an audit rating. |
## Rules that stay in force
+40
View File
@@ -0,0 +1,40 @@
# Final review reconciliation, 2026-11-05
**Source base:** `a9eae04` (merge of PR 17, record-files publication metadata), branch `docs/release-readiness-reconciliation`. Present status is in [session state](SESSION-STATE.md).
This records what one static review covered. It is not an audit rating and not a release or production claim.
## Scope and limits
- Method: a source and test **read** of the server and Echo at the base above. Nothing was built, run or deployed for the review itself.
- Validation of the resulting patch: on the working tree, root `./mvnw verify install` passed 53 tests with zero errors, failures or skips and 0 Checkstyle violations, and the frozen 1.5.0 YAML's SHA-256 equals the runtime YAML. Base demo verification also passed all 8 HTTP/presence/migration tests, zero failures/errors/skips and 0 Checkstyle violations (61 tests total). Earlier cohort counts in [session state](SESSION-STATE.md) remain historical and were not rerun on final public inputs; no whole-cohort or public-release verification is claimed.
- Found no actionable source blocker among the traced paths: admin single/bulk processes, device approval and revocation, session end, scan idempotency, session mirroring, Echo copies and retry, and the optional file-integration guard.
- Not verifiable here: Android-source items (A3, A5, A6, A8, A10, A11), which rely on the Android repository's own closure report.
- Not read in that pass: the GS1 renderer-side classes (`EchoGs1`, `EchoTemplates`, `EchoTemplatePublisher`, `EchoOwnerAdapters`), `EchoDemoAuthentication`, `LocalStagingChunkStorage`, `PrivateObjectFiles`, and the Record Files and Echo test sources.
- Not covered: physical output (copies, GS1 print and rescan), the owner Generation/Printing behavior, browser E2E re-run, MC9400, HTTPS and the identity provider. These remain pending as listed in [production readiness](PRODUCTION-READINESS.md). The three earlier dated reviews predate GS1, copies, photo capture and the 1.5 raw metadata.
## Actions taken from the review
- Status documents reconciled to the committed GS1 source and PR 17.
- [Operations and recovery](OPERATIONS.md) added; it is a procedure, not an executed drill.
- A frozen 1.5.0 OpenAPI baseline added to the contract test; the existing baselines and assertions remain. No runtime or contract change.
- `UI-API.md` operation table now lists all seven operations; root `pom.xml` url, scm and issue tracker point at Gitea.
- Deferred: the screen-upload 409 and every other operation's 409 share the generic text "Installation already enrolled" in `MobileJsonSpec`. Correcting it is a contract text change needing a patch version bump and regenerated YAML, so it is not done here.
## Supplement: findings checked after the review
Scope: the four findings raised after the static review. This is not an audit rating or a score.
- **F1, concurrent metadata overwrite:** fixed in `PrivateObjectFiles` and regression-tested by `PrivateObjectFilesTest.lateSecondWriterIsRefusedAndFirstWritersBytesAndMetadataRemain` (also `existingObjectIsNeverReplaced`). The controlled interleaving reproduced mismatched metadata on the old implementation; all eight storage tests then passed with the fix.
- **F2, dot bucket traversal:** fixed and regression-tested by `PrivateObjectFilesTest.dotBucketsAreRefusedAndValidBucketsStillWork` (also `keysCannotTraverseOrAlias`).
- **F3, ZPL command injection:** disproven at source. In the owner Generation renderer, `ZplRenderer.java` lines 326-331 encode every UTF-8 byte under `^FH`. `RendererTest.typedZplEscapesCommandsAndEmitsRasterBarcodes` covers `^XA`, `~JA` and underscore input. That source is owner-held; the exact published artifacts are still pending qualification.
- **F4, label clipping:** the assumption is guarded by renderer placement bounds, which refuse overwide text. No universal media-fidelity claim is made.
The fixes were verified by the 2026-11-05 combined five-module `verify` (196 tests, zero failures, errors and skips, zero Checkstyle violations) on qualified private owner dependencies, not on published artifacts. Pending: owner artifact qualification, physical output and any enterprise object-store or high-availability recovery (unsupported, not tasks of this release).
## Inherited guardrail heuristics (visible, not disabled)
Two generic guardrail reports are heuristic mismatches, not missing behavior. They stay visible as existing signals; no check was disabled and no test marker was added to satisfy them.
- **Cross-QBit references.** Root `src` imports no other QBit's package and its POM depends only on core, Javalin and test libraries. The references are in the separate Echo and Record Files modules, where they are the purpose. Any allowlist belongs at the guardrail, not here.
- **OpenAPI and test markers.** The contract is covered by `MobileScannersApiContractTest` (generated against committed YAML and the 1.0.0, 1.1.0, 1.4.0 and 1.5.0 baselines, exact path set and schemas) and real discovery in `MobileHttpAcceptanceTest`. A generic string-marker scan cannot see this.
+30 -33
View File
@@ -1,54 +1,51 @@
# Session state
Current as of **2026-11-04**. This is the one place for present status; keep it short. Open work is in [TODO](TODO.md), release gates in [production readiness](PRODUCTION-READINESS.md), and all earlier logs in [history](history/README.md). Nothing here claims a production 1.0.
Current as of **2026-11-05**. Open work is in [TODO](TODO.md), release gates in [production readiness](PRODUCTION-READINESS.md), and prior evidence in [history](history/README.md).
## Source and publication
Active local feature work: GS1-128 duplication (barcode plus HRI), native table/record browsing and record photo attachment. See [implementation plan](GS1-RECORD-PHOTOS-PLAN.md). The GS1 profile covers the requested GTIN/production-date/serial structure and selected additional AIs. Original scans remain unchanged; canonical renderer input and HRI use additive nullable columns. Root/demo and the coherent Echo reactor passed; two synthetic outputs from the real renderer independently decoded with exact GS1 data and matching HRI. The scanner/demo/Echo reactor passed 135 tests. Disposable MC9400 native acceptance passed GS1 capture and saved-record browsing, plus camera/gallery attachment from record details with lifecycle cleanup. Android unit tests, lint and APK builds passed. The composed cohort also passed 188 tests (scanner 53, base demo 8, Record Files 18, Echo 74, Echo demo 35), with Checkstyle. No deployment, physical GS1 print/rescan or production qualification is claimed.
GS1-128 duplication, including barcode and printed HRI, is merged through [PR 15](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/pulls/15), commit `f9b0eb2`, and included in the [Forge source prerelease](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/releases/tag/echo-gs1-source-20261104). This is source publication, not a published Echo Maven dependency. PRs 17 and 18 add publication metadata, the frozen contract baseline and recovery guidance; those changes are retained in this feature branch.
The [rich demo fixture](MOBILE-TEST-DATA.md) and [record UX improvements](history/MOBILE-UX-2026-11-05.md) are implemented. Native browsing, record Actions and photo presentation belong to the companion Android repository. The user authorized the final release, signing and publication for this bounded goal. Android [PR 15](https://git.kof22.com/QRun-IO/qqq-android/pulls/15) merged as `c782aaaaba3f62422232436a9000af9d4b38fc82` after all CI. Maven publication switches stay disabled until the public dependency cohort is adopted.
| Item | State |
| --- | --- |
| Source repository | Gitea `git.kof22.com/QRun-IO/qbit-mobile-scanners`, default branch `develop`. GitHub is a shadow/archive. |
| Scanner PR 12 (development snapshots) | Merged as `286e2e0`. Run 3645 succeeded. The snapshot `com.kingsrook.qbits:qbit-mobile-scanners:0.1.0-20261103.233448-3645` was read back anonymously (JAR, POM, sources, Javadocs); its 37 Java source files match the reviewed source. |
| Scanner PR 13 (Echo uses released Printing) | Merged as `1345197`. Both Echo POMs default to released Printing `1.0.0`. CI runs 4157 and 4158 succeeded. |
| Android host (`qqq-android`) | PRs 8 and 10 merged into `main` at `0ae2101`. SDK run 3634 passed and its artifacts were verified (`0.1.1-20261103.232145-3634`). Signing/documentation changes are merged through PR 14 (`464814f`), and signed preview `scanner-v1.0.0` is published. These are development libraries, not a production release. |
| Production 1.0 | Not released. All Maven publication switches other than development snapshots remain off. |
| Primary forge | Gitea `QRun-IO/qbit-mobile-scanners`, default `develop`; Android uses protected `main`. GitHub is a shadow/archive. |
| Scanner development snapshot | PR 12 merged; run 3645 published `0.1.0-20261103.233448-3645`, anonymously read back and compared to its reviewed source. |
| Printing | PR 13 merged; Echo defaults to released Printing `1.0.0`. |
| Android libraries and signed preview | PRs 8/10 merged at `0ae2101`, documentation through PR 14 (`464814f`); SDK run 3634 verified. `scanner-v1.0.0` is a signed preview, not production qualification. |
| Production 1.0 | Not released. Publication policy and switches are unchanged. |
## Local runtime
One local server (the composed Echo launcher, see [local demo](LOCAL-DEMO.md)) runs Mobile Scanners/Echo, Collaboration/Record Files, Document Generation and Printing on loopback host port `57832`. It uses public QQQ `4.1.0-RC.21` and released Printing `1.0.0`, and preserves its existing database, accounts and printer configuration.
The maintained composed Echo server and **existing TC22 app** were updated in place on November 5. Database and attachment objects were backed up first; accounts, enrollment, app data and printer configuration were preserved. Live reads verified 1,000 test orders, 12,000 items and 3,000 history entries; orders have 39 fields, six groups and two associations.
| Contract | Version |
| --- | --- |
| QQQ semantic UI/UX API | 1.10 |
| `mobile-scanners` extension | 1.5 |
| `printing` extension | 2.3 |
| `mobile-record-files` vocabulary | 1.0 |
Use **Test Data → Test Orders → a record → Actions → Add photo**. Details, Items, History and Photos tabs provide direct access. Search/filter/count and Back preserve table context. Approved photos have thumbnails/previews; quarantined uploads remain listed as awaiting review. Ordinary read/attach permissions still apply.
The Android client reaches it through a device-side forward from port 8080 to the host port, so the app URL stays `http://127.0.0.1:8080`. This HTTP loopback demo is separate from any HTTPS production deployment.
**Approved-scanner photos.** The Echo host sets Record Files `quarantineCapturedFiles=false`, so a JPEG/PNG captured by a currently approved device is `AVAILABLE` at once. Uploads, imports and attachment-panel files stay `QUARANTINED`. Parent tables are Echo scans plus the test orders, items and events; the scanner's existing `.attach` grants are retained. Only `admin` gains `record-files.release`; scanner grants are unchanged. Three earlier quarantined photos were released through the audited admin Release process. The server's current aggregate was built from public Core SNAPSHOT, current Collaboration, the scoped local Record Files change and qualified private document inputs, not a final public cohort. The scoped Record Files change is [owner PR 1](https://git.kof22.com/QRun-IO/qbit-record-files/pulls/1) (policy commit `3f08c584`), open and unmerged; its public CI currently fails an enrollment check that the coordinator owns, so neither the check nor its merge gates are changed here. No version or POM was guessed.
## Verification of the current cohort
The [local demo](LOCAL-DEMO.md) source defaults remain QQQ `4.1.0-RC.21` and Printing `1.0.0` pending the public-cohort update; the maintained runtime uses the verified development aggregate described above. Host port is `57832`. The TC22 development app retains its loopback forwarding; the signed MC9400 app connects through trusted HTTPS. Contract versions remain UI API 1.10, mobile-scanners 1.5, printing 2.3 and mobile-record-files 1.0. This is local development qualification, not a customer production deployment.
- **198 tests pass** with no failures, errors or skips: scanner 53, base demo 8, mobile Record Files 18, Printing bridge 16, Echo 68, Echo demo 35. Checkstyle passes.
- The 94 scanner Java source files match the reviewed source exactly.
- No private QQQ backend artifact is in this cohort; all QQQ backend dependencies resolve to public RC.21 and Printing is the released `1.0.0`.
- The earlier 377-test run plus 15 admin-browser E2E cases remains valid historical evidence. This session did not re-run the browser suite, so no new browser run is claimed.
- Not public yet: the document, Record Files and Scripting owner artifacts (Generation, its bridges and copies support) are still qualified owner inputs awaiting a canonical published cohort. The owner is releasing Core, Collaboration and Next UI foundations, but no completed public versions are recorded here and no POM was changed in anticipation of them.
## Verification
## Handhelds
- Photo/admin change set (root-reported, not rerun in this review): 69 owner tests and 197 scanner/Echo tests passed (runs of 31, 158 and 8), 13 focused Android tests passed, and the relevant Checkstyle passed. These groupings differ from the module split below, so no per-module counts are claimed. Public immutable dependency qualification is still pending.
- Live evidence: a fresh synthetic gallery PNG uploaded from the production-signed MC9400 app returned "Saved photo", and the server showed `AVAILABLE` with the exact parent, type, size and SHA-256. An older photo's Available state, thumbnail and Preview control were observed. The new photo's full preview was not clicked because ADB disconnected. No camera-hardware or full-preview proof is claimed.
- The earlier combined five-module `verify` (2026-11-05, base `9e8685f` plus the storage fixes) passed **196 server tests** with zero failures, errors and skips and zero Checkstyle violations: scanner 53, base demo 8, mobile Record Files 22, Echo 74, Echo demo 39. It used qualified private owner dependencies; there is no public-dependency or physical-output qualification, and the gate must be repeated when the public cohort is adopted. The new build parent's report-only schema-location/contract-test findings remain visible.
- Storage/recovery PR 21 is merged (`59959247`), with bounded cold-restore and race proof complete. Website 0.1.17 is complete. Evidence: `PrivateObjectFilesTest.concurrentSameKeyWritersLeaveOneWinnersBytesAndMetadata` and `dotBucketsAreRefusedAndValidBucketsStillWork`; recovery acceptance `EchoDemoAcceptanceTest.restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited` (details in [OPERATIONS](OPERATIONS.md#isolated-local-demo-recovery-acceptance-automated-passed)).
- Android passed **232 unit tests**, lint with **0 errors/0 warnings**, and the debug APK build. E2E APK/instrumentation builds and eight runner guard tests also passed.
- Isolated MC9400 native acceptance passed search/filter/paging/Back, grouped/related records, camera/gallery upload, parent-scoped saved-photo list, approved preview, rotation, pending-upload navigation protection and forced-logout cleanup. All requests matched the semantic API. Process-photo regression also passed.
- The final date-only correction received unit/lint/build checks and actual TC22 verification. Actual-device screenshots confirm Actions → Add photo, dates/timezones, currency, related tabs, photo empty state and return to page two. Synthetic camera/gallery inputs in automated acceptance are not a new operator camera report.
- Earlier 135/188/198/377-test cohorts and 15 browser E2E cases are historical evidence, not a fresh run against future public dependencies. The snapshot source comparison applies to its original revision only.
- 2026-11-06, branch `fix/refresh-device-versions`: the MC9400 in-place 1.0.0 to 1.0.1 update left the device record and every later scan reporting `appVersion` 1.0.0, because only enrollment ever wrote `manufacturer`/`model`/`osVersion`/`appVersion` and no later client request carried them. Extension 1.6 adds the same optional fields to `MobileScannersHeartbeatRequestV1`; the heartbeat refreshes the device record when a value differs and scans copy the refreshed record (see [UI-API](UI-API.md)). The Android host change (send `osVersion`/`appVersion` on heartbeat) is on the same-named branch in the Android repository; devices need that client build before their versions refresh. Root module: 56 tests, zero Checkstyle violations, new baseline `mobile-scanners-1.6.0.yaml`.
- **Zebra TC22 (original, unchanged).** Development APK (SHA-256 `38A11155BF6A9C089A1EE6D9E44401E8FF39497B7150B51A85F1F916148BE60D`, version 0.1.0, code 1). On November 4 the operator reported that all outstanding manual tests pass; that is accepted as an operator report, not new per-case evidence, and the checks are not to be requested again. See [TC22 acceptance](TC22-ACCEPTANCE.md).
- **Zebra MC9400 (Android 13, added).** A signed release 1.0 build was installed and launched, then at the user's request the device was configured like the TC22 for now: the same development APK, device port 8080 forwarded to the host server over paired wireless ADB, signed in as the scanner account, and approved through the ordinary device-approval process. A physical trigger scan and backend record check are still pending.
- **Signed APK.** A signed 1.0 (code 2) APK exists, verified with the new QRun key and its backup (see the Android repository's `RELEASING` documentation). There is no production rollout and no automatic APK-signing CI yet.
## Handhelds and remaining limits
## Echo (scan, render, print)
Previous TC22 APK SHA-256: `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`; prior preview hashes are in [history](history/RICH-RECORDS-2026-11-05.md).
Optional example app. Copies per scan defaults to 1 (range 1–100); one print job per scan, with printer and document chosen server-side at setup and the same value and symbology rendered within the supported profiles. Admitted and refused barcode profiles are listed in the [Echo README](../examples/echo/README.md#fidelity-and-limits); universal barcode fidelity is not claimed.
Signed Android `1.0.1` (code 3) was built from the identical merged source tree (PR 15) and installed in place on the MC9400. The installed bytes match the built APK, login and enrollment were preserved, the authorized Scan Capture screen reopened, and the HTTPS connection was established. The MC9400 physical trigger is **done**: an actual operator trigger produced a Saved scan, and the backend confirmed EMDK CODE128, AIM and raw bytes with server-derived attribution. The barcode scanned was not the literal string requested, so no exact-test-string claim is made.
## Where things are
Shared Authenticator server configuration is verified: real trusted HTTPS, UI API 1.10, both scanner and printing extensions and channels, exact callbacks and a REQUIRED nonce. Native client login, logout and account switching are **not yet tested** (this needs the MC9400 wireless-debugging endpoint), so no native-auth pass is claimed. The Printing client required-nonce fix (PR 11, `443f642`; 352 Windows/CLI tests and build passed) awaits hosted Core. Pending: native Authentik login/logout/account switching, the final public Core/Collaboration/document/files/scripting/bridge artifacts, a public-cohort rerun, GS1 physical print/rescan, signing-APK download publication and owned cleanup.
- Status and plans: this file, [TODO](TODO.md), [production readiness](PRODUCTION-READINESS.md).
- Run it: [local demo](LOCAL-DEMO.md), [TC22 acceptance](TC22-ACCEPTANCE.md), [tests](TESTING.md).
- Contract: [semantic UI API](UI-API.md), [device sessions](DEVICE-SESSIONS.md), [branding](BRANDING.md).
- Echo: [library](../examples/echo/README.md), [runnable demo](../examples/echo/demo/README.md).
- Evidence and old logs: [history](history/README.md); dated review documents stay in this directory.
The November 4 TC22 operator group pass remains accepted and is not requested again. Physical GS1 print/rescan, provider callback acceptance and fleet rollout remain open. Echo supports only its documented [barcode profiles](../examples/echo/README.md#fidelity-and-limits). Company profile/wallpaper is separate tracked scope, not a blocker for this release goal.
Operations and recovery guidance is in [OPERATIONS](OPERATIONS.md). The automated stopped-H2 plus object snapshot/restore case passed as isolated local-demo recovery acceptance (no physical printing, no normal demo data changed). Enterprise object-store/HA and non-H2 databases are unsupported and unqualified, not release tasks. The [dated review](REVIEW-RECONCILIATION-2026-11-05.md) records its scope and supplement. Public owner dependencies require a separate reviewed adoption and a new gate.
+30
View File
@@ -0,0 +1,30 @@
# TC22 visual and interaction review
Follow-up: all eight findings were addressed in the existing app and maintained demo. See [implementation and verification](history/MOBILE-UX-2026-11-05.md). The review below preserves the original observations.
Reviewed November 5 on the actual TC22 running the existing app and maintained demo, using synthetic Test Orders. Directly inspected the first and second table pages, all six order groups, photo controls, related items, an item detail, history links and Back navigation. This is a visual/interaction review, not a new camera, printer or hardware acceptance claim. No records or photos were changed.
## Assessment
Readable and functional, but the current presentation is inefficient for repeated handheld work. Large text and buttons help readability; hierarchy, navigation and access to frequent actions need improvement. The data volume exposes limitations that smaller records concealed.
## Prioritized changes
| Priority | Observed issue | Recommendation |
| --- | --- | --- |
| P1 | Page two → order 26 → Android Back returns to page one. | Preserve table page, scroll position and filters per navigation entry. Verify Back after multiple record visits. |
| P1 | Reaching photo buttons required five long swipes from the top of order 1. Items/history follow all 39 fields. | Add direct Details, Items, Photos and History navigation, with a prominent Add photo action. Make detail groups collapsible and provide a section selector. |
| P1 | Toolbar says only “Record”; order identity disappears while scrolling. Child records use the same title. | Keep a compact record reference and record type visible. Show a parent-order link on item details; keep record labels out of telemetry. |
| P1 | A 1,000-row list has Previous/Next/Refresh but no visible search, filters, count or page indication. | Add server-backed search, relevant filters and “26–50 of 1,000.” Consider scan-to-find where declared metadata supports it. |
| P2 | Table entries are centered blue links with uneven wrapping; child descriptions occupy three lines. History shows only event identifiers. | Use left-aligned rows with a strong reference, concise secondary text, status and a clear navigation affordance. Show item quantity/SKU and history timestamp/status through metadata-defined summaries. |
| P2 | Item heading repeats the full description in very large type, then repeats it as a field. Persistent branding/header and diagnostic footer consume substantial space. | Use a short reference heading and secondary description. Consolidate the header and reduce connected status to a compact indicator; retain accessible text and touch targets. |
| P2 | Dates/timestamps and money read like raw values; timestamp example includes `T` and `Z`. | Apply metadata-aware currency, units and localized date/time formatting with timezone context. Preserve leading zeros. Show an explicit empty-value indicator. |
| P2 | Photo controls offer capture/select but this screen provides no visible saved-photo gallery or attachment count. | Add thumbnails/count, preview, upload progress and clear pending-review/retry states. Keep the destination record visible near photo actions. |
## What works
Text is legible, field labels are distinguishable, sections have clear headings, and the blue photo buttons are easy to identify. Leading zeros, Unicode and multiline values displayed correctly. Parent-to-item navigation worked. Table pagination is available without scrolling the whole page.
## Suggested order
Fix navigation state first. Then add persistent record identity and direct photo/related-record access. Next improve list summaries/search and reduce repeated content. Finish with formatting and attachment presentation. Implement generic metadata-driven controls rather than order-specific layouts. No UI fixes were made during this review.
+11 -6
View File
@@ -1,6 +1,6 @@
# TODO
Open work only, as of **2026-11-04**. Present status is in [session state](SESSION-STATE.md); release gates are in [production readiness](PRODUCTION-READINESS.md). The earlier ledger, including every completed item, is archived in [history](history/TODO-through-2026-11-04.md).
Open work only, as of **2026-11-05**. Present status is in [session state](SESSION-STATE.md); release gates are in [production readiness](PRODUCTION-READINESS.md). The earlier ledger, including every completed item, is archived in [history](history/TODO-through-2026-11-04.md).
## Needs an owner outside this repository
@@ -9,10 +9,13 @@ Open work only, as of **2026-11-04**. Present status is in [session state](SESSI
Note: redesign of the shared CI pipeline is outside this scanner/local Echo scope and is not tracked here; the local demo does not depend on it.
- [ ] **Record Files `quarantineCapturedFiles`.** The Echo host (`EchoDemo`) and the optional integration test already call/require `RecordFilesQBitConfig.withQuarantineCapturedFiles(Boolean)` (nullable, null inherits `quarantineNewFiles`; applies only to CAPTURE intake with a provider-verified device identity). The owner's scoped change (policy commit `3f08c584`, PR 1) is open and unmerged, and its CI currently fails a not-enrolled check owned by the coordinator; do not change CI or weaken merge gates. Adopt the released version as a reviewed pin once public; no coordinate is known, so none was guessed and this source builds only against the local owner input until then.
## This repository
- [ ] Finish GS1-128 duplication with printed HRI: native/HTTP acceptance, composed demo verification, GS1 template setup and physical print/rescan of the requested label profile.
- [ ] Deliver native permitted-table browsing, record details and camera/gallery attachment from the selected record, with permission/lifecycle and native E2E coverage.
- [ ] Qualify GS1 template/media setup and **physical print/rescan** of the requested label profile. Software duplication, native acceptance and maintained-demo adoption are complete.
Retention stays indefinite by default and any change is a host-owned policy ([operations](OPERATIONS.md#retention)). Enterprise object-store and high-availability recovery, and database backends other than H2, are unsupported/unqualified deployment options, not tasks of this release. The 2026-11-05 gate (196 tests, zero failures/errors/skips, zero Checkstyle violations) used qualified private owner dependencies; the required public-cohort rerun remains listed below.
- [ ] Optionally correct the generic 409 text in `MobileJsonSpec` (screen upload is a stale-frame conflict) with a patch version bump and regenerated contract; deferred.
- [ ] Complete the authorized readiness work in [the active plan](GS1-RECORD-PHOTOS-PLAN.md), retaining explicit external/hardware gates.
- [ ] Re-run the full gate (root, demo, Echo, Echo demo, Record Files, browser E2E) on whatever dependency set is adopted next, and record the actual counts.
@@ -20,13 +23,15 @@ Note: redesign of the shared CI pipeline is outside this scanner/local Echo scop
## Hardware and device follow-up
- [ ] **MC9400:** one physical trigger scan, then confirm the backend record. The device is installed, signed in and approved ([session state](SESSION-STATE.md#handhelds)).
- [ ] Native Authentik login, logout and account switching on a handheld (not yet tested; needs the MC9400 wireless-debugging endpoint). The shared Authenticator server configuration is verified; the Printing client nonce fix (PR 11) awaits hosted Core. The MC9400 physical trigger check is done ([session state](SESSION-STATE.md#handhelds-and-remaining-limits)).
- [ ] Optional: click the full preview of the newly uploaded gallery photo on a handheld (not observed because ADB disconnected). No camera-hardware proof is claimed.
- [ ] Optional: capture per-case evidence (photos, logs, a scanned-back label) on a future run. The November 4 operator pass is accepted as is and is not to be requested again.
## Release (deliberately last)
- [ ] Signed-build qualification on a device against an HTTPS server (enrollment, scanner path, navigation, update), then distribution. The QRun key, signed 1.0 APK and direct/MDM channel are done; a signed preview is published, but no production rollout or automatic APK-signing CI is qualified.
- [ ] HTTPS deployment, host-owned database migration beyond H2, and the remaining gates in [production readiness](PRODUCTION-READINESS.md).
- [ ] Final distribution of the signed 1.0.1 build and the public dependencies it waits on. Signing is done: the build was installed in place on an MC9400 through ADB, preserved enrollment, and reopened Scan Capture against the HTTPS server. Remaining device checks are listed above; no automatic APK-signing CI is qualified. Also open: signing-APK download publication, owned cleanup, and a rerun of the public cohort with the final public Core/Collaboration/document/files/scripting/bridge artifacts.
- [ ] Company profile/wallpaper is a separate tracked scope and not a blocker for this bounded release goal.
- [ ] HTTPS deployment by the host (any non-H2 migration is host-owned and unsupported here) and the remaining gates in [production readiness](PRODUCTION-READINESS.md).
## Not in version one
+9 -2
View File
@@ -1,6 +1,6 @@
# Mobile Scanners semantic UI API
Mobile Scanners is an independently versioned QQQ UI API extension. `MobileScannersQBitProducer` registers the `mobile-scanners` contract, currently **1.5.0** (core UI API 1.10 on the maintained local server), through `MiddlewareSpecExtensionsMetaData`. The framework generates its routes, discovery advertisement and merged OpenAPI from the same endpoint specs. Optional operator-consented device-screen viewing is described in [DEVICE-SCREEN-VIEWING.md](DEVICE-SCREEN-VIEWING.md); app-content remains the default. There is no separate `/mobile/v1` API or legacy alias.
Mobile Scanners is an independently versioned QQQ UI API extension. `MobileScannersQBitProducer` registers the `mobile-scanners` contract, currently **1.6.0** (core UI API 1.10 on the maintained local server), through `MiddlewareSpecExtensionsMetaData`. The framework generates its routes, discovery advertisement and merged OpenAPI from the same endpoint specs. Optional operator-consented device-screen viewing is described in [DEVICE-SCREEN-VIEWING.md](DEVICE-SCREEN-VIEWING.md); app-content remains the default. There is no separate `/mobile/v1` API or legacy alias.
The host must also install `MobileScannersRouteProvider` on the same server. This provider binds verified device headers to the current request; it does not register additional business routes. Keep the versioned QQQ routes enabled. The qbit registers the `android` client channel while preserving the host's existing channel policies. The demo disables legacy unversioned middleware routes.
@@ -26,11 +26,18 @@ Every path below is relative to `/qqq/v1/qbit/mobile-scanners`.
| `GET /status` | `mobileScannersStatus` | Public | 200, `MobileScannersStatusV1` |
| `POST /devices/enroll` | `mobileScannersEnrollDevice` | QQQ user session, `mobile.scan` | 201, `MobileScannersEnrollmentV1` |
| `GET /devices/current` | `mobileScannersCurrentDevice` | QQQ user session, `mobile.scan`, both device headers | 200, `MobileScannersDeviceV1` |
| `POST /sessions/heartbeat` | `mobileScannersSessionHeartbeat` | QQQ user session (cookie, bearer or OAuth2), both device headers | 200, `MobileScannersHeartbeatV1` |
| `POST /sessions/current/end` | `mobileScannersEndCurrentSession` | QQQ user session (cookie, bearer or OAuth2), both device headers | 200, `MobileScannersSessionEndedV1` |
| `POST /sessions/current/screen` | `mobileScannersUploadSessionScreen` | QQQ user session (cookie, bearer or OAuth2), both device headers | 204, no body; its 409 means a stale frame or sequence, not an enrollment conflict |
All seven operations also declare 400, 401, 403, 404, 429 and 500 error responses (`BasicErrorResponseV1`). The published YAML labels every operation's 409 "Installation already enrolled"; that text is accurate only for enrollment. Session behavior is in [device sessions](DEVICE-SESSIONS.md) and [screen viewing](DEVICE-SCREEN-VIEWING.md).
Public discovery, OpenAPI, branding and connectivity ignore stale device headers. Authentication discovery/session/logout also remain available when a previously enrolled credential is no longer valid. Responses from extension handlers use `Cache-Control: no-store`.
Enrollment takes `MobileScannersEnrollRequestV1`: required canonical UUID `installationId`, optional `displayName`, `manufacturer`, `model`, `osVersion` and `appVersion` strings, each at most 200 characters. The JSON request body is limited to 4096 characters. A duplicate installation returns HTTP 409 and never rotates or discloses its existing credential. A new installation starts `PENDING` and receives its private `deviceToken` once.
Since extension 1.6, `MobileScannersHeartbeatRequestV1` also accepts the same optional `manufacturer`, `model`, `osVersion` and `appVersion` strings (at most 200 characters, 400 otherwise). An approved device's record is updated only when a sent value differs; omitted, null or blank values leave the enrolled values unchanged. Scan events copy the device record at save time, so an in-place app or OS update is reflected on the next heartbeat and every later scan, while earlier scans keep the versions that produced them. The Android host sends `osVersion` and `appVersion` on every heartbeat.
Send `X-QQQ-Device-Id` and `X-QQQ-Device-Token` on authenticated device/core metadata, widget and process requests to this same origin. The device ID is a UUID; the token is private and at most 128 characters. These headers supplement the authenticated user session. They never replace user permission checks. Never put a token in metadata, URLs, process values, screenshots, logs or fixtures; never forward credentials to a branding asset origin. Store the issued credential privately. Only its digest is stored server-side.
`devices/current` returns `deviceId`, `displayName` and `status`, never a token. `PENDING` and `REVOKED` devices may read their status. Scanner access still requires `APPROVED`, checked when launching and saving, including asynchronous/resumed execution. Device approval/revocation uses the ordinary QQQ processes, including single-record and bulk selections.
@@ -76,7 +83,7 @@ Schema migration (H2, idempotent, nullable): `ALTER TABLE mobileScan ADD COLUMN
## Contract checks and compatibility
The published fragment is [mobile-scanners.yaml](../src/main/resources/openapi/qqq-v1/mobile-scanners.yaml); the initial release baseline is [mobile-scanners-1.0.0.yaml](../src/test/resources/openapi/mobile-scanners-1.0.0.yaml), with additional frozen baselines such as [mobile-scanners-1.4.0.yaml](../src/test/resources/openapi/mobile-scanners-1.4.0.yaml) (the contract immediately before the 1.5.0 capture metadata). `MobileScannersApiContractTest` rejects generated-document drift, unresolved contract structure, incompatible baseline changes and missing version bumps using QQQ's shared contract checker. Object required fields and scanner numeric bounds are standard OpenAPI keywords, added by small Schema subclasses because RC20's base model does not expose those setters.
The published fragment is [mobile-scanners.yaml](../base/src/main/resources/openapi/qqq-v1/mobile-scanners.yaml); the initial release baseline is [mobile-scanners-1.0.0.yaml](../base/src/test/resources/openapi/mobile-scanners-1.0.0.yaml), with additional frozen baselines such as [mobile-scanners-1.4.0.yaml](../base/src/test/resources/openapi/mobile-scanners-1.4.0.yaml) (the contract immediately before the 1.5.0 capture metadata) and [mobile-scanners-1.5.0.yaml](../base/src/test/resources/openapi/mobile-scanners-1.5.0.yaml), the frozen 1.5.0 release snapshot that later changes are checked against. `MobileScannersApiContractTest` rejects generated-document drift, unresolved contract structure, incompatible baseline changes and missing version bumps using QQQ's shared contract checker. Object required fields and scanner numeric bounds are standard OpenAPI keywords, added by small Schema subclasses because RC20's base model does not expose those setters.
For an intentional contract edit, update specs/schemas and the extension semantic version, then regenerate only the current fragment:
+28
View File
@@ -0,0 +1,28 @@
# Mobile record UX completion — November 5
The [TC22 review](../TC22-UIUX-REVIEW-2026-11-05.md) identified eight usability issues. All are addressed in the companion Android application and this demo's ordinary metadata:
- Table page/scroll/search/filter state survives record navigation and recreation; search, exact text filters and counts use the semantic query/count APIs.
- Record identity stays visible above Details, related-record and Photos tabs. Groups collapse; a section selector and child-to-parent navigation shorten large records.
- Left-aligned summaries, short item labels, localized dates/timezones, currency/units and explicit empty values improve readability. Diagnostic detail is behind Connected.
- **Actions → Add photo** opens the existing camera/gallery upload flow. Saved photos are scoped to the parent, show lifecycle status and use the owner's authorized tracked download for available-image previews. Quarantined originals remain unavailable.
Record Files deliberately refuses aggregate counts: the gallery uses bounded pages with one lookahead row, without weakening permissions. Images have bounded byte/bitmap memory, exact length/hash checks, same-origin authenticated delivery and no image disk cache. Navigation guards remain active during pending uploads, including rotation; logout removes owned temporary staging.
## Verification and adoption
The composed feature cohort passed 189 tests (scanner 53, base demo 8, mobile Record Files 18, Echo 74, Echo demo 36) with Checkstyle. Android passed 232 unit tests and lint (0 errors/0 warnings), plus debug/E2E/instrumentation builds and eight native runner guard tests. Isolated MC9400 rich-record acceptance passed grouping, related paging, search/filter/Back preservation, camera/gallery persistence, parent-scoped gallery, approved preview, rotation, blocked navigation and forced-logout cleanup. Process-photo regression passed separately. All native requests matched the published semantic API.
The date fallback needed to recognize the server's default `%s` metadata and differing raw/display JSON representations. Regression tests and a final unit/lint/APK gate cover that correction; actual TC22 screenshots show formatted dates and timezone context. The final date-only change did not repeat the full native suite.
The maintained server was updated with a database/object backup and no reset. The ordinary TC22 application was replaced in place after matching its signer; the installed bytes match SHA-256 `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`. Login, enrollment, server address and existing data survived. Actual TC22 inspection confirmed Actions, Take photo/Choose photo, Details/Items/History/Photos, formatted values and order 26 → Android Back → 26–50 of 1,000. No real camera capture or print was submitted during this visual check.
Earlier UX-build hashes `23BA59F0EEE34D5B9ABBE5D45158A57C98416A5D7D69337090129AB6C853EABC` and `25DAB25D92FF709E4DA5DAA9949A501113E71DC050FF12E07C17C06EE06514E5` were superseded by the date correction. The preceding rich-record preview is preserved [separately](RICH-RECORDS-2026-11-05.md).
## Source and remaining limits
The user authorized commit, push and merge after the local update. Latest upstream scanner PRs 17/18 are retained. Reviews: [scanner/server PR 19](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/pulls/19) and [Android PR 15](https://git.kof22.com/QRun-IO/qqq-android/pulls/15), which includes actual TC22 screenshots. Publication switches and repository dependency defaults are unchanged.
The combined source passed a fresh **193-test** server gate: 53 scanner, 8 base demo, 22 mobile Record Files, 74 Echo and 36 Echo demo, zero failures/errors/skips and passing Checkstyle. The inherited build parent adds four guardrail cases; report-only extension schema-location/contract-test findings remain visible. Every source/resource file in the isolated build was matched to the merged working tree by count and SHA-256. Owner-coordinate and Checkstyle-path overrides existed only in that verification copy. A direct build against the old default Record Files snapshot failed because its authorization API is missing; the qualified running-demo cohort passes. Public owner-dependency adoption remains separate work.
The November 4 TC22 operator pass remains accepted. Physical GS1 print/rescan, MC9400 trigger acceptance, HTTPS/signed-build qualification and public owner-dependency adoption remain distinct open gates. No production release or new Maven artifact publication is claimed.
+2
View File
@@ -1,5 +1,7 @@
# Documentation history
Latest checkpoint: [record UX completion, November 5](MOBILE-UX-2026-11-05.md). Earlier preview: [rich record fixture](RICH-RECORDS-2026-11-05.md).
Superseded rolling logs, preserved verbatim as evidence. They are **not** current status: many entries describe work as pending that was later completed. Read the current entry points instead:
- [Project README](../../README.md), [session state](../SESSION-STATE.md), [TODO](../TODO.md), [production readiness](../PRODUCTION-READINESS.md)
+7
View File
@@ -0,0 +1,7 @@
# Rich record preview — November 5
Before this update, the maintained demo ran the earlier 198-test public RC.21/Printing 1.0 cohort. The first TC22 records/photo preview was installed in place with SHA-256 `79E6CD0559419E059EC253DD0364C13BDDB3C27F566F0DA9A478FBB70D99A038`; the user then clarified that the existing app and existing server should be used, without a separate manual QA application.
The rich-record update added demo-only synthetic tables, ordinary metadata sections/associations, read-only permissions and explicit attachment grants. Native grouping and child queries use the versioned semantic API. Verification caught missing section tiers in metadata serialization and missing Origin headers in the composed-server test helper; both were corrected without weakening server guards. The final gate passed 189 tests and Android unit/lint/build checks. Isolated MC9400 native acceptance passed before adoption.
The maintained database and objects were backed up; the server restarted with the new application JARs and the same owner dependency cohort. Scanner-account reads verified all fixture counts and layout metadata. The existing TC22 app was updated with matching signer and installed bytes verified; its data and connection remained intact. Current details are in [session state](../SESSION-STATE.md).
+1 -1
View File
@@ -16,7 +16,7 @@ Date: 2026-11-02. Scope: `examples/echo/demo/**`. Source checkpoint, not a relea
`EchoDemo` extends `MobileScannersDemo` (database, `scanner`/`admin` accounts, native server) and composes Collaboration, Record Files, Printing, Document Generation and `EchoHost.install` from their public classes on one private H2 file (`WRITE_DELAY=0`). Composition runs under a scoped system context that is restored afterwards, then `QInstanceEnricher` enriches the instance, because the server only validates and the enricher registers Collaboration's record-feature permissions. `LocalFilesystemAttachmentStorage` implements the attachment owner's storage SPI over `PrivateObjectFiles` (hashed keys, temp-file-then-link, no overwrite) and adds no policy. `TrustedLocalEchoDemo` is the explicit developer launcher; the production `EchoDemo` default fails closed. See `README.md` for build, run and environment.
Authentication is the existing demo password/session module plus exact grants. `scanner`: `echo.use`, `printing_operator`, `document-generation.view|generate|print`, `record-files.view|manage`, `echoScan.attach` and the `printing_tenant` row key (replaced, never appended, on each validation); then `CollaborationSecurity.applyToSession`. `admin` additionally: `printing_admin`, `document-generation.configure|publish`. No Record Files release/configure/audit for anyone.
Authentication is the existing demo password/session module plus exact grants. `scanner`: `echo.use`, `printing_operator`, `document-generation.view|generate|print`, `record-files.view|manage`, `echoScan.attach` and the `printing_tenant` row key (replaced, never appended, on each validation); then `CollaborationSecurity.applyToSession`. `admin` additionally: `printing_admin`, `document-generation.configure|publish`, `record-files.release` (so approved captured photos release through the normal audited process). The scanner never gets release; no Record Files configure/review/audit for anyone.
## Test isolation
+6 -5
View File
@@ -15,7 +15,7 @@ There is **no default port**: pass one as the argument or set `ECHO_DEMO_PORT`.
## Status
The demo and Echo library tests pass with Checkstyle clean (current cohort: Echo 68, composed demo 35 tests; see [session state](../../../docs/SESSION-STATE.md)). The QQQ default is 4.1.0-RC.21 and Printing defaults to released 1.0.0; the other owner qbits remain qualified owner inputs until their public release. The composed acceptance test passes with a controlled synthetic renderer and a simulated Printing queue, so it is **not physical output**. TC22 physical acceptance is separately operator-reported as passing (November 4, group report). Not production-ready and not a public SDK release. The dated [`CLAUDE-HANDOFF.md`](CLAUDE-HANDOFF.md) is a historical checkpoint.
The demo and Echo library tests pass with Checkstyle clean (counts change per run; see [session state](../../../docs/SESSION-STATE.md) for the latest reported results). The QQQ default is 4.1.0-RC.21 and Printing defaults to released 1.0.0; the other owner qbits remain qualified owner inputs until their public release. The composed acceptance test passes with a controlled synthetic renderer and a simulated Printing queue, so it is **not physical output**. TC22 physical acceptance is separately operator-reported as passing (November 4, group report). Not production-ready and not a public SDK release. The dated [`CLAUDE-HANDOFF.md`](CLAUDE-HANDOFF.md) is a historical checkpoint.
## Trusted local development launcher
@@ -88,7 +88,7 @@ The legacy `ECHO_TEMPLATE_ID`, `ECHO_TEMPLATE_MEDIA_ID` and `ECHO_TEMPLATE_MEDIA
## Accounts and permissions
`scanner` receives exactly: `echo.use`, `printing_operator`, `document-generation.view|generate|print`, `record-files.view|manage` (manage is the owner's intake permission) and the `printing_tenant` row key. `admin` receives the same plus `printing_admin` (Printing's native pairing/approval/setup processes) and `document-generation.configure|publish` to prepare templates. The scanner never gets these, and nobody gets Record Files release/configure/audit. Scans still need an enrolled and approved device, so use the existing mobile enrollment flow.
`scanner` receives exactly: `echo.use`, `printing_operator`, `document-generation.view|generate|print`, `record-files.view|manage` (manage is the owner's intake permission), `.attach` on `echoScan` and the three mobile test-data tables (`testOrder`, `testOrderItem`, `testOrderEvent`) and the `printing_tenant` row key. `admin` receives the same plus `printing_admin` (Printing's native pairing/approval/setup processes) and `document-generation.configure|publish` to prepare templates. The scanner never gets these. Only `admin` also receives `record-files.release`, so approved captured photos can be released through the normal audited process; nobody gets Record Files configure/review/audit. Scans still need an enrolled and approved device, so use the existing mobile enrollment flow.
## Operator order
@@ -101,10 +101,11 @@ The legacy `ECHO_TEMPLATE_ID`, `ECHO_TEMPLATE_MEDIA_ID` and `ECHO_TEMPLATE_MEDIA
The host also installs the existing `qbit-mobile-scanners-record-files` integration, so an approved scanner can attach photos to one of its own retained Echo scans. Nothing new is served: Record Files' registered, versioned `record-files` upload API (`/qqq/v1/qbit/record-files/...`) and the independently versioned `mobile-record-files` widget vocabulary are advertised in `/qqq/v1/version`, behind the same native auth and device guards.
- **Process:** `echoScanPhotos` ("Echo scan photos"), listed in the Echo app. It requires `echo.use`, `echoScan.attach` and a verified approved device. The first step lists the actor's own scans (the table's record security lock decides; label is the barcode and type, never the record id). The server then re-reads the chosen scan through the collaboration owner's visibility and `attach` checks and builds the `RECORD_FILES_CAPTURE` widget from the record that check returned, on the fixed `echoScan` table. The client never supplies a table or record reference, and rendering the widget directly names no parent. Record Files authorizes the parent again on every upload operation.
- **Policy:** JPEG/PNG only, 20 MiB per file, up to four files, 256 KiB chunks, private staging under the demo's `objects` directory (separate staging namespace). Retained originals follow the existing Record Files quarantine setting.
- **Needs a real scan first:** the list is empty until the scanner has Echo scans. Nothing is seeded outside this module's tests.
- **Policy:** JPEG/PNG only, 20 MiB per file, up to four files, 256 KiB chunks, private staging under the demo's `objects` directory (separate staging namespace). The Echo host sets Record Files' `quarantineCapturedFiles=false` (leaving `quarantineNewFiles=true` and the generated-file setting unchanged), so a JPEG/PNG saved from this process by a device that `MobileCaptureIdentityProvider` currently verifies as approved is registered `AVAILABLE` and can be previewed immediately. The same upload API on any other host, and every upload, import or attachment-panel file here, stay `QUARANTINED` until released. This is a host trust decision, not a content scan or human review; no release event is written and review status is unchanged. **Requires a Record Files version that contains `RecordFilesQBitConfig.withQuarantineCapturedFiles(Boolean)`. The owner's change is an open, unmerged PR; this checkout's dependency pin has not been updated and builds only against the local owner input until a public version is pinned.** Files already uploaded stay `QUARANTINED` until released through the normal `record_files_file_record_files_release` process.
- **Test-data records:** the host also registers the test orders, items and events as Record Files parents, so a scanner can add photos from a record's Actions menu. The same approved-device policy and authorization re-check apply. Release of any quarantined file is admin-only (`record-files.release`).
- **Needs a real scan first (for the `echoScanPhotos` process):** the list is empty until the scanner has Echo scans. Nothing is seeded outside this module's tests.
- **Existing databases:** the Record Files upload tables are added by the same tolerated-reapply schema step; plan the migration for a database from another version.
- **Not claimed:** no real photo upload, native capture or device run has been verified here. That needs the Android host against this server.
- **Not claimed:** one synthetic gallery PNG uploaded from the signed MC9400 app was observed `AVAILABLE` with matching parent, type, size and SHA-256 (see [session state](../../../docs/SESSION-STATE.md)). No camera-hardware capture and no full preview of that photo are claimed.
## Existing databases
+7 -7
View File
@@ -16,15 +16,15 @@
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.1.0-RC.21</qqq.version>
<qqq.version>4.1.0-RC.26</qqq.version>
<mobile.version>0.1.0-SNAPSHOT</mobile.version>
<echo.version>0.1.0-SNAPSHOT</echo.version>
<generation.version>0.1.0-SNAPSHOT</generation.version>
<generation.version>1.1.0-RC.1</generation.version>
<renderer.version>${generation.version}</renderer.version>
<printing.version>1.0.0</printing.version>
<recordfiles.version>0.1.0-SNAPSHOT</recordfiles.version>
<printing.version>1.2.0-RC.1</printing.version>
<recordfiles.version>0.4.0-RC.1</recordfiles.version>
<mobilerecordfiles.version>0.1.0-SNAPSHOT</mobilerecordfiles.version>
<collaboration.version>0.1.0-record-files-SNAPSHOT</collaboration.version>
<collaboration.version>1.2.0-RC.1</collaboration.version>
</properties>
<dependencyManagement><dependencies>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId><version>${qqq.version}</version><type>pom</type><scope>import</scope></dependency>
@@ -40,14 +40,14 @@
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-mobile-scanners-record-files</artifactId><version>${mobilerecordfiles.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-collaboration</artifactId><version>${collaboration.version}</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.35</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.38</version></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.2.224</version></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter-api</artifactId><version>6.0.2</version><scope>test</scope></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter-engine</artifactId><version>6.0.2</version><scope>test</scope></dependency>
</dependencies>
<build><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version><configuration><showWarnings>true</showWarnings><failOnWarning>true</failOnWarning><proc>none</proc><compilerArgs><arg>-Xlint:deprecation</arg><arg>-Xlint:unchecked</arg><arg>-Werror</arg></compilerArgs></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.2</version><configuration><systemPropertyVariables><qqq.logger.logSessionId.disabled>true</qqq.logger.logSessionId.disabled></systemPropertyVariables></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.6</version><configuration><systemPropertyVariables><qqq.logger.logSessionId.disabled>true</qqq.logger.logSessionId.disabled></systemPropertyVariables></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version></plugin>
<plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version><configuration><mainClass>com.kingsrook.examples.echo.demo.EchoDemo</mainClass></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-checkstyle-plugin</artifactId><version>3.6.0</version><dependencies><dependency><groupId>com.puppycrawl.tools</groupId><artifactId>checkstyle</artifactId><version>14.3.0</version></dependency></dependencies><configuration><configLocation>${project.basedir}/../../../checkstyle/checkstyle.xml</configLocation><includeTestSourceDirectory>true</includeTestSourceDirectory><failOnViolation>true</failOnViolation><violationSeverity>warning</violationSeverity></configuration><executions><execution><phase>validate</phase><goals><goal>check</goal></goals></execution></executions></plugin>
@@ -36,6 +36,7 @@ import com.kingsrook.qbits.printing.PrintingService;
import com.kingsrook.qbits.recordfiles.RecordFilesQBitConfig;
import com.kingsrook.qbits.recordfiles.RecordFilesQBitProducer;
import com.kingsrook.qbits.recordfiles.RecordFilesSchema;
import com.kingsrook.qbits.recordfiles.download.DownloadConfiguration;
import com.kingsrook.qbits.recordfiles.lifecycle.RecordFilesAttachmentLifecycle;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -220,8 +221,16 @@ public class EchoDemo extends MobileScannersDemo implements AutoCloseable
// Documents are the operator's published, Echo-compatible templates, discovered per run; the legacy
// ECHO_TEMPLATE_* host pin no longer selects one.
EchoHost.install(instance, new EchoHost.Deployment(BACKEND, BACKEND, BACKEND, dataSource, printing, renderer, maximumOutputBytes));
RecordFilesQBitConfig recordFiles = new RecordFilesQBitConfig().withBackendName(BACKEND).withParentTables(List.of(EchoQBitProducer.SCAN_TABLE))
MobileTestData.register(instance);
RecordFilesQBitConfig recordFiles = new RecordFilesQBitConfig().withBackendName(BACKEND)
.withParentTables(List.of(EchoQBitProducer.SCAN_TABLE, MobileTestData.ORDERS, MobileTestData.ITEMS, MobileTestData.EVENTS))
.withQuarantineGeneratedFiles(settings.releaseGeneratedFiles() ? Boolean.FALSE : null)
// Narrow, host-only opt-in: only CAPTURE intake with the verified approved-device identity from
// MobileCaptureIdentityProvider (JPEG/PNG, bounded; see EchoPhotoCapture.uploadConfiguration) is AVAILABLE
// on registration. quarantineNewFiles stays true for uploads, imports and attachment-panel files.
// Requires the Record Files version that contains withQuarantineCapturedFiles.
.withQuarantineCapturedFiles(Boolean.FALSE)
.withDownloadConfiguration(new DownloadConfiguration("http://127.0.0.1:" + settings.port() + "/qqq/v1", 20 * 1024 * 1024, 2))
.withUploadConfiguration(EchoPhotoCapture.uploadConfiguration());
new RecordFilesQBitProducer().withQBitConfig(recordFiles).produce(instance);
// Configuring uploads makes the producer register the versioned record-files extension; the mobile widget
@@ -268,6 +277,7 @@ public class EchoDemo extends MobileScannersDemo implements AutoCloseable
collaborationTables(statement, instance);
script(statement, UploadJournalTable.h2Script());
script(statement, RecordFilesSchema.h2Script(config(instance, RecordFilesQBitConfig.class)));
MobileTestData.initialize(connection, instance);
}
}
@@ -22,7 +22,10 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** The existing demo password/session validation plus exactly the grants the
** owners' source requires. The validated account selects the role, never a
** request field. Printing and Record Files administration is granted to no one.
** request field. Only the fixed admin account receives Printing administration,
** template configure/publish and Record Files release (so approved scanner photos
** can be released through the normal audited process); the scanner receives none
** of them, and no one receives Record Files configure or review rights.
*******************************************************************************/
public class EchoDemoAuthentication extends DemoAuthenticationModule
{
@@ -35,14 +38,15 @@ public class EchoDemoAuthentication extends DemoAuthenticationModule
*/
static final List<String> SCANNER = List.of(EchoQBitProducer.PERMISSION, "printing_operator", "document-generation.view",
"document-generation.generate", "document-generation.print", "record-files.view", "record-files.manage",
EchoQBitProducer.SCAN_TABLE + ".attach");
EchoQBitProducer.SCAN_TABLE + ".attach", MobileTestData.ORDERS + ".attach", MobileTestData.ITEMS + ".attach", MobileTestData.EVENTS + ".attach");
/**
** Admin also gets Printing's own administrator permission, which its native
** device pairing/approval/setup processes require, and what is needed to
** prepare and publish templates. The scanner never receives these.
** prepare and publish templates, and Record Files release for approved
** captured photos. The scanner never receives these.
*/
static final List<String> ADMIN_ADDITIONS = List.of("printing_admin", "document-generation.configure", "document-generation.publish");
static final List<String> ADMIN_ADDITIONS = List.of("printing_admin", "document-generation.configure", "document-generation.publish", "record-files.release");
private static volatile String tenant;
@@ -0,0 +1,251 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.examples.echo.EchoSourceReadPermissions;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Association;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
/*******************************************************************************
** Synthetic, read-only browsing fixtures for the local demo. Ordinary metadata
** owns sections and relationships; no custom mobile endpoints are involved.
*******************************************************************************/
final class MobileTestData
{
static final String ORDERS = "testOrder";
static final String ITEMS = "testOrderItem";
static final String EVENTS = "testOrderEvent";
static final List<String> TABLES = List.of(ORDERS, ITEMS, EVENTS);
static final int ORDER_COUNT = 1000;
/***************************************************************************
** Keep all fixtures in the demo, visible to the ordinary Echo demo reader.
***************************************************************************/
static void register(QInstance instance)
{
QTableMetaData orders = table(ORDERS, "Test Orders");
section(orders, "overview", "Order overview", "id:INTEGER", "reference:STRING", "status:STRING", "priority:STRING", "orderDate:DATE", "requestedDate:DATE", "expedited:BOOLEAN");
section(orders, "customer", "Customer", "customerCode:STRING", "customerName:STRING", "contactName:STRING", "email:STRING", "phone:STRING", "purchaseOrder:STRING");
section(orders, "shipping", "Shipping", "addressLine1:STRING", "addressLine2:STRING", "city:STRING", "region:STRING", "postalCode:STRING", "country:STRING", "carrier:STRING", "trackingNumber:STRING");
section(orders, "amounts", "Amounts", "currency:STRING", "subtotal:DECIMAL", "tax:DECIMAL", "shippingCost:DECIMAL", "total:DECIMAL", "paid:BOOLEAN");
section(orders, "warehouse", "Warehouse", "warehouse:STRING", "bin:STRING", "itemCount:INTEGER", "weightKg:DECIMAL", "fragile:BOOLEAN", "lotCode:STRING");
section(orders, "notes", "Notes and audit", "instructions:STRING", "notes:STRING", "createdAt:DATE_TIME", "updatedAt:DATE_TIME", "externalReference:STRING", "optionalNote:STRING");
orders.withRecordLabelFormatAndFields("%s — %s", "reference", "customerName");
orders.withSearchFields(List.of("reference", "customerName"));
for(String name : List.of("subtotal", "tax", "shippingCost", "total"))
{
orders.getField(name).withDisplayFormat("$%,.2f");
}
orders.getField("weightKg").withDisplayFormat("%,.2f kg");
QTableMetaData items = table(ITEMS, "Order Items");
section(items, "item", "Item", "id:INTEGER", "reference:STRING", "sku:STRING", "quantity:INTEGER", "description:STRING", "orderId:INTEGER");
section(items, "pricing", "Pricing and handling", "unitPrice:DECIMAL", "lineTotal:DECIMAL", "bin:STRING", "lotCode:STRING", "fragile:BOOLEAN", "notes:STRING");
items.withRecordLabelFormatAndFields("%s · %s × %s", "reference", "quantity", "sku");
items.withSearchFields(List.of("reference", "sku", "description"));
items.getField("unitPrice").withDisplayFormat("$%,.2f");
items.getField("lineTotal").withDisplayFormat("$%,.2f");
QTableMetaData events = table(EVENTS, "Order History");
section(events, "event", "History entry", "id:INTEGER", "status:STRING", "createdAt:DATE_TIME", "notes:STRING", "reference:STRING", "orderId:INTEGER");
events.withRecordLabelFormatAndFields("%s · %s", "reference", "status");
events.withSearchFields(List.of("reference", "status", "notes"));
instance.addTable(orders);
instance.addTable(items);
instance.addTable(events);
associate(instance, orders, items, "Items");
associate(instance, orders, events, "History");
instance.addApp(new QAppMetaData().withName("mobileTestData").withLabel("Test Data").withPermissionRules(readRules())
.withSection(new QAppSection().withName("browse").withLabel("Synthetic test records").withTables(TABLES)));
}
/***************************************************************************
** Native read permission enforcement also protects related-record queries.
***************************************************************************/
private static QPermissionRules readRules()
{
return new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withCustomPermissionChecker(new QCodeReference(EchoSourceReadPermissions.class));
}
/***************************************************************************
** Physical names deliberately match metadata names for the demo's H2 schema.
***************************************************************************/
private static QTableMetaData table(String name, String label)
{
return new QTableMetaData().withName(name).withLabel(label).withBackendName(EchoDemo.BACKEND)
.withBackendDetails(new RDBMSTableBackendDetails().withTableName(name)).withPrimaryKeyField("id").withPermissionRules(readRules());
}
/***************************************************************************
** The ordinary metadata section controls order and grouping on every client.
***************************************************************************/
private static void section(QTableMetaData table, String name, String label, String... definitions)
{
List<String> names = new ArrayList<>();
for(String definition : definitions)
{
String[] parts = definition.split(":");
names.add(parts[0]);
table.withField(new QFieldMetaData(parts[0], QFieldType.valueOf(parts[1])).withIsEditable(false));
}
table.withSection(new QFieldSection().withName(name).withLabel(label).withTier(Tier.T1).withFieldNames(names));
}
/***************************************************************************
** Associations use declared joins, never client-inferred foreign-key names.
***************************************************************************/
private static void associate(QInstance instance, QTableMetaData parent, QTableMetaData child, String label)
{
String join = parent.getName() + "_" + child.getName();
instance.addJoin(new QJoinMetaData().withName(join).withLeftTable(parent.getName()).withRightTable(child.getName())
.withType(JoinType.ONE_TO_MANY).withJoinOn(new JoinOn("id", "orderId")));
parent.withAssociation(new Association().withName(label).withAssociatedTableName(child.getName()).withJoinName(join));
}
/***************************************************************************
** Add only missing fixture rows. Reapplying never resets photos or edits.
** This metadata-derived schema is intentionally H2-only and demo-only.
***************************************************************************/
static void initialize(Connection connection, QInstance instance) throws SQLException
{
for(String name : TABLES)
{
QTableMetaData table = instance.getTable(name);
List<QFieldMetaData> fields = new ArrayList<>(table.getFields().values());
List<String> columns = new ArrayList<>();
for(QFieldMetaData field : fields)
{
String type = switch(field.getType())
{
case INTEGER -> "INTEGER";
case BOOLEAN -> "BOOLEAN";
case DATE -> "DATE";
case DATE_TIME -> "TIMESTAMP";
case DECIMAL -> "DECIMAL(16,2)";
default -> "VARCHAR(4000)";
};
columns.add(field.getName() + " " + type + (field.getName().equals("id") ? " PRIMARY KEY" : ""));
}
try(Statement statement = connection.createStatement())
{
statement.execute("CREATE TABLE IF NOT EXISTS " + name + " (" + String.join(",", columns) + ")");
if(!name.equals(ORDERS))
{
statement.execute("CREATE INDEX IF NOT EXISTS idx_" + name + "_order ON " + name + " (orderId)");
}
}
String names = String.join(",", fields.stream().map(QFieldMetaData::getName).toList());
String placeholders = String.join(",", java.util.Collections.nCopies(fields.size(), "?"));
String sql = "INSERT INTO " + name + " (" + names + ") SELECT " + placeholders + " WHERE NOT EXISTS (SELECT 1 FROM " + name + " WHERE id = ?)";
int perOrder = name.equals(ORDERS) ? 1 : name.equals(ITEMS) ? 12 : 3;
boolean autoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
try(PreparedStatement insert = connection.prepareStatement(sql))
{
for(int id = 1; id <= ORDER_COUNT * perOrder; id++)
{
int order = (id - 1) / perOrder + 1;
for(int index = 0; index < fields.size(); index++)
{
insert.setObject(index + 1, value(name, fields.get(index), id, order));
}
insert.setInt(fields.size() + 1, id);
insert.addBatch();
if(id % 250 == 0)
{
insert.executeBatch();
}
}
insert.executeBatch();
connection.commit();
}
catch(SQLException failure)
{
connection.rollback();
throw failure;
}
finally
{
connection.setAutoCommit(autoCommit);
}
}
}
/***************************************************************************
** Deterministic, obviously synthetic values exercise long, empty, Unicode,
** leading-zero, numeric, boolean, date and date-time display paths.
***************************************************************************/
private static Object value(String table, QFieldMetaData field, int id, int order)
{
return switch(field.getName())
{
case "id" -> id;
case "orderId" -> order;
case "reference" -> (table.equals(ORDERS) ? "TEST-" : table.equals(ITEMS) ? "ITEM-" : "EVENT-") + String.format(java.util.Locale.ROOT, "%06d", id);
case "customerName" -> "Synthetic Customer " + order + (order % 3 == 0 ? " — Café & Supply" : "");
case "customerCode", "sku", "lotCode", "externalReference", "postalCode" -> String.format(java.util.Locale.ROOT, "%08d", id);
case "email" -> "customer" + order + "@example.invalid";
case "phone" -> "Not a real telephone number";
case "status" -> List.of("New", "Picking", "Packed", "Shipped", "On hold").get(id % 5);
case "priority" -> id % 4 == 0 ? "High" : "Normal";
case "currency" -> "USD";
case "country" -> "US";
case "description" -> "Synthetic product " + id + " — reusable storage container with a deliberately long description for wrapping";
case "notes", "instructions" -> "Synthetic test data only.\nOrder " + order + ": inspect all sides and attach a photo.\nAccents: café, naïve; symbols: & / +.";
case "optionalNote", "addressLine2" -> id % 2 == 0 ? null : "Optional example " + id;
case "itemCount" -> 12;
default -> switch(field.getType())
{
case BOOLEAN -> id % 2 == 0;
case INTEGER -> id % 9 + 1;
case DECIMAL -> BigDecimal.valueOf(1000L + id, 2);
case DATE -> LocalDate.of(2026, 1, 1).plusDays(id % 300);
case DATE_TIME -> LocalDateTime.of(2026, 1, 1, 10, 30).plusHours(id);
default -> "Test " + field.getName() + " " + order;
};
};
}
}
@@ -30,12 +30,17 @@ import com.fasterxml.jackson.databind.ObjectMapper;
** file name, so no key can address another path, alias another object or climb
** out of the root. Objects are written to a temporary file in their own folder
** and linked into place, so a reader never sees a partial object and an existing
** object is never replaced.
** object is never replaced. Publication is serialized within one JVM only; the
** demo does not support several processes sharing one private root.
*******************************************************************************/
final class PrivateObjectFiles
{
private static final ObjectMapper JSON = new ObjectMapper();
private static final int MAXIMUM_KEY = 2048;
// Guards final existence check, metadata publication and linking (and delete) across
// all instances in this JVM. The demo is a single process; this is not a cross-process lock.
private static final Object PUBLISH_LOCK = new Object();
private final Path root;
@@ -68,7 +73,7 @@ final class PrivateObjectFiles
***************************************************************************/
Path file(String bucket, String key)
{
if(bucket == null || !bucket.matches("[A-Za-z0-9._-]{1,64}") || key == null || key.isEmpty() || key.length() > MAXIMUM_KEY)
if(bucket == null || !bucket.matches("[A-Za-z0-9._-]{1,64}") || bucket.equals(".") || bucket.equals("..") || key == null || key.isEmpty() || key.length() > MAXIMUM_KEY)
{
throw new IllegalArgumentException("Object bucket or key is outside policy");
}
@@ -117,8 +122,7 @@ final class PrivateObjectFiles
}
}
JSON.writeValue(sidecar.toFile(), metadata == null ? Map.of() : metadata);
Files.move(sidecar, metadataFile(target), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
Files.createLink(target, temporary);
publish(target, temporary, sidecar);
}
finally
{
@@ -129,6 +133,41 @@ final class PrivateObjectFiles
/***************************************************************************
** Publish the metadata and then the object, as one step with respect to
** other writers and deleters in this JVM. The lock is shared by every
** instance, so two instances over the same root cannot interleave. It covers
** only the final existence check and two cheap file operations, never the
** copy of the content. A winner's metadata is therefore never replaced by a
** loser's, and the metadata exists whenever the object does. This does not
** coordinate separate processes.
***************************************************************************/
private static void publish(Path target, Path temporary, Path sidecar) throws IOException
{
Path metadataFile = metadataFile(target);
synchronized(PUBLISH_LOCK)
{
if(Files.exists(target, java.nio.file.LinkOption.NOFOLLOW_LINKS))
{
throw new FileAlreadyExistsException("Object already exists");
}
// no object exists here, so any metadata is a stale leftover and may be replaced
Files.move(sidecar, metadataFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
try
{
Files.createLink(target, temporary);
}
catch(IOException | RuntimeException failure)
{
Files.deleteIfExists(metadataFile);
throw failure;
}
}
}
/***************************************************************************
** Read an object's identity; absent means empty. Links are refused.
***************************************************************************/
@@ -162,8 +201,11 @@ final class PrivateObjectFiles
void delete(String bucket, String key) throws IOException
{
Path target = file(bucket, key);
Files.deleteIfExists(target);
Files.deleteIfExists(metadataFile(target));
synchronized(PUBLISH_LOCK)
{
Files.deleteIfExists(target);
Files.deleteIfExists(metadataFile(target));
}
}
@@ -16,14 +16,21 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
@@ -34,6 +41,7 @@ import com.kingsrook.examples.echo.EchoTemplatePublisher;
import com.kingsrook.qbits.documentgeneration.DocumentDefinitionService;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationQBitConfig;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.recordfiles.RecordFilesTables;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -71,11 +79,17 @@ class EchoDemoAcceptanceTest
private static final int RENDERER_CAP = 262144;
private static final String CODE_VALUE = "001234567890";
private static final String QR_VALUE = "QR-VALUE-0099";
/** EchoPermissions.checkPermissionsThrowing: the refusal for any actor without a currently approved scanner. */
private static final String ECHO_DEVICE_REFUSAL = "Echo requires an approved scanner and current Echo access.";
private static final Set<Integer> PROTECTED = Set.of(57832, 51631, 18081, 18086, 18443, 19443, 18447, 18448, 18547, 18549, 18593);
@TempDir
Path root;
/** A second, separate temporary directory: the only place the snapshot is written to or read from. */
@TempDir
Path snapshotDirectory;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build();
private String origin;
private String deviceId;
@@ -823,6 +837,442 @@ class EchoDemoAcceptanceTest
/***************************************************************************
** Everything a restore must give back exactly, read straight from the private
** database and object directory rather than through the application.
** retainedFiles are the Record Files owner's own rows (RecordFilesTables.FILE):
** id, source table, source record, attachment, size.
***************************************************************************/
private record Capture(List<List<String>> echoScans, List<List<String>> mobileScans, List<List<String>> retainedFiles, Map<String, String> objects)
{
}
/***************************************************************************
** The status and error text of a refused request; see refusal().
***************************************************************************/
private record Refusal(int status, String message)
{
}
/***************************************************************************
** Bounded backup/restore qualification of the composed host, on private
** temporary paths only. A synthetic approved device scans an exact
** leading-zero value through real HTTP and the real Record Files service
** retains the generated original (the owner's retained-file row names the scan
** as its source table and record). With every server stopped and the H2 files
** closed, the database and object directory are copied together. A second scan
** and file, a revocation and an ended session are then made, and only the two
** temporary directories are restored. The restored state must equal the
** snapshot and the later changes must be gone. The reverted approval is
** trusted until the existing Revoke and End Session processes re-audit it,
** after which a run opened before the revocation, and a new sign-in, are
** refused by the host's own authorization. The renderer is controlled and the
** printer is a database fixture: no job is printed, and no normal demo
** database, service or external provider is touched. Retained files are read
** from the owner's table, not through its service, and this does not qualify
** production storage.
***************************************************************************/
@Test
void restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited() throws Exception
{
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
String firstZpl = "^XA^FO48,48^FDcode128|" + CODE_VALUE + "^FS^XZ";
String secondZpl = "^XA^FO48,48^FDqr|" + QR_VALUE + "^FS^XZ";
String printerRowId;
Integer template;
String staleCookie;
String sessionId;
String firstEventId;
Capture atSnapshot;
// 1. Enroll, approve and scan through the real host; the original is retained by the real service.
int firstPort = freePort();
EchoDemoSettings first = new EchoDemoSettings(root, firstPort, TENANT, TOKEN, true, null, null);
origin = "http://127.0.0.1:" + firstPort;
try(EchoDemo demo = new EchoDemo(first, new ControlledRenderer(RENDERER_CAP), RENDERER_CAP))
{
demo.initializeDatabase(scannerPassword, adminPassword);
printerRowId = enrollPrinter(demo, first);
demo.start(firstPort);
String admin = login("admin", adminPassword);
staleCookie = login("scanner", scannerPassword);
template = publishTemplate(demo, printerRowId, admin, "Echo recovery label", MEDIA, REVISION, 101600, 152400, 300);
enrollAndApproveDevice(staleCookie, admin);
HttpResponse<String> heartbeat = request("POST", "/qqq/v1/qbit/mobile-scanners/sessions/heartbeat", staleCookie, "{\"foreground\":true,\"screenAvailable\":false}", true);
assertEquals(200, heartbeat.statusCode(), "The approved device must open a session.");
sessionId = new JSONObject(heartbeat.body()).getString("sessionId");
JSONObject firstEvent = event("LABEL-TYPE-CODE128", CODE_VALUE);
firstEventId = firstEvent.getString("eventId");
assertEquals("QUEUED", scanInNewRun(staleCookie, printerRowId, template, firstEvent).getString("printStatus"));
atSnapshot = capture(first);
}
finally
{
stopDatabase(first);
}
assertEquals(1, atSnapshot.echoScans().size());
List<String> scan = atSnapshot.echoScans().getFirst();
assertEquals(firstEventId, scan.get(1));
assertEquals(CODE_VALUE, scan.get(3), "The exact leading-zero text must be stored.");
assertEquals("LABEL-TYPE-CODE128", scan.get(4));
assertEquals("code128", scan.get(5));
assertEquals(1, atSnapshot.mobileScans().size());
assertEquals(scan.get(2), atSnapshot.mobileScans().getFirst().get(0), "The Echo scan must name its mobile scan.");
assertEquals(firstEventId, atSnapshot.mobileScans().getFirst().get(1));
assertEquals(CODE_VALUE, atSnapshot.mobileScans().getFirst().get(2));
assertEquals("scanner", atSnapshot.mobileScans().getFirst().get(3), "Attribution is derived by the server.");
assertEquals(deviceId, atSnapshot.mobileScans().getFirst().get(4));
assertEquals(Set.of(firstZpl), retainedOriginals(), "Record Files retained exactly the generated original.");
// the owner's retained-file row: source table and record are this exact scan, and the size is that of the original
assertEquals(1, atSnapshot.retainedFiles().size(), "Exactly one retained-file row exists for the one scan.");
List<String> file = atSnapshot.retainedFiles().getFirst();
assertEquals(EchoQBitProducer.SCAN_TABLE, file.get(1), "The retained file's source table is the Echo scan table.");
assertEquals(scan.get(0), file.get(2), "The retained file's source record is this exact scan.");
assertNotNull(file.get(3), "The retained file references its standard attachment.");
assertEquals(String.valueOf(firstZpl.length()), file.get(4), "The retained size is that of the generated original.");
// 2. Snapshot the closed database and the object directory together, into the other temporary directory.
takeSnapshot();
// 3. Change state after the snapshot, then stop.
int secondPort = freePort();
EchoDemoSettings second = new EchoDemoSettings(root, secondPort, TENANT, TOKEN, true, null, null);
origin = "http://127.0.0.1:" + secondPort;
try(EchoDemo demo = new EchoDemo(second, new ControlledRenderer(RENDERER_CAP), RENDERER_CAP))
{
demo.start(secondPort);
String admin = login("admin", adminPassword);
scanInNewRun(staleCookie, printerRowId, template, event("QRCODE", QR_VALUE));
runSelected("mobileDeviceRevoke", "mobileDevice", deviceId, new JSONObject(), admin);
runSelected("mobileSessionEnd", "mobileSession", sessionId, new JSONObject().put("reason", "post-snapshot change"), admin);
awaitSessionEnded(second, sessionId, staleCookie);
assertEquals(List.of(List.of("REVOKED")), sql(second, "SELECT status FROM mobileDevice WHERE id = '" + deviceId + "'", 1));
assertEquals(2, sql(second, "SELECT id FROM echo_scan", 1).size());
assertEquals(2, sql(second, "SELECT id FROM record_files_file", 1).size());
assertEquals(Set.of(firstZpl, secondZpl), retainedOriginals());
}
finally
{
stopDatabase(second);
}
// 4. Restore only the two temporary directories, then read them with no application running.
restoreSnapshot();
int thirdPort = freePort();
EchoDemoSettings third = new EchoDemoSettings(root, thirdPort, TENANT, TOKEN, true, null, null);
try
{
assertEquals(atSnapshot, capture(third), "The restored scan rows, retained-file row and object bytes must equal the snapshot.");
assertEquals(List.of(List.of("APPROVED")), sql(third, "SELECT status FROM mobileDevice WHERE id = '" + deviceId + "'", 1),
"The restore reverts the later revocation, so the device is approved again.");
assertEquals(List.of(List.of("ACTIVE")), sql(third, "SELECT status FROM mobileSession WHERE id = '" + sessionId + "'", 1),
"The restore reverts the later session end.");
assertEquals(1, sql(third, "SELECT id FROM demoSession WHERE id = '" + staleCookie + "'", 1).size(), "The earlier sign-in session is restored too.");
assertEquals(1, sql(third, "SELECT id FROM printing_job", 1).size());
assertEquals(Set.of(firstZpl), retainedOriginals(), "No object from after the snapshot may remain.");
}
finally
{
stopDatabase(third);
}
// 5. Restart on a new ephemeral port: the restored scan is served, stale authority works until re-audit, and then is refused.
ControlledRenderer renderer = new ControlledRenderer(RENDERER_CAP);
origin = "http://127.0.0.1:" + thirdPort;
try(EchoDemo demo = new EchoDemo(third, renderer, RENDERER_CAP))
{
demo.start(thirdPort);
JSONArray scans = query(EchoQBitProducer.SCAN_TABLE, staleCookie);
assertEquals(1, scans.length());
assertEquals(CODE_VALUE, scans.getJSONObject(0).getJSONObject("values").getString("barcode"));
HttpResponse<String> current = request("GET", "/qqq/v1/qbit/mobile-scanners/devices/current", staleCookie, null, true);
assertEquals(200, current.statusCode());
assertEquals("APPROVED", new JSONObject(current.body()).getString("status"), "Reopening without a re-audit would trust the reverted approval.");
// a real run, opened while the restored approval is still trusted and parked at its scan step
String run = openRun(staleCookie, printerRowId, template);
JSONObject lateScan = event("QRCODE", "STALE-SUBMIT-0001");
String admin = login("admin", adminPassword);
runSelected("mobileDeviceRevoke", "mobileDevice", deviceId, new JSONObject(), admin);
assertEquals(List.of(List.of("REVOKED")), sql(third, "SELECT status FROM mobileDevice WHERE id = '" + deviceId + "'", 1));
// the sign-in session is still valid, so this refusal is the host's device authorization, not authentication
assertEquals(200, request("GET", "/qqq/v1/qbit/mobile-scanners/devices/current", staleCookie, null, true).statusCode());
assertAuthorizationRefused(refusal(form("/qqq/v1/processes/echoPrint/" + run + "/step/scan", staleCookie, lateScan, true), staleCookie));
assertAuthorizationRefused(refusal(form("/qqq/v1/processes/echoPrint/init", staleCookie, new JSONObject(), true), staleCookie));
runSelected("mobileSessionEnd", "mobileSession", sessionId, new JSONObject().put("reason", "restore re-audit"), admin);
awaitSessionEnded(third, sessionId, staleCookie);
assertEquals(401, request("GET", "/qqq/v1/metaData", staleCookie, null, true).statusCode(), "The ended session must no longer authenticate.");
assertEquals(401, form("/qqq/v1/processes/echoPrint/" + run + "/step/scan", staleCookie, lateScan, true).statusCode(),
"The same run cannot be continued with the ended session.");
String freshCookie = login("scanner", scannerPassword);
assertAuthorizationRefused(refusal(form("/qqq/v1/processes/echoPrint/init", freshCookie, new JSONObject(), true), freshCookie));
assertEquals(0, renderer.renders.size(), "Nothing may be generated after the restore.");
assertEquals(1, sql(third, "SELECT id FROM echo_scan", 1).size());
assertEquals(1, sql(third, "SELECT id FROM mobileScan", 1).size());
assertEquals(1, sql(third, "SELECT id FROM printing_job", 1).size());
assertEquals(atSnapshot.objects(), objectHashes(), "Refused submissions must not change retained files.");
}
finally
{
stopDatabase(third);
}
}
/***************************************************************************
** A new Echo run, then one scan in it, as the approved scanner device.
***************************************************************************/
private JSONObject scanInNewRun(String scanner, String printerRowId, Integer template, JSONObject event) throws Exception
{
String process = openRun(scanner, printerRowId, template);
return echo(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, event, true), scanner).getJSONObject("values");
}
/***************************************************************************
** A new Echo run chosen through setup, ready for its scan step.
***************************************************************************/
private String openRun(String scanner, String printerRowId, Integer template) throws Exception
{
String process = echo(form("/qqq/v1/processes/echoPrint/init", scanner, new JSONObject(), true), scanner).getString("processUUID");
assertEquals("scan", echo(form("/qqq/v1/processes/echoPrint/" + process + "/step/setup", scanner,
new JSONObject().put("printerId", printerRowId).put("mediaId", MEDIA).put("templateId", template), true), scanner).getString("nextStep"));
return process;
}
/***************************************************************************
** Run an existing administrator process on one selected record, from its
** Actions menu, exactly as the browser and native client do.
***************************************************************************/
private void runSelected(String process, String table, String recordId, JSONObject input, String admin) throws Exception
{
JSONObject selection = new JSONObject().put("recordsParam", "recordIds").put("recordIds", recordId).put("tableName", table);
JSONObject launched = complete(form("/qqq/v1/processes/" + process + "/init", admin, selection, false), process, admin, false);
assertEquals("input", launched.getString("nextStep"));
complete(form("/qqq/v1/processes/" + process + "/" + launched.getString("processUUID") + "/step/input", admin, input, false), process, admin, false);
}
/***************************************************************************
** The End Session process commits the tombstone, then removes the stored
** authentication session asynchronously; wait for both, within a bound.
***************************************************************************/
private static void awaitSessionEnded(EchoDemoSettings settings, String sessionId, String cookie) throws Exception
{
long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos();
List<List<String>> state;
List<List<String>> remaining;
boolean done;
do
{
state = sql(settings, "SELECT status, authCleanupStatus FROM mobileSession WHERE id = '" + sessionId + "'", 2);
remaining = sql(settings, "SELECT id FROM demoSession WHERE id = '" + cookie + "'", 1);
done = List.of(List.of("ENDED", "REQUESTED")).equals(state) && remaining.isEmpty();
if(!done)
{
Thread.sleep(50);
}
}
while(!done && System.nanoTime() < deadline);
assertEquals(List.of(List.of("ENDED", "REQUESTED")), state, "The session must be ended with cleanup requested.");
assertTrue(remaining.isEmpty(), "The stored authentication session must be removed.");
}
/***************************************************************************
** Follow an Echo process request to its outcome: the immediate HTTP status,
** or, when the step went asynchronous, the final status body. Returns the
** HTTP status and the "error" text, with no assertion about either.
***************************************************************************/
private Refusal refusal(HttpResponse<String> response, String cookie) throws Exception
{
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
JSONObject body = new JSONObject(response.body());
while(response.statusCode() == 200 && ("JOB_STARTED".equals(body.optString("type")) || "RUNNING".equals(body.optString("type"))))
{
assertTrue(System.nanoTime() < deadline, "Native process timed out.");
Thread.sleep(25);
response = request("GET", "/qqq/v1/processes/" + EchoQBitProducer.PROCESS + "/" + body.getString("processUUID") + "/status/" + body.getString("jobUUID"), cookie, null, true);
body = new JSONObject(response.body());
}
return new Refusal(response.statusCode(), body.optString("error"));
}
/***************************************************************************
** The exact refusal of a revoked device under a still-valid sign-in: the
** Echo process permission checker's own message (EchoPermissions), as a 403
** or as the process error, and never a success or any other failure.
***************************************************************************/
private static void assertAuthorizationRefused(Refusal refusal)
{
assertTrue(refusal.status() == 403 || refusal.status() == 200, "Unexpected status " + refusal.status() + ": " + refusal.message());
assertEquals(ECHO_DEVICE_REFUSAL, refusal.message(), "The refusal must be Echo's own device authorization message.");
}
/***************************************************************************
** Read the durable state straight from the closed-to-the-app database and
** the object directory.
***************************************************************************/
private Capture capture(EchoDemoSettings settings) throws Exception
{
List<List<String>> echoScans = sql(settings, "SELECT id, eventId, mobileScanId, barcode, symbology, rendererSymbology, printJobId, state FROM echo_scan ORDER BY id", 8);
List<List<String>> mobileScans = sql(settings, "SELECT id, eventId, barcode, userId, deviceId FROM mobileScan ORDER BY id", 5);
List<List<String>> retainedFiles = sql(settings, "SELECT id, table_name, record_id, attachment_id, size_bytes FROM " + RecordFilesTables.FILE + " ORDER BY id", 5);
return new Capture(echoScans, mobileScans, retainedFiles, objectHashes());
}
/***************************************************************************
** The SHA-256 of every file under the object directory, by relative path,
** including each object's metadata file.
***************************************************************************/
private Map<String, String> objectHashes() throws Exception
{
Path objects = root.resolve("objects");
Map<String, String> hashes = new TreeMap<>();
try(Stream<Path> files = Files.walk(objects))
{
for(Path file : files.filter(Files::isRegularFile).toList())
{
hashes.put(objects.relativize(file).toString().replace('\\', '/'), HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file))));
}
}
return hashes;
}
/***************************************************************************
** The content of every retained original (an object that is not metadata).
***************************************************************************/
private Set<String> retainedOriginals() throws Exception
{
Set<String> retained = new HashSet<>();
try(Stream<Path> files = Files.walk(root.resolve("objects")))
{
for(Path file : files.filter(Files::isRegularFile).filter(path -> !path.getFileName().toString().endsWith(".json")).toList())
{
retained.add(Files.readString(file, StandardCharsets.US_ASCII));
}
}
return retained;
}
/***************************************************************************
** Close the private file database so its files are complete and unlocked.
** The server is already stopped when this runs.
***************************************************************************/
private static void stopDatabase(EchoDemoSettings settings) throws Exception
{
QContext.clear();
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); Statement statement = connection.createStatement())
{
statement.execute("SHUTDOWN");
}
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
}
/***************************************************************************
** Copy the closed database and object directory, together, to the snapshot
** directory.
***************************************************************************/
private void takeSnapshot() throws Exception
{
for(String name : List.of("db", "objects"))
{
copyTree(root.resolve(name), snapshotDirectory.resolve(name));
}
try(Stream<Path> files = Files.list(snapshotDirectory.resolve("db")))
{
List<String> names = files.map(path -> path.getFileName().toString()).toList();
assertTrue(names.stream().anyMatch(name -> name.endsWith(".mv.db")), "The snapshot must contain the database file: " + names);
assertFalse(names.stream().anyMatch(name -> name.endsWith(".lock.db")), "The database must be closed before the snapshot.");
}
}
/***************************************************************************
** Replace only this test's database and object directories from the snapshot.
***************************************************************************/
private void restoreSnapshot() throws Exception
{
Path liveRoot = root.toAbsolutePath().normalize();
Path snapshotRoot = snapshotDirectory.toAbsolutePath().normalize();
assertFalse(snapshotRoot.startsWith(liveRoot) || liveRoot.startsWith(snapshotRoot), "The two temporary directories must be separate.");
for(String name : List.of("db", "objects"))
{
Path live = liveRoot.resolve(name).normalize();
assertTrue(live.startsWith(liveRoot), "Only paths inside this test's temporary directory may be replaced.");
assertTrue(Files.isDirectory(snapshotRoot.resolve(name)));
deleteTree(live);
copyTree(snapshotRoot.resolve(name), live);
}
}
/***************************************************************************
** Copy a directory tree, creating directories and preserving file times.
***************************************************************************/
private static void copyTree(Path from, Path to) throws Exception
{
try(Stream<Path> paths = Files.walk(from))
{
for(Path source : paths.toList())
{
Path target = to.resolve(from.relativize(source).toString());
if(Files.isDirectory(source))
{
Files.createDirectories(target);
}
else
{
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
}
}
}
}
/***************************************************************************
** Delete a directory tree, children first.
***************************************************************************/
private static void deleteTree(Path tree) throws Exception
{
try(Stream<Path> paths = Files.walk(tree))
{
for(Path path : paths.sorted(Comparator.reverseOrder()).toList())
{
Files.delete(path);
}
}
}
/***************************************************************************
** One scan event with a unique id and the exact scanned text.
***************************************************************************/
@@ -110,8 +110,8 @@ class EchoDemoSettingsTest
void permissionSetsAreLeastPrivilege()
{
assertEquals(List.of("echo.use", "printing_operator", "document-generation.view", "document-generation.generate", "document-generation.print",
"record-files.view", "record-files.manage", "echoScan.attach"), EchoDemoAuthentication.SCANNER);
assertEquals(List.of("printing_admin", "document-generation.configure", "document-generation.publish"), EchoDemoAuthentication.ADMIN_ADDITIONS);
assertFalse(EchoDemoAuthentication.SCANNER.stream().anyMatch(name -> name.endsWith(".configure") || name.endsWith(".publish") || name.endsWith("admin")));
"record-files.view", "record-files.manage", "echoScan.attach", "testOrder.attach", "testOrderItem.attach", "testOrderEvent.attach"), EchoDemoAuthentication.SCANNER);
assertEquals(List.of("printing_admin", "document-generation.configure", "document-generation.publish", "record-files.release"), EchoDemoAuthentication.ADMIN_ADDITIONS);
assertFalse(EchoDemoAuthentication.SCANNER.stream().anyMatch(name -> name.endsWith(".configure") || name.endsWith(".publish") || name.endsWith("admin") || name.endsWith(".release")));
}
}
@@ -6,6 +6,10 @@
package com.kingsrook.examples.echo.demo;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
@@ -14,17 +18,25 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.sql.DriverManager;
import java.time.Duration;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.recordfiles.files.IntakeRequest;
import com.kingsrook.qbits.recordfiles.files.RetainedFileService;
import com.kingsrook.qbits.recordfiles.review.FileReviewService;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.TableBasedAuthenticationModule;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -53,7 +65,8 @@ class EchoDemoStartupTest
@TempDir
Path root;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5))
.cookieHandler(new java.net.CookieManager(null, java.net.CookiePolicy.ACCEPT_ALL)).build();
@@ -151,6 +164,7 @@ class EchoDemoStartupTest
demo.start(port);
QInstance instance = demo.defineQInstance();
seedPreviewPhotos(instance, ADMIN_PASSWORD);
assertNotNull(instance.getApp("echo"));
assertNotNull(instance.getProcess("echoPrint"));
assertNotNull(instance.getTable("echoScan"));
@@ -165,12 +179,31 @@ class EchoDemoStartupTest
assertFalse(scanner.hasPermission("printing_admin") || scanner.hasPermission("document-generation.publish"));
assertTrue(admin.hasPermission("printing_admin") && admin.hasPermission("document-generation.publish"));
assertFalse(stranger.hasPermission("echo.use") || stranger.hasPermission("printing_operator"));
assertTrue(admin.hasPermission("record-files.release"));
assertFalse(scanner.hasPermission("record-files.release") || stranger.hasPermission("record-files.release"));
assertFalse(admin.hasPermission("record-files.configure") || admin.hasPermission("record-files.review"));
// Revalidation keeps the role derived from the validated user, never widening or dropping it.
assertTrue(EchoDemoAuthentication.grant(instance, admin).hasPermission("record-files.release"));
assertFalse(EchoDemoAuthentication.grant(instance, scanner).hasPermission("record-files.release"));
HttpResponse<String> version = send(port, "/printing/v1/version", null, null);
assertEquals(200, version.statusCode());
assertTrue(version.body().contains("protocolVersion"));
assertNotEquals(200, send(port, "/qqq/v1/metaData", null, null).statusCode());
assertEquals(200, login(port, "scanner", SCANNER_PASSWORD).statusCode());
HttpResponse<String> metadata = send(port, "/qqq/v1/metaData/table/testOrder", null, null);
assertEquals(200, metadata.statusCode());
assertTrue(metadata.body().contains("Order overview") && metadata.body().contains("associatedTableName"));
HttpResponse<String> page = send(port, "/qqq/v1/table/testOrder/query", null,
"{\"filter\":{\"skip\":25,\"limit\":25,\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":true}]}}");
assertEquals(200, page.statusCode());
assertTrue(page.body().contains("TEST-000026"));
assertFalse(page.body().contains("TEST-000001"));
HttpResponse<String> children = send(port, "/qqq/v1/table/testOrderItem/query", null,
"{\"filter\":{\"limit\":10,\"criteria\":[{\"fieldName\":\"orderId\",\"operator\":\"EQUALS\",\"values\":[26]}]}}");
assertEquals(200, children.statusCode());
assertTrue(children.body().contains("ITEM-000301"));
assertFalse(children.body().contains("ITEM-000001"));
assertNotEquals(200, login(port, "scanner", "not-the-password-1").statusCode());
assertEquals(0, renderer.calls.get());
@@ -203,6 +236,64 @@ class EchoDemoStartupTest
/***************************************************************************
** Disposable real-server fixture for native record browsing acceptance.
** Requires a fresh temporary directory; never accepts a database to adopt.
***************************************************************************/
public static void main(String[] args) throws Exception
{
if(!"rich-records-e2e".equals(System.getenv("ECHO_FIXTURE_TYPE")))
{
throw new IllegalArgumentException("An isolated record fixture is required.");
}
int port = Integer.parseInt(args[0]);
if(port < 1024 || PROTECTED.contains(port))
{
throw new IllegalArgumentException("Protected runtime port.");
}
System.setProperty("qqq.logger.logSessionId.disabled", "true");
Path directory = Files.createTempDirectory("qqq-rich-records-");
EchoDemoSettings settings = new EchoDemoSettings(directory, port, "rich-records-test", TOKEN, false, null, null);
EchoDemo demo = new EchoDemo(settings, new RefusingRenderer());
Runtime.getRuntime().addShutdownHook(new Thread(demo::close));
demo.initializeDatabase(System.getenv("ECHO_FIXTURE_SCANNER_PASSWORD"), System.getenv("ECHO_FIXTURE_ADMIN_PASSWORD"));
seedPreviewPhotos(demo.defineQInstance(), System.getenv("ECHO_FIXTURE_ADMIN_PASSWORD"));
demo.start(port);
}
/***************************************************************************
** Synthetic approved images exist only in the disposable native fixture.
** A second parent's image makes accidental unscoped gallery reads detectable.
***************************************************************************/
private static void seedPreviewPhotos(QInstance instance, String password) throws Exception
{
QContext.setQInstance(instance);
String basic = Base64.getEncoder().encodeToString(("admin:" + password).getBytes(StandardCharsets.UTF_8));
QSession session = new EchoDemoAuthentication().createSession(instance, Map.of(TableBasedAuthenticationModule.BASIC_AUTH_KEY, basic))
.withPermission("record-files.release").withSecurityKeyValue("qqq.session.userId", "admin");
QContext.init(instance, session);
try(ByteArrayOutputStream output = new ByteArrayOutputStream())
{
ImageIO.write(new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB), "jpg", output);
byte[] bytes = output.toByteArray();
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
for(int parent : new int[] {26, 27})
{
var file = new RetainedFileService().intake(new IntakeRequest("preview-" + parent, MobileTestData.ORDERS, parent,
"Preview example " + parent + ".jpg", hash).withContentType("image/jpeg"), new ByteArrayInputStream(bytes));
new FileReviewService().release(file.id(), "Synthetic native preview fixture");
}
}
finally
{
QContext.clear();
}
}
/***************************************************************************
** One loopback request; a body makes it a JSON POST.
***************************************************************************/
@@ -169,8 +169,11 @@ class EchoPhotoCaptureTest
RecordFilesQBitConfig config = (RecordFilesQBitConfig) instance.getQBits().values().stream().map(bit -> bit.getConfig())
.filter(RecordFilesQBitConfig.class::isInstance).findFirst().orElseThrow();
assertEquals(List.of(EchoQBitProducer.SCAN_TABLE), config.getParentTables());
assertEquals(List.of(EchoQBitProducer.SCAN_TABLE, MobileTestData.ORDERS, MobileTestData.ITEMS, MobileTestData.EVENTS), config.getParentTables());
assertEquals(Set.of("image/jpeg", "image/png"), config.getUploadConfiguration().limits().acceptedMediaTypes());
assertEquals(Boolean.FALSE, config.getQuarantineCapturedFiles(), "approved-device captures are admitted immediately");
assertEquals(Boolean.TRUE, config.getQuarantineNewFiles(), "every other origin keeps the quarantine default");
assertNull(config.getQuarantineGeneratedFiles(), "the generated-output policy is unchanged");
assertTrue(config.getUploadConfiguration().createIdentityProvider() instanceof MobileCaptureIdentityProvider);
assertTrue(config.getUploadConfiguration().createStorage() instanceof LocalStagingChunkStorage);
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.sql.DriverManager;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Fixture volume, relationships, variety and preserving reapplication on H2.
*******************************************************************************/
class MobileTestDataTest
{
/***************************************************************************
** Every section uses real fields and every child belongs to one test order.
** Existing fixture values survive repeated startup, just like attachments.
***************************************************************************/
@Test
void seedsRichRelatedRecordsWithoutReplacingExistingRows() throws Exception
{
QInstance instance = new QInstance();
MobileTestData.register(instance);
var table = instance.getTable(MobileTestData.ORDERS);
assertEquals(39, table.getFields().size());
assertEquals(6, table.getSections().size());
assertEquals(2, table.getAssociations().size());
assertTrue(table.getSections().stream().flatMap(section -> section.getFieldNames().stream()).allMatch(table.getFields()::containsKey));
try(var connection = DriverManager.getConnection("jdbc:h2:mem:" + UUID.randomUUID() + ";DATABASE_TO_UPPER=FALSE"); var statement = connection.createStatement())
{
MobileTestData.initialize(connection, instance);
statement.executeUpdate("UPDATE testOrder SET notes = 'Retain this value' WHERE id = 1");
MobileTestData.initialize(connection, instance);
for(String name : MobileTestData.TABLES)
{
try(var rows = statement.executeQuery("SELECT COUNT(*) FROM " + name))
{
rows.next();
assertEquals(name.equals(MobileTestData.ORDERS) ? 1000 : name.equals(MobileTestData.ITEMS) ? 12000 : 3000, rows.getInt(1));
}
}
try(var rows = statement.executeQuery("SELECT notes, customerCode, optionalNote FROM testOrder WHERE id = 1"))
{
rows.next();
assertEquals("Retain this value", rows.getString(1));
assertEquals("00000001", rows.getString(2));
assertEquals("Optional example 1", rows.getString(3));
}
try(var rows = statement.executeQuery("SELECT COUNT(*) FROM testOrderItem i JOIN testOrder o ON i.orderId = o.id WHERE o.id = 26"))
{
rows.next();
assertEquals(12, rows.getInt(1));
}
}
}
}
@@ -8,16 +8,24 @@ package com.kingsrook.examples.echo.demo;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -109,6 +117,147 @@ class PrivateObjectFilesTest
/***************************************************************************
** Dot path segments are not buckets; ordinary names containing dots are.
***************************************************************************/
@Test
void dotBucketsAreRefusedAndValidBucketsStillWork() throws IOException
{
PrivateObjectFiles files = new PrivateObjectFiles(root);
assertThrows(IllegalArgumentException.class, () -> files.file(".", "k"));
assertThrows(IllegalArgumentException.class, () -> files.file("..", "k"));
assertThrows(IllegalArgumentException.class, () -> files.create("..", "k", bytes("x"), 1024, Map.of()));
assertTrue(files.file("canonical", "k").normalize().startsWith(root.resolve("canonical")));
assertTrue(files.file("a.b-c_d", "k").normalize().startsWith(root.resolve("a.b-c_d")));
assertTrue(files.file("..x", "k").normalize().startsWith(root.resolve("..x")));
files.create("canonical", "k", bytes("ok"), 1024, Map.of("a", "b"));
assertEquals(Map.of("a", "b"), files.find("canonical", "k").orElseThrow().metadata());
}
/***************************************************************************
** A stream that, on its first read, reports that its writer is inside the
** write path and then waits until the test releases that writer.
***************************************************************************/
private static InputStream gated(String value, CountDownLatch entered, CountDownLatch release)
{
ByteArrayInputStream delegate = bytes(value);
return new InputStream()
{
private boolean first = true;
@Override
public int read() throws IOException
{
gate();
return delegate.read();
}
@Override
public int read(byte[] buffer, int offset, int length) throws IOException
{
gate();
return delegate.read(buffer, offset, length);
}
private void gate() throws IOException
{
if(first)
{
first = false;
entered.countDown();
try
{
if(!release.await(30, TimeUnit.SECONDS))
{
throw new IOException("Test writer was never released");
}
}
catch(InterruptedException failure)
{
Thread.currentThread().interrupt();
throw new IOException(failure);
}
}
}
};
}
/***************************************************************************
** Two adapters over one root write the same key with different bytes and
** metadata. Both pass the initial existence check and block in their streams;
** the first is released and completes, then the second is released and must
** be refused at the final existence check without touching the first's
** bytes or metadata (the old code replaced the metadata before failing).
***************************************************************************/
@Test
void lateSecondWriterIsRefusedAndFirstWritersBytesAndMetadataRemain() throws Exception
{
PrivateObjectFiles first = new PrivateObjectFiles(root);
PrivateObjectFiles second = new PrivateObjectFiles(root);
CountDownLatch enteredOne = new CountDownLatch(1);
CountDownLatch enteredTwo = new CountDownLatch(1);
CountDownLatch releaseOne = new CountDownLatch(1);
CountDownLatch releaseTwo = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(2);
try
{
Future<?> one = executor.submit(() ->
{
first.create("canonical", "same", gated("content-from-one", enteredOne, releaseOne), 1024, Map.of("writer", "one"));
return null;
});
Future<?> two = executor.submit(() ->
{
second.create("canonical", "same", gated("content-from-two-longer", enteredTwo, releaseTwo), 1024, Map.of("writer", "two"));
return null;
});
// both writers are past their initial existence check and blocked in their streams
assertTrue(enteredOne.await(30, TimeUnit.SECONDS), "first writer should enter the write path");
assertTrue(enteredTwo.await(30, TimeUnit.SECONDS), "second writer should enter the write path");
// first finishes completely while second stays blocked
releaseOne.countDown();
one.get(30, TimeUnit.SECONDS);
assertFalse(two.isDone(), "second writer must still be blocked in its stream");
// second now reaches publication with an object already in place and must refuse
releaseTwo.countDown();
ExecutionException failure = assertThrows(ExecutionException.class, () -> two.get(30, TimeUnit.SECONDS));
assertInstanceOf(FileAlreadyExistsException.class, failure.getCause());
var stored = first.find("canonical", "same").orElseThrow();
assertEquals("content-from-one", Files.readString(stored.file()));
assertEquals(Map.of("writer", "one"), stored.metadata());
assertEquals(stored.metadata(), second.find("canonical", "same").orElseThrow().metadata());
}
finally
{
releaseOne.countDown();
releaseTwo.countDown();
executor.shutdownNow();
assertTrue(executor.awaitTermination(30, TimeUnit.SECONDS));
}
try(Stream<Path> all = Files.walk(root))
{
assertFalse(all.anyMatch(path -> path.getFileName().toString().startsWith(".incoming-")));
}
}
/***************************************************************************
** Delete removes the object and its metadata; absent is not an error.
***************************************************************************/
+6 -5
View File
@@ -16,15 +16,15 @@
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.1.0-RC.21</qqq.version>
<qqq.version>4.1.0-RC.26</qqq.version>
<mobile.version>0.1.0-SNAPSHOT</mobile.version>
<!-- Echo's copies per scan needs a Generation build with the print-copies change (appended copies on
DocumentGenerationService.Request and GenerationPorts.PrintRequest, supportsCopies on the printing bridge,
migration 20261102-print-copies-h2.sql). Printing 1.0.0 is released and qualified; only the Generation
cohort still needs a coherent published input. Until that is published at this coordinate, override
generation.version with a coherent build that has it; no private build is named here. -->
<generation.version>0.1.0-SNAPSHOT</generation.version>
<printing.version>1.0.0</printing.version>
<generation.version>1.1.0-RC.1</generation.version>
<printing.version>1.2.0-RC.1</printing.version>
</properties>
<dependencyManagement><dependencies>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId><version>${qqq.version}</version><type>pom</type><scope>import</scope></dependency>
@@ -37,14 +37,15 @@
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-record-files</artifactId><version>${generation.version}</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.2.224</version><scope>test</scope></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>42.7.8</version><scope>test</scope></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter-api</artifactId><version>6.0.2</version><scope>test</scope></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter-engine</artifactId><version>6.0.2</version><scope>test</scope></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.35</version><scope>test</scope></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.38</version><scope>test</scope></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-mobile-scanners-demo</artifactId><version>${mobile.version}</version><scope>test</scope></dependency>
</dependencies>
<build><resources><resource><directory>src/main/resources</directory><filtering>false</filtering></resource><resource><directory>${project.basedir}/../..</directory><filtering>false</filtering><includes><include>LICENSE</include><include>NOTICE</include></includes><targetPath>META-INF/qqq-echo</targetPath></resource></resources><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version><configuration><showWarnings>true</showWarnings><failOnWarning>true</failOnWarning><proc>none</proc><compilerArgs><arg>-Xlint:deprecation</arg><arg>-Xlint:unchecked</arg><arg>-Werror</arg></compilerArgs></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.2</version></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.6</version></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-checkstyle-plugin</artifactId><version>3.6.0</version><dependencies><dependency><groupId>com.puppycrawl.tools</groupId><artifactId>checkstyle</artifactId><version>14.3.0</version></dependency></dependencies><configuration><configLocation>${project.basedir}/../../checkstyle/checkstyle.xml</configLocation><includeTestSourceDirectory>true</includeTestSourceDirectory><failOnViolation>true</failOnViolation><violationSeverity>warning</violationSeverity></configuration><executions><execution><phase>validate</phase><goals><goal>check</goal></goals></execution></executions></plugin>
</plugins></build>
</project>
@@ -10,10 +10,14 @@ import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecke
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.metadata.ProcessMetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
@@ -35,7 +39,13 @@ public final class EchoPermissions implements CustomPermissionChecker
{
throw new QPermissionDeniedException("Echo access is required; direct state writes are not allowed.");
}
EchoConfig.current().identities.current();
// Browsing the application performs no scan or print operation.
// Process execution, widget and record access still require approved device authority.
boolean processDiscovery = metadata instanceof QProcessMetaData && (action instanceof MetaDataInput || action instanceof ProcessMetaDataInput);
if(!(metadata instanceof QAppMetaData) && !processDiscovery)
{
EchoConfig.current().identities.current();
}
}
catch(Exception failure)
{
@@ -43,3 +53,4 @@ public final class EchoPermissions implements CustomPermissionChecker
}
}
}
@@ -118,7 +118,7 @@ public final class EchoPrintingDestinations implements EchoDestinations
String refusal = refusal(current.mode(), scan);
if(refusal != null)
{
throw new QException(refusal);
throw new EchoScanRejected(refusal);
}
return current.plan();
}
@@ -274,7 +274,8 @@ public final class EchoPrintingDestinations implements EchoDestinations
for(Object item : scene.getJSONArray("elements"))
{
JSONObject text = (JSONObject) item;
if("text".equals(text.optString("type")) && "/record/humanReadable".equals(text.optString("valuePath"))
if(("text".equals(text.optString("type")) || "rasterText".equals(text.optString("type")))
&& "/record/humanReadable".equals(text.optString("valuePath"))
&& text.optInt("y", -1) > barcode.optInt("y", -1))
{
return true;
@@ -293,15 +294,15 @@ public final class EchoPrintingDestinations implements EchoDestinations
{
if((mode == SceneMode.GS1) != "gs1-128".equals(scan.rendererSymbology()))
{
return "GS1-128 scans require the GS1-128 document; that document accepts only GS1-128. Leave Echo and choose the matching document; no print was requested.";
return "GS1-128 scans require the GS1-128 document; that document accepts only GS1-128. No print was requested. Scan a barcode matching the selected document.";
}
if(mode == SceneMode.PLAIN && EchoTemplates.SCANNER_SYMBOLOGIES.contains(scan.rendererSymbology()))
{
return "EAN and UPC need the scanner-fidelity document. Leave Echo and choose it; no print was requested.";
return "EAN and UPC need the scanner-fidelity document. No print was requested. Scan a barcode matching the selected document.";
}
if(mode == SceneMode.SCANNER && scan.aimIdentifier() == null)
{
return "The scanner-fidelity document needs the scanner's AIM identifier and this scan has none. Leave Echo and choose the plain document; no print was requested.";
return "The scanner-fidelity document needs the scanner's AIM identifier and this scan has none. No print was requested. Scan a barcode with its AIM identifier.";
}
return null;
}
@@ -349,8 +350,7 @@ public final class EchoPrintingDestinations implements EchoDestinations
for(Object item : new JSONArray(mediaProfilesJson == null ? "[]" : mediaProfilesJson))
{
PrintingMediaProfile profile = PrintingMediaProfile.parse((JSONObject) item);
choices.add(new MediaChoice(profile.id(), profile.name() + " (" + String.format(Locale.ROOT, "%.1f x %.1f mm, %d dpi",
profile.widthMicrons() / 1000.0, profile.heightMicrons() / 1000.0, profile.dpiX()) + ")"));
choices.add(new MediaChoice(profile.id(), mediaLabel(profile)));
}
}
catch(RuntimeException failure)
@@ -362,6 +362,26 @@ public final class EchoPrintingDestinations implements EchoDestinations
/***************************************************************************
** Prefer exact quarter-inch stock sizes; other stock retains metric units.
** Keep meaningful printer names without repeating a dimensions-only name.
***************************************************************************/
private static String mediaLabel(PrintingMediaProfile profile)
{
boolean inches = profile.widthMicrons() % 6350 == 0 && profile.heightMicrons() % 6350 == 0;
int divisor = inches ? 25400 : 1000;
String width = java.math.BigDecimal.valueOf(profile.widthMicrons()).divide(java.math.BigDecimal.valueOf(divisor)).stripTrailingZeros().toPlainString();
String height = java.math.BigDecimal.valueOf(profile.heightMicrons()).divide(java.math.BigDecimal.valueOf(divisor)).stripTrailingZeros().toPlainString();
String resolution = profile.dpiX() == profile.dpiY() ? String.valueOf(profile.dpiX()) : profile.dpiX() + " × " + profile.dpiY();
String name = profile.name().strip();
String remainder = name.toLowerCase(Locale.ROOT).replaceAll("\\b(inches|inch|in|mm|label|paper|dpi)\\b", "")
.replaceAll("[\\s0-9.,x×()·-]+", "");
boolean generic = remainder.isBlank() || List.of("custom", "default", "media", "userdefined", "windowsqueuedefault").contains(remainder);
return (generic ? "" : name + " · ") + width + " × " + height + (inches ? " in label · " : " mm label · ") + resolution + " dpi";
}
/***************************************************************************
** Select one reported media profile by identity; never guess dimensions.
***************************************************************************/
@@ -19,6 +19,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueRangeBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
@@ -87,7 +88,7 @@ public final class EchoQBitProducer
instance.addTable(scan);
instance.addWidget(new QWidgetMetaData().withName(WIDGET).withType("composite").withLabel("Echo barcode scanner")
.withCodeReference(new QCodeReference(EchoWidget.class)).withPermissionRules(rules()));
instance.addProcess(new QProcessMetaData().withName(PROCESS).withLabel("Echo — scan to print").withPermissionRules(rules())
instance.addProcess(new QProcessMetaData().withName(PROCESS).withLabel("Echo Printing").withIcon(new QIcon().withName("account_tree")).withPermissionRules(rules())
.withCancelStep(new QBackendStepMetaData().withName("cancel").withCode(new QCodeReference(EchoSteps.Cancel.class)))
.withStep(new QBackendStepMetaData().withName("begin").withCode(new QCodeReference(EchoSteps.Begin.class)))
.withStep(new QFrontendStepMetaData().withName("setup").withLabel("Choose printer, paper or label, and document")
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Only deterministic pre-print barcode/document incompatibility uses this type.
*******************************************************************************/
final class EchoScanRejected extends QException
{
/***************************************************************************
** The message contains guidance, never a scanned payload.
***************************************************************************/
EchoScanRejected(String message)
{
super(message);
}
}
@@ -175,12 +175,21 @@ public final class EchoService
{
requireText(runId, 36);
MobileDeviceAuthorization.Identity identity = identity();
Scan scan = Scan.from(context);
if(!identity.actorId().equals(context.get("userId")) || !identity.deviceId().equals(context.get("deviceId"))
if(context == null || !identity.actorId().equals(context.get("userId")) || !identity.deviceId().equals(context.get("deviceId"))
|| !EchoQBitProducer.APP.equals(context.get("appId")) || !EchoQBitProducer.PROCESS.equals(context.get("processName")))
{
throw new QException("Scan context does not match this approved scanner sign-in.");
}
Scan scan;
try
{
scan = Scan.from(context);
}
catch(EchoScanRejected rejection)
{
store.requireUnadmitted(runId, identity, requireText(context.get("scanId"), 64), claimedCopies);
throw rejection;
}
EchoStore.Admission admission = store.claim(runId, identity, scan, claimedCopies);
if(admission.acceptedJob() != null)
{
@@ -215,7 +224,16 @@ public final class EchoService
{
throw new QException("This Echo run has no chosen document. Leave Echo and choose again.");
}
EchoDestinations.Plan plan = destinations.plan(identity, destination, admission.document(), scan);
EchoDestinations.Plan plan;
try
{
plan = destinations.plan(identity, destination, admission.document(), scan);
}
catch(EchoScanRejected rejection)
{
store.reject(runId, admission.scanId(), scan.eventId());
throw rejection;
}
if(plan == null || plan.templateId() == null || plan.templateId() < 1 || plan.templateRevisionId() == null || plan.templateRevisionId() < 1
|| plan.format() == null || !stockMatches(plan.stock(), destination))
{
@@ -375,7 +393,7 @@ public final class EchoService
case "EAN8", "LABEL-TYPE-EAN8" -> "ean8";
case "UPCA", "LABEL-TYPE-UPCA" -> "upca";
case "UPCE0", "LABEL-TYPE-UPCE0" -> "upce";
default -> throw new QException("This barcode type cannot yet be reproduced faithfully by Echo. No print was requested.");
default -> throw new EchoScanRejected("This barcode type cannot yet be reproduced faithfully by Echo. No print was requested.");
};
///////////////////////////////////////////////////////////////////////////////////////
// the qualified first-physical profile is printable ASCII 0x20..0x7E for all types; //
@@ -385,11 +403,18 @@ public final class EchoService
if(gs1)
{
renderer = "gs1-128";
EchoGs1.parse(value);
try
{
EchoGs1.parse(value);
}
catch(QException invalidGs1)
{
throw new EchoScanRejected(invalidGs1.getMessage());
}
}
else if("gs1-128".equals(renderer) || value.chars().anyMatch(character -> character < 0x20 || character > 0x7E))
{
throw new QException("This scan needs control/binary/GS1 or non-ASCII payload handling that Echo has not qualified; only printable ASCII is supported. No print was requested.");
throw new EchoScanRejected("This scan needs control/binary/GS1 or non-ASCII payload handling that Echo has not qualified; only printable ASCII is supported. No print was requested.");
}
String aim = requireFidelityMetadata(context, renderer, value);
return new Scan(event, value, type, renderer, source, captured, mobileScan, received, aim);
@@ -409,23 +434,23 @@ public final class EchoService
boolean gs1 = "gs1-128".equals(renderer);
if(aim != null && !(gs1 ? "]C1" : PLAIN_AIM.get(renderer)).equals(aim))
{
throw new QException("This scan reports a barcode mode (GS1, ECI, structured or full-ASCII) that Echo cannot reproduce faithfully. No print was requested.");
throw new EchoScanRejected("This scan reports a barcode mode (GS1, ECI, structured or full-ASCII) that Echo cannot reproduce faithfully. No print was requested.");
}
if(EchoTemplates.SCANNER_SYMBOLOGIES.contains(renderer))
{
if(aim == null)
{
throw new QException("EAN and UPC need the scanner's AIM identifier to be reproduced faithfully, and this scan has none. No print was requested.");
throw new EchoScanRejected("EAN and UPC need the scanner's AIM identifier to be reproduced faithfully, and this scan has none. No print was requested.");
}
if(!validRetailValue(renderer, value))
{
throw new QException("This EAN or UPC value has the wrong length, number system or check digit. No print was requested.");
throw new EchoScanRejected("This EAN or UPC value has the wrong length, number system or check digit. No print was requested.");
}
}
Object raw = context.get("rawDataBase64");
if(gs1 && raw == null)
{
throw new QException("GS1-128 duplication needs the scanner's original raw bytes and AIM identifier. No print was requested.");
throw new EchoScanRejected("GS1-128 duplication needs the scanner's original raw bytes and AIM identifier. No print was requested.");
}
if(raw != null)
{
@@ -93,12 +93,16 @@ public final class EchoSteps
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String run = null;
java.util.Map<String, java.io.Serializable> context = null;
try
{
run = boundRun(input, output);
Integer claimed = copies(input.getValue("copies"));
var context = MobileScannerActions.capture(input, EchoQBitProducer.APP);
context = MobileScannerActions.capture(input, EchoQBitProducer.APP);
EchoService.Acceptance accepted = EchoConfig.current().service.print(run, context, claimed);
output.addValue("scanRejected", false);
output.addValue("rejectedEventId", null);
output.addValue("rejectionMessage", null);
output.addValue("scanContext", new HashMap<>(context));
output.addValue("scanId", context.get("scanId"));
output.addValue("echoScanId", accepted.scanId());
@@ -110,6 +114,19 @@ public final class EchoSteps
output.addValue(EchoQBitProducer.WIDGET, new EchoWidget.Data(accepted.copies()));
output.getProcessState().setNextStepName("scan");
}
catch(EchoScanRejected rejection)
{
output.addValue("scanRejected", true);
output.addValue("rejectedEventId", context.get("eventId"));
output.addValue("rejectionMessage", rejection.getMessage());
output.addValue("scanSaved", false);
for(String name : java.util.List.of("acknowledgedEventId", "scanId", "echoScanId", "printJobId", "printStatus", "scanContext"))
{
output.addValue(name, null);
}
restorePinnedCopies(run, output);
output.getProcessState().setNextStepName("scan");
}
catch(QException failure)
{
restorePinnedCopies(run, output);
@@ -76,9 +76,56 @@ final class EchoStore
throw new QException("Echo setup is already complete or closed. Leave Echo to choose again.");
}
write(connection, "UPDATE echo_run SET state='OPEN',printerRecordId=?,mediaId=?,mediaRevision=?,widthMicrons=?,heightMicrons=?,dpiX=?,dpiY=?,templateId=?,templateRevisionId=?,copies=? WHERE id=?",
destination.printerRecordId(), destination.mediaId(), destination.mediaRevision(), Integer.toString(destination.widthMicrons()),
Integer.toString(destination.heightMicrons()), Integer.toString(destination.dpiX()), Integer.toString(destination.dpiY()),
document.templateId().toString(), document.templateRevisionId().toString(), Integer.toString(copies), id);
destination.printerRecordId(), destination.mediaId(), destination.mediaRevision(), destination.widthMicrons(),
destination.heightMicrons(), destination.dpiX(), destination.dpiY(),
document.templateId(), document.templateRevisionId(), copies, id);
connection.commit();
}
catch(Exception failure)
{
rollback(connection, failure);
if(failure instanceof QException expected)
{
throw expected;
}
throw unavailable();
}
}
catch(SQLException failure)
{
throw unavailable();
}
}
/***************************************************************************
** Canonical capture already retains invalid barcode evidence. A pre-admission
** refusal is safe only for an owned open run with no uncertain or prior work.
***************************************************************************/
void requireUnadmitted(String runId, MobileDeviceAuthorization.Identity identity, String scanId, Integer copies) throws QException
{
try(Connection connection = dataSource.getConnection())
{
connection.setAutoCommit(false);
try
{
Run run = run(connection, runId, identity);
if(!"OPEN".equals(run.state()) || run.pending() != null || copies != null && copies != run.copies())
{
throw new QException("This run cannot acknowledge a new rejected scan; resolve its current state first.");
}
try(PreparedStatement query = connection.prepareStatement("SELECT id FROM echo_scan WHERE id=?"))
{
query.setString(1, scanId);
try(ResultSet rows = query.executeQuery())
{
if(rows.next())
{
throw new QException("This scan has prior work and requires reconciliation.");
}
}
}
connection.commit();
}
catch(Exception failure)
@@ -167,6 +214,10 @@ final class EchoStore
connection.commit();
return new Admission(id, destination, acceptedJob, false, pinned, run.document(), run.copies());
}
if("REJECTED".equals(state) && pinned == null)
{
throw new EchoScanRejected("This barcode was rejected before printing. Scan a barcode matching the selected document.");
}
if(run.pending() != null && !run.pending().equals(scan.eventId()))
{
throw new QException("The previous print is unconfirmed. Retry that scan or leave Echo before starting another.");
@@ -220,11 +271,11 @@ final class EchoStore
try(Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(
"UPDATE echo_scan SET state='IN_FLIGHT',templateId=?,templateRevisionId=?,format=?,topMicrons=?,rightMicrons=?,bottomMicrons=?,leftMicrons=? WHERE id=? AND state IN ('IN_FLIGHT','ATTENTION') AND templateId IS NULL"))
{
String[] values = {plan.templateId().toString(), plan.templateRevisionId().toString(), plan.format().name(), plan.stock().topMicrons().toString(),
plan.stock().rightMicrons().toString(), plan.stock().bottomMicrons().toString(), plan.stock().leftMicrons().toString(), scanId};
Object[] values = {plan.templateId(), plan.templateRevisionId(), plan.format().name(), plan.stock().topMicrons(),
plan.stock().rightMicrons(), plan.stock().bottomMicrons(), plan.stock().leftMicrons(), scanId};
for(int index = 0; index < values.length; index++)
{
statement.setString(index + 1, values[index]);
statement.setObject(index + 1, values[index]);
}
if(statement.executeUpdate() != 1)
{
@@ -268,6 +319,43 @@ final class EchoStore
/***************************************************************************
** The pin and rejection compare-and-set compete; only one can win. Preserve
** the rejected capture, and release only its exact pending run binding.
***************************************************************************/
void reject(String runId, String scanId, String eventId) throws QException
{
try(Connection connection = dataSource.getConnection())
{
connection.setAutoCommit(false);
try(PreparedStatement statement = connection.prepareStatement(
"UPDATE echo_scan SET state='REJECTED' WHERE id=? AND runId=? AND eventId=? AND state IN ('IN_FLIGHT','ATTENTION') AND templateId IS NULL AND templateRevisionId IS NULL AND printJobId IS NULL"))
{
statement.setString(1, scanId);
statement.setString(2, runId);
statement.setString(3, eventId);
if(statement.executeUpdate() != 1)
{
connection.rollback();
throw new QException("This scan requires reconciliation; it cannot be acknowledged as rejected.");
}
write(connection, "UPDATE echo_run SET pendingEventId=NULL WHERE id=? AND pendingEventId=?", runId, eventId);
connection.commit();
}
catch(SQLException failure)
{
rollback(connection, failure);
throw unavailable();
}
}
catch(SQLException failure)
{
throw unavailable();
}
}
/***************************************************************************
** A failed or lost response keeps its event identity and pending run binding.
** No automatic retry, replacement print request or success acknowledgment.
@@ -378,13 +466,13 @@ final class EchoStore
/***************************************************************************
** All SQL identifiers are fixed source; external values are bound parameters.
***************************************************************************/
private static void write(Connection connection, String sql, String... values) throws SQLException
private static void write(Connection connection, String sql, Object... values) throws SQLException
{
try(PreparedStatement statement = connection.prepareStatement(sql))
{
for(int index = 0; index < values.length; index++)
{
statement.setString(index + 1, values[index]);
statement.setObject(index + 1, values[index]);
}
statement.executeUpdate();
}
@@ -0,0 +1,29 @@
-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0
-- Host migration input, not automatically executed by metadata registration.
CREATE TABLE IF NOT EXISTS echo_run (
id VARCHAR(36) PRIMARY KEY, actorId VARCHAR(200) NOT NULL, deviceId VARCHAR(36) NOT NULL,
publicSessionId VARCHAR(36) NOT NULL, printerRecordId VARCHAR(200) NOT NULL, mediaId VARCHAR(200) NOT NULL,
mediaRevision VARCHAR(64) NOT NULL DEFAULT '', widthMicrons INT NOT NULL DEFAULT 0, heightMicrons INT NOT NULL DEFAULT 0,
dpiX INT NOT NULL DEFAULT 0, dpiY INT NOT NULL DEFAULT 0,
state VARCHAR(30) NOT NULL, pendingEventId VARCHAR(36)
);
-- Preserving migration: the document chosen once at setup. Existing rows keep NULL and are never cleared.
ALTER TABLE echo_run ADD COLUMN IF NOT EXISTS templateId INT;
ALTER TABLE echo_run ADD COLUMN IF NOT EXISTS templateRevisionId INT;
CREATE TABLE IF NOT EXISTS echo_scan (
id VARCHAR(64) PRIMARY KEY, runId VARCHAR(36) NOT NULL REFERENCES echo_run(id),
eventId VARCHAR(36) NOT NULL, actorId VARCHAR(200) NOT NULL, deviceId VARCHAR(36) NOT NULL, mobileScanId VARCHAR(64) NOT NULL,
barcode VARCHAR(4096) NOT NULL, symbology VARCHAR(200) NOT NULL, rendererSymbology VARCHAR(100) NOT NULL,
scanSource VARCHAR(200) NOT NULL, capturedAt VARCHAR(40) NOT NULL, receivedAt VARCHAR(40) NOT NULL, state VARCHAR(30) NOT NULL,
printJobId VARCHAR(200), UNIQUE(runId, eventId)
,templateId INT, templateRevisionId INT, format VARCHAR(10), topMicrons INT, rightMicrons INT, bottomMicrons INT, leftMicrons INT
);
-- Preserving migration: the scanner's AIM identifier (for example ]E0), when the capture carried one. Existing rows keep NULL
-- (no AIM was recorded) and are never rewritten. Other databases need the equivalent additive nullable VARCHAR(3) column.
ALTER TABLE echo_scan ADD COLUMN IF NOT EXISTS aimIdentifier VARCHAR(3);
-- Preserving migration: copies printed per scan, chosen once at setup. Existing runs keep exactly one copy (their prior behaviour).
-- Other databases need the equivalent additive INT NOT NULL DEFAULT 1 column.
ALTER TABLE echo_run ADD COLUMN IF NOT EXISTS copies INT NOT NULL DEFAULT 1;
-- Derived canonical GS1 input and HRI. Original barcode is never rewritten. Other hosts need equivalent nullable columns.
ALTER TABLE echo_scan ADD COLUMN IF NOT EXISTS rendererValue VARCHAR(4096);
ALTER TABLE echo_scan ADD COLUMN IF NOT EXISTS humanReadable VARCHAR(4096);
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.metadata.ProcessMetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** Navigation does not perform scanning; execution retains approved-device checks. */
class EchoAppVisibilityTest
{
/** Remove the synthetic session after each test. */
@AfterEach
void clear()
{
QContext.clear();
}
/** Authorized navigation must not require hardware enrollment. */
@Test
void authorizedBrowserCanSeeAppWithoutADevice()
{
QContext.init(null, new QSession().withUser(new QUser().withIdReference("fake-operator")).withPermissions("echo.use"));
assertDoesNotThrow(() -> new EchoPermissions().checkPermissionsThrowing(new AbstractActionInput(), new QAppMetaData().withName("echo")));
}
/** Navigation remains permission protected. */
@Test
void browserWithoutEchoPermissionCannotSeeApp()
{
QContext.init(null, new QSession().withUser(new QUser().withIdReference("fake-operator")));
assertThrows(QPermissionDeniedException.class,
() -> new EchoPermissions().checkPermissionsThrowing(new AbstractActionInput(), new QAppMetaData().withName("echo")));
}
/** Seeing the app never authorizes scanning without an approved device. */
@Test
void browserWithoutApprovedDeviceCannotExecuteScanProcess()
{
QContext.init(null, new QSession().withUser(new QUser().withIdReference("fake-operator")).withPermissions("echo.use"));
assertThrows(QPermissionDeniedException.class,
() -> new EchoPermissions().checkPermissionsThrowing(new RunProcessInput(), new QProcessMetaData().withName("echoPrint")));
}
/** Catalog discovery exposes the process without performing its device-bound setup. */
@Test
void authorizedBrowserCanDiscoverProcessWithoutADevice()
{
QContext.init(null, new QSession().withUser(new QUser().withIdReference("fake-operator")).withPermissions("echo.use"));
assertDoesNotThrow(() -> new EchoPermissions().checkPermissionsThrowing(new MetaDataInput(), new QProcessMetaData().withName("echoPrint")));
assertDoesNotThrow(
() -> new EchoPermissions().checkPermissionsThrowing(new ProcessMetaDataInput(), new QProcessMetaData().withName("echoPrint")));
}
/** Catalog discovery still requires Echo permission. */
@Test
void browserWithoutPermissionCannotDiscoverProcess()
{
QContext.init(null, new QSession().withUser(new QUser().withIdReference("fake-operator")));
assertThrows(QPermissionDeniedException.class,
() -> new EchoPermissions().checkPermissionsThrowing(new MetaDataInput(), new QProcessMetaData().withName("echoPrint")));
}
}
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/*******************************************************************************
** Published centered GS1 scenes retain the same data and placement contract.
*******************************************************************************/
class EchoCenteredLabelTest
{
static final String SCENE = """
{"schema":"qqq-zpl-scene/1","elements":[
{"type":"barcode","symbology":"gs1-128","valuePath":"/record/rendererValue","quiet":10,"x":36,"y":36,
"fit":{"widthMicrons":95504,"heightMicrons":16000},"align":"center"},
{"type":"rasterText","valuePath":"/record/humanReadable","x":36,"y":206,"fontAsset":"forge-echo-abel-regular",
"fontSize":44,"width":1128,"height":58,"align":"center"}]}
""";
/** Both renderer-supported human-readable element forms are admitted. */
@Test
void centeredRasterAndLegacyTextAreBothGs1Documents()
{
assertEquals(EchoPrintingDestinations.SceneMode.GS1, EchoPrintingDestinations.sceneMode(SCENE));
assertEquals(EchoPrintingDestinations.SceneMode.GS1, EchoPrintingDestinations.sceneMode(SCENE.replace("rasterText", "text")));
}
/** Text remains derived from GS1 data and below the barcode origin. */
@Test
void wrongHumanReadableBindingOrPlacementStillRefuses()
{
for(String type : java.util.List.of("text", "rasterText"))
{
JSONObject wrong = new JSONObject(SCENE.replace("rasterText", type));
wrong.getJSONArray("elements").getJSONObject(1).put("valuePath", "/record/barcode");
assertNull(EchoPrintingDestinations.sceneMode(wrong.toString()));
for(int y : java.util.List.of(35, 36))
{
JSONObject above = new JSONObject(SCENE.replace("rasterText", type));
above.getJSONArray("elements").getJSONObject(1).put("y", y);
assertNull(EchoPrintingDestinations.sceneMode(above.toString()));
}
}
}
/** Raster text never relaxes the barcode binding or bounded fit requirement. */
@Test
void barcodeBindingAndFitRemainMandatory()
{
assertNull(EchoPrintingDestinations.sceneMode(SCENE.replace("/record/rendererValue", "/record/barcode")));
JSONObject scene = new JSONObject(SCENE);
scene.getJSONArray("elements").getJSONObject(0).remove("fit");
assertNull(EchoPrintingDestinations.sceneMode(scene.toString()));
}
/** Human-readable stock dimensions preserve native profile identities. */
@Test
void readableStockNamesPreserveIdentityAndBothResolutions() throws Exception
{
assertEquals("4 × 1 in label · 300 dpi", label("4 x 1", 101600, 25400, 300, 300));
assertEquals("4 × 1 in label · 300 dpi", label("Windows queue default (101.6 × 25.4 mm, 300 × 300 DPI)", 101600, 25400, 300, 300));
assertEquals("100 × 50 mm label · 203 dpi", label("100 x 50 mm", 100000, 50000, 203, 203));
assertEquals("Shipping · 4 × 1 in label · 300 × 600 dpi", label("Shipping", 101600, 25400, 300, 600));
}
/** Build one synthetic printer profile using the existing owner wire schema. */
private static String label(String name, int width, int height, int dpiX, int dpiY) throws Exception
{
JSONObject profile = new JSONObject().put("id", "synthetic-media").put("revision", "a".repeat(64)).put("name", name)
.put("widthMicrons", width).put("heightMicrons", height).put("dpiX", dpiX).put("dpiY", dpiY)
.put("source", "WINDOWS_QUEUE_DEFAULT").put("applyMode", "DRIVER_DEFAULT");
var choice = EchoPrintingDestinations.choices(new org.json.JSONArray().put(profile).toString()).getFirst();
assertEquals("synthetic-media", choice.id());
return choice.label();
}
}
@@ -191,9 +191,9 @@ class EchoOwnerAdaptersTest
List<EchoDestinations.MediaChoice> choices = EchoPrintingDestinations.choices(PROFILES);
assertEquals(1, choices.size());
assertEquals("4x6", choices.getFirst().id());
assertTrue(choices.getFirst().label().contains("101.6 x 152.4 mm, 300 dpi"));
assertEquals(1, EchoMediaChoices.select(choices, null, "4 x", 0, 50).size());
assertEquals(0, EchoMediaChoices.select(choices, null, "label", 0, 50).size());
assertEquals("4 × 6 in label · 300 dpi", choices.getFirst().label());
assertEquals(1, EchoMediaChoices.select(choices, null, "4 ×", 0, 50).size());
assertEquals(1, EchoMediaChoices.select(choices, null, "label", 0, 50).size());
assertEquals(0, EchoMediaChoices.select(choices, List.of("2x1"), null, 0, 50).size());
assertEquals("4x6", EchoMediaChoices.select(choices, List.of("4x6"), null, 0, 50).getFirst().getId());
assertEquals(0, EchoMediaChoices.select(choices, null, null, 1, 50).size());
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.mobilescanners.EchoMobileFixture;
import com.kingsrook.qbits.mobilescanners.MobileDeviceAuthorization;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.postgresql.ds.PGSimpleDataSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Isolated PostgreSQL 17 contract: numeric bindings, preserving DDL and pin CAS.
*******************************************************************************/
class EchoPostgresTest
{
/***************************************************************************
** No production database credentials are used; the caller supplies a fixture.
***************************************************************************/
@Test
@EnabledIfSystemProperty(named = "echo.postgres.url", matches = ".+")
void setupAndPinUseNativeIntegerBindings() throws Exception
{
PGSimpleDataSource dataSource = new PGSimpleDataSource();
dataSource.setURL(System.getProperty("echo.postgres.url"));
dataSource.setUser("postgres");
String schema = "echo_fixture_" + UUID.randomUUID().toString().replace("-", "");
try(var connection = dataSource.getConnection(); var statement = connection.createStatement())
{
assertEquals(17, connection.getMetaData().getDatabaseMajorVersion());
statement.execute("CREATE SCHEMA " + schema);
}
dataSource.setCurrentSchema(schema);
try
{
String sql;
try(var resource = getClass().getResourceAsStream("/db/echo-postgres.sql"))
{
sql = new String(resource.readAllBytes(), StandardCharsets.UTF_8);
}
try(var connection = dataSource.getConnection(); var statement = connection.createStatement())
{
statement.execute(sql);
}
new EchoMobileFixture(new QInstance(), "jdbc:h2:mem:" + schema + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1");
var identity = MobileDeviceAuthorization.captureCurrent().orElseThrow();
EchoStore store = new EchoStore(dataSource);
store.begin("synthetic-run", identity);
var destination = new EchoDestinations.Destination("synthetic-printer", "4x6", "synthetic-revision", 101600, 152400, 300, 300);
store.select("synthetic-run", identity, destination, new EchoDestinations.Document(7, 11), 3);
assertEquals(3, store.copies("synthetic-run", identity));
try(var connection = dataSource.getConnection(); var statement = connection.createStatement())
{
statement.execute("INSERT INTO echo_scan(id,runId,eventId,actorId,deviceId,mobileScanId,barcode,symbology,rendererSymbology,scanSource,capturedAt,receivedAt,state)"
+ " VALUES ('synthetic-scan','synthetic-run','synthetic-event','synthetic-actor','synthetic-device','synthetic-mobile-scan','001234','CODE128','code128','fixture','fixture','fixture','IN_FLIGHT')");
}
var plan = new EchoDestinations.Plan(7, 11, new GenerationPorts.Stock(101600, 152400, 3000, 3000, 3000, 3000, 300), GenerationPorts.Format.ZPL);
store.requireUnadmitted("synthetic-run", identity, "new-scan", 3);
assertThrows(QException.class, () -> store.requireUnadmitted("synthetic-run", identity, "synthetic-scan", 3));
store.pin("synthetic-scan", plan);
assertThrows(EchoStore.PinNotWon.class, () -> store.pin("synthetic-scan", plan));
assertThrows(QException.class, () -> store.reject("synthetic-run", "synthetic-scan", "synthetic-event"));
try(var connection = dataSource.getConnection(); var statement = connection.createStatement())
{
statement.execute("INSERT INTO echo_scan(id,runId,eventId,actorId,deviceId,mobileScanId,barcode,symbology,rendererSymbology,scanSource,capturedAt,receivedAt,state)"
+ " VALUES ('rejected-scan','synthetic-run','rejected-event','synthetic-actor','synthetic-device','rejected-mobile','synthetic','QRCODE','qr','fixture','fixture','fixture','IN_FLIGHT')");
statement.execute("UPDATE echo_run SET pendingEventId='rejected-event' WHERE id='synthetic-run'");
}
assertThrows(QException.class, () -> store.requireUnadmitted("synthetic-run", identity, "new-scan", 3));
store.reject("synthetic-run", "rejected-scan", "rejected-event");
assertThrows(EchoStore.PinNotWon.class, () -> store.pin("rejected-scan", plan));
store.requireUnadmitted("synthetic-run", identity, "new-scan", 3);
try(var connection = dataSource.getConnection(); var statement = connection.createStatement())
{
statement.execute(sql);
try(var rows = statement.executeQuery("SELECT templateId,templateRevisionId,topMicrons,barcode,rendererValue,humanReadable FROM echo_scan WHERE id='synthetic-scan'"))
{
assertTrue(rows.next());
assertEquals(7, rows.getInt(1));
assertEquals(11, rows.getInt(2));
assertEquals(3000, rows.getInt(3));
assertEquals("001234", rows.getString(4));
assertEquals(null, rows.getString(5));
assertEquals(null, rows.getString(6));
}
}
}
finally
{
EchoMobileFixture.clear();
try(var connection = dataSource.getConnection(); var statement = connection.createStatement())
{
statement.execute("DROP SCHEMA " + schema + " CASCADE");
}
}
}
}
@@ -67,6 +67,7 @@ class EchoTest
private Runnable afterAccept;
private Runnable afterLookup;
private QInstance metadata;
private boolean rejectQr;
private boolean documentWithdrawn;
private int documentRevisionShift;
private final List<Integer> plannedDocuments = new java.util.concurrent.CopyOnWriteArrayList<>();
@@ -156,6 +157,10 @@ class EchoTest
@Override
public Plan plan(MobileDeviceAuthorization.Identity identity, Destination destination, Document document, EchoService.Scan scan) throws QException
{
if(rejectQr && "QRCODE".equals(scan.symbology()))
{
throw new EchoScanRejected("Wrong barcode type; no print was requested.");
}
plannedDocuments.add(document.templateId());
if(!authorizeDocument(identity, destination, document.templateId()).equals(document))
{
@@ -273,6 +278,21 @@ class EchoTest
** Setup is a real frontend, then both scans return to the same scan step.
***************************************************************************/
@Test
void echoPrintingUsesProcessIdentityAndIcon()
{
var process = QContext.getQInstance().getProcess(EchoQBitProducer.PROCESS);
assertEquals("echoPrint", process.getName());
assertEquals("Echo Printing", process.getLabel());
assertEquals("account_tree", process.getIcon().getName());
assertEquals("Echo Scans", QContext.getQInstance().getTable(EchoQBitProducer.SCAN_TABLE).getLabel());
}
/*******************************************************************************
** Existing runtime behavior remains unchanged by presentation metadata.
*******************************************************************************/
@Test
void processChoosesOnceAndLoopsWithExactSymbology() throws Exception
{
RunProcessOutput setup = new RunProcessAction().execute(new RunProcessInput().withInputSource(com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource.USER).withProcessName(EchoQBitProducer.PROCESS));
@@ -310,6 +330,93 @@ class EchoTest
/***************************************************************************
** Rejection releases scanning without losing setup or retaining receipts.
***************************************************************************/
@Test
void rejectedBarcodeKeepsSetupAndClearsReceipts() throws Exception
{
rejectQr = true;
RunProcessOutput setup = new RunProcessAction().execute(new RunProcessInput().withInputSource(com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource.USER).withProcessName(EchoQBitProducer.PROCESS));
RunProcessOutput ready = new RunProcessAction().execute(resume(setup, "setup").withValue("printerId", "printer").withValue("mediaId", "4x6").withValue("templateId", 7).withValue("copies", 3));
for(int index = 0; index < 2; index++)
{
RunProcessInput wrong = resume(ready, "scan");
wrong.getValues().putAll(event("QRCODE", "synthetic-wrong-type"));
String rejectedEvent = wrong.getValueString("eventId");
RunProcessOutput rejected = new RunProcessAction().execute(wrong);
assertEquals(true, rejected.getValue("scanRejected"));
assertEquals(rejectedEvent, rejected.getValue("rejectedEventId"));
assertEquals(false, rejected.getValue("scanSaved"));
assertNull(rejected.getValue("acknowledgedEventId"));
assertNull(rejected.getValue("scanId"));
assertNull(rejected.getValue("printJobId"));
assertEquals(index, requests.size());
for(String field : List.of("echoRunId", "printerId", "mediaId", "templateId", "copies"))
{
assertEquals(ready.getValue(field), rejected.getValue(field));
}
ready = scan(rejected, "CODE128", "synthetic-valid");
assertEquals(true, ready.getValue("scanSaved"));
assertEquals(false, ready.getValue("scanRejected"));
assertNull(ready.getValue("rejectedEventId"));
assertNull(ready.getValue("rejectionMessage"));
assertEquals(index + 1, requests.size());
}
}
/***************************************************************************
** Rejected replays never print; a won pin cannot be relabeled as rejection.
***************************************************************************/
@Test
void rejectionCannotReleasePinnedWorkAndReplayRemainsRejected() throws Exception
{
rejectQr = true;
String run = config.service.start("printer", "4x6", 7, 3);
var wrong = captured("QRCODE", "synthetic-wrong-type");
assertThrows(EchoScanRejected.class, () -> config.service.print(run, wrong));
assertThrows(EchoScanRejected.class, () -> config.service.print(run, wrong));
assertTrue(requests.isEmpty());
var valid = captured("CODE128", "synthetic-valid");
var receipt = config.service.print(run, valid);
var store = new EchoStore(dataSource);
assertThrows(QException.class, () -> store.reject(run, receipt.scanId(), receipt.eventId()));
assertEquals(receipt, config.service.print(run, valid));
assertEquals(1, requests.size());
assertThrows(QException.class, () -> store.pin(wrong.get("scanId").toString(), acceptedPlans.get("echo:" + receipt.scanId())));
}
/***************************************************************************
** Unsupported types and invalid GS1 return ready without losing setup.
***************************************************************************/
@Test
void unsupportedAndInvalidGs1RecoverButUncertainWorkDoesNot() throws Exception
{
RunProcessOutput setup = new RunProcessAction().execute(new RunProcessInput().withInputSource(com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource.USER).withProcessName(EchoQBitProducer.PROCESS));
RunProcessOutput ready = new RunProcessAction().execute(resume(setup, "setup").withValue("printerId", "printer").withValue("mediaId", "4x6").withValue("templateId", 7));
RunProcessOutput unsupported = scan(ready, "MAXICODE", "synthetic");
assertEquals(true, unsupported.getValue("scanRejected"));
RunProcessInput invalid = resume(unsupported, "scan");
invalid.getValues().putAll(event("CODE128", "invalid-gs1"));
invalid.addValue("aimIdentifier", "]C1");
invalid.addValue("rawDataBase64", java.util.Base64.getEncoder().encodeToString("invalid-gs1".getBytes(StandardCharsets.US_ASCII)));
RunProcessOutput rejected = new RunProcessAction().execute(invalid);
assertEquals(true, rejected.getValue("scanRejected"));
assertTrue(requests.isEmpty());
assertEquals(true, scan(rejected, "CODE128", "synthetic-valid").getValue("scanSaved"));
String run = ready.getValueString("echoRunId");
uncertain = true;
assertThrows(QException.class, () -> config.service.print(run, captured("CODE128", "uncertain")));
QException blocked = assertThrows(QException.class, () -> config.service.print(run, captured("MAXICODE", "unsupported")));
assertFalse(blocked instanceof EchoScanRejected);
}
/***************************************************************************
** Runs without a chosen count print exactly one copy, and the run stores it.
***************************************************************************/
@@ -1481,6 +1588,13 @@ class EchoTest
config.service.close(run);
desktop("operator", true);
assertEquals("000001", source(acceptedScan.scanId()).getValueString("barcode"));
var metadataInput = new com.kingsrook.qqq.backend.core.model.actions.metadata.ProcessMetaDataInput();
metadataInput.setProcessName(EchoQBitProducer.PROCESS);
var processView = new com.kingsrook.qqq.backend.core.actions.metadata.ProcessMetaDataAction().execute(metadataInput);
assertTrue(processView.getProcess().getHasPermission());
assertThrows(QException.class, () -> new RunProcessAction().execute(new RunProcessInput()
.withInputSource(com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource.USER).withProcessName(EchoQBitProducer.PROCESS)));
assertThrows(QException.class, () -> config.service.begin(UUID.randomUUID().toString()));
assertThrows(QException.class, () -> config.service.print(run, context));
assertThrows(QException.class, () -> new EchoPermissions().checkPermissionsThrowing(
new RunProcessInput().withProcessName(EchoQBitProducer.PROCESS), QContext.getQInstance().getProcess(EchoQBitProducer.PROCESS)));
@@ -1617,3 +1731,5 @@ class EchoTest
"eventId", UUID.randomUUID().toString(), "capturedAt", "2026-11-01T17:00:00Z"));
}
}
@@ -66,7 +66,7 @@ class EchoHttpFixtureTest
JSONObject item = (JSONObject) extension;
if("mobile-scanners".equals(item.getString("name")))
{
mobile15 = "1.5.0".equals(item.getString("version"));
mobile15 = item.getString("version").matches("1\\.([5-9]|[1-9][0-9]+)\\.[0-9]+");
}
}
assertTrue(mobile15);
@@ -111,7 +111,23 @@ class EchoHttpFixtureTest
assertTrue(firstValues.getString("printJobId").startsWith("fixture-job-"));
JSONObject retry = complete(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, firstEvent, true), EchoQBitProducer.PROCESS, scanner, true);
assertEquals(firstValues.getString("printJobId"), retry.getJSONObject("values").getString("printJobId"));
JSONObject unsupported = event("MAXICODE");
JSONObject rejected = complete(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, unsupported, true), EchoQBitProducer.PROCESS, scanner, true);
assertEquals("scan", rejected.getString("nextStep"));
JSONObject rejection = rejected.getJSONObject("values");
assertTrue(rejection.getBoolean("scanRejected"));
assertFalse(rejection.getBoolean("scanSaved"));
assertEquals(unsupported.getString("eventId"), rejection.getString("rejectedEventId"));
assertFalse(rejection.getString("rejectionMessage").isBlank());
assertEquals(run, rejection.getString("echoRunId"));
for(String cleared : java.util.List.of("acknowledgedEventId", "scanId", "printJobId"))
{
assertTrue(rejection.isNull(cleared), "No prior positive receipt survives rejection.");
}
JSONObject second = complete(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, event("QRCODE"), true), EchoQBitProducer.PROCESS, scanner, true);
assertFalse(second.getJSONObject("values").getBoolean("scanRejected"));
assertTrue(second.getJSONObject("values").isNull("rejectedEventId"));
assertTrue(second.getJSONObject("values").isNull("rejectionMessage"));
assertEquals(run, second.getJSONObject("values").getString("echoRunId"));
assertEquals("scan", second.getString("nextStep"));
JSONArray scans = query(EchoQBitProducer.SCAN_TABLE, scanner);
@@ -58,8 +58,13 @@ function Initialize-IsolatedScannerSource
Remove-IsolatedBuildDirectory -ModuleRoot $ModuleRoot -LeafName 'scanner-source'
$destination = Join-Path $ModuleRoot 'target/scanner-source'
New-Item -ItemType Directory -Path $destination -Force | Out-Null
foreach($name in @('pom.xml', 'mvnw.cmd', 'mvnw', '.mvn', 'src', 'checkstyle'))
foreach($name in @('mvnw.cmd', 'mvnw', '.mvn', 'checkstyle'))
{
Copy-Item -LiteralPath (Join-Path $ScannerRoot $name) -Destination $destination -Recurse -Force
}
# The scanner base module (pom.xml, src) lives in base/ since the reactor layout; the scratch tree keeps the single-module shape.
foreach($name in @('pom.xml', 'src'))
{
Copy-Item -LiteralPath (Join-Path (Join-Path $ScannerRoot 'base') $name) -Destination $destination -Recurse -Force
}
}
+17 -1
View File
@@ -70,7 +70,23 @@ OpenAPI identifiers are `getQbitRecordFilesCapabilities`,
`postQbitRecordFilesUploadsByUploadIdCancel`. Native invoker/credential-scope IDs
instead use `METHOD /qbit/record-files/...`; capability gating must distinguish them.
## Reproducible isolated verification
## Publication
This module is a module of the repository's reactor (root `pom.xml`, artifact
`qbit-mobile-scanners-reactor`), next to `base` (`qbit-mobile-scanners`). It inherits
the root's single `<revision>` and the same `qbit-build-parent` 2.1.0, Apache-2.0
metadata and canonical Gitea URLs. One root `mvn deploy` (the shared Gitea Actions
`maven` workflow) tests and publishes the reactor POM, `qbit-mobile-scanners` and
`qbit-mobile-scanners-record-files` at one version to the Gitea Maven registry.
Its dependencies are immutable releases from that registry: `qbit-record-files`
0.1.0-RC.1 and `qbit-collaboration` 1.1.0-RC.1, with QQQ 4.1.0-RC.22 from the root.
Build only this module and the base with `./mvnw -pl integrations/record-files -am verify`.
## Reproducible isolated verification (legacy local harness)
The isolated scripts below date from the pre-publication frozen-candidate adoption on a
Windows workstation and are not part of the release path; they predate the reactor
layout and are kept as evidence tooling only.
From this directory, run `./Verify-Isolated.ps1`. It verifies every checksum in the
retained bundle, seeds a private repository from the local Maven cache, copies and
@@ -8,12 +8,12 @@ $ErrorActionPreference = 'Stop'
$test = Join-Path $PSScriptRoot ('target/path-regression-' + [Guid]::NewGuid().ToString('N'))
$inputRoot = Join-Path $test 'input'
$moduleRoot = Join-Path $test 'module-root'
New-Item -ItemType Directory -Force -Path $inputRoot, $moduleRoot, (Join-Path $inputRoot '.mvn'), (Join-Path $inputRoot 'src'), (Join-Path $inputRoot 'checkstyle'), (Join-Path $moduleRoot 'target/isolated-m2') | Out-Null
foreach($name in @('pom.xml', 'mvnw.cmd', 'mvnw')) { Set-Content -LiteralPath (Join-Path $inputRoot $name) -Value 'fixture' }
$removedSource = Join-Path $inputRoot 'src/Removed.java'
New-Item -ItemType Directory -Force -Path $inputRoot, $moduleRoot, (Join-Path $inputRoot '.mvn'), (Join-Path $inputRoot 'base/src'), (Join-Path $inputRoot 'checkstyle'), (Join-Path $moduleRoot 'target/isolated-m2') | Out-Null
foreach($name in @('mvnw.cmd', 'mvnw', 'base/pom.xml')) { Set-Content -LiteralPath (Join-Path $inputRoot $name) -Value 'fixture' }
$removedSource = Join-Path $inputRoot 'base/src/Removed.java'
Set-Content -LiteralPath $removedSource -Value 'old-source'
New-Item -ItemType Directory -Force -Path (Join-Path $inputRoot 'src/main/resources') | Out-Null
$removedResource = Join-Path $inputRoot 'src/main/resources/removed.txt'
New-Item -ItemType Directory -Force -Path (Join-Path $inputRoot 'base/src/main/resources') | Out-Null
$removedResource = Join-Path $inputRoot 'base/src/main/resources/removed.txt'
Set-Content -LiteralPath $removedResource -Value 'old-resource'
$sentinel = Join-Path $moduleRoot 'target/isolated-m2/keep.txt'
Set-Content -LiteralPath $sentinel -Value 'repository-must-survive'
@@ -23,7 +23,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $scratch 'target/classes')
Set-Content -LiteralPath (Join-Path $scratch 'target/classes/Removed.class') -Value 'stale-class'
Remove-Item -LiteralPath $removedSource
Remove-Item -LiteralPath $removedResource
Set-Content -LiteralPath (Join-Path $inputRoot 'src/Current.java') -Value 'current-source'
Set-Content -LiteralPath (Join-Path $inputRoot 'base/src/Current.java') -Value 'current-source'
Initialize-IsolatedScannerSource -ScannerRoot $inputRoot -ModuleRoot $moduleRoot
if((Test-Path -LiteralPath (Join-Path $scratch 'src/Removed.java')) -or (Test-Path -LiteralPath (Join-Path $scratch 'target/classes/Removed.class'))) { throw 'Removed source/class survived the fresh snapshot.' }
if(Test-Path -LiteralPath (Join-Path $scratch 'src/main/resources/removed.txt')) { throw 'Removed resource survived the fresh snapshot.' }
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more